Init commit

This commit is contained in:
Denozordec committed 2026-05-02 01:17:08 +07:00
commit f3f831653f
104 files changed
+43827

No files matched your search

+1
View File
@@ -0,0 +1 @@
{"version":"0.0.1","configurations":[{"name":"MikrotikManager","runtimeExecutable":"npm","runtimeArgs":["run","dev"],"port":3000,"autoPort":true}]}
+65
View File
@@ -0,0 +1,65 @@
{
"permissions": {
"allow": [
"Bash(npm install *)",
"mcp__Claude_Preview__preview_start",
"Bash(npx tsc *)",
"Bash(npm run *)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:3000/dashboard)",
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000__TRACKED_VAR__)",
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:3001__TRACKED_VAR__)",
"PowerShell(netstat -ano | Select-String \":300\" | Where-Object { $_ -match \"LISTENING\" })",
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:__TRACKED_VAR__/dashboard)",
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:3333__TRACKED_VAR__)",
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:59959__TRACKED_VAR__)",
"WebFetch(domain:git.shts.su)",
"Bash(curl -s \"https://git.shts.su/denozord/router-lists-ui/raw/branch/v5/frontend/src/RouteOptimizerPage.jsx\")",
"Bash(curl -s \"https://git.shts.su/denozord/router-lists-ui/raw/branch/v5/frontend/src/OspfToolsPage.jsx\")",
"Bash(node -e ' *)",
"Bash(powershell -Command \"Get-Item 'C:\\\\Users\\\\shats\\\\.claude\\\\projects\\\\C--Users-shats-Dev-MikrotikManager-3\\\\b1dbd554-4665-40c0-bb5b-19d63bb494a0.jsonl'\")",
"Bash(node -e \"const { createRequire } = require\\('module'\\); const r = createRequire\\(__filename\\); const lucide = r\\('lucide-react'\\); ['SlackIcon','WebhookIcon'].forEach\\(n => console.log\\(n, !!lucide[n]\\)\\)\")",
"mcp__Claude_in_Chrome__tabs_context_mcp",
"Bash(npx eslint *)",
"Bash(grep -E \"^C:\\\\\\\\\\\\\\\\| [0-9]+:[0-9]+ error\")",
"Bash(grep -E \"\\(\\\\\\\\\\\\\\\\.*\\\\.tsx$\\)|\\(error\\)\")",
"Bash(python -c \"import sys,json; data=json.load\\(sys.stdin\\); [print\\(m['ruleId'], m['line'], m['message'][:60]\\) for f in data for m in f['messages'] if m['severity']==2]\")",
"Bash(python -c \"import sys,json; data=json.load\\(sys.stdin\\); [print\\(m['ruleId'], m['line'], m['message'][:80]\\) for f in data for m in f['messages'] if m['severity']==2]\")",
"Bash(npx next *)",
"Bash(dir \"C:\\\\Users\\\\shats\\\\Dev\\\\MikrotikManager-3\\\\app\\\\\\(main\\)\")",
"Bash(Get-ChildItem \"C:\\\\Users\\\\shats\\\\Dev\\\\MikrotikManager-3\\\\app\\\\\\(main\\)\" -Directory)",
"Bash(Select-Object Name)",
"Bash(Sort-Object Name)",
"Bash(awk 'NR>=508 && NR<=730' \"app/\\(main\\)/servers/page.tsx\")",
"Bash(grep -n \"Fragment\\\\|<>\\\\s*$\" \"app/\\(main\\)/filters/page.tsx\")",
"Bash(grep -n \"\\\\.map\\\\|Fragment\\\\|<>\\\\s*$\" \"app/\\(main\\)/ospf/page.tsx\")",
"Bash(grep -v \"//.*console\\\\|ErrorRecord\\\\|console\\\\.error.*err\\\\|any>\\\\|: any\\\\|as any\\\\|any\\\\[\\\\]\\\\|Record<.*any\\\\|Promise<any\")",
"Bash(curl -s http://localhost:8000/api/servers)",
"Bash(curl -s -X POST http://localhost:8000/api/servers/1/poll)",
"Bash(curl -s http://localhost:8000/api/servers/1)",
"Bash(curl -s http://localhost:8000/api/servers/1/snapshots)",
"Bash(curl -s -X DELETE http://localhost:8000/api/servers/1 -w \"\\\\nHTTP %{http_code}\")",
"Bash(curl -s http://localhost:8000/api/servers/999)",
"Bash(curl -s -X POST http://localhost:8000/api/servers -H \"Content-Type: application/json\" -d \"{}\")",
"Bash(curl -s -X POST http://localhost:8000/api/servers/test-connection -H 'Content-Type: application/json' -d '{\"host\":\"192.168.0.254\",\"port\":80,\"useSsl\":false,\"verifySsl\":false,\"apiPath\":\"/rest\",\"username\":\"admin\",\"password\":\"\"}')",
"Bash(curl -s -X POST http://localhost:8000/api/servers -H 'Content-Type: application/json' -d '{\"host\":\"192.168.0.254\",\"port\":80,\"useSsl\":false,\"verifySsl\":false,\"username\":\"admin\",\"password\":\"test123\",\"name\":\"RB5009 Home\",\"type\":\"home-router\",\"site\":\"MSK\",\"country\":\"RU\",\"asn\":\"\",\"comment\":\"Тестовый роутер\",\"enabled\":true}')",
"Bash(python3 -m json.tool)",
"Bash(curl -s -X POST http://localhost:8000/api/servers -H 'Content-Type: application/json' -d '{\"host\":\"192.168.0.254\",\"port\":80,\"useSsl\":false,\"verifySsl\":false,\"username\":\"admin\",\"password\":\"test123\",\"name\":\"RB5009 Home\",\"type\":\"home-router\",\"site\":\"MSK\",\"country\":\"RU\"}')",
"Bash(node -e \"const d=require\\('fs'\\).readFileSync\\('/dev/stdin','utf8'\\); const a=JSON.parse\\(d\\); a.forEach\\(s=>console.log\\(s.id, s.host, s.name\\)\\)\")",
"Bash(curl -s http://localhost:8000/api/bgp/sessions)",
"Bash(curl -s \"http://localhost:8000/api/bgp/sessions/raw/3\")",
"Bash(node -e \"0\")",
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://vpsville.msk.rt.shx.su/rest/routing/ospf/neighbor\")",
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://vpsville.msk.rt.shx.su/rest/routing/ospf/instance\")",
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://vpsville.msk.rt.shx.su/rest/routing/ospf/area\")",
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://vpsville.msk.rt.shx.su/rest/routing/ospf/interface-template\")",
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://vpsville.msk.rt.shx.su/rest/ip/route?.type=ospf\")",
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://vpsville.msk.rt.shx.su/rest/ip/route\")",
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://192.168.0.254/rest/routing/ospf/neighbor\")",
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://192.168.0.254/rest/routing/ospf/instance\")",
"Bash(curl -s http://localhost:8000/api/ospf/all)",
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://192.168.0.254/rest/routing/bfd/session\")",
"Bash(curl -su \"admin:Ifwrbqifwrbq1995\" \"http://vpsville.msk.rt.shx.su/rest/routing/bfd/session\")",
"Bash(node -e \"const j=JSON.parse\\(require\\('fs'\\).readFileSync\\(0,'utf8'\\)\\); console.log\\('bfd:', JSON.stringify\\(j.bfdSessions, null, 2\\)\\)\")"
]
}
}
+1
View File
@@ -0,0 +1 @@
node_modules
+823
View File
@@ -0,0 +1,823 @@
"use client"
import { useState, useMemo } from "react"
import { PageHeader } from "@/components/page-header"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter,
} from "@/components/ui/sheet"
import {
BellIcon, BellOffIcon, RefreshCwIcon, PlusIcon,
CableIcon, NetworkIcon, RouteIcon, UserIcon, ServerIcon,
TimerIcon, WifiOffIcon, TrendingDownIcon,
EyeIcon, EyeOffIcon, CopyIcon, SendIcon, CheckIcon, XIcon,
Trash2Icon,
} from "lucide-react"
import { cn } from "@/lib/utils"
// ─── types ────────────────────────────────────────────────────────────────────
type AlertType = "gre-tunnel" | "bgp-peer" | "bgp-prefix" | "gre-client" | "server" | "rtt" | "loss" | "traffic"
type AlertSeverity = "critical" | "warning" | "info"
type AlertCooldown = "1м" | "5м" | "15м" | "1ч" | "4ч" | "24ч"
interface AlertRule {
id: string
name: string
type: AlertType
target: string // display label
condition: string // display label
severity: AlertSeverity
enabled: boolean
cooldown: AlertCooldown
lastFired: string | null // relative time string
chatId: string // override; empty = use global
}
interface HistoryEntry {
id: string
ruleName: string
severity: AlertSeverity
message: string
time: string
sent: boolean
}
interface TelegramConfig {
token: string
chatId: string
connected: boolean
}
// ─── static config ────────────────────────────────────────────────────────────
const TYPE_META: Record<AlertType, {
Icon: React.FC<{ className?: string }>
label: string
iconClass: string
bg: string
}> = {
"gre-tunnel": { Icon: CableIcon, label: "GRE-туннель", iconClass: "text-violet-500", bg: "bg-violet-500/10" },
"bgp-peer": { Icon: NetworkIcon, label: "BGP-сосед", iconClass: "text-blue-500", bg: "bg-blue-500/10" },
"bgp-prefix": { Icon: RouteIcon, label: "BGP-префикс", iconClass: "text-sky-500", bg: "bg-sky-500/10" },
"gre-client": { Icon: UserIcon, label: "GRE-клиент", iconClass: "text-purple-500", bg: "bg-purple-500/10" },
"server": { Icon: ServerIcon, label: "Сервер", iconClass: "text-slate-500", bg: "bg-slate-500/10" },
"rtt": { Icon: TimerIcon, label: "Задержка (RTT)", iconClass: "text-amber-500", bg: "bg-amber-500/10" },
"loss": { Icon: WifiOffIcon, label: "Потери пакетов", iconClass: "text-orange-500", bg: "bg-orange-500/10" },
"traffic": { Icon: TrendingDownIcon, label: "Низкий трафик", iconClass: "text-rose-500", bg: "bg-rose-500/10" },
}
const SEVERITY_META: Record<AlertSeverity, { label: string; dot: string; badge: string; chip: string }> = {
critical: {
label: "Критическое",
dot: "bg-red-500",
badge: "bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-400",
chip: "border-red-400 bg-red-500/10 text-red-600 dark:text-red-400",
},
warning: {
label: "Предупреждение",
dot: "bg-amber-400",
badge: "bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-400",
chip: "border-amber-400 bg-amber-500/10 text-amber-600 dark:text-amber-400",
},
info: {
label: "Информационное",
dot: "bg-blue-500",
badge: "bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-400",
chip: "border-blue-400 bg-blue-500/10 text-blue-600 dark:text-blue-400",
},
}
const TYPE_TARGETS: Record<AlertType, string[]> = {
"gre-tunnel": ["gre-ivanov-01 / core-01", "gre-ivanov-01 / lab-01", "gre-ivanov-02 / core-01", "gre-ivanov-03 / lab-01", "gre-petrov-01 / core-01", "gre-petrov-01 / lab-01", "gre-kozlov-01 / core-01"],
"bgp-peer": ["mt-msk-core-01", "mt-spb-edge-01", "mt-fra-edge-01", "mt-ams-edge-01", "mt-sgp-edge-01", "mt-ams-test-01", "mt-msk-lab-01"],
"bgp-prefix": ["любой префикс", "185.13.0.0/22", "77.88.8.0/24", "8.8.8.0/24", "1.1.1.0/24"],
"gre-client": ["gre-ivanov-01", "gre-ivanov-02", "gre-ivanov-03", "gre-petrov-01", "gre-kozlov-01"],
"server": ["mt-msk-core-01", "mt-spb-edge-01", "mt-fra-edge-01", "mt-ams-edge-01", "mt-sgp-edge-01", "mt-ams-test-01", "mt-msk-lab-01"],
"rtt": ["mt-msk-core-01 → 8.8.8.8", "mt-spb-edge-01 → 8.8.8.8", "mt-fra-edge-01 → 8.8.8.8", "mt-ams-edge-01 → 1.1.1.1", "mt-sgp-edge-01 → 1.1.1.1"],
"loss": ["mt-msk-core-01 → 8.8.8.8", "mt-spb-edge-01 → 8.8.8.8", "mt-fra-edge-01 → 8.8.8.8", "mt-ams-edge-01 → 1.1.1.1", "mt-sgp-edge-01 → 1.1.1.1"],
"traffic": ["mt-msk-core-01", "mt-spb-edge-01", "mt-fra-edge-01", "mt-ams-edge-01", "mt-sgp-edge-01", "mt-ams-test-01", "mt-msk-lab-01"],
}
const TYPE_CONDITIONS: Record<AlertType, string[]> = {
"gre-tunnel": ["перешёл в offline", "восстановился"],
"bgp-peer": ["разорвал сессию", "восстановил сессию"],
"bgp-prefix": ["был отозван", "был получен"],
"gre-client": ["отключился", "подключился"],
"server": ["перешёл в offline", "перешёл в degraded", "восстановился"],
"rtt": ["> порога", "< порога"],
"loss": ["> порога"],
"traffic": ["RX < порога", "TX < порога"],
}
const THRESHOLD_UNIT: Partial<Record<AlertType, string>> = {
rtt: "мс",
loss: "%",
traffic: "Мбит/с",
}
const COOLDOWNS: AlertCooldown[] = ["1м", "5м", "15м", "1ч", "4ч", "24ч"]
// ─── mock data ────────────────────────────────────────────────────────────────
const INIT_RULES: AlertRule[] = [
{ id: "r1", name: "GRE-туннель Иванов offline", type: "gre-tunnel", target: "gre-ivanov-01 / core-01", condition: "перешёл в offline", severity: "critical", enabled: true, cooldown: "5м", lastFired: "3ч назад", chatId: "" },
{ id: "r2", name: "BGP-сосед core-01 down", type: "bgp-peer", target: "mt-msk-core-01", condition: "разорвал сессию", severity: "critical", enabled: true, cooldown: "15м", lastFired: "1д назад", chatId: "" },
{ id: "r3", name: "Высокая задержка FRA", type: "rtt", target: "mt-fra-edge-01 → 8.8.8.8", condition: "> 120 мс", severity: "warning", enabled: true, cooldown: "15м", lastFired: "45м назад", chatId: "" },
{ id: "r4", name: "Потери пакетов AMS", type: "loss", target: "mt-ams-edge-01 → 1.1.1.1", condition: "> 5%", severity: "warning", enabled: true, cooldown: "5м", lastFired: null, chatId: "" },
{ id: "r5", name: "Сервер SGP offline", type: "server", target: "mt-sgp-edge-01", condition: "перешёл в offline", severity: "critical", enabled: true, cooldown: "1ч", lastFired: "2д назад", chatId: "" },
{ id: "r6", name: "BGP-префикс отозван core-01", type: "bgp-prefix", target: "любой префикс", condition: "был отозван", severity: "info", enabled: true, cooldown: "5м", lastFired: "6ч назад", chatId: "-1009876543210" },
{ id: "r7", name: "GRE-клиент Козлов offline", type: "gre-client", target: "gre-kozlov-01", condition: "отключился", severity: "warning", enabled: false, cooldown: "1ч", lastFired: null, chatId: "" },
{ id: "r8", name: "Низкий трафик AMS-edge", type: "traffic", target: "mt-ams-edge-01", condition: "RX < 10 Мбит/с", severity: "info", enabled: false, cooldown: "4ч", lastFired: null, chatId: "" },
]
const INIT_HISTORY: HistoryEntry[] = [
{ id: "h1", ruleName: "Высокая задержка FRA", severity: "warning", message: "RTT mt-fra-edge-01 → 8.8.8.8: 137 мс (порог 120 мс)", time: "45м назад", sent: true },
{ id: "h2", ruleName: "GRE-туннель Иванов offline", severity: "critical", message: "gre-ivanov-01 / core-01 перешёл в offline", time: "3ч назад", sent: true },
{ id: "h3", ruleName: "BGP-префикс отозван core-01", severity: "info", message: "Префикс 185.13.0.0/22 отозван на mt-msk-core-01", time: "6ч назад", sent: true },
{ id: "h4", ruleName: "BGP-сосед core-01 down", severity: "critical", message: "BGP-сессия с mt-msk-core-01 разорвана", time: "1д назад", sent: true },
{ id: "h5", ruleName: "Сервер SGP offline", severity: "critical", message: "mt-sgp-edge-01 недоступен, ping timeout", time: "2д назад", sent: false },
]
const INIT_TG: TelegramConfig = {
token: "7412358964:AAFkL9xZqBb2pC8nYrVtHmwKjXeOdSuN1A",
chatId: "-1001234567890",
connected: true,
}
// ─── small helpers ────────────────────────────────────────────────────────────
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button role="switch" aria-checked={checked} onClick={() => onChange(!checked)}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors",
checked ? "bg-primary" : "bg-muted-foreground/30",
)}>
<span className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform",
checked ? "translate-x-4" : "translate-x-0.5",
)} />
</button>
)
}
function FieldLabel({ children }: { children: React.ReactNode }) {
return <p className="text-sm font-medium mb-1.5 leading-none">{children}</p>
}
function FieldHint({ children }: { children: React.ReactNode }) {
return <p className="text-xs text-muted-foreground mt-1.5">{children}</p>
}
function NativeSelect({ value, onChange, children, className }: {
value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string
}) {
return (
<select
value={value}
onChange={e => onChange(e.target.value)}
className={cn(
// exact same tokens as the project's Input component
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1",
"text-sm text-foreground transition-colors outline-none",
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
"dark:bg-input/30",
className,
)}
>
{children}
</select>
)
}
function SeverityDot({ severity }: { severity: AlertSeverity }) {
return <span className={cn("inline-block size-2 rounded-full shrink-0", SEVERITY_META[severity].dot)} />
}
function SeverityBadge({ severity }: { severity: AlertSeverity }) {
return (
<span className={cn("text-[10px] px-1.5 py-0.5 rounded-full font-medium whitespace-nowrap", SEVERITY_META[severity].badge)}>
{severity === "critical" ? "Критич." : severity === "warning" ? "Предупр." : "Инфо"}
</span>
)
}
// ─── alert rule row ───────────────────────────────────────────────────────────
function AlertRuleRow({ rule, onToggle, onDelete }: {
rule: AlertRule
onToggle: (id: string, enabled: boolean) => void
onDelete: (id: string) => void
}) {
const { Icon, iconClass, bg } = TYPE_META[rule.type]
return (
<div className={cn(
"flex items-center gap-3 px-4 py-3 hover:bg-muted/20 transition-colors group",
!rule.enabled && "opacity-55",
)}>
{/* toggle */}
<Toggle checked={rule.enabled} onChange={v => onToggle(rule.id, v)} />
{/* severity dot */}
<SeverityDot severity={rule.severity} />
{/* type icon */}
<div className={cn("size-7 rounded-md flex items-center justify-center shrink-0", bg)}>
<Icon className={cn("size-3.5", iconClass)} />
</div>
{/* name + target */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{rule.name}</p>
<p className="text-[11px] text-muted-foreground truncate">
{TYPE_META[rule.type].label} · <span className="font-mono">{rule.target}</span>
{" · "}{rule.condition}
</p>
</div>
{/* cooldown */}
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground shrink-0">
{rule.cooldown}
</span>
{/* chat override badge */}
{rule.chatId && (
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded border border-border text-muted-foreground hidden xl:inline shrink-0">
#{rule.chatId.slice(-6)}
</span>
)}
{/* last fired */}
<span className={cn(
"text-[11px] shrink-0 w-[80px] text-right",
rule.lastFired ? "text-muted-foreground" : "text-muted-foreground/40",
)}>
{rule.lastFired ?? "—"}
</span>
{/* severity badge */}
<div className="hidden lg:block shrink-0">
<SeverityBadge severity={rule.severity} />
</div>
{/* actions (visible on hover) */}
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button
onClick={() => onDelete(rule.id)}
className="size-7 rounded flex items-center justify-center text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors">
<Trash2Icon className="size-3.5" />
</button>
</div>
</div>
)
}
// ─── telegram config card ─────────────────────────────────────────────────────
function TelegramCard({ cfg, onChange }: {
cfg: TelegramConfig
onChange: (c: TelegramConfig) => void
}) {
const [showToken, setShowToken] = useState(false)
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<"ok" | "fail" | null>(null)
const handleTest = () => {
setTesting(true)
setTestResult(null)
setTimeout(() => {
setTesting(false)
setTestResult(cfg.connected ? "ok" : "fail")
setTimeout(() => setTestResult(null), 3000)
}, 1400)
}
const _maskedToken = cfg.token.replace(/:.+/, ":••••••••••••••••••••••••")
return (
<Card>
<CardHeader className="pb-3 pt-4 px-4">
<CardTitle className="text-sm flex items-center justify-between">
<span className="flex items-center gap-2">
<span className="text-base">✈️</span> Telegram
</span>
<span className={cn(
"flex items-center gap-1.5 text-[11px] font-normal px-2 py-0.5 rounded-full",
cfg.connected
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
: "bg-muted text-muted-foreground",
)}>
<span className={cn("size-1.5 rounded-full", cfg.connected ? "bg-emerald-500" : "bg-muted-foreground")} />
{cfg.connected ? "Подключён" : "Не настроен"}
</span>
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 space-y-3">
{/* bot token */}
<div>
<FieldLabel>Bot Token</FieldLabel>
<div className="flex gap-1.5">
<Input
type={showToken ? "text" : "password"}
value={cfg.token}
onChange={e => onChange({ ...cfg, token: e.target.value })}
className="text-xs font-mono h-8"
placeholder="1234567890:AAF..."
/>
<button
onClick={() => setShowToken(v => !v)}
className="size-8 rounded-md border border-input flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0">
{showToken ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
</button>
<button
onClick={() => navigator.clipboard.writeText(cfg.token)}
className="size-8 rounded-md border border-input flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0">
<CopyIcon className="size-3.5" />
</button>
</div>
</div>
{/* default chat id */}
<div>
<FieldLabel>Chat ID по умолчанию</FieldLabel>
<Input
value={cfg.chatId}
onChange={e => onChange({ ...cfg, chatId: e.target.value })}
className="text-xs font-mono h-8"
placeholder="-100xxxxxxxxxx"
/>
<p className="text-[10px] text-muted-foreground mt-1">
Для групп используйте отрицательный ID. Каждое правило может переопределить.
</p>
</div>
{/* test button */}
<Button
size="sm" variant="outline" className="w-full h-8 text-xs gap-2"
onClick={handleTest} disabled={testing}>
{testing ? (
<RefreshCwIcon className="size-3.5 animate-spin" />
) : testResult === "ok" ? (
<CheckIcon className="size-3.5 text-emerald-500" />
) : testResult === "fail" ? (
<XIcon className="size-3.5 text-destructive" />
) : (
<SendIcon className="size-3.5" />
)}
{testing ? "Отправка…"
: testResult === "ok" ? "Сообщение отправлено"
: testResult === "fail" ? "Ошибка отправки"
: "Отправить тестовое сообщение"}
</Button>
</CardContent>
</Card>
)
}
// ─── history card ─────────────────────────────────────────────────────────────
function HistoryCard({ entries }: { entries: HistoryEntry[] }) {
return (
<Card>
<CardHeader className="pb-2 pt-4 px-4">
<CardTitle className="text-sm">Журнал срабатываний</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4">
{entries.length === 0 ? (
<p className="text-xs text-muted-foreground py-2">Нет срабатываний</p>
) : (
<div className="flex flex-col gap-2.5">
{entries.map(e => (
<div key={e.id} className="flex gap-2.5 items-start">
<SeverityDot severity={e.severity} />
<div className="flex-1 min-w-0">
<p className="text-[11px] font-medium leading-tight truncate">{e.ruleName}</p>
<p className="text-[10px] text-muted-foreground leading-snug mt-0.5 line-clamp-2">{e.message}</p>
</div>
<div className="text-right shrink-0">
<p className="text-[10px] text-muted-foreground whitespace-nowrap">{e.time}</p>
<span className={cn(
"text-[9px] font-medium px-1 py-0.5 rounded mt-0.5 inline-block",
e.sent
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
: "bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-400",
)}>
{e.sent ? "✓ отправлено" : "✗ ошибка"}
</span>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
)
}
// ─── add rule sheet ───────────────────────────────────────────────────────────
const BLANK_FORM = {
name: "",
type: "gre-tunnel" as AlertType,
target: "",
condition: "",
threshold: "",
severity: "warning" as AlertSeverity,
cooldown: "5м" as AlertCooldown,
chatId: "",
}
function AddRuleSheet({ open, onClose, onSave }: {
open: boolean
onClose: () => void
onSave: (rule: Omit<AlertRule, "id" | "lastFired" | "enabled">) => void
}) {
const [form, setForm] = useState({ ...BLANK_FORM })
// reset when open
const handleOpen = (o: boolean) => { if (o) setForm({ ...BLANK_FORM }) }
const targets = TYPE_TARGETS[form.type]
const conditions = TYPE_CONDITIONS[form.type]
const unit = THRESHOLD_UNIT[form.type]
const conditionDisplay = unit && form.threshold
? `${form.condition.replace("порога", form.threshold + " " + unit)}`
: form.condition
const canSave = form.name.trim() && form.target && form.condition && (!unit || form.threshold)
const handleSave = () => {
if (!canSave) return
onSave({
name: form.name.trim(),
type: form.type,
target: form.target,
condition: conditionDisplay || form.condition,
severity: form.severity,
cooldown: form.cooldown,
chatId: form.chatId.trim(),
})
onClose()
}
return (
<Sheet open={open} onOpenChange={o => { if (!o) onClose(); handleOpen(o) }}>
{/* p-0 + gap-0: we control all spacing internally */}
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-md">
{/* ── fixed header ── */}
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
<SheetTitle className="flex items-center gap-2 text-base">
<BellIcon className="size-4" />
Новое правило оповещения
</SheetTitle>
</SheetHeader>
{/* ── scrollable body ── */}
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
{/* name */}
<div className="flex flex-col gap-1.5">
<FieldLabel>Название правила</FieldLabel>
<Input
placeholder="Например: GRE-туннель Иванов offline"
value={form.name}
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
/>
</div>
{/* type */}
<div className="flex flex-col gap-1.5">
<FieldLabel>Тип события</FieldLabel>
<NativeSelect
value={form.type}
onChange={v => setForm(f => ({ ...f, type: v as AlertType, target: "", condition: "", threshold: "" }))}
>
{(Object.entries(TYPE_META) as [AlertType, typeof TYPE_META[AlertType]][]).map(([t, m]) => (
<option key={t} value={t}>{m.label}</option>
))}
</NativeSelect>
</div>
{/* target + condition on same row when both are simple selects */}
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<FieldLabel>Объект</FieldLabel>
<NativeSelect value={form.target} onChange={v => setForm(f => ({ ...f, target: v }))}>
<option value="" disabled>— выбрать —</option>
{targets.map(t => <option key={t} value={t}>{t}</option>)}
</NativeSelect>
</div>
<div className="flex flex-col gap-1.5">
<FieldLabel>Условие</FieldLabel>
<NativeSelect value={form.condition} onChange={v => setForm(f => ({ ...f, condition: v }))}>
<option value="" disabled>— выбрать —</option>
{conditions.map(c => <option key={c} value={c}>{c}</option>)}
</NativeSelect>
</div>
</div>
{/* threshold — only for RTT / loss / traffic */}
{unit && (
<div className="flex flex-col gap-1.5">
<FieldLabel>Пороговое значение</FieldLabel>
<div className="flex items-center gap-0">
<Input
type="number" min="0"
value={form.threshold}
onChange={e => setForm(f => ({ ...f, threshold: e.target.value }))}
className="rounded-r-none"
placeholder="0"
/>
<span className="h-8 px-3 flex items-center rounded-r-lg border border-l-0 border-input
bg-muted text-sm text-muted-foreground shrink-0">
{unit}
</span>
</div>
</div>
)}
{/* severity — vertical radio cards */}
<div className="flex flex-col gap-1.5">
<FieldLabel>Серьёзность</FieldLabel>
<div className="flex flex-col gap-1.5">
{(["critical", "warning", "info"] as AlertSeverity[]).map(s => {
const active = form.severity === s
return (
<button key={s} onClick={() => setForm(f => ({ ...f, severity: s }))}
className={cn(
"flex items-center gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors w-full",
active ? SEVERITY_META[s].chip : "border-input hover:bg-muted/50 text-foreground",
)}>
<span className={cn("size-2.5 rounded-full shrink-0", SEVERITY_META[s].dot)} />
<div className="min-w-0">
<p className="text-sm font-medium leading-none">{SEVERITY_META[s].label}</p>
<p className="text-xs text-muted-foreground mt-0.5 leading-none">
{s === "critical" ? "Немедленное уведомление, звуковой сигнал"
: s === "warning" ? "Важное событие, тихое уведомление"
: "Информационное, без уведомления"}
</p>
</div>
{active && <CheckIcon className="size-4 ml-auto shrink-0 opacity-70" />}
</button>
)
})}
</div>
</div>
{/* cooldown */}
<div className="flex flex-col gap-1.5">
<FieldLabel>Повторять не чаще чем</FieldLabel>
<div className="flex gap-1.5 flex-wrap">
{COOLDOWNS.map(c => (
<button key={c} onClick={() => setForm(f => ({ ...f, cooldown: c }))}
className={cn(
"px-3 py-1.5 rounded-lg border text-sm font-mono transition-colors",
form.cooldown === c
? "border-primary bg-primary/10 text-primary font-semibold"
: "border-input text-muted-foreground hover:bg-muted/50 hover:text-foreground",
)}>
{c}
</button>
))}
</div>
</div>
{/* chat id override */}
<div className="flex flex-col gap-1.5">
<FieldLabel>Chat ID — переопределить</FieldLabel>
<Input
value={form.chatId}
onChange={e => setForm(f => ({ ...f, chatId: e.target.value }))}
className="font-mono text-sm"
placeholder="-100xxxxxxxxxx"
/>
<FieldHint>Пусто → используется глобальный Chat ID из настроек Telegram</FieldHint>
</div>
{/* Telegram message preview */}
{form.name && form.target && form.condition && (
<div className="rounded-xl border border-border bg-muted/40 overflow-hidden">
<div className="px-3 py-2 border-b border-border/60 flex items-center gap-1.5">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wide">
Предпросмотр сообщения
</span>
</div>
<div className="px-3 py-2.5 font-mono text-xs leading-relaxed space-y-0.5">
<p>
{form.severity === "critical" ? "🔴" : form.severity === "warning" ? "🟡" : "🔵"}
{" "}<span className="font-semibold">{form.name}</span>
</p>
<p className="text-muted-foreground">Объект: {form.target}</p>
<p className="text-muted-foreground">
Событие: {conditionDisplay || form.condition}
</p>
<p className="text-muted-foreground">Cooldown: {form.cooldown}</p>
<p className="text-muted-foreground">
Chat: {form.chatId || "(глобальный)"}
</p>
</div>
</div>
)}
</div>
{/* ── fixed footer ── */}
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
<Button variant="outline" className="flex-1" onClick={onClose}>Отмена</Button>
<Button className="flex-1" onClick={handleSave} disabled={!canSave}>
Сохранить правило
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
// ─── page ─────────────────────────────────────────────────────────────────────
type SeverityFilter = AlertSeverity | "all"
export default function AlertsPage() {
const [rules, setRules] = useState<AlertRule[]>(INIT_RULES)
const [history] = useState<HistoryEntry[]>(INIT_HISTORY)
const [tg, setTg] = useState<TelegramConfig>(INIT_TG)
const [sheetOpen, setSheetOpen] = useState(false)
const [sevFilter, setSevFilter] = useState<SeverityFilter>("all")
const [onlyActive, setOnlyActive] = useState(false)
const [search, setSearch] = useState("")
const total = rules.length
const active = rules.filter(r => r.enabled).length
const critical = rules.filter(r => r.severity === "critical").length
const warning = rules.filter(r => r.severity === "warning").length
const info = rules.filter(r => r.severity === "info").length
const filteredRules = useMemo(() => rules.filter(r => {
if (sevFilter !== "all" && r.severity !== sevFilter) return false
if (onlyActive && !r.enabled) return false
if (search && !r.name.toLowerCase().includes(search.toLowerCase()) &&
!r.target.toLowerCase().includes(search.toLowerCase())) return false
return true
}), [rules, sevFilter, onlyActive, search])
const handleToggle = (id: string, enabled: boolean) =>
setRules(rs => rs.map(r => r.id === id ? { ...r, enabled } : r))
const handleDelete = (id: string) =>
setRules(rs => rs.filter(r => r.id !== id))
const handleAdd = (rule: Omit<AlertRule, "id" | "lastFired" | "enabled">) => {
setRules(rs => [...rs, {
...rule,
id: `r${Date.now()}`,
enabled: true,
lastFired: null,
}])
}
// Summary chip click handler
const handleChip = (sev: SeverityFilter) =>
setSevFilter(f => f === sev ? "all" : sev)
const chipActive = (sev: SeverityFilter) => sevFilter === sev
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Система" }, { label: "Оповещения" }]}
actions={
<>
<Button variant="outline" size="sm">
<RefreshCwIcon className="size-4" />Обновить
</Button>
<Button size="sm" onClick={() => setSheetOpen(true)}>
<PlusIcon className="size-4" />Добавить правило
</Button>
</>
}
/>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
{/* ── summary chip bar ── */}
<div className="flex items-center gap-2 flex-wrap">
{/* stat chips */}
{([
{ key: "all", label: `Всего: ${total}`, cls: "border-border bg-muted/50 text-foreground" },
{ key: "critical", label: `Критических: ${critical}`, cls: SEVERITY_META.critical.chip },
{ key: "warning", label: `Предупреждений: ${warning}`, cls: SEVERITY_META.warning.chip },
{ key: "info", label: `Информационных: ${info}`, cls: SEVERITY_META.info.chip },
] as { key: SeverityFilter; label: string; cls: string }[]).map(({ key, label, cls }) => (
<button key={key} onClick={() => handleChip(key)}
className={cn(
"text-xs px-3 py-1.5 rounded-full border font-medium transition-colors",
chipActive(key)
? cls
: "border-border bg-muted/40 text-muted-foreground hover:bg-muted",
)}>
{label}
</button>
))}
<div className="w-px h-4 bg-border mx-1" />
{/* active count chip */}
<button onClick={() => setOnlyActive(v => !v)}
className={cn(
"text-xs px-3 py-1.5 rounded-full border font-medium transition-colors",
onlyActive
? "border-emerald-400 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "border-border bg-muted/40 text-muted-foreground hover:bg-muted",
)}>
Активных: {active}
</button>
{/* telegram status */}
<span className={cn(
"ml-auto text-xs px-3 py-1.5 rounded-full border flex items-center gap-1.5 font-medium",
tg.connected
? "border-emerald-400 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "border-red-400 bg-red-500/10 text-red-600 dark:text-red-400",
)}>
<span className={cn("size-1.5 rounded-full", tg.connected ? "bg-emerald-500" : "bg-red-500")} />
{tg.connected ? "Telegram: Подключён" : "Telegram: Не настроен"}
</span>
</div>
{/* ── main layout ── */}
<div className="grid grid-cols-1 xl:grid-cols-[1fr_300px] gap-5 items-start">
{/* ── rules card ── */}
<Card>
<CardHeader className="pb-0 pt-4 px-4">
<div className="flex items-center justify-between gap-3">
<CardTitle className="text-sm">Правила оповещения</CardTitle>
<Input
placeholder="Поиск…"
value={search}
onChange={e => setSearch(e.target.value)}
className="h-7 text-xs max-w-[200px]"
/>
</div>
</CardHeader>
<CardContent className="px-0 pb-0 pt-2">
{filteredRules.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground/40">
<BellOffIcon className="size-8 mb-2 opacity-40" />
<p className="text-sm">Правила не найдены</p>
</div>
) : (
<div className="divide-y divide-border/60">
{filteredRules.map(r => (
<AlertRuleRow
key={r.id}
rule={r}
onToggle={handleToggle}
onDelete={handleDelete}
/>
))}
</div>
)}
{/* footer */}
<div className="px-4 py-2.5 border-t flex items-center justify-between text-[11px] text-muted-foreground">
<span>
{filteredRules.length} из {total} правил
{onlyActive && " · только активные"}
{sevFilter !== "all" && ` · ${SEVERITY_META[sevFilter].label.toLowerCase()}`}
</span>
<button
onClick={() => setSheetOpen(true)}
className="flex items-center gap-1 hover:text-foreground transition-colors">
<PlusIcon className="size-3" />Добавить правило
</button>
</div>
</CardContent>
</Card>
{/* ── right sidebar ── */}
<div className="flex flex-col gap-5">
<TelegramCard cfg={tg} onChange={setTg} />
<HistoryCard entries={history} />
</div>
</div>
</div>
</div>
<AddRuleSheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
onSave={handleAdd}
/>
</div>
)
}
+84
View File
@@ -0,0 +1,84 @@
"use client"
import { PageHeader } from "@/components/page-header"
import { DataTable } from "@/components/data-table"
import { asns } from "@/lib/data"
import { Button } from "@/components/ui/button"
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon } from "lucide-react"
export default function AsnsPage() {
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Данные" }, { label: "ASN" }]}
actions={
<>
<Button variant="outline" size="sm"><UploadIcon className="size-4" />Импорт</Button>
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
<Button size="sm"><PlusIcon className="size-4" />Добавить ASN</Button>
</>
}
/>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">ASN</h1>
<p className="text-sm text-muted-foreground mt-1">
Автономные системы — источники маршрутов для BGP-фильтров
</p>
</div>
<DataTable
data={asns}
searchPlaceholder="Поиск по ASN, организации…"
searchKeys={["asn", "org", "country"]}
columns={[
{
key: "asn",
label: "ASN",
render: (d) => <span className="font-mono font-semibold">{d.asn}</span>,
},
{
key: "org",
label: "Организация",
render: (d) => <span className="font-medium">{d.org}</span>,
},
{
key: "country",
label: "Страна",
render: (d) => <span className="text-xs border border-border rounded px-2 py-0.5">{d.country}</span>,
},
{
key: "prefixes",
label: "Префиксов",
render: (d) => <span className="font-mono tabular-nums">{d.prefixes.toLocaleString("ru")}</span>,
},
{
key: "filter",
label: "Фильтр",
render: (d) => (
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
<FilterIcon className="size-3 text-muted-foreground" />{d.filter}
</span>
),
},
{
key: "updated",
label: "Обновлён",
render: (d) => <span className="text-xs text-muted-foreground">{d.updated}</span>,
},
{
key: "enabled",
label: "Статус",
render: (d) => (
<span className={`text-xs font-medium ${d.enabled ? "text-emerald-600" : "text-muted-foreground"}`}>
{d.enabled ? "Активен" : "Отключён"}
</span>
),
},
]}
/>
</div>
</div>
</div>
)
}
+680
View File
@@ -0,0 +1,680 @@
"use client"
import { useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { StatusBadge } from "@/components/status-badge"
import { backups as initialBackups, servers } from "@/lib/data"
import type { Backup } from "@/lib/data"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
import {
RefreshCwIcon, PlusIcon, DownloadIcon, Trash2Icon,
HardDriveIcon, ClockIcon, ServerIcon, CheckCircleIcon,
FolderIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
// ─── small UI helpers ─────────────────────────────────────────────────────────
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button type="button" role="switch" aria-checked={checked} onClick={() => onChange(!checked)}
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors ${checked ? "bg-primary" : "bg-input"}`}>
<span className={`pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${checked ? "translate-x-4" : "translate-x-0"}`} />
</button>
)
}
function SectionTitle({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
return (
<div className="flex items-center gap-2 pb-1">
<span className="text-muted-foreground">{icon}</span>
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
<div className="flex-1 h-px bg-border" />
</div>
)
}
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">{label}</label>
{children}
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
</div>
)
}
function SegmentedControl<T extends string>({ value, onChange, options }: {
value: T; onChange: (v: T) => void; options: { value: T; label: string }[]
}) {
return (
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5 w-fit">
{options.map((o) => (
<button key={o.value} type="button" onClick={() => onChange(o.value)}
className={`px-3 py-1 text-sm rounded transition-colors ${value === o.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>
{o.label}
</button>
))}
</div>
)
}
// ─── types ────────────────────────────────────────────────────────────────────
type PageTab = "history" | "settings"
type KindFilter = "all" | "auto" | "manual"
type BackupFreq = "daily" | "weekly" | "monthly"
type StorageType = "local" | "ftp" | "scp" | "smb"
type BackupFormat = "rsc" | "backup"
const WEEK_DAYS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]
// ─── defaults ─────────────────────────────────────────────────────────────────
const defaultSchedule = {
enabled: true,
frequency: "daily" as BackupFreq,
hour: 3,
minute: 0,
weekDay: 0,
monthDay: 1,
keepCount: 7,
format: "rsc" as BackupFormat,
}
const defaultStorage = {
type: "local" as StorageType,
localPath: "/var/backup/mikrotik",
host: "",
port: "",
username: "",
password: "",
remotePath: "/mikrotik-backups",
share: "backups",
showPassword: false,
}
// ════════════════════════════════════════════════════════════════════════════
export default function BackupsPage() {
// ── state ──────────────────────────────────────────────────────────────
const [tab, setTab] = useState<PageTab>("history")
const [backupList, setBackupList] = useState<Backup[]>(initialBackups)
const [kindFilter, setKindFilter] = useState<KindFilter>("all")
// Schedule
const [schedule, setSchedule] = useState(defaultSchedule)
const setSched = <K extends keyof typeof defaultSchedule>(k: K, v: (typeof defaultSchedule)[K]) =>
setSchedule((s) => ({ ...s, [k]: v }))
// Storage
const [storage, setStorage] = useState(defaultStorage)
const setStore = <K extends keyof typeof defaultStorage>(k: K, v: (typeof defaultStorage)[K]) =>
setStorage((s) => ({ ...s, [k]: v }))
// Server selection (all enabled by default)
const [selectedServers, setSelectedServers] = useState<Set<string>>(
new Set(servers.map((s) => s.id))
)
function toggleServer(id: string) {
setSelectedServers((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
}
// Settings saved flash
const [saved, setSaved] = useState(false)
function handleSave() {
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
// Manual backup sheet
const [manualOpen, setManualOpen] = useState(false)
const [manualServers, setManualServers] = useState<Set<string>>(new Set())
const [manualNotes, setManualNotes] = useState("")
function toggleManualServer(id: string) {
setManualServers((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
}
function handleManualBackup() {
const now = new Date()
const ts = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,"0")}-${String(now.getDate()).padStart(2,"0")}_${String(now.getHours()).padStart(2,"0")}-${String(now.getMinutes()).padStart(2,"0")}`
const newBackups: Backup[] = Array.from(manualServers).map((sid, i) => {
const sv = servers.find((s) => s.id === sid)!
return {
id: `b${Date.now()}${i}`,
server: sv.name,
filename: `${sv.name}_${ts}_manual.rsc`,
size: `${Math.floor(60 + Math.random()*80)} КБ`,
created: "Только что",
kind: "manual",
notes: manualNotes || "",
}
})
setBackupList((list) => [...newBackups, ...list])
setManualOpen(false)
setManualServers(new Set())
setManualNotes("")
setTab("history")
}
// Delete backup
function handleDelete(id: string) {
setBackupList((list) => list.filter((b) => b.id !== id))
}
// ── derived ────────────────────────────────────────────────────────────
const filtered = useMemo(() =>
backupList.filter((b) => kindFilter === "all" || b.kind === kindFilter),
[backupList, kindFilter]
)
const autoCount = backupList.filter((b) => b.kind === "auto").length
const manualCount = backupList.filter((b) => b.kind === "manual").length
const serverCount = new Set(backupList.map((b) => b.server)).size
// Schedule summary string
const schedSummary = (() => {
if (!schedule.enabled) return "Отключено"
const t = `${String(schedule.hour).padStart(2,"0")}:${String(schedule.minute).padStart(2,"0")}`
if (schedule.frequency === "daily") return `Каждый день в ${t}`
if (schedule.frequency === "weekly") return `Каждую неделю (${WEEK_DAYS[schedule.weekDay]}) в ${t}`
return `${schedule.monthDay}-го числа каждого месяца в ${t}`
})()
// Storage path summary
const pathSummary = storage.type === "local"
? storage.localPath || "/var/backup/mikrotik"
: `${storage.type.toUpperCase()}://${storage.host || "host"}${storage.remotePath || "/"}`
// ── render ─────────────────────────────────────────────────────────────
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Бэкапы" }]}
actions={
<>
<Button variant="outline" size="sm" onClick={() => { setManualServers(new Set(servers.map(s=>s.id))); setManualOpen(true) }}>
<RefreshCwIcon className="size-4" />Снять со всех
</Button>
<Button size="sm" onClick={() => { setManualServers(new Set()); setManualNotes(""); setManualOpen(true) }}>
<PlusIcon className="size-4" />Новый бэкап
</Button>
</>
}
/>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
{/* Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: "Всего бэкапов", value: backupList.length, icon: <HardDriveIcon className="size-4" /> },
{ label: "Авто", value: autoCount, icon: <ClockIcon className="size-4" /> },
{ label: "Вручную", value: manualCount, icon: <PlusIcon className="size-4" /> },
{ label: "Серверов охвачено",value: serverCount, icon: <ServerIcon className="size-4" /> },
].map((s) => (
<Card key={s.label}>
<CardContent className="px-5 py-4 flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
</div>
<span className="text-muted-foreground/40">{s.icon}</span>
</CardContent>
</Card>
))}
</div>
{/* Info bar */}
<div className="flex items-center gap-4 text-xs text-muted-foreground px-1">
<span className="flex items-center gap-1.5">
<ClockIcon className="size-3" />
{schedSummary}
</span>
<span className="h-3 w-px bg-border" />
<span className="flex items-center gap-1.5">
<FolderIcon className="size-3" />
{pathSummary}
</span>
<span className="h-3 w-px bg-border" />
<span className="flex items-center gap-1.5">
<ServerIcon className="size-3" />
{selectedServers.size} из {servers.length} серверов
</span>
<button onClick={() => setTab("settings")}
className="ml-auto text-xs text-primary hover:underline">
Изменить настройки →
</button>
</div>
{/* Tabs */}
<div className="flex items-center gap-1 border-b border-border">
{([
{ id: "history", label: "История бэкапов" },
{ id: "settings", label: "Настройки" },
] as { id: PageTab; label: string }[]).map((t) => (
<button key={t.id} onClick={() => setTab(t.id)}
className={`px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors ${tab === t.id ? "border-primary text-foreground" : "border-transparent text-muted-foreground hover:text-foreground"}`}>
{t.label}
</button>
))}
</div>
{/* ── История ──────────────────────────────────────────────────── */}
{tab === "history" && (
<Card>
<div className="flex items-center gap-3 px-5 py-3 border-b flex-wrap">
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
{([
{ value: "all", label: "Все", count: backupList.length },
{ value: "auto", label: "Авто", count: autoCount },
{ value: "manual", label: "Вручную", count: manualCount },
] as { value: KindFilter; label: string; count: number }[]).map((t) => (
<button key={t.value} onClick={() => setKindFilter(t.value)}
className={`flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors ${kindFilter === t.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>
{t.label}
<span className="text-xs tabular-nums opacity-60">{t.count}</span>
</button>
))}
</div>
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} бэкапов</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
<th className="text-left font-medium px-5 py-3">Файл</th>
<th className="text-left font-medium px-4 py-3">Сервер</th>
<th className="text-left font-medium px-4 py-3">Размер</th>
<th className="text-left font-medium px-4 py-3">Тип</th>
<th className="text-left font-medium px-4 py-3">Заметки</th>
<th className="text-left font-medium px-4 py-3">Создан</th>
<th className="w-28 px-3 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{filtered.length === 0 && (
<tr><td colSpan={7} className="px-5 py-10 text-center text-sm text-muted-foreground">Нет бэкапов</td></tr>
)}
{filtered.map((b) => (
<tr key={b.id} className="hover:bg-muted/40 transition-colors group">
<td className="px-5 py-3 font-mono text-xs font-medium">{b.filename}</td>
<td className="px-4 py-3 text-sm text-muted-foreground">{b.server}</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">{b.size}</td>
<td className="px-4 py-3">
<span className={cn("text-xs px-2 py-0.5 rounded border font-medium",
b.kind === "manual"
? "bg-blue-500/10 text-blue-400 border-blue-500/20"
: "bg-muted text-muted-foreground border-border"
)}>
{b.kind === "auto" ? "авто" : "вручную"}
</span>
</td>
<td className="px-4 py-3 text-xs text-muted-foreground max-w-[200px] truncate">{b.notes || "—"}</td>
<td className="px-4 py-3 text-xs text-muted-foreground">{b.created}</td>
<td className="px-3 py-3">
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button variant="ghost" size="icon" className="size-7" title="Скачать">
<DownloadIcon className="size-3.5" />
</Button>
<Button variant="ghost" size="icon" className="size-7" title="Восстановить">
<RefreshCwIcon className="size-3.5" />
</Button>
<Button variant="ghost" size="icon" className="size-7 text-destructive hover:text-destructive"
title="Удалить" onClick={() => handleDelete(b.id)}>
<Trash2Icon className="size-3.5" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
{/* ── Настройки ────────────────────────────────────────────────── */}
{tab === "settings" && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
{/* Расписание */}
<Card>
<CardContent className="px-5 py-5 flex flex-col gap-5">
<SectionTitle icon={<ClockIcon className="size-3.5" />}>Расписание</SectionTitle>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Автоматический бэкап</p>
<p className="text-xs text-muted-foreground mt-0.5">Создавать бэкапы по расписанию</p>
</div>
<Toggle checked={schedule.enabled} onChange={(v) => setSched("enabled", v)} />
</div>
<div className={cn("flex flex-col gap-4 transition-opacity", !schedule.enabled && "opacity-40 pointer-events-none")}>
<Field label="Частота">
<SegmentedControl
value={schedule.frequency}
onChange={(v) => setSched("frequency", v)}
options={[
{ value: "daily", label: "Ежедневно" },
{ value: "weekly", label: "Еженедельно" },
{ value: "monthly", label: "Ежемесячно" },
]}
/>
</Field>
{schedule.frequency === "weekly" && (
<Field label="День недели">
<div className="flex gap-1">
{WEEK_DAYS.map((d, i) => (
<button key={i} type="button" onClick={() => setSched("weekDay", i)}
className={cn(
"w-9 h-9 rounded text-sm font-medium border transition-colors",
schedule.weekDay === i
? "bg-primary text-primary-foreground border-primary"
: "border-border text-muted-foreground hover:text-foreground"
)}>
{d}
</button>
))}
</div>
</Field>
)}
{schedule.frequency === "monthly" && (
<Field label="День месяца" hint="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>
)
}
+979
View File
@@ -0,0 +1,979 @@
"use client"
import { Fragment, useState, useMemo, useEffect } from "react"
import { PageHeader } from "@/components/page-header"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import {
RefreshCwIcon, DownloadIcon, SearchIcon,
ActivityIcon, BarChart3Icon, ChevronRightIcon, ChevronDownIcon,
ArrowDownIcon, ClipboardCopyIcon, ServerIcon,
XIcon,
} from "lucide-react"
import { useDataSource } from "@/lib/data-source"
// ─── types ────────────────────────────────────────────────────────────────────
type BgpState = "Established" | "Active" | "Idle" | "Connect" | "OpenSent" | "OpenConfirm"
type BgpType = "eBGP" | "iBGP"
type BgpAfi = "IPv4 Unicast" | "IPv6 Unicast" | "VPNv4 Unicast"
type BgpTab = "sessions" | "routers" | "analytics"
type StateFilter = "all" | BgpState
type TypeFilter = "all" | BgpType
interface BgpSession {
id: string
serverId: string
serverLabel: string
serverSite: string
peerIp: string
remoteAs: number
localAs: number
routerId: string
description: string
state: BgpState
type: BgpType
afi: BgpAfi
uptime: string | null
holdTime: number
keepalive: number
prefixesRx: number
prefixesTx: number
prefixesActive: number
inputMessages: number
outputMessages: number
capabilities: string[]
lastError: string | null
}
// ─── AS name lookup ───────────────────────────────────────────────────────────
const AS_NAMES: Record<number, string> = {
8359: "МТС / Tele2",
13238: "Яндекс",
12389: "Ростелеком",
24940: "Hetzner",
6777: "AMS-IX",
1299: "Telia",
65001: "iBGP internal",
}
// ─── mock data ────────────────────────────────────────────────────────────────
const SESSIONS: BgpSession[] = [
// ── srv1 (mt-msk-core-01) ────────────────────────────────────────────
{
id: "s1", serverId: "srv1", serverLabel: "mt-msk-core-01", serverSite: "MSK",
peerIp: "185.1.1.1", remoteAs: 8359, localAs: 65001,
routerId: "185.1.1.1", description: "МТС — upstream transit",
state: "Established", type: "eBGP", afi: "IPv4 Unicast",
uptime: "14д 6ч 22м", holdTime: 90, keepalive: 30,
prefixesRx: 280241, prefixesTx: 42, prefixesActive: 277894,
inputMessages: 1842204, outputMessages: 14412,
capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP", "ADD-PATH"],
lastError: null,
},
{
id: "s2", serverId: "srv1", serverLabel: "mt-msk-core-01", serverSite: "MSK",
peerIp: "77.88.8.1", remoteAs: 13238, localAs: 65001,
routerId: "77.88.44.1", description: "Яндекс — IX peering",
state: "Established", type: "eBGP", afi: "IPv4 Unicast",
uptime: "12д 3ч 11м", holdTime: 90, keepalive: 30,
prefixesRx: 11840, prefixesTx: 42, prefixesActive: 11840,
inputMessages: 184220, outputMessages: 14100,
capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP"],
lastError: null,
},
{
id: "s3", serverId: "srv1", serverLabel: "mt-msk-core-01", serverSite: "MSK",
peerIp: "10.200.0.1", remoteAs: 65001, localAs: 65001,
routerId: "10.0.0.2", description: "iBGP → SPB-EDGE",
state: "Established", type: "iBGP", afi: "IPv4 Unicast",
uptime: "8д 14ч 5м", holdTime: 90, keepalive: 30,
prefixesRx: 620, prefixesTx: 280283, prefixesActive: 620,
inputMessages: 48210, outputMessages: 512800,
capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP", "Route-Target Constraint"],
lastError: null,
},
{
id: "s4", serverId: "srv1", serverLabel: "mt-msk-core-01", serverSite: "MSK",
peerIp: "10.200.1.1", remoteAs: 65001, localAs: 65001,
routerId: "10.0.0.3", description: "iBGP → FRA-EDGE",
state: "Established", type: "iBGP", afi: "IPv4 Unicast",
uptime: "12д 2ч 18м", holdTime: 90, keepalive: 30,
prefixesRx: 512, prefixesTx: 280283, prefixesActive: 512,
inputMessages: 41022, outputMessages: 488200,
capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP"],
lastError: null,
},
{
id: "s5", serverId: "srv1", serverLabel: "mt-msk-core-01", serverSite: "MSK",
peerIp: "10.200.2.1", remoteAs: 65001, localAs: 65001,
routerId: "10.0.0.4", description: "iBGP → AMS-EDGE",
state: "Active", type: "iBGP", afi: "IPv4 Unicast",
uptime: null, holdTime: 90, keepalive: 30,
prefixesRx: 0, prefixesTx: 0, prefixesActive: 0,
inputMessages: 0, outputMessages: 0,
capabilities: [],
lastError: "Hold timer expired",
},
// ── srv2 (mt-spb-edge-01) ────────────────────────────────────────────
{
id: "s6", serverId: "srv2", serverLabel: "mt-spb-edge-01", serverSite: "SPB",
peerIp: "195.54.55.1", remoteAs: 12389, localAs: 65001,
routerId: "195.54.55.1", description: "Ростелеком — upstream",
state: "Established", type: "eBGP", afi: "IPv4 Unicast",
uptime: "9д 1ч 44м", holdTime: 90, keepalive: 30,
prefixesRx: 192440, prefixesTx: 42, prefixesActive: 191002,
inputMessages: 982440, outputMessages: 12200,
capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP"],
lastError: null,
},
{
id: "s7", serverId: "srv2", serverLabel: "mt-spb-edge-01", serverSite: "SPB",
peerIp: "10.200.0.1", remoteAs: 65001, localAs: 65001,
routerId: "10.0.0.1", description: "iBGP → MSK-CORE",
state: "Established", type: "iBGP", afi: "IPv4 Unicast",
uptime: "8д 14ч 3м", holdTime: 90, keepalive: 30,
prefixesRx: 280283, prefixesTx: 620, prefixesActive: 277000,
inputMessages: 512800, outputMessages: 48210,
capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP", "Route-Target Constraint"],
lastError: null,
},
// ── srv3 (mt-fra-edge-01) ────────────────────────────────────────────
{
id: "s8", serverId: "srv3", serverLabel: "mt-fra-edge-01", serverSite: "FRA",
peerIp: "91.108.4.1", remoteAs: 24940, localAs: 65001,
routerId: "91.108.4.1", description: "Hetzner — upstream",
state: "Established", type: "eBGP", afi: "IPv4 Unicast",
uptime: "21д 7ч 12м", holdTime: 90, keepalive: 30,
prefixesRx: 218820, prefixesTx: 42, prefixesActive: 215400,
inputMessages: 1184200, outputMessages: 11800,
capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP", "Graceful Restart"],
lastError: null,
},
{
id: "s9", serverId: "srv3", serverLabel: "mt-fra-edge-01", serverSite: "FRA",
peerIp: "193.188.128.1", remoteAs: 6777, localAs: 65001,
routerId: "193.188.128.1", description: "AMS-IX — peering (idle)",
state: "Idle", type: "eBGP", afi: "IPv4 Unicast",
uptime: null, holdTime: 90, keepalive: 30,
prefixesRx: 0, prefixesTx: 0, prefixesActive: 0,
inputMessages: 821, outputMessages: 412,
capabilities: [],
lastError: "Administratively down",
},
{
id: "s10", serverId: "srv3", serverLabel: "mt-fra-edge-01", serverSite: "FRA",
peerIp: "10.200.1.1", remoteAs: 65001, localAs: 65001,
routerId: "10.0.0.1", description: "iBGP → MSK-CORE",
state: "Established", type: "iBGP", afi: "IPv4 Unicast",
uptime: "12д 2ч 14м", holdTime: 90, keepalive: 30,
prefixesRx: 280283, prefixesTx: 512, prefixesActive: 278100,
inputMessages: 488200, outputMessages: 41022,
capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP"],
lastError: null,
},
// ── srv6 (mt-ams-test-01) ────────────────────────────────────────────
{
id: "s11", serverId: "srv6", serverLabel: "mt-ams-test-01", serverSite: "AMS",
peerIp: "80.249.208.1", remoteAs: 6777, localAs: 65001,
routerId: "0.0.0.0", description: "AMS-IX — negotiating",
state: "OpenSent", type: "eBGP", afi: "IPv4 Unicast",
uptime: null, holdTime: 90, keepalive: 30,
prefixesRx: 0, prefixesTx: 0, prefixesActive: 0,
inputMessages: 2, outputMessages: 1,
capabilities: [],
lastError: null,
},
{
id: "s12", serverId: "srv6", serverLabel: "mt-ams-test-01", serverSite: "AMS",
peerIp: "10.200.5.1", remoteAs: 65001, localAs: 65001,
routerId: "10.0.0.1", description: "iBGP → MSK-CORE",
state: "Established", type: "iBGP", afi: "IPv4 Unicast",
uptime: "5д 9ч 17м", holdTime: 90, keepalive: 30,
prefixesRx: 280283, prefixesTx: 14, prefixesActive: 276000,
inputMessages: 184200, outputMessages: 4100,
capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP"],
lastError: null,
},
]
// ─── visual helpers ───────────────────────────────────────────────────────────
const STATE_STYLE: Record<BgpState, { bg: string; text: string; dot: string; label: string }> = {
Established: { bg: "bg-emerald-500/10", text: "text-emerald-600 dark:text-emerald-400", dot: "bg-emerald-500", label: "Established" },
Active: { bg: "bg-amber-500/10", text: "text-amber-600 dark:text-amber-400", dot: "bg-amber-500", label: "Active" },
Idle: { bg: "bg-slate-500/10", text: "text-slate-500 dark:text-slate-400", dot: "bg-slate-500", label: "Idle" },
Connect: { bg: "bg-blue-500/10", text: "text-blue-600 dark:text-blue-400", dot: "bg-blue-500", label: "Connect" },
OpenSent: { bg: "bg-violet-500/10", text: "text-violet-600 dark:text-violet-400", dot: "bg-violet-500", label: "OpenSent" },
OpenConfirm: { bg: "bg-violet-500/10", text: "text-violet-600 dark:text-violet-400", dot: "bg-violet-500", label: "OpenConfirm" },
}
const TYPE_STYLE: Record<BgpType, { bg: string; text: string }> = {
eBGP: { bg: "bg-blue-500/10", text: "text-blue-600 dark:text-blue-400" },
iBGP: { bg: "bg-purple-500/10", text: "text-purple-600 dark:text-purple-400" },
}
function StateBadge({ state }: { state: BgpState }) {
const s = STATE_STYLE[state]
return (
<span className={cn("inline-flex items-center gap-1.5 rounded border px-2 py-0.5 text-[11px] font-semibold",
s.bg, s.text, "border-current/20")}>
<span className={cn("size-1.5 rounded-full shrink-0", s.dot)} />
{s.label}
</span>
)
}
function TypeBadge({ type }: { type: BgpType }) {
const s = TYPE_STYLE[type]
return (
<span className={cn("inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-semibold",
s.bg, s.text, "border-current/20")}>
{type}
</span>
)
}
function CapChip({ cap }: { cap: string }) {
return (
<span className="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium border bg-muted/60 text-muted-foreground border-border/60">
{cap}
</span>
)
}
function fmtNum(n: number) {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`
return String(n)
}
function PrefixBar({ rx, tx, active }: { rx: number; tx: number; active: number }) {
const max = Math.max(rx, 1)
return (
<div className="flex flex-col gap-1.5 text-[10px] font-mono">
{[
{ label: "Получено", val: rx, color: "bg-emerald-500", w: rx / max },
{ label: "Активных", val: active, color: "bg-sky-500", w: active / max },
{ label: "Отправлено", val: tx, color: "bg-blue-400", w: Math.min(tx / max, 1) },
].map(r => (
<div key={r.label} className="flex items-center gap-2">
<span className="w-20 text-muted-foreground shrink-0">{r.label}</span>
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div className={cn("h-full rounded-full", r.color)}
style={{ width: `${Math.max(r.w * 100, r.val > 0 ? 2 : 0)}%` }} />
</div>
<span className="w-14 text-right tabular-nums">{fmtNum(r.val)}</span>
</div>
))}
</div>
)
}
// ─── RSC snippet ──────────────────────────────────────────────────────────────
function rscSnippet(s: BgpSession) {
return `/routing bgp connection\nadd name=peer-as${s.remoteAs} remote.address=${s.peerIp}/32 \\\n remote.as=${s.remoteAs} local.role=${s.type === "eBGP" ? "ebgp" : "ibgp"} \\\n output.filter-chain=export-filter input.filter=import-filter \\\n routing-table=main`
}
// ─── session expanded row ─────────────────────────────────────────────────────
function SessionDetail({ s }: { s: BgpSession }) {
const [copied, setCopied] = useState(false)
function copy() {
navigator.clipboard.writeText(rscSnippet(s)).then(() => {
setCopied(true); setTimeout(() => setCopied(false), 1800)
})
}
return (
<div className="px-4 pb-4 pt-2 bg-muted/20 border-t border-border/60">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
{[
{ label: "Router ID", value: s.routerId },
{ label: "Hold / KA", value: `${s.holdTime}s / ${s.keepalive}s` },
{ label: "AFI/SAFI", value: s.afi },
{ label: "Сообщения ↓/↑", value: `${fmtNum(s.inputMessages)} / ${fmtNum(s.outputMessages)}` },
].map(({ label, value }) => (
<div key={label}>
<p className="text-[10px] text-muted-foreground mb-0.5">{label}</p>
<p className="text-xs font-mono font-medium">{value}</p>
</div>
))}
</div>
{/* prefix bars */}
{s.state === "Established" && (
<div className="mb-4">
<p className="text-[10px] text-muted-foreground mb-2 uppercase tracking-wider font-semibold">Префиксы</p>
<PrefixBar rx={s.prefixesRx} tx={s.prefixesTx} active={s.prefixesActive} />
</div>
)}
{/* capabilities */}
{s.capabilities.length > 0 && (
<div className="mb-4">
<p className="text-[10px] text-muted-foreground mb-1.5 uppercase tracking-wider font-semibold">Capabilities</p>
<div className="flex flex-wrap gap-1.5">
{s.capabilities.map(c => <CapChip key={c} cap={c} />)}
</div>
</div>
)}
{/* last error */}
{s.lastError && (
<div className="mb-4 flex items-center gap-2 rounded-md border border-red-500/20 bg-red-500/5 px-3 py-2">
<span className="size-1.5 rounded-full bg-red-500 shrink-0" />
<p className="text-xs font-mono text-red-500">{s.lastError}</p>
</div>
)}
{/* rsc export */}
<div className="mt-2">
<p className="text-[10px] text-muted-foreground mb-1.5 uppercase tracking-wider font-semibold">RouterOS Export</p>
<div className="rounded-md bg-[#0a0f1a] border border-white/8 px-3 py-2.5 flex items-start justify-between gap-3">
<pre className="text-[10px] font-mono text-[#94a3b8] leading-relaxed whitespace-pre-wrap flex-1 min-w-0">
{rscSnippet(s)}
</pre>
<button onClick={copy}
className={cn(
"shrink-0 flex items-center gap-1 text-[10px] px-2 py-1 rounded border transition-colors",
copied
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-400"
: "border-white/10 text-white/40 hover:text-white/70 hover:border-white/20",
)}>
<ClipboardCopyIcon className="size-3" />
{copied ? "Скопировано" : "Копировать"}
</button>
</div>
</div>
</div>
)
}
// ─── backend mapping ──────────────────────────────────────────────────────────
interface BackendBgpSession {
id: string; serverId: number; serverName: string; serverSite: string; serverCountry: string
name: string; peerIp: string; remoteAs: number; localAs: number
localId: string; remoteId: string; state: string; type: "eBGP" | "iBGP"
uptime: string | null; holdTime: number; keepalive: number
prefixesRx: number; prefixesTx: number
inputMessages: number; outputMessages: number
capabilities: string[]; lastError: string | null
}
function backendToFrontend(b: BackendBgpSession): BgpSession {
return {
id: `${b.serverId}-${b.id}`,
serverId: String(b.serverId),
serverLabel: b.serverName,
serverSite: b.serverSite,
peerIp: b.peerIp,
remoteAs: b.remoteAs,
localAs: b.localAs,
routerId: b.remoteId || b.localId,
description: b.name,
state: (b.state as BgpState) || "Idle",
type: b.type,
afi: "IPv4 Unicast",
uptime: b.uptime,
holdTime: b.holdTime,
keepalive: b.keepalive,
prefixesRx: b.prefixesRx,
prefixesTx: b.prefixesTx,
prefixesActive: b.prefixesRx,
inputMessages: b.inputMessages,
outputMessages: b.outputMessages,
capabilities: b.capabilities,
lastError: b.lastError,
}
}
// ─── sessions tab ─────────────────────────────────────────────────────────────
const STATE_FILTERS: Array<{ value: StateFilter; label: string }> = [
{ value: "all", label: "Все" },
{ value: "Established", label: "Established" },
{ value: "Active", label: "Active" },
{ value: "Idle", label: "Idle" },
{ value: "OpenSent", label: "OpenSent" },
]
function SessionsTab({ sessions }: { sessions: BgpSession[] }) {
const [search, setSearch] = useState("")
const [stateFilter, setStateFilter] = useState<StateFilter>("all")
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
const [expandedId, setExpandedId] = useState<string | null>(null)
const q = search.toLowerCase()
const filtered = useMemo(() => sessions.filter(s => {
if (stateFilter !== "all" && s.state !== stateFilter) return false
if (typeFilter !== "all" && s.type !== typeFilter) return false
if (q && !s.peerIp.includes(q) && !s.description.toLowerCase().includes(q)
&& !s.serverLabel.includes(q) && !String(s.remoteAs).includes(q)
&& !(AS_NAMES[s.remoteAs] ?? "").toLowerCase().includes(q)) return false
return true
}), [sessions, q, stateFilter, typeFilter])
return (
<div className="flex flex-col gap-4">
{/* filter bar */}
<div className="flex items-center gap-2 flex-wrap">
{/* search */}
<div className="relative">
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<input
value={search} onChange={e => setSearch(e.target.value)}
placeholder="IP, AS, описание…"
className="h-8 pl-8 pr-3 w-52 text-xs bg-muted/50 border border-border rounded-md outline-none focus:border-primary transition-colors"
/>
{search && (
<button onClick={() => setSearch("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
<XIcon className="size-3" />
</button>
)}
</div>
{/* state filter */}
<div className="flex items-center gap-0.5 p-0.5 rounded-md border border-border bg-muted/40">
{STATE_FILTERS.map(f => (
<button key={f.value} onClick={() => setStateFilter(f.value)}
className={cn(
"px-2.5 py-1 text-[11px] rounded transition-colors whitespace-nowrap",
stateFilter === f.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
)}>
{f.label}
</button>
))}
</div>
{/* type filter */}
<div className="flex items-center gap-0.5 p-0.5 rounded-md border border-border bg-muted/40">
{(["all", "eBGP", "iBGP"] as const).map(t => (
<button key={t} onClick={() => setTypeFilter(t)}
className={cn(
"px-2.5 py-1 text-[11px] rounded transition-colors",
typeFilter === t ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
)}>
{t === "all" ? "Все типы" : t}
</button>
))}
</div>
<span className="text-xs text-muted-foreground ml-auto">
{filtered.length} из {sessions.length}
</span>
</div>
{/* table */}
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b bg-muted/40">
<th className="w-8" />
{["Роутер", "Peer IP", "Remote AS", "Описание", "Тип", "Состояние", "Uptime", "Prefixes ↓", "Prefixes ↑"].map(h => (
<th key={h} className="text-left px-3 py-2.5 font-medium text-muted-foreground whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{filtered.map(s => {
const isOpen = expandedId === s.id
return (
<Fragment key={s.id}>
<tr
onClick={() => setExpandedId(isOpen ? null : s.id)}
className={cn(
"cursor-pointer transition-colors",
isOpen ? "bg-muted/30" : "hover:bg-muted/20",
)}>
<td className="pl-3 py-2.5">
{isOpen
? <ChevronDownIcon className="size-3.5 text-muted-foreground" />
: <ChevronRightIcon className="size-3.5 text-muted-foreground" />}
</td>
<td className="px-3 py-2.5 font-mono whitespace-nowrap">{s.serverLabel}</td>
<td className="px-3 py-2.5 font-mono">{s.peerIp}</td>
<td className="px-3 py-2.5 font-mono">
<div className="flex items-center gap-1.5">
<span>AS{s.remoteAs}</span>
{AS_NAMES[s.remoteAs] && (
<span className="text-muted-foreground text-[10px]">{AS_NAMES[s.remoteAs]}</span>
)}
</div>
</td>
<td className="px-3 py-2.5 text-muted-foreground max-w-[180px] truncate">{s.description}</td>
<td className="px-3 py-2.5"><TypeBadge type={s.type} /></td>
<td className="px-3 py-2.5"><StateBadge state={s.state} /></td>
<td className="px-3 py-2.5 font-mono tabular-nums text-muted-foreground">
{s.uptime ?? "—"}
</td>
<td className="px-3 py-2.5 font-mono tabular-nums text-right">
{s.prefixesRx > 0
? <span className="text-emerald-600 dark:text-emerald-400">{fmtNum(s.prefixesRx)}</span>
: <span className="text-muted-foreground">—</span>}
</td>
<td className="px-3 py-2.5 font-mono tabular-nums text-right">
{s.prefixesTx > 0
? <span className="text-blue-500">{fmtNum(s.prefixesTx)}</span>
: <span className="text-muted-foreground">—</span>}
</td>
</tr>
{isOpen && (
<tr>
<td colSpan={10} className="p-0">
<SessionDetail s={s} />
</td>
</tr>
)}
</Fragment>
)
})}
{filtered.length === 0 && (
<tr>
<td colSpan={10} className="px-4 py-8 text-center text-sm text-muted-foreground">
Нет сессий по заданным фильтрам
</td>
</tr>
)}
</tbody>
</table>
</div>
</Card>
</div>
)
}
// ─── routers tab ──────────────────────────────────────────────────────────────
function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
const byRouter = useMemo(() => {
const map: Record<string, { label: string; site: string; sessions: BgpSession[] }> = {}
sessions.forEach(s => {
if (!map[s.serverId]) map[s.serverId] = { label: s.serverLabel, site: s.serverSite, sessions: [] }
map[s.serverId].sessions.push(s)
})
return Object.entries(map).map(([id, v]) => ({ id, ...v }))
}, [sessions])
return (
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
{byRouter.map(router => {
const estCnt = router.sessions.filter(s => s.state === "Established").length
const downCnt = router.sessions.length - estCnt
const totalRx = router.sessions.reduce((a, s) => a + s.prefixesRx, 0)
return (
<Card key={router.id} className="overflow-hidden gap-0 py-0">
{/* header */}
<div className="flex items-center gap-3 px-4 py-3 border-b">
<ServerIcon className="size-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium leading-none">{router.label}</p>
<p className="text-[11px] font-mono text-muted-foreground mt-0.5">{router.site} · AS65001</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{estCnt > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-emerald-500/10 text-emerald-500 border border-emerald-500/25 font-medium">
✓ {estCnt}
</span>
)}
{downCnt > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-500/10 text-amber-500 border border-amber-500/25 font-medium">
⚠ {downCnt}
</span>
)}
</div>
</div>
{/* session rows */}
<div className="divide-y divide-border/60">
{router.sessions.map(s => {
const ss = STATE_STYLE[s.state]
return (
<div key={s.id} className="flex items-center gap-3 px-4 py-2.5 hover:bg-muted/30 transition-colors">
<span className={cn("size-2 rounded-full shrink-0", ss.dot)} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-mono">{s.peerIp}</span>
<TypeBadge type={s.type} />
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-[10px] text-muted-foreground">AS{s.remoteAs}</span>
{AS_NAMES[s.remoteAs] && (
<span className="text-[10px] text-muted-foreground/60">· {AS_NAMES[s.remoteAs]}</span>
)}
</div>
</div>
<div className="shrink-0 text-right">
{s.prefixesRx > 0 && (
<p className="text-[10px] font-mono text-emerald-500 tabular-nums">
↓ {fmtNum(s.prefixesRx)}
</p>
)}
<p className={cn("text-[10px] font-semibold", ss.text)}>{s.state}</p>
</div>
</div>
)
})}
</div>
{/* footer summary */}
{totalRx > 0 && (
<div className="flex items-center gap-2 px-4 py-2 border-t bg-muted/20">
<ArrowDownIcon className="size-3 text-emerald-500" />
<span className="text-[11px] font-mono text-muted-foreground">
Всего получено: <span className="text-emerald-600 dark:text-emerald-400 font-semibold">{fmtNum(totalRx)}</span> префиксов
</span>
</div>
)}
</Card>
)
})}
</div>
)
}
// ─── analytics tab ────────────────────────────────────────────────────────────
function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
// top peers by prefixes received (eBGP only)
const topPeers = useMemo(() =>
[...sessions]
.filter(s => s.prefixesRx > 0)
.sort((a, b) => b.prefixesRx - a.prefixesRx)
.slice(0, 8),
[sessions]
)
const maxRx = topPeers[0]?.prefixesRx ?? 1
// state distribution
const stateCounts = useMemo(() => {
const map: Record<string, number> = {}
sessions.forEach(s => { map[s.state] = (map[s.state] ?? 0) + 1 })
return Object.entries(map).sort((a, b) => b[1] - a[1]) as [BgpState, number][]
}, [sessions])
// total prefix stats
const totalRx = sessions.reduce((a, s) => a + s.prefixesRx, 0)
const totalActive = sessions.reduce((a, s) => a + s.prefixesActive, 0)
const ebgpSessions = sessions.filter(s => s.type === "eBGP").length
const ibgpSessions = sessions.filter(s => s.type === "iBGP").length
return (
<div className="flex flex-col gap-5">
{/* summary row */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{[
{ label: "Всего префиксов", value: fmtNum(totalRx), color: "text-emerald-600 dark:text-emerald-400" },
{ label: "Активных маршрутов", value: fmtNum(totalActive), color: "text-sky-600 dark:text-sky-400" },
{ label: "eBGP сессий", value: ebgpSessions, color: "" },
{ label: "iBGP сессий", value: ibgpSessions, color: "" },
].map(s => (
<Card key={s.label}>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">{s.label}</p>
<p className={cn("text-2xl font-semibold tabular-nums mt-0.5", s.color)}>{s.value}</p>
</CardContent>
</Card>
))}
</div>
<div className="grid grid-cols-1 xl:grid-cols-[1fr_320px] gap-5">
{/* prefixes by peer — horizontal bar chart */}
<Card>
<CardContent className="pt-4 pb-4 px-5">
<p className="text-sm font-semibold mb-4">Топ-8 пиров по полученным префиксам</p>
<div className="flex flex-col gap-3">
{topPeers.map((s, i) => {
const pct = (s.prefixesRx / maxRx) * 100
const ss = STATE_STYLE[s.state]
return (
<div key={s.id} className="flex items-center gap-3">
<span className="text-[11px] font-mono text-muted-foreground w-4 tabular-nums text-right">
{i + 1}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1 gap-2">
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs font-mono truncate">{s.peerIp}</span>
<span className="text-[10px] text-muted-foreground shrink-0">AS{s.remoteAs}</span>
<TypeBadge type={s.type} />
</div>
<span className={cn("text-[10px] font-semibold shrink-0 tabular-nums font-mono", "text-emerald-600 dark:text-emerald-400")}>
{fmtNum(s.prefixesRx)}
</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className={cn("h-full rounded-full transition-all", ss.dot)}
style={{ width: `${pct}%`, opacity: s.state === "Established" ? 0.8 : 0.3 }}
/>
</div>
<p className="text-[10px] text-muted-foreground mt-0.5">{s.serverLabel}</p>
</div>
</div>
)
})}
{topPeers.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">Нет данных о префиксах</p>
)}
</div>
</CardContent>
</Card>
{/* right column */}
<div className="flex flex-col gap-4">
{/* state distribution */}
<Card>
<CardContent className="pt-4 pb-4 px-5">
<p className="text-sm font-semibold mb-3">Распределение состояний</p>
{stateCounts.length === 0 ? (
<p className="text-xs text-muted-foreground">Нет данных</p>
) : (
<div className="flex flex-col gap-2">
{stateCounts.map(([state, count]) => {
const ss = STATE_STYLE[state]
const pct = sessions.length > 0 ? (count / sessions.length) * 100 : 0
return (
<div key={state} className="flex items-center gap-2">
<span className={cn("size-2 rounded-full shrink-0", ss.dot)} />
<span className="text-xs w-24">{state}</span>
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
<div className={cn("h-full rounded-full", ss.dot)} style={{ width: `${pct}%`, opacity: 0.75 }} />
</div>
<span className="text-xs font-mono tabular-nums w-6 text-right">{count}</span>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
{/* eBGP vs iBGP */}
<Card>
<CardContent className="pt-4 pb-4 px-5">
<p className="text-sm font-semibold mb-3">eBGP vs iBGP</p>
{sessions.length === 0 ? (
<p className="text-xs text-muted-foreground">Нет данных</p>
) : (
<>
{[
{ type: "eBGP" as BgpType, label: "Внешние (eBGP)", count: ebgpSessions },
{ type: "iBGP" as BgpType, label: "Внутренние (iBGP)", count: ibgpSessions },
].map(({ type, count }) => {
const pct = sessions.length > 0 ? (count / sessions.length) * 100 : 0
return (
<div key={type} className="flex items-center gap-2 mb-2 last:mb-0">
<TypeBadge type={type} />
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
<div className={cn("h-full rounded-full", type === "eBGP" ? "bg-blue-500" : "bg-purple-500")}
style={{ width: `${pct}%`, opacity: 0.75 }} />
</div>
<span className="text-xs font-mono w-4 text-right">{count}</span>
</div>
)
})}
{/* active AS list */}
<div className="mt-4 pt-3 border-t border-border/60">
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-2 font-semibold">Автономные системы</p>
<div className="flex flex-col gap-1">
{Object.entries(
sessions.filter(s => s.type === "eBGP").reduce<Record<number, number>>((acc, s) => {
acc[s.remoteAs] = (acc[s.remoteAs] ?? 0) + 1; return acc
}, {})
).sort((a, b) => b[1] - a[1]).map(([as, cnt]) => (
<div key={as} className="flex items-center justify-between text-[11px]">
<span className="font-mono text-muted-foreground">AS{as}</span>
<span className="text-muted-foreground/60">{AS_NAMES[Number(as)] ?? ""}</span>
<span className="font-mono tabular-nums">{cnt}</span>
</div>
))}
</div>
</div>
</>
)}
</CardContent>
</Card>
</div>
</div>
</div>
)
}
// ─── page ─────────────────────────────────────────────────────────────────────
const TABS: Array<{ id: BgpTab; label: string; icon: React.ReactNode }> = [
{ id: "sessions", label: "Сессии", icon: <ActivityIcon className="size-3.5" /> },
{ id: "routers", label: "По роутерам", icon: <ServerIcon className="size-3.5" /> },
{ id: "analytics", label: "Аналитика", icon: <BarChart3Icon className="size-3.5" /> },
]
export default function BgpPage() {
const [activeTab, setActiveTab] = useState<BgpTab>("sessions")
const { mode, backendUrl, backendStatus } = useDataSource()
const isLive = mode === "live" && backendStatus === true
const [liveSessions, setLiveSessions] = useState<BgpSession[]>([])
const [loading, setLoading] = useState(false)
const [fetchedAt, setFetchedAt] = useState<Date | null>(null)
const [liveError, setLiveError] = useState<string | null>(null)
const [fetchTick, setFetchTick] = useState(0)
useEffect(() => {
if (!isLive) { setLiveSessions([]); return }
let cancelled = false
setLoading(true)
setLiveError(null)
fetch(`${backendUrl}/api/bgp/sessions`)
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`)
return r.json() as Promise<BackendBgpSession[]>
})
.then(data => {
if (cancelled) return
setLiveSessions(data.map(backendToFrontend))
setFetchedAt(new Date())
setLoading(false)
})
.catch((err: unknown) => {
if (cancelled) return
setLiveError(err instanceof Error ? err.message : String(err))
setLoading(false)
})
return () => { cancelled = true }
}, [isLive, backendUrl, fetchTick])
// Use live or mock data for all tabs and KPI
const sessions = isLive ? liveSessions : SESSIONS
const established = sessions.filter(s => s.state === "Established").length
const notEstab = sessions.length - established
const totalRx = sessions.reduce((a, s) => a + s.prefixesRx, 0)
const serverCount = useMemo(
() => new Set(liveSessions.map(s => s.serverId)).size,
[liveSessions],
)
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "BGP" }]}
actions={
<>
<Button variant="outline" size="sm" onClick={() => setFetchTick(t => t + 1)}>
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
Обновить
</Button>
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
</>
}
/>
{/* tab bar */}
<div className="border-b bg-background shrink-0">
<div className="flex items-center px-6">
{TABS.map(t => (
<button key={t.id} onClick={() => setActiveTab(t.id)}
className={cn(
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === t.id
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
)}>
{t.icon}{t.label}
</button>
))}
</div>
</div>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
{/* data source banner */}
{isLive && loading && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<RefreshCwIcon className="size-3.5 animate-spin" />
Загрузка BGP-сессий…
</div>
)}
{isLive && !loading && fetchedAt && !liveError && (
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1.5 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2.5 py-0.5 text-[11px] font-medium text-emerald-600 dark:text-emerald-400">
<span className="size-1.5 rounded-full bg-emerald-500" />
Живые данные · {serverCount} серверов · обновлено {fetchedAt.toLocaleTimeString("ru")}
</span>
<button
onClick={() => setFetchTick(t => t + 1)}
className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground transition-colors">
<RefreshCwIcon className="size-3" />
Обновить
</button>
</div>
)}
{isLive && liveError && (
<div className="flex items-center gap-2 rounded-md border border-amber-500/30 bg-amber-500/8 px-3 py-2">
<span className="size-1.5 rounded-full bg-amber-500 shrink-0" />
<p className="text-xs text-amber-600 dark:text-amber-400">Ошибка загрузки: {liveError}</p>
</div>
)}
{isLive && !loading && liveSessions.length === 0 && !liveError && fetchedAt && (
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
BGP не настроен ни на одном сервере
</div>
)}
{mode === "mock" && (
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
Моковые данные
</span>
)}
{/* KPI strip */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{[
{ label: "Сессий всего", value: sessions.length, color: "" },
{ label: "Established", value: established, color: "text-emerald-600 dark:text-emerald-400" },
{ label: "Не установлено", value: notEstab, color: notEstab > 0 ? "text-amber-500" : "text-muted-foreground" },
{ label: "Получено префиксов", value: fmtNum(totalRx),color: "" },
].map(s => (
<Card key={s.label}>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">{s.label}</p>
<p className={cn("text-2xl font-semibold tabular-nums mt-0.5", s.color)}>{s.value}</p>
</CardContent>
</Card>
))}
</div>
{/* alert: not-established sessions */}
{notEstab > 0 && (
<div className="flex items-center gap-2.5 rounded-lg border border-amber-500/30 bg-amber-500/8 px-4 py-2.5">
<span className="size-2 rounded-full bg-amber-500 shrink-0" />
<p className="text-sm text-amber-600 dark:text-amber-400">
<span className="font-semibold">{notEstab} сессии</span> не в состоянии Established —
проверьте {sessions.filter(s => s.state !== "Established").map(s => s.peerIp).join(", ")}
</p>
</div>
)}
{/* tab content */}
{activeTab === "sessions" && <SessionsTab sessions={sessions} />}
{activeTab === "routers" && <RoutersTab sessions={sessions} />}
{activeTab === "analytics" && <AnalyticsTab sessions={sessions} />}
</div>
</div>
</div>
)
}
+397
View File
@@ -0,0 +1,397 @@
"use client"
import { useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { routerCertificates, servers } from "@/lib/data"
import type { RouterCertificate, CertStatus } from "@/lib/data"
import { Flag } from "@/components/flag"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import {
SearchIcon, ShieldCheckIcon, ShieldAlertIcon, ShieldOffIcon,
BadgeCheckIcon, AlertTriangleIcon, AlertCircleIcon,
CalendarIcon, KeyRoundIcon, ServerIcon, PlusIcon,
ChevronDownIcon, ChevronRightIcon,
} from "lucide-react"
// ─── helpers ──────────────────────────────────────────────────────────────────
const STATUS_CONFIG: Record<CertStatus, {
label: string; icon: React.ReactNode; badge: string; row: string
}> = {
valid: {
label: "Действителен",
icon: <BadgeCheckIcon className="size-4 text-emerald-500" />,
badge: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
row: "",
},
expired: {
label: "Истёк",
icon: <ShieldOffIcon className="size-4 text-red-500" />,
badge: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
row: "bg-red-500/5",
},
revoked: {
label: "Отозван",
icon: <ShieldAlertIcon className="size-4 text-amber-500" />,
badge: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
row: "bg-amber-500/5",
},
}
function daysLeftColor(days: number): string {
if (days < 0) return "text-red-500"
if (days <= 7) return "text-red-500"
if (days <= 30) return "text-amber-500"
return "text-emerald-600 dark:text-emerald-400"
}
function daysLeftBar(days: number, total = 365): number {
if (days <= 0) return 0
return Math.min(100, Math.round((days / total) * 100))
}
function serverForCert(cert: RouterCertificate) {
return servers.find((s) => s.id === cert.serverId)
}
// ─── Certificate row ──────────────────────────────────────────────────────────
function CertRow({
cert,
expanded,
onToggle,
}: {
cert: RouterCertificate
expanded: boolean
onToggle: () => void
}) {
const srv = serverForCert(cert)
const cfg = STATUS_CONFIG[cert.status]
const pct = daysLeftBar(cert.daysLeft)
return (
<div className={cn("border-b last:border-b-0", cfg.row)}>
<div
className="grid grid-cols-[20px_1fr_1fr_1fr_160px_auto_auto] gap-3 px-4 py-3 items-center hover:bg-muted/30 transition-colors cursor-pointer"
onClick={onToggle}
>
{/* expand */}
<button className="text-muted-foreground" onClick={(e) => { e.stopPropagation(); onToggle() }}>
{expanded
? <ChevronDownIcon className="size-3.5" />
: <ChevronRightIcon className="size-3.5" />}
</button>
{/* name */}
<div className="min-w-0">
<div className="flex items-center gap-2">
{cfg.icon}
<span className="font-medium text-sm truncate">{cert.name}</span>
</div>
<p className="text-xs text-muted-foreground font-mono mt-0.5 truncate">{cert.commonName}</p>
</div>
{/* server */}
<div className="flex items-center gap-1.5 text-xs text-muted-foreground min-w-0">
{srv ? <><Flag code={srv.country} size={12} /><span className="font-mono truncate">{srv.name}</span></> : <ServerIcon className="size-3.5" />}
</div>
{/* issued by */}
<p className="text-xs text-muted-foreground truncate">{cert.issuedBy}</p>
{/* days left */}
<div>
<div className="flex items-center justify-between text-xs mb-1">
<span className={cn("font-mono font-medium", daysLeftColor(cert.daysLeft))}>
{cert.daysLeft < 0 ? `Истёк ${-cert.daysLeft}д назад` : `${cert.daysLeft}д осталось`}
</span>
<span className="text-muted-foreground text-[10px]">{cert.validUntil}</span>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={cn("h-full rounded-full transition-all",
cert.daysLeft < 0 ? "bg-red-500" :
cert.daysLeft <= 7 ? "bg-red-500" :
cert.daysLeft <= 30 ? "bg-amber-500" :
"bg-emerald-500"
)}
style={{ width: `${pct}%` }}
/>
</div>
</div>
{/* usage badges */}
<div className="flex flex-wrap gap-1">
{cert.usage.map((u) => (
<span key={u} className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground border">
{u}
</span>
))}
</div>
{/* status */}
<span className={cn("text-[11px] font-mono px-2 py-0.5 rounded border whitespace-nowrap", cfg.badge)}>
{cfg.label}
</span>
</div>
{/* expanded detail */}
{expanded && (
<div className="px-10 pb-4 grid grid-cols-2 sm:grid-cols-4 gap-4 text-xs border-t border-border/50 pt-3">
<div>
<p className="text-muted-foreground mb-1">Key size</p>
<p className="font-mono font-medium">{cert.keySize} bit</p>
</div>
<div>
<p className="text-muted-foreground mb-1">Действителен с</p>
<p className="font-mono">{cert.validFrom}</p>
</div>
<div>
<p className="text-muted-foreground mb-1">SAN / Alt Names</p>
<div className="flex flex-wrap gap-1">
{cert.sans.length > 0
? cert.sans.map((s) => <span key={s} className="font-mono bg-muted px-1.5 py-0.5 rounded">{s}</span>)
: <span className="text-muted-foreground">—</span>}
</div>
</div>
<div>
<p className="text-muted-foreground mb-1">Trusted</p>
<p className={cert.trusted ? "text-emerald-600 dark:text-emerald-400" : "text-red-500"}>
{cert.trusted ? "Да (доверенный)" : "Нет (не доверенный)"}
</p>
</div>
</div>
)}
</div>
)
}
// ════════════════════════════════════════════════════════════════════════════
export default function CertificatesPage() {
const [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState<CertStatus | "all">("all")
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
const expiring = useMemo(
() => routerCertificates.filter((c) => c.status === "valid" && c.daysLeft >= 0 && c.daysLeft <= 30),
[],
)
const expired = useMemo(() => routerCertificates.filter((c) => c.status === "expired"), [])
const filtered = useMemo(() => {
return routerCertificates.filter((c) => {
if (statusFilter !== "all" && c.status !== statusFilter) return false
if (!search) return true
const q = search.toLowerCase()
return (
c.name.toLowerCase().includes(q) ||
c.commonName.toLowerCase().includes(q) ||
c.issuedBy.toLowerCase().includes(q) ||
c.sans.some((s) => s.includes(q))
)
})
}, [search, statusFilter])
function toggleExpand(id: string) {
setExpandedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
}
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Сертификаты" }]}
actions={
<Button size="sm">
<PlusIcon className="size-4" />Выпустить сертификат
</Button>
}
/>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
{/* Alerts */}
{(expiring.length > 0 || expired.length > 0) && (
<div className="flex flex-col gap-2">
{expired.length > 0 && (
<div className="flex items-start gap-3 rounded-lg bg-red-500/5 border border-red-500/20 px-4 py-3 text-sm">
<AlertCircleIcon className="size-5 text-red-500 shrink-0 mt-0.5" />
<div>
<p className="font-medium text-red-600 dark:text-red-400">
{expired.length} {expired.length === 1 ? "истёкший сертификат" : "истёкших сертификата"}
</p>
<p className="text-muted-foreground text-xs mt-0.5">
{expired.map((c) => c.name).join(", ")} — требуют обновления
</p>
</div>
</div>
)}
{expiring.length > 0 && (
<div className="flex items-start gap-3 rounded-lg bg-amber-500/5 border border-amber-500/20 px-4 py-3 text-sm">
<AlertTriangleIcon className="size-5 text-amber-500 shrink-0 mt-0.5" />
<div>
<p className="font-medium text-amber-600 dark:text-amber-400">
{expiring.length} {expiring.length === 1 ? "сертификат истекает" : "сертификата истекают"} в течение 30 дней
</p>
<p className="text-muted-foreground text-xs mt-0.5">
{expiring.map((c) => `${c.name} (${c.daysLeft}д)`).join(", ")}
</p>
</div>
</div>
)}
</div>
)}
{/* KPI */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: "Всего", value: routerCertificates.length, icon: <ShieldCheckIcon className="size-4 text-muted-foreground" /> },
{ label: "Действующих", value: routerCertificates.filter((c) => c.status === "valid").length, icon: <BadgeCheckIcon className="size-4 text-emerald-500" /> },
{ label: "Истекают", value: expiring.length, icon: <AlertTriangleIcon className="size-4 text-amber-500" /> },
{ label: "Истёкших", value: expired.length, icon: <AlertCircleIcon className="size-4 text-red-500" /> },
].map((s) => (
<Card key={s.label}>
<CardContent className="px-5 py-4 flex items-start justify-between">
<div>
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
</div>
<div className="mt-0.5">{s.icon}</div>
</CardContent>
</Card>
))}
</div>
{/* Table */}
<Card>
<div className="flex items-center gap-3 px-4 py-3 border-b flex-wrap">
{/* search */}
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[240px]">
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
<input
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
placeholder="Поиск по имени, CN, эмитенту…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{/* status filter */}
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
{(["all", "valid", "expired", "revoked"] as const).map((s) => (
<button key={s}
onClick={() => setStatusFilter(s)}
className={cn(
"px-3 py-1 text-xs rounded whitespace-nowrap transition-colors",
statusFilter === s
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}>
{s === "all" ? "Все" : s === "valid" ? "Действующие" : s === "expired" ? "Истёкшие" : "Отозванные"}
</button>
))}
</div>
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} сертификатов</span>
</div>
{/* table header */}
<div className="grid grid-cols-[20px_1fr_1fr_1fr_160px_auto_auto] gap-3 px-4 py-2 border-b text-[10px] font-semibold uppercase tracking-widest text-muted-foreground bg-muted/20">
<span />
<span>Имя / CN</span>
<span>Сервер</span>
<span>Выпущен</span>
<div className="flex items-center gap-1"><CalendarIcon className="size-3" />Срок</div>
<div className="flex items-center gap-1"><KeyRoundIcon className="size-3" />Использование</div>
<span>Статус</span>
</div>
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
<ShieldCheckIcon className="size-10 mb-3 opacity-20" />
<p className="text-sm font-medium">Сертификаты не найдены</p>
</div>
) : (
filtered.map((cert) => (
<CertRow
key={cert.id}
cert={cert}
expanded={expandedIds.has(cert.id)}
onToggle={() => toggleExpand(cert.id)}
/>
))
)}
</Card>
{/* RouterOS reference */}
<Card>
<CardContent className="px-5 py-4">
<p className="text-xs font-medium text-muted-foreground mb-3">
RouterOS 7 · /certificate — команды управления
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
{[
{
title: "Создать CA",
lines: [
"/certificate add \\",
" name=my-ca \\",
" common-name=MyCA \\",
" key-size=4096 \\",
" days-valid=3650 \\",
" key-usage=key-cert-sign,crl-sign",
"/certificate sign my-ca",
],
},
{
title: "Выпустить сертификат",
lines: [
"/certificate add \\",
" name=router-cert \\",
" common-name=router.example.com \\",
" subject-alt-name=\\",
" IP:10.0.0.1 \\",
" key-size=2048 days-valid=365",
"/certificate sign router-cert \\",
" ca=my-ca",
],
},
{
title: "Статус и экспорт",
lines: [
"# Список:",
"/certificate print",
"",
"# Экспорт (PKCS12):",
"/certificate export-certificate \\",
" router-cert \\",
" export-passphrase=secret",
"",
"# Импорт:",
"/certificate import \\",
" file-name=cert.crt",
],
},
].map((b) => (
<div key={b.title}>
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">{b.title}</p>
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto whitespace-pre">
{b.lines.join("\n")}
</pre>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
</div>
)
}
+356
View File
@@ -0,0 +1,356 @@
"use client"
import { useState, useMemo } from "react"
import { PageHeader } from "@/components/page-header"
import {
Card, CardContent, CardHeader, CardTitle, CardDescription,
} from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
PlusIcon, SearchIcon, TagIcon, ServerIcon, FilterIcon,
ChevronRightIcon, CopyIcon, CheckIcon, TrashIcon, PencilIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { filters } from "@/lib/data"
// ─── types ────────────────────────────────────────────────────────────────────
type CommType = "standard" | "no-export" | "no-advertise" | "local-as" | "custom"
interface Community {
id: string
value: string // e.g. "65001:100"
name: string
description: string
type: CommType
filterIds: string[] // which filters use this community
serverCount: number
prefixCount: number
action: "permit" | "deny" | "local-pref" | "metric"
actionValue?: number // e.g. local-pref value
enabled: boolean
}
// ─── mock data ────────────────────────────────────────────────────────────────
const COMMUNITIES: Community[] = [
{
id: "c1", value: "65001:100", name: "youtube-bypass",
description: "Пометка для трафика YouTube — маршрутизация через SPB/FRA exit nodes",
type: "standard", filterIds: ["f1"], serverCount: 4, prefixCount: 842,
action: "local-pref", actionValue: 200, enabled: true,
},
{
id: "c2", value: "65001:200", name: "streaming-eu",
description: "EU-стриминг (Netflix, Twitch) — выход через FRA/AMS",
type: "standard", filterIds: ["f1", "f2"], serverCount: 3, prefixCount: 614,
action: "local-pref", actionValue: 180, enabled: true,
},
{
id: "c3", value: "65001:300", name: "cdn-bypass",
description: "Обход CDN-провайдеров через прямые пиринговые IX-точки",
type: "standard", filterIds: ["f3"], serverCount: 5, prefixCount: 4218,
action: "local-pref", actionValue: 210, enabled: true,
},
{
id: "c4", value: "65002:100", name: "cdn-secondary",
description: "Резервный CDN-путь при деградации основного",
type: "standard", filterIds: ["f3"], serverCount: 5, prefixCount: 1842,
action: "metric", actionValue: 50, enabled: true,
},
{
id: "c5", value: "65001:400", name: "office-direct",
description: "Office 365 / Teams — прямой выход без туннелирования",
type: "standard", filterIds: ["f5"], serverCount: 6, prefixCount: 882,
action: "permit", enabled: true,
},
{
id: "c6", value: "65001:500", name: "gaming-ll",
description: "Low-latency gaming — приоритет по минимальному RTT (Discord, Steam)",
type: "standard", filterIds: ["f6"], serverCount: 4, prefixCount: 1212,
action: "local-pref", actionValue: 250, enabled: true,
},
{
id: "c7", value: "65002:200", name: "gaming-ll-backup",
description: "Резервный путь для gaming-low-latency",
type: "standard", filterIds: ["f6"], serverCount: 4, prefixCount: 412,
action: "metric", actionValue: 80, enabled: true,
},
{
id: "c8", value: "65007:999", name: "school-block",
description: "Блокировка соцсетей для школьного сегмента LAN",
type: "standard", filterIds: ["f4"], serverCount: 2, prefixCount: 412,
action: "deny", enabled: false,
},
{
id: "c9", value: "no-export", name: "no-export",
description: "Не объявлять маршрут за пределы AS (RFC 1997)",
type: "no-export", filterIds: [], serverCount: 1, prefixCount: 0,
action: "permit", enabled: true,
},
{
id: "c10", value: "no-advertise", name: "no-advertise",
description: "Не передавать маршрут ни одному BGP-пиру (RFC 1997)",
type: "no-advertise", filterIds: [], serverCount: 1, prefixCount: 0,
action: "permit", enabled: true,
},
]
const TYPE_LABELS: Record<CommType, string> = {
"standard": "Стандартный",
"no-export": "No-export",
"no-advertise":"No-advertise",
"local-as": "Local-AS",
"custom": "Кастомный",
}
const ACTION_LABELS: Record<Community["action"], string> = {
"permit": "Permit",
"deny": "Deny",
"local-pref": "Local-pref",
"metric": "MED/Metric",
}
const ACTION_COLOR: Record<Community["action"], string> = {
"permit": "text-emerald-500",
"deny": "text-red-500",
"local-pref": "text-blue-500",
"metric": "text-amber-500",
}
// ─── page ─────────────────────────────────────────────────────────────────────
export default function CommunitiesPage() {
const [search, setSearch] = useState("")
const [typeFilter, setTypeFilter] = useState<CommType | "all">("all")
const [copied, setCopied] = useState<string | null>(null)
const [selected, setSelected] = useState<Community | null>(null)
const filtered = useMemo(() => {
const q = search.toLowerCase()
return COMMUNITIES.filter(c => {
const matchQ = !q || c.value.toLowerCase().includes(q) || c.name.toLowerCase().includes(q) || c.description.toLowerCase().includes(q)
const matchT = typeFilter === "all" || c.type === typeFilter
return matchQ && matchT
})
}, [search, typeFilter])
const handleCopy = (value: string) => {
navigator.clipboard.writeText(value).catch(() => {})
setCopied(value)
setTimeout(() => setCopied(null), 1500)
}
const filterName = (id: string) => filters.find(f => f.id === id)?.name ?? id
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Данные" }, { label: "Communities" }]}
actions={
<Button size="sm">
<PlusIcon className="size-4" />Добавить
</Button>
}
/>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
{/* ── summary ── */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ label: "Всего communities", value: String(COMMUNITIES.length) },
{ label: "Активных", value: String(COMMUNITIES.filter(c => c.enabled).length) },
{ label: "Стандартных", value: String(COMMUNITIES.filter(c => c.type === "standard").length) },
{ label: "Использует фильтры",value: String(new Set(COMMUNITIES.flatMap(c => c.filterIds)).size) },
].map(({ label, value }) => (
<Card key={label}>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-2xl font-semibold tabular-nums">{value}</p>
</CardContent>
</Card>
))}
</div>
<div className="grid grid-cols-[1fr_320px] gap-5">
{/* ── main table ── */}
<div className="flex flex-col gap-3">
{/* toolbar */}
<div className="flex gap-2 flex-wrap">
<div className="relative flex-1 min-w-[180px]">
<SearchIcon className="absolute left-2.5 top-2 size-4 text-muted-foreground" />
<Input className="pl-8 h-8 text-sm" placeholder="Поиск community…" value={search} onChange={e => setSearch(e.target.value)} />
</div>
<div className="flex gap-1">
{(["all", "standard", "no-export", "no-advertise", "custom"] as const).map(t => (
<Button key={t} size="sm" variant={typeFilter === t ? "default" : "outline"}
className="h-8 text-xs px-2.5" onClick={() => setTypeFilter(t)}>
{t === "all" ? "Все" : TYPE_LABELS[t] ?? t}
</Button>
))}
</div>
</div>
{/* list */}
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-muted-foreground">
<th className="text-left font-medium px-4 py-2.5">Community</th>
<th className="text-left font-medium px-4 py-2.5">Имя / описание</th>
<th className="text-left font-medium px-4 py-2.5">Тип</th>
<th className="text-left font-medium px-4 py-2.5">Действие</th>
<th className="text-right font-medium px-4 py-2.5">Маршрутов</th>
<th className="text-right font-medium px-4 py-2.5">Серверов</th>
<th className="px-4 py-2.5" />
</tr>
</thead>
<tbody>
{filtered.map(c => (
<tr
key={c.id}
onClick={() => setSelected(c)}
className={cn(
"border-b last:border-0 cursor-pointer hover:bg-muted/40 transition-colors",
selected?.id === c.id && "bg-primary/5",
!c.enabled && "opacity-50",
)}
>
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<TagIcon className="size-3.5 text-muted-foreground shrink-0" />
<span className="font-mono text-xs font-medium bg-muted px-1.5 py-0.5 rounded">
{c.value}
</span>
<button
onClick={e => { e.stopPropagation(); handleCopy(c.value) }}
className="text-muted-foreground/40 hover:text-muted-foreground transition-colors"
>
{copied === c.value
? <CheckIcon className="size-3" />
: <CopyIcon className="size-3" />
}
</button>
</div>
</td>
<td className="px-4 py-2.5">
<p className="font-medium text-xs">{c.name}</p>
<p className="text-xs text-muted-foreground line-clamp-1">{c.description}</p>
</td>
<td className="px-4 py-2.5">
<span className="text-xs text-muted-foreground">{TYPE_LABELS[c.type]}</span>
</td>
<td className="px-4 py-2.5">
<span className={cn("text-xs font-medium", ACTION_COLOR[c.action])}>
{ACTION_LABELS[c.action]}{c.actionValue !== undefined ? ` ${c.actionValue}` : ""}
</span>
</td>
<td className="px-4 py-2.5 text-right font-mono text-xs">
{c.prefixCount > 0 ? c.prefixCount.toLocaleString() : "—"}
</td>
<td className="px-4 py-2.5 text-right">
<div className="flex items-center justify-end gap-1">
<ServerIcon className="size-3 text-muted-foreground" />
<span className="font-mono text-xs">{c.serverCount}</span>
</div>
</td>
<td className="px-4 py-2.5">
<ChevronRightIcon className="size-4 text-muted-foreground/40" />
</td>
</tr>
))}
</tbody>
</table>
{filtered.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 gap-2 text-muted-foreground">
<TagIcon className="size-8 opacity-30" />
<p className="text-sm">Ничего не найдено</p>
</div>
)}
</div>
</Card>
</div>
{/* ── detail panel ── */}
{selected ? (
<Card className="h-fit sticky top-0">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div>
<div className="flex items-center gap-2 mb-1">
<span className={cn(
"inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium",
selected.enabled
? "bg-emerald-500/10 text-emerald-600"
: "bg-muted text-muted-foreground"
)}>
{selected.enabled ? "Активен" : "Выключен"}
</span>
</div>
<CardTitle className="text-sm font-mono">{selected.value}</CardTitle>
<CardDescription className="text-xs mt-0.5">{selected.name}</CardDescription>
</div>
<div className="flex gap-1 shrink-0">
<Button size="sm" variant="ghost" className="size-7 p-0"><PencilIcon className="size-3.5" /></Button>
<Button size="sm" variant="ghost" className="size-7 p-0 text-destructive hover:text-destructive"><TrashIcon className="size-3.5" /></Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<p className="text-xs text-muted-foreground">{selected.description}</p>
<div className="space-y-2">
{[
["Тип", TYPE_LABELS[selected.type]],
["Действие", `${ACTION_LABELS[selected.action]}${selected.actionValue !== undefined ? ` ${selected.actionValue}` : ""}`],
["Маршрутов", selected.prefixCount > 0 ? selected.prefixCount.toLocaleString() : "—"],
["Серверов", String(selected.serverCount)],
].map(([k, v]) => (
<div key={k} className="flex justify-between gap-2">
<span className="text-xs text-muted-foreground">{k}</span>
<span className="text-xs font-medium">{v}</span>
</div>
))}
</div>
{selected.filterIds.length > 0 && (
<div>
<p className="text-xs text-muted-foreground mb-2 flex items-center gap-1.5">
<FilterIcon className="size-3" />Использующие фильтры
</p>
<div className="flex flex-wrap gap-1.5">
{selected.filterIds.map(fid => (
<span key={fid} className="text-[11px] bg-muted px-2 py-0.5 rounded font-mono">
{filterName(fid)}
</span>
))}
</div>
</div>
)}
<div className="pt-2 border-t flex flex-col gap-2">
<Button size="sm" variant="outline" className="w-full justify-start gap-2" onClick={() => handleCopy(selected.value)}>
{copied === selected.value ? <CheckIcon className="size-3.5" /> : <CopyIcon className="size-3.5" />}
Скопировать значение
</Button>
</div>
</CardContent>
</Card>
) : (
<Card className="h-fit">
<CardContent className="flex flex-col items-center justify-center py-12 gap-3 text-center">
<TagIcon className="size-8 text-muted-foreground/30" />
<p className="text-xs text-muted-foreground">Выберите community<br />для просмотра деталей</p>
</CardContent>
</Card>
)}
</div>
</div>
</div>
</div>
)
}
+473
View File
@@ -0,0 +1,473 @@
"use client"
import { useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { routerContainers, servers } from "@/lib/data"
import type { RouterContainer } from "@/lib/data"
import { Flag } from "@/components/flag"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
DropdownMenuItem, DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu"
import {
BoxIcon, PlayIcon, StopCircleIcon, SearchIcon,
MoreHorizontalIcon, Trash2Icon, PencilIcon, PowerIcon,
CodeXmlIcon, CopyIcon, CheckIcon, ActivityIcon, ServerIcon,
TerminalIcon, AlertCircleIcon,
} from "lucide-react"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
// ─── helpers ──────────────────────────────────────────────────────────────────
function serverFor(id: string) {
return servers.find((s) => s.id === id)
}
function statusConfig(status: RouterContainer["status"]) {
return {
running: {
dot: "bg-emerald-500 animate-pulse",
badge: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
label: "Running",
},
stopped: {
dot: "bg-muted-foreground",
badge: "bg-muted/50 text-muted-foreground border-border",
label: "Stopped",
},
error: {
dot: "bg-red-500 animate-pulse",
badge: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
label: "Error",
},
}[status]
}
// ─── RSC generator ────────────────────────────────────────────────────────────
function generateContainerRsc(c: RouterContainer): string {
const srv = serverFor(c.serverId)
const lines: string[] = []
lines.push(`# RouterOS Container — ${c.name}`)
if (srv) lines.push(`# Сервер: ${srv.name} (${srv.host})`)
lines.push(`# Образ: ${c.image}:${c.tag}`)
lines.push(`# RouterOS 7.4+ · /container`)
lines.push(``)
// interface
for (const iface of c.interfaces) {
lines.push(`/interface/veth/add name=${iface} address=172.17.0.2/24 gateway=172.17.0.1`)
}
lines.push(``)
// envs
if (c.envs.length > 0) {
lines.push(`/container/envs/add name=${c.name}-envs \\`)
for (const { key, value } of c.envs) {
lines.push(` ${key}="${value}" \\`)
}
lines.push(``)
}
// mounts
for (const m of c.mounts) {
lines.push(`/container/mounts/add name=${c.name}-mount-${m.dst.replace(/\//g, "-").slice(1)} \\`)
if (m.src) lines.push(` src=${m.src} \\`)
lines.push(` dst=${m.dst}`)
lines.push(``)
}
// container
lines.push(`/container/add \\`)
lines.push(` remote-image=${c.image}:${c.tag} \\`)
lines.push(` interface=${c.interfaces[0] ?? "veth-container"} \\`)
if (c.envs.length > 0) lines.push(` envlist=${c.name}-envs \\`)
if (c.mounts.length > 0) lines.push(` mounts=${c.mounts.map((m) => `${c.name}-mount-${m.dst.replace(/\//g, "-").slice(1)}`).join(",")} \\`)
if (c.cmd) lines.push(` cmd="${c.cmd}" \\`)
if (c.startOnBoot) lines.push(` start-on-boot=yes \\`)
if (c.comment) lines.push(` comment="${c.comment}" \\`)
lines.push(` logging=yes`)
return lines.join("\n")
}
// ─── Export Sheet ─────────────────────────────────────────────────────────────
function ExportSheet({ open, container, onClose }: {
open: boolean; container: RouterContainer | null; onClose: () => void
}) {
const [copied, setCopied] = useState(false)
const code = useMemo(() => container ? generateContainerRsc(container) : "", [container])
function handleCopy() {
navigator.clipboard.writeText(code).then(() => {
setCopied(true); setTimeout(() => setCopied(false), 2000)
})
}
return (
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl">
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
<div className="flex items-start justify-between gap-4">
<div>
<SheetTitle>Экспорт Container</SheetTitle>
<SheetDescription>RouterOS 7.4+ · /container · /interface/veth</SheetDescription>
</div>
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}>
{copied ? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</> : <><CopyIcon className="size-3.5" />Копировать</>}
</Button>
</div>
</SheetHeader>
<div className="flex-1 overflow-y-auto">
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all">
{code.split("\n").map((line, i) => {
const isComment = line.startsWith("#")
const isCmd = /^\//.test(line.trimStart())
const isParam = /^\s+[a-z]/.test(line)
return (
<span key={i} className={
isComment ? "text-muted-foreground"
: isCmd ? "text-sky-400"
: isParam ? "text-violet-300"
: "text-foreground"
}>
{line}{"\n"}
</span>
)
})}
</pre>
</div>
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
<Button className="flex-1" onClick={handleCopy}>
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
{copied ? "Скопировано" : "Копировать .rsc"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
// ─── Container card ───────────────────────────────────────────────────────────
function ContainerCard({
container,
onExport,
}: {
container: RouterContainer
onExport: () => void
}) {
const srv = serverFor(container.serverId)
const cfg = statusConfig(container.status)
return (
<Card className="overflow-hidden">
<div className="px-4 py-3 border-b flex items-center justify-between gap-2 bg-muted/10">
<div className="flex items-center gap-2 min-w-0">
<span className={cn("size-2 rounded-full shrink-0", cfg.dot)} />
<span className="font-mono font-semibold text-sm truncate">{container.name}</span>
<span className={cn("text-[10px] font-mono px-1.5 py-0.5 rounded border shrink-0", cfg.badge)}>
{cfg.label}
</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger render={
<Button variant="ghost" size="icon" className="size-7 shrink-0">
<MoreHorizontalIcon className="size-4" />
</Button>
} />
<DropdownMenuContent side="bottom" align="end">
{container.status === "running" ? (
<DropdownMenuItem><StopCircleIcon className="size-4 text-amber-500" />Остановить</DropdownMenuItem>
) : (
<DropdownMenuItem><PlayIcon className="size-4 text-emerald-500" />Запустить</DropdownMenuItem>
)}
<DropdownMenuItem><TerminalIcon className="size-4" />Логи</DropdownMenuItem>
<DropdownMenuItem><PencilIcon className="size-4" />Редактировать</DropdownMenuItem>
<DropdownMenuItem onClick={onExport}><CodeXmlIcon className="size-4" />Экспорт .rsc</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem><PowerIcon className="size-4" />Перезапустить</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive"><Trash2Icon className="size-4" />Удалить</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<CardContent className="px-4 py-3 flex flex-col gap-3">
{/* image */}
<div className="flex items-center gap-2">
<BoxIcon className="size-3.5 text-muted-foreground shrink-0" />
<span className="font-mono text-xs text-foreground/80">
{container.image}:<span className="text-muted-foreground">{container.tag}</span>
</span>
</div>
{/* server */}
{srv && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<ServerIcon className="size-3.5 shrink-0" />
<Flag code={srv.country} size={12} />
<span className="font-mono">{srv.name}</span>
</div>
)}
{/* uptime + stats */}
{container.status === "running" && (
<div className="flex items-center gap-4 text-xs text-muted-foreground border-t pt-2.5">
{container.uptime && (
<div className="flex items-center gap-1">
<ActivityIcon className="size-3 text-emerald-500" />
<span>{container.uptime}</span>
</div>
)}
{container.cpu !== undefined && (
<div>
<span className="text-muted-foreground">CPU </span>
<span className={cn("font-mono font-medium",
container.cpu > 50 ? "text-amber-500" : "text-foreground")}>
{container.cpu}%
</span>
</div>
)}
{container.memMb !== undefined && (
<div>
<span className="text-muted-foreground">RAM </span>
<span className="font-mono font-medium">{container.memMb} МБ</span>
</div>
)}
</div>
)}
{/* interfaces */}
{container.interfaces.length > 0 && (
<div className="flex flex-wrap gap-1">
{container.interfaces.map((i) => (
<span key={i} className="text-[10px] font-mono px-1.5 py-0.5 bg-muted border rounded text-muted-foreground">
{i}
</span>
))}
</div>
)}
{/* mounts */}
{container.mounts.length > 0 && (
<div className="flex flex-col gap-1">
{container.mounts.map((m, idx) => (
<div key={idx} className="text-[11px] font-mono text-muted-foreground flex items-center gap-1">
<span className="text-muted-foreground/40">→</span>
<span>{m.dst}</span>
{m.src && <><span className="text-muted-foreground/40">←</span><span>{m.src}</span></>}
</div>
))}
</div>
)}
{container.comment && (
<p className="text-xs text-muted-foreground border-t pt-2">{container.comment}</p>
)}
</CardContent>
</Card>
)
}
// ════════════════════════════════════════════════════════════════════════════
export default function ContainersPage() {
const [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState<RouterContainer["status"] | "all">("all")
const [exportContainer, setExportContainer] = useState<RouterContainer | null>(null)
const filtered = useMemo(() => {
return routerContainers.filter((c) => {
if (statusFilter !== "all" && c.status !== statusFilter) return false
if (!search) return true
const q = search.toLowerCase()
return (
c.name.toLowerCase().includes(q) ||
c.image.toLowerCase().includes(q) ||
(serverFor(c.serverId)?.name.toLowerCase().includes(q) ?? false)
)
})
}, [search, statusFilter])
const running = routerContainers.filter((c) => c.status === "running").length
const stopped = routerContainers.filter((c) => c.status === "stopped").length
const errors = routerContainers.filter((c) => c.status === "error").length
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Контейнеры" }]}
actions={
<Button size="sm">
<BoxIcon className="size-4" />Новый контейнер
</Button>
}
/>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
{/* KPI */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: "Всего", value: routerContainers.length, icon: <BoxIcon className="size-4 text-muted-foreground" /> },
{ label: "Running", value: running, icon: <PlayIcon className="size-4 text-emerald-500" /> },
{ label: "Stopped", value: stopped, icon: <StopCircleIcon className="size-4 text-muted-foreground" /> },
{ label: "Ошибок", value: errors, icon: <AlertCircleIcon className="size-4 text-red-500" /> },
].map((s) => (
<Card key={s.label}>
<CardContent className="px-5 py-4 flex items-start justify-between">
<div>
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
</div>
<div className="mt-0.5">{s.icon}</div>
</CardContent>
</Card>
))}
</div>
{/* Info banner */}
<div className="flex items-start gap-3 rounded-lg bg-violet-500/5 border border-violet-500/20 px-4 py-3 text-sm">
<BoxIcon className="size-5 text-violet-500 shrink-0 mt-0.5" />
<div>
<p className="font-medium text-violet-600 dark:text-violet-400">Контейнеры доступны с RouterOS 7.4+</p>
<p className="text-muted-foreground text-xs mt-0.5">
Поддерживаются Docker-совместимые образы. Требуется установка пакета <code className="font-mono bg-muted px-1 rounded">container</code>.
Интерфейсы veth создаются автоматически.
</p>
</div>
</div>
{/* Toolbar */}
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[240px]">
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
<input
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
placeholder="Поиск по имени, образу, серверу…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
{(["all", "running", "stopped", "error"] as const).map((s) => (
<button key={s}
onClick={() => setStatusFilter(s)}
className={cn(
"px-3 py-1 text-xs rounded whitespace-nowrap transition-colors",
statusFilter === s
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}>
{s === "all" ? "Все" : s === "running" ? "Running" : s === "stopped" ? "Stopped" : "Error"}
</button>
))}
</div>
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} контейнеров</span>
</div>
{/* Grid */}
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
<BoxIcon className="size-10 mb-3 opacity-20" />
<p className="text-sm font-medium">Контейнеры не найдены</p>
<p className="text-xs mt-1">Добавьте первый контейнер или измените фильтр</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{filtered.map((c) => (
<ContainerCard
key={c.id}
container={c}
onExport={() => setExportContainer(c)}
/>
))}
</div>
)}
{/* RouterOS reference */}
<Card>
<CardContent className="px-5 py-4">
<p className="text-xs font-medium text-muted-foreground mb-3">
RouterOS 7.4+ · /container — быстрые команды
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
{[
{
title: "Установка пакета",
lines: [
"# Скачать пакет container:",
"# mikrotik.com → Software",
"",
"/system/package/install",
" container",
"",
"# Перезагрузить:",
"/system/reboot",
],
},
{
title: "Создать контейнер",
lines: [
"# veth интерфейс:",
"/interface/veth/add \\",
" name=veth-nginx \\",
" address=172.17.0.2/24 \\",
" gateway=172.17.0.1",
"",
"# Контейнер:",
"/container/add \\",
" remote-image=nginx:alpine \\",
" interface=veth-nginx",
],
},
{
title: "Управление",
lines: [
"# Список:",
"/container/print",
"",
"# Запуск:",
"/container/start 0",
"",
"# Остановка:",
"/container/stop 0",
"",
"# Логи:",
"/container/shell 0",
],
},
].map((b) => (
<div key={b.title}>
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">{b.title}</p>
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto whitespace-pre">
{b.lines.join("\n")}
</pre>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
<ExportSheet
open={!!exportContainer}
container={exportContainer}
onClose={() => setExportContainer(null)}
/>
</div>
)
}
+242
View File
@@ -0,0 +1,242 @@
import { PageHeader } from "@/components/page-header"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { StatusDot } from "@/components/status-dot"
import { StatusBadge } from "@/components/status-badge"
import { Sparkline } from "@/components/sparkline"
import { LatencyChart } from "@/components/dashboard/latency-chart"
import { BandwidthChart } from "@/components/dashboard/bandwidth-chart"
import { servers, pingProbes, systemEvents, dashLatency, traffic } from "@/lib/data"
import { Flag } from "@/components/flag"
import { AlertCircleIcon, AlertTriangleIcon, InfoIcon, FilterIcon, DownloadIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
function StatCard({
label, value, unit, delta, deltaDir, spark, sparkColor,
}: {
label: string; value: string; unit?: string; delta?: string
deltaDir?: "up" | "down"; spark?: number[]; sparkColor?: string
}) {
return (
<Card className="relative overflow-hidden">
<CardContent className="pt-5 pb-4 px-5">
<p className="text-sm font-medium text-muted-foreground">{label}</p>
<div className="flex items-baseline gap-1.5 mt-1">
<span className="text-3xl font-semibold tracking-tight tabular-nums">{value}</span>
{unit && <span className="text-sm text-muted-foreground">{unit}</span>}
</div>
{delta && (
<p className={`text-xs mt-1 flex items-center gap-1 ${deltaDir === "up" ? "text-emerald-600 dark:text-emerald-400" : deltaDir === "down" ? "text-red-500 dark:text-red-400" : "text-muted-foreground"}`}>
{delta}
</p>
)}
{spark && spark.length > 1 && (
<div className="absolute right-4 bottom-4 opacity-60">
<Sparkline data={spark} width={80} height={32} color={sparkColor ?? "currentColor"} filled />
</div>
)}
</CardContent>
</Card>
)
}
export default function DashboardPage() {
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Обзор" }, { label: "Дашборд" }]}
actions={
<>
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
</>
}
/>
<div className="flex-1 overflow-y-auto">
<div className="p-6 flex flex-col gap-6">
{/* KPI row */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
label="Серверы онлайн" value="6" unit="/ 7"
delta="+1 со вчерашнего" deltaDir="up"
spark={[5,5,6,6,5,6,6,6,7,6,6,6,6,6,6,6,6]}
sparkColor="var(--chart-line-1)"
/>
<StatCard
label="Активные фильтры" value="5" unit="/ 6"
delta="2.4к доменов синхронизировано" deltaDir="up"
spark={[3,4,4,5,4,5,5,5,5,5,5,5,5,5,5]}
sparkColor="var(--chart-line-2)"
/>
<StatCard
label="BGP-префиксы" value="8 432"
delta="+412 в последнем обновлении" deltaDir="up"
spark={[7800,7900,8000,8100,8050,8120,8200,8240,8300,8350,8380,8400,8420,8430,8432]}
sparkColor="var(--chart-line-4)"
/>
<StatCard
label="Активные алерты" value="4"
delta="2 критических · 2 предупреждения" deltaDir="down"
spark={[1,2,2,3,3,4,5,4,4,4,3,3,4,4,4]}
sparkColor="var(--chart-5)"
/>
</div>
{/* Latency chart + Events */}
<div className="grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-4">
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Задержка до серверов</CardTitle>
<p className="text-sm text-muted-foreground mt-0.5">Последние 60 минут · ping от монитора → серверы MikroTik</p>
</div>
</div>
</CardHeader>
<CardContent className="pt-0 px-3">
<LatencyChart series={dashLatency} />
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base">Последние события</CardTitle>
<Button variant="ghost" size="sm" className="text-xs h-7">Все →</Button>
</div>
<p className="text-sm text-muted-foreground">Система и BGP-активность</p>
</CardHeader>
<CardContent className="pt-0 px-0">
<div className="divide-y divide-border">
{systemEvents.map((e) => (
<div key={e.id} className="grid grid-cols-[20px_1fr_auto] gap-3 px-5 py-3 items-start">
<div className="mt-0.5">
{e.sev === "destructive" && <AlertCircleIcon className="size-4 text-red-500" />}
{e.sev === "warning" && <AlertTriangleIcon className="size-4 text-amber-500" />}
{e.sev === "info" && <InfoIcon className="size-4 text-blue-500" />}
</div>
<div>
<p className="text-[13px] font-medium leading-tight">{e.title}</p>
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">{e.message}</p>
</div>
<span className="text-[11px] font-mono text-muted-foreground">{e.when}</span>
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* Bandwidth + Server status */}
<div className="grid grid-cols-1 lg:grid-cols-[3fr_2fr] gap-4">
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Пропускная способность</CardTitle>
<p className="text-sm text-muted-foreground mt-0.5">Суммарный RX / TX по всем серверам</p>
</div>
<div className="flex items-center gap-3 text-xs">
<span className="flex items-center gap-1.5"><span className="w-3 h-0.5 bg-foreground/80 rounded inline-block" />RX 318 Мбит/с</span>
<span className="flex items-center gap-1.5"><span className="w-3 h-0.5 bg-blue-500 rounded inline-block" />TX 244 Мбит/с</span>
</div>
</div>
</CardHeader>
<CardContent className="pt-0 px-3">
<BandwidthChart rx={traffic.rx} tx={traffic.tx} />
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Состояние серверов</CardTitle>
<p className="text-sm text-muted-foreground mt-0.5">7 узлов MikroTik</p>
</div>
<Button variant="outline" size="sm" className="h-7 text-xs">Управление →</Button>
</div>
</CardHeader>
<CardContent className="pt-0 px-0">
<div className="divide-y divide-border">
{servers.slice(0, 5).map((s) => (
<div key={s.id} className="grid grid-cols-[10px_1fr_auto_auto] gap-3 px-5 py-2.5 items-center">
<StatusDot status={s.status} pulse />
<div>
<p className="text-[13px] font-medium leading-tight">{s.name}</p>
<p className="text-[11px] font-mono text-muted-foreground flex items-center gap-1">
<Flag code={s.country} className="not-mono" />{s.host} · {s.site} · {s.asn}
</p>
</div>
<span className={`text-xs font-mono ${s.latency == null ? "text-red-500" : s.latency > 60 ? "text-amber-500" : "text-muted-foreground"}`}>
{s.latency == null ? "недоступен" : `${s.latency}мс`}
</span>
<StatusBadge status={s.status} />
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* Ping probes table */}
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Активные пробы</CardTitle>
<p className="text-sm text-muted-foreground mt-0.5">8 ping-зондов · опрос раз в секунду</p>
</div>
<Button variant="outline" size="sm" className="h-7 text-xs">Открыть монитор →</Button>
</div>
</CardHeader>
<CardContent className="pt-0 px-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
<th className="text-left font-medium px-5 py-2.5">Проба</th>
<th className="text-left font-medium px-4 py-2.5">Цель</th>
<th className="text-left font-medium px-4 py-2.5">Фильтр</th>
<th className="text-right font-medium px-4 py-2.5">RTT</th>
<th className="text-right font-medium px-4 py-2.5">Потери</th>
<th className="text-left font-medium px-4 py-2.5 w-36">60с</th>
<th className="text-left font-medium px-4 py-2.5">Статус</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{pingProbes.map((p) => {
const sparkColor = p.status === "down" ? "hsl(0 84% 60%)" : p.status === "warn" ? "hsl(32 94% 44%)" : "hsl(142 76% 36%)"
return (
<tr key={p.id} className="hover:bg-muted/40 transition-colors">
<td className="px-5 py-2.5 font-medium">{p.name}</td>
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground">{p.target}</td>
<td className="px-4 py-2.5">
<span className="inline-flex items-center gap-1 text-xs border border-border rounded px-2 py-0.5">
<FilterIcon className="size-3 text-muted-foreground" />{p.filter}
</span>
</td>
<td className="px-4 py-2.5 font-mono text-right">{p.rtt == null ? "—" : `${p.rtt} мс`}</td>
<td className={`px-4 py-2.5 font-mono text-right ${p.loss > 5 ? "text-red-500" : p.loss > 0 ? "text-amber-500" : "text-muted-foreground"}`}>
{p.loss}%
</td>
<td className="px-4 py-2.5">
<Sparkline data={p.series} width={120} height={24} color={sparkColor} />
</td>
<td className="px-4 py-2.5">
<StatusBadge status={p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"} />
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
)
}
+86
View File
@@ -0,0 +1,86 @@
"use client"
import { PageHeader } from "@/components/page-header"
import { DataTable } from "@/components/data-table"
import { domains } from "@/lib/data"
import { Button } from "@/components/ui/button"
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon } from "lucide-react"
export default function DomainsPage() {
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Данные" }, { label: "Домены" }]}
actions={
<>
<Button variant="outline" size="sm"><UploadIcon className="size-4" />Импорт</Button>
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
<Button size="sm"><PlusIcon className="size-4" />Добавить домен</Button>
</>
}
/>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Домены</h1>
<p className="text-sm text-muted-foreground mt-1">
Отслеживаемые домены для BGP-фильтрации — синхронизируются в address-list MikroTik
</p>
</div>
<DataTable
data={domains}
searchPlaceholder="Поиск по домену…"
searchKeys={["domain", "asn", "filter"]}
columns={[
{
key: "domain",
label: "Домен",
render: (d) => <span className="font-medium">{d.domain}</span>,
},
{
key: "resolvedIp",
label: "Resolved IP",
render: (d) => <span className="font-mono text-xs text-muted-foreground">{d.resolvedIp}</span>,
},
{
key: "asn",
label: "ASN",
render: (d) => <span className="font-mono text-xs">{d.asn}</span>,
},
{
key: "purpose",
label: "Назначение",
render: (d) => (
<span className="text-xs border border-border rounded px-2 py-0.5">{d.purpose}</span>
),
},
{
key: "filter",
label: "Фильтр",
render: (d) => (
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
<FilterIcon className="size-3 text-muted-foreground" />{d.filter}
</span>
),
},
{
key: "updated",
label: "Обновлён",
render: (d) => <span className="text-xs text-muted-foreground">{d.updated}</span>,
},
{
key: "enabled",
label: "Статус",
render: (d) => (
<span className={`text-xs font-medium ${d.enabled ? "text-emerald-600" : "text-muted-foreground"}`}>
{d.enabled ? "Активен" : "Отключён"}
</span>
),
},
]}
/>
</div>
</div>
</div>
)
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+864
View File
@@ -0,0 +1,864 @@
"use client"
import { useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { greTunnels, grePools, servers } from "@/lib/data"
import type { GreTunnel, GreStatus, IpsecEncAlg, IpsecAuthAlg, IpsecDhGroup, IkeVersion } from "@/lib/data"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, DropdownMenuGroup,
} from "@/components/ui/dropdown-menu"
import { Flag } from "@/components/flag"
import {
PlusIcon, SearchIcon, RefreshCwIcon, MoreHorizontalIcon,
LockIcon, LockOpenIcon, ShieldCheckIcon, NetworkIcon,
EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon,
CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon, CopyIcon, CheckIcon,
} from "lucide-react"
// ─── label maps ─────────────────────────────────────────────────────────────
const ENC_LABELS: Record<IpsecEncAlg, string> = { "aes-128": "AES-128", "aes-192": "AES-192", "aes-256": "AES-256" }
const AUTH_LABELS: Record<IpsecAuthAlg, string> = { sha1: "SHA-1", sha256: "SHA-256", sha512: "SHA-512" }
const DH_LABELS: Record<IpsecDhGroup, string> = {
modp1024: "DH-2 (1024)", modp2048: "DH-14 (2048)", modp4096: "DH-16 (4096)",
ecp256: "ECP-256", ecp384: "ECP-384", ecp521: "ECP-521",
}
const IKE_LABELS: Record<IkeVersion, string> = { ikev1: "IKEv1", ikev2: "IKEv2" }
const ENC_ROS: Record<IpsecEncAlg, string> = { "aes-128": "aes-128-cbc", "aes-192": "aes-192-cbc", "aes-256": "aes-256-cbc" }
const AUTH_ROS: Record<IpsecAuthAlg, string> = { sha1: "sha1", sha256: "sha256", sha512: "sha512" }
const STATUS_MAP: Record<GreStatus, { label: string; dot: string }> = {
up: { label: "Up", dot: "bg-emerald-500" },
degraded: { label: "Degraded", dot: "bg-amber-500" },
down: { label: "Down", dot: "bg-red-500" },
}
const serverById = Object.fromEntries(servers.map((s) => [s.id, s]))
const poolById = Object.fromEntries(grePools.map((p) => [p.id, p]))
// ─── RouterOS code generator ─────────────────────────────────────────────────
function generateRosCommands(t: GreTunnel): string {
const lines: string[] = []
const srv = serverById[t.serverId]
lines.push(`# Сервер: ${srv?.name ?? t.serverId} (${srv?.host ?? ""})`)
lines.push(`# Сгенерировано RouterLists · ${new Date().toLocaleDateString("ru")}`)
lines.push("")
// GRE interface
lines.push("# ── GRE-интерфейс ────────────────────────────────────────────")
const greParts = [
`/interface gre add`,
` name=${t.name}`,
` remote-address=${t.remoteAddress}`,
t.localAddress !== "0.0.0.0" ? ` local-address=${t.localAddress}` : null,
t.ipsec ? ` ipsec-secret="${t.ipsec.secret}"` : null,
` mtu=${t.mtu}`,
t.keepaliveInterval > 0 ? ` keepalive=${t.keepaliveInterval}s,${t.keepaliveRetries}` : ` keepalive=0`,
` dscp=${t.dscp}`,
` clamp-tcp-mss=${t.clampTcpMss ? "yes" : "no"}`,
` allow-fast-path=${t.allowFastPath ? "yes" : "no"}`,
t.comment ? ` comment="${t.comment}"` : null,
!t.enabled ? ` disabled=yes` : null,
].filter(Boolean) as string[]
lines.push(greParts.join(" \\\n"))
// Inner IP
lines.push("")
lines.push("# ── Внутренний IP ────────────────────────────────────────────")
lines.push(`/ip address add \\`)
lines.push(` address=${t.localInnerIp} \\`)
lines.push(` interface=${t.name}`)
// IPsec manual equivalent
if (t.ipsec) {
const ikeMode = t.ipsec.ikeVersion === "ikev2" ? "ike2" : "ike1"
const pfsGroup = t.ipsec.pfs ? t.ipsec.dhGroup : "none"
lines.push("")
lines.push("# ── IPsec (авто через ipsec-secret; ручной эквивалент) ───────")
lines.push("")
lines.push(`/ip ipsec peer add \\`)
lines.push(` name=${t.name} \\`)
lines.push(` address=${t.remoteAddress} \\`)
lines.push(` exchange-mode=${ikeMode} \\`)
lines.push(` auth-method=pre-shared-key \\`)
lines.push(` secret="${t.ipsec.secret}"`)
lines.push("")
lines.push(`/ip ipsec proposal add \\`)
lines.push(` name=${t.name} \\`)
lines.push(` enc-algorithms=${ENC_ROS[t.ipsec.encAlg]} \\`)
lines.push(` auth-algorithms=${AUTH_ROS[t.ipsec.authAlg]} \\`)
lines.push(` pfs-group=${pfsGroup} \\`)
lines.push(` lifetime=${t.ipsec.lifetime}`)
lines.push("")
lines.push(`/ip ipsec policy add \\`)
lines.push(` src-address=${t.localAddress !== "0.0.0.0" ? t.localAddress + "/32" : "0.0.0.0/0"} \\`)
lines.push(` dst-address=${t.remoteAddress}/32 \\`)
lines.push(` proposal=${t.name} \\`)
lines.push(` tunnel=yes`)
}
return lines.join("\n")
}
// ─── small ui helpers ────────────────────────────────────────────────────────
function TunnelStatus({ status }: { status: GreStatus }) {
const s = STATUS_MAP[status]
return (
<span className="inline-flex items-center gap-1.5 text-sm">
<span className={`size-1.5 rounded-full ${s.dot}`} />
{s.label}
</span>
)
}
function IpsecBadge({ secured }: { secured: boolean }) {
return secured ? (
<span className="inline-flex items-center gap-1 text-xs font-medium border rounded px-2 py-0.5 bg-emerald-500/10 text-emerald-400 border-emerald-500/20">
<LockIcon className="size-3" /> IPsec
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs font-medium border rounded px-2 py-0.5 bg-muted text-muted-foreground border-border">
<LockOpenIcon className="size-3" /> Открытый
</span>
)
}
function Field({ label, hint, required, children }: {
label: string; hint?: string; required?: boolean; children: React.ReactNode
}) {
return (
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">
{label}{required && <span className="text-destructive ml-0.5">*</span>}
</label>
{children}
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
</div>
)
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors ${checked ? "bg-primary" : "bg-input"}`}
>
<span className={`pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${checked ? "translate-x-4" : "translate-x-0"}`} />
</button>
)
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-2 py-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
<div className="flex-1 h-px bg-border" />
</div>
)
}
function SegmentedControl<T extends string>({ value, onChange, options }: {
value: T; onChange: (v: T) => void; options: { value: T; label: string }[]
}) {
return (
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5 w-fit">
{options.map((o) => (
<button key={o.value} type="button" onClick={() => onChange(o.value)}
className={`px-3 py-1 text-sm rounded transition-colors ${value === o.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>
{o.label}
</button>
))}
</div>
)
}
// ─── default form states ─────────────────────────────────────────────────────
const defaultTunnelForm = {
name: "", serverId: "", localAddress: "", remoteAddress: "",
poolId: "", localInnerIp: "", remoteInnerIp: "", comment: "", enabled: true,
ipsecEnabled: false, ipsecSecret: "", ipsecShowSecret: false,
ipsecIkeVersion: "ikev2" as IkeVersion,
ipsecEncAlg: "aes-256" as IpsecEncAlg,
ipsecAuthAlg: "sha256" as IpsecAuthAlg,
ipsecDhGroup: "modp2048" as IpsecDhGroup,
ipsecLifetime: "1d 00:00:00", ipsecPfs: true,
mtu: 1476, keepaliveInterval: 10, keepaliveRetries: 10,
dscp: "inherit", clampTcpMss: true, allowFastPath: true,
showAdvanced: false,
}
const defaultPoolForm = { name: "", cidr: "", comment: "" }
type TabFilter = "all" | "up" | "ipsec" | "plain"
type PageTab = "tunnels" | "pools"
// ════════════════════════════════════════════════════════════════════════════
export default function GrePage() {
const [pageTab, setPageTab] = useState<PageTab>("tunnels")
const [tabFilter, setTabFilter] = useState<TabFilter>("all")
const [search, setSearch] = useState("")
const [tunnelOpen, setTunnelOpen] = useState(false)
const [poolOpen, setPoolOpen] = useState(false)
const [codePreviewTunnel, setCodePreviewTunnel] = useState<GreTunnel | null>(null)
const [copied, setCopied] = useState(false)
const [tForm, setTForm] = useState(defaultTunnelForm)
const [pForm, setPForm] = useState(defaultPoolForm)
const setT = <K extends keyof typeof defaultTunnelForm>(k: K, v: (typeof defaultTunnelForm)[K]) =>
setTForm((f) => ({ ...f, [k]: v }))
const filtered = useMemo(() => {
return greTunnels.filter((t) => {
if (tabFilter === "up" && t.status !== "up") return false
if (tabFilter === "ipsec" && !t.ipsec) return false
if (tabFilter === "plain" && t.ipsec) return false
if (!search) return true
const q = search.toLowerCase()
return (
t.name.toLowerCase().includes(q) ||
t.remoteAddress.includes(q) ||
t.localInnerIp.includes(q) ||
serverById[t.serverId]?.name.toLowerCase().includes(q)
)
})
}, [tabFilter, search])
const upCount = greTunnels.filter((t) => t.status === "up").length
const ipsecCount = greTunnels.filter((t) => t.ipsec).length
const tunnelTabs: { value: TabFilter; label: string; count: number }[] = [
{ value: "all", label: "Все", count: greTunnels.length },
{ value: "up", label: "Активные", count: upCount },
{ value: "ipsec", label: "С IPsec", count: ipsecCount },
{ value: "plain", label: "Без IPsec", count: greTunnels.length - ipsecCount },
]
function handleCopy(code: string) {
navigator.clipboard.writeText(code).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "GRE-туннели" }]}
actions={
<>
<Button variant="outline" size="sm"><RefreshCwIcon className="size-4" />Обновить статус</Button>
<Button size="sm" onClick={() => { setTForm(defaultTunnelForm); setTunnelOpen(true) }}>
<PlusIcon className="size-4" />Добавить туннель
</Button>
</>
}
/>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
{/* Legacy banner */}
<div className="flex items-start gap-3 rounded-lg bg-amber-500/5 border border-amber-500/20 px-4 py-3 text-sm">
<ShieldCheckIcon className="size-5 text-amber-500 shrink-0 mt-0.5" />
<div>
<p className="font-medium text-amber-600 dark:text-amber-400">
В RouterOS 7.x рекомендуется использовать WireGuard вместо GRE+IPsec
</p>
<p className="text-muted-foreground text-xs mt-0.5">
WireGuard проще в настройке, обеспечивает лучшую производительность и современную криптографию (ChaCha20-Poly1305).
GRE-туннели остаются поддерживаемыми, но WireGuard — предпочтительный выбор для новых развёртываний.
</p>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-4 gap-4">
{[
{ label: "Всего туннелей", value: greTunnels.length, icon: <NetworkIcon className="size-4 text-muted-foreground" /> },
{ label: "Активно", value: upCount, icon: <ShieldCheckIcon className="size-4 text-emerald-500" /> },
{ label: "Защищены IPsec", value: ipsecCount, icon: <LockIcon className="size-4 text-violet-400" /> },
{ label: "IP-пулов", value: grePools.length, icon: <NetworkIcon className="size-4 text-sky-400" /> },
].map((s) => (
<Card key={s.label}>
<CardContent className="px-5 py-4 flex items-start justify-between">
<div>
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
</div>
<div className="mt-0.5">{s.icon}</div>
</CardContent>
</Card>
))}
</div>
{/* Page tabs */}
<div className="flex items-center gap-1 border-b">
{(["tunnels", "pools"] as PageTab[]).map((tab) => (
<button key={tab} onClick={() => setPageTab(tab)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors -mb-px ${pageTab === tab ? "border-foreground text-foreground" : "border-transparent text-muted-foreground hover:text-foreground"}`}>
{tab === "tunnels" ? "Туннели" : "IP-пулы"}
</button>
))}
</div>
{/* ── Tunnels ── */}
{pageTab === "tunnels" && (
<Card>
<div className="flex items-center gap-3 px-5 py-3 border-b flex-wrap">
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
{tunnelTabs.map((t) => (
<button key={t.value} onClick={() => setTabFilter(t.value)}
className={`flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors ${tabFilter === t.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>
{t.label}
<span className="text-xs tabular-nums opacity-60">{t.count}</span>
</button>
))}
</div>
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[220px]">
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
<input className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
placeholder="Поиск по имени, IP…" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} туннелей</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
<th className="text-left font-medium px-5 py-3">Интерфейс / Сервер</th>
<th className="text-left font-medium px-4 py-3">Эндпоинты</th>
<th className="text-left font-medium px-4 py-3">Внутренний IP</th>
<th className="text-left font-medium px-4 py-3">Пул</th>
<th className="text-left font-medium px-4 py-3">IPsec</th>
<th className="text-left font-medium px-4 py-3">Шифрование</th>
<th className="text-center font-medium px-4 py-3">MTU</th>
<th className="text-left font-medium px-4 py-3">Keepalive</th>
<th className="text-left font-medium px-4 py-3">Статус</th>
<th className="w-20 px-3 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{filtered.map((t) => {
const srv = serverById[t.serverId]
const pool = poolById[t.poolId]
return (
<tr key={t.id} className="hover:bg-muted/40 transition-colors">
<td className="px-5 py-3">
<p className="font-medium font-mono text-[13px]">{t.name}</p>
<p className="text-xs text-muted-foreground mt-0.5 flex items-center gap-1">
{srv && <Flag code={srv.country} />}
{srv?.name ?? t.serverId}
</p>
</td>
<td className="px-4 py-3">
<p className="font-mono text-xs">
{t.localAddress === "0.0.0.0" ? <span className="text-muted-foreground">авто</span> : t.localAddress}
</p>
<p className="font-mono text-xs text-muted-foreground">→ {t.remoteAddress}</p>
</td>
<td className="px-4 py-3">
<p className="font-mono text-xs">{t.localInnerIp}</p>
<p className="font-mono text-xs text-muted-foreground">{t.remoteInnerIp}</p>
</td>
<td className="px-4 py-3">
<span className="text-xs text-muted-foreground font-mono">{pool?.name ?? "—"}</span>
</td>
<td className="px-4 py-3"><IpsecBadge secured={!!t.ipsec} /></td>
<td className="px-4 py-3">
{t.ipsec ? (
<div className="flex flex-col gap-0.5">
<span className="text-xs font-mono">{ENC_LABELS[t.ipsec.encAlg]} / {AUTH_LABELS[t.ipsec.authAlg]}</span>
<span className="text-xs text-muted-foreground font-mono">
{DH_LABELS[t.ipsec.dhGroup].split(" ")[0]} · {IKE_LABELS[t.ipsec.ikeVersion]}{t.ipsec.pfs && " · PFS"}
</span>
</div>
) : <span className="text-xs text-muted-foreground">—</span>}
</td>
<td className="px-4 py-3 text-center font-mono text-xs">{t.mtu}</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">
{t.keepaliveInterval === 0 ? "откл." : `${t.keepaliveInterval}с / ${t.keepaliveRetries}`}
</td>
<td className="px-4 py-3"><TunnelStatus status={t.status} /></td>
{/* actions */}
<td className="px-3 py-3">
<div className="flex items-center gap-1 justify-end">
{/* Code preview button */}
<Button
variant="ghost" size="icon" className="size-7"
title="Предпросмотр кода RouterOS"
onClick={() => setCodePreviewTunnel(t)}
>
<CodeXmlIcon className="size-3.5" />
</Button>
{/* Actions dropdown */}
<DropdownMenu>
<DropdownMenuTrigger render={
<Button variant="ghost" size="icon" className="size-7">
<MoreHorizontalIcon className="size-4" />
</Button>
} />
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuGroup>
<DropdownMenuLabel>{t.name}</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setCodePreviewTunnel(t)}>
<CodeXmlIcon className="size-4" /> Просмотр кода
</DropdownMenuItem>
<DropdownMenuItem>
<PencilIcon className="size-4" /> Редактировать
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<PowerIcon className="size-4" />
{t.enabled ? "Выключить" : "Включить"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<Trash2Icon className="size-4" /> Удалить туннель
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</Card>
)}
{/* ── IP Pools ── */}
{pageTab === "pools" && (
<Card>
<div className="flex items-center justify-between px-5 py-3 border-b">
<span className="text-sm text-muted-foreground">{grePools.length} пула</span>
<Button size="sm" variant="outline" onClick={() => { setPForm(defaultPoolForm); setPoolOpen(true) }}>
<PlusIcon className="size-4" />Добавить пул
</Button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
<th className="text-left font-medium px-5 py-3">Имя пула</th>
<th className="text-left font-medium px-4 py-3">Диапазон CIDR</th>
<th className="text-right font-medium px-4 py-3">Назначено /30</th>
<th className="text-right font-medium px-4 py-3">Доступно /30</th>
<th className="text-left font-medium px-4 py-3">Использование</th>
<th className="text-left font-medium px-4 py-3">Назначение</th>
<th className="w-10 px-3 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{grePools.map((pool) => {
const pct = Math.round((pool.allocated / pool.total) * 100)
return (
<tr key={pool.id} className="hover:bg-muted/40 transition-colors">
<td className="px-5 py-3 font-mono text-[13px] font-medium">{pool.name}</td>
<td className="px-4 py-3 font-mono text-xs">{pool.cidr}</td>
<td className="px-4 py-3 text-right tabular-nums">{pool.allocated}</td>
<td className="px-4 py-3 text-right tabular-nums text-muted-foreground">{pool.total - pool.allocated}</td>
<td className="px-4 py-3 min-w-[140px]">
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div className={`h-full rounded-full ${pct > 80 ? "bg-amber-500" : "bg-emerald-500"}`} style={{ width: `${pct}%` }} />
</div>
<span className="text-xs text-muted-foreground tabular-nums w-8 text-right">{pct}%</span>
</div>
</td>
<td className="px-4 py-3 text-muted-foreground text-xs">{pool.comment}</td>
<td className="px-3 py-3">
<DropdownMenu>
<DropdownMenuTrigger render={
<Button variant="ghost" size="icon" className="size-7">
<MoreHorizontalIcon className="size-4" />
</Button>
} />
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem><PencilIcon className="size-4" /> Редактировать</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive"><Trash2Icon className="size-4" /> Удалить пул</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
<div className="border-t px-5 py-4">
<p className="text-xs font-medium text-muted-foreground mb-3">Назначения по пулам</p>
<div className="flex flex-col gap-3">
{grePools.map((pool) => {
const poolTunnels = greTunnels.filter((t) => t.poolId === pool.id)
return (
<div key={pool.id}>
<p className="text-xs font-mono font-medium mb-1.5">{pool.name}</p>
<div className="flex flex-wrap gap-2">
{poolTunnels.map((t) => (
<div key={t.id} className="flex items-center gap-2 border border-border rounded-md px-3 py-1.5 bg-muted/30 text-xs">
<span className={`size-1.5 rounded-full ${STATUS_MAP[t.status].dot}`} />
<span className="font-mono font-medium">{t.name}</span>
<span className="text-muted-foreground">{t.localInnerIp} ↔ {t.remoteInnerIp}</span>
{t.ipsec && <LockIcon className="size-3 text-emerald-400" />}
</div>
))}
</div>
</div>
)
})}
</div>
</div>
</Card>
)}
{/* RouterOS reference */}
<Card>
<CardContent className="px-5 py-4">
<p className="text-xs font-medium text-muted-foreground mb-3">RouterOS 7.20+ — параметры GRE-интерфейса</p>
<div className="grid grid-cols-2 gap-x-8 gap-y-1 text-xs font-mono">
{[
["/interface gre add", ""],
[" name=", "имя интерфейса"],
[" remote-address=", "IP удалённого конца (обязателен)"],
[" local-address=", "0.0.0.0 = авто"],
[" ipsec-secret=", "PSK → auto peer+policy+proposal"],
[" mtu=", "по умолчанию 1476"],
[" keepalive=", "10s,10 (интервал,попытки)"],
[" dscp=", "inherit | 0-63"],
[" clamp-tcp-mss=", "yes | no"],
[" allow-fast-path=", "yes | no"],
["/ip address add", ""],
[" address=x.x.x.x/30", "внутренний IP туннеля"],
[" interface=<name>", ""],
].map(([cmd, desc], i) => (
<div key={i} className="flex gap-2 py-0.5">
<span className="text-foreground/70 shrink-0">{cmd}</span>
{desc && <span className="text-muted-foreground"># {desc}</span>}
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
{/* ══ Sheet: Code Preview ════════════════════════════════════════════════ */}
<Sheet open={!!codePreviewTunnel} onOpenChange={(open) => { if (!open) setCodePreviewTunnel(null) }}>
<SheetContent side="right" className="w-full sm:max-w-2xl flex flex-col gap-0 p-0">
{codePreviewTunnel && (() => {
const code = generateRosCommands(codePreviewTunnel)
return (
<>
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<div className="flex items-start justify-between gap-4">
<div>
<SheetTitle className="font-mono">{codePreviewTunnel.name}</SheetTitle>
<SheetDescription>Команды RouterOS 7.20+ для создания туннеля</SheetDescription>
</div>
<Button
variant="outline" size="sm"
className="shrink-0 gap-1.5"
onClick={() => handleCopy(code)}
>
{copied
? <><CheckIcon className="size-3.5 text-emerald-500" /> Скопировано</>
: <><CopyIcon className="size-3.5" /> Копировать</>}
</Button>
</div>
</SheetHeader>
<div className="flex-1 overflow-y-auto">
{/* meta strip */}
<div className="flex flex-wrap gap-3 px-6 py-3 border-b bg-muted/30 text-xs">
<span className="flex items-center gap-1.5">
<span className={`size-1.5 rounded-full ${STATUS_MAP[codePreviewTunnel.status].dot}`} />
{STATUS_MAP[codePreviewTunnel.status].label}
</span>
<span className="text-muted-foreground">·</span>
<span>{serverById[codePreviewTunnel.serverId]?.name}</span>
<span className="text-muted-foreground">·</span>
<span className="font-mono">{codePreviewTunnel.localAddress === "0.0.0.0" ? "авто" : codePreviewTunnel.localAddress} → {codePreviewTunnel.remoteAddress}</span>
{codePreviewTunnel.ipsec && (
<>
<span className="text-muted-foreground">·</span>
<span className="flex items-center gap-1 text-emerald-400"><LockIcon className="size-3" /> IPsec {IKE_LABELS[codePreviewTunnel.ipsec.ikeVersion]}</span>
</>
)}
</div>
{/* code block */}
<pre className="px-6 py-5 text-xs font-mono leading-relaxed text-foreground/90 whitespace-pre overflow-x-auto select-all">
{code.split("\n").map((line, i) => {
const isComment = line.startsWith("#")
const isSection = isComment && line.includes("──")
const isKey = /^\s+[a-z]/.test(line)
return (
<span key={i} className={
isSection ? "text-muted-foreground/60"
: isComment ? "text-muted-foreground"
: isKey ? "text-sky-400/90"
: "text-foreground"
}>
{line}
{"\n"}
</span>
)
})}
</pre>
</div>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
<Button className="flex-1 gap-1.5" onClick={() => handleCopy(code)}>
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
{copied ? "Скопировано" : "Копировать команды"}
</Button>
</SheetFooter>
</>
)
})()}
</SheetContent>
</Sheet>
{/* ══ Sheet: Add Tunnel ══════════════════════════════════════════════════ */}
<Sheet open={tunnelOpen} onOpenChange={setTunnelOpen}>
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>Новый GRE-туннель</SheetTitle>
<SheetDescription>RouterOS 7.20+ · /interface gre add</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
<div className="flex flex-col gap-4">
<SectionTitle>Основные</SectionTitle>
<Field label="Имя интерфейса" required hint="Только латиница, цифры и дефис, например gre-msk-spb">
<Input className="font-mono" placeholder="gre-msk-spb" value={tForm.name} onChange={(e) => setT("name", e.target.value)} />
</Field>
<Field label="Сервер (MikroTik)" required>
<select value={tForm.serverId} onChange={(e) => setT("serverId", e.target.value)}
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
<option value="" disabled>Выбрать сервер…</option>
{servers.map((s) => <option key={s.id} value={s.id}>{s.name} ({s.site})</option>)}
</select>
</Field>
<Field label="Комментарий">
<Input placeholder="Описание туннеля" value={tForm.comment} onChange={(e) => setT("comment", e.target.value)} />
</Field>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Включён</span>
<Toggle checked={tForm.enabled} onChange={(v) => setT("enabled", v)} />
</div>
</div>
<div className="flex flex-col gap-4">
<SectionTitle>Эндпоинты</SectionTitle>
<Field label="Локальный адрес" hint="Оставьте пустым или 0.0.0.0 для автоопределения">
<Input className="font-mono" placeholder="0.0.0.0" value={tForm.localAddress} onChange={(e) => setT("localAddress", e.target.value)} />
</Field>
<Field label="Удалённый адрес" required hint="Внешний IP удалённого MikroTik">
<Input className="font-mono" placeholder="203.0.113.1" value={tForm.remoteAddress} onChange={(e) => setT("remoteAddress", e.target.value)} />
</Field>
</div>
<div className="flex flex-col gap-4">
<SectionTitle>Внутренний IP</SectionTitle>
<Field label="IP-пул" required hint="Из какого пула выделяется /30-блок">
<select value={tForm.poolId} onChange={(e) => setT("poolId", e.target.value)}
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
<option value="" disabled>Выбрать пул…</option>
{grePools.map((p) => <option key={p.id} value={p.id}>{p.name} ({p.cidr}) — свободно {p.total - p.allocated} блоков</option>)}
</select>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Локальный IP" required hint="/ip address на этом конце">
<Input className="font-mono" placeholder="10.200.0.1/30" value={tForm.localInnerIp} onChange={(e) => setT("localInnerIp", e.target.value)} />
</Field>
<Field label="Удалённый IP" required hint="/ip address на другом конце">
<Input className="font-mono" placeholder="10.200.0.2/30" value={tForm.remoteInnerIp} onChange={(e) => setT("remoteInnerIp", e.target.value)} />
</Field>
</div>
</div>
<div className="flex flex-col gap-4">
<SectionTitle>IPsec</SectionTitle>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Включить IPsec</p>
<p className="text-xs text-muted-foreground">RouterOS автоматически создаст peer, policy и proposal</p>
</div>
<Toggle checked={tForm.ipsecEnabled} onChange={(v) => setT("ipsecEnabled", v)} />
</div>
{tForm.ipsecEnabled && (
<div className="flex flex-col gap-4 pl-4 border-l-2 border-emerald-500/30">
<Field label="Пароль (PSK)" required hint="ipsec-secret — pre-shared key для автоматического IKE">
<div className="relative">
<Input type={tForm.ipsecShowSecret ? "text" : "password"} className="font-mono pr-9"
placeholder="Минимум 8 символов" value={tForm.ipsecSecret} onChange={(e) => setT("ipsecSecret", e.target.value)} />
<button type="button" onClick={() => setT("ipsecShowSecret", !tForm.ipsecShowSecret)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
{tForm.ipsecShowSecret ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
</button>
</div>
</Field>
<Field label="IKE-версия">
<SegmentedControl value={tForm.ipsecIkeVersion} onChange={(v) => setT("ipsecIkeVersion", v)}
options={[{ value: "ikev1", label: "IKEv1" }, { value: "ikev2", label: "IKEv2 (рек.)" }]} />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Шифрование">
<select value={tForm.ipsecEncAlg} onChange={(e) => setT("ipsecEncAlg", e.target.value as IpsecEncAlg)}
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
{(Object.entries(ENC_LABELS) as [IpsecEncAlg, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</Field>
<Field label="Хеш-алгоритм">
<select value={tForm.ipsecAuthAlg} onChange={(e) => setT("ipsecAuthAlg", e.target.value as IpsecAuthAlg)}
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
{(Object.entries(AUTH_LABELS) as [IpsecAuthAlg, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</Field>
</div>
<Field label="DH-группа" hint="Группа Диффи-Хеллмана для обмена ключами">
<select value={tForm.ipsecDhGroup} onChange={(e) => setT("ipsecDhGroup", e.target.value as IpsecDhGroup)}
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
{(Object.entries(DH_LABELS) as [IpsecDhGroup, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Срок жизни SA" hint="Формат: 1d 00:00:00">
<Input className="font-mono" value={tForm.ipsecLifetime} onChange={(e) => setT("ipsecLifetime", e.target.value)} />
</Field>
<div className="flex items-center justify-between pt-6">
<span className="text-sm font-medium">PFS</span>
<Toggle checked={tForm.ipsecPfs} onChange={(v) => setT("ipsecPfs", v)} />
</div>
</div>
</div>
)}
</div>
<div className="flex flex-col gap-4">
<button type="button" onClick={() => setT("showAdvanced", !tForm.showAdvanced)}
className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors">
{tForm.showAdvanced ? <ChevronDownIcon className="size-3.5" /> : <ChevronRightIcon className="size-3.5" />}
Дополнительно
<div className="flex-1 h-px bg-border" />
</button>
{tForm.showAdvanced && (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-3 gap-3">
<Field label="MTU" hint="По умолч. 1476">
<Input type="number" className="font-mono" value={tForm.mtu} onChange={(e) => setT("mtu", Number(e.target.value))} />
</Field>
<Field label="Keepalive, с" hint="0 = откл.">
<Input type="number" className="font-mono" value={tForm.keepaliveInterval} onChange={(e) => setT("keepaliveInterval", Number(e.target.value))} />
</Field>
<Field label="Попытки">
<Input type="number" className="font-mono" value={tForm.keepaliveRetries} onChange={(e) => setT("keepaliveRetries", Number(e.target.value))} />
</Field>
</div>
<Field label="DSCP">
<select value={tForm.dscp} onChange={(e) => setT("dscp", e.target.value)}
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
<option value="inherit">inherit</option>
{Array.from({ length: 64 }, (_, i) => <option key={i} value={String(i)}>{i}</option>)}
</select>
</Field>
{[
{ key: "clampTcpMss" as const, label: "Clamp TCP MSS", desc: "Ограничить MSS до MTU туннеля" },
{ key: "allowFastPath" as const, label: "Allow Fast Path", desc: "Аппаратное ускорение трафика" },
].map(({ key, label, desc }) => (
<div key={key} className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">{label}</p>
<p className="text-xs text-muted-foreground">{desc}</p>
</div>
<Toggle checked={tForm[key] as boolean} onChange={(v) => setT(key, v)} />
</div>
))}
</div>
)}
</div>
</div>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
<Button className="flex-1" onClick={() => setTunnelOpen(false)}>Создать туннель</Button>
</SheetFooter>
</SheetContent>
</Sheet>
{/* ══ Sheet: Add Pool ════════════════════════════════════════════════════ */}
<Sheet open={poolOpen} onOpenChange={setPoolOpen}>
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>Новый IP-пул</SheetTitle>
<SheetDescription>Пул адресов для назначения внутренних IP GRE-туннелям</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
<div className="flex flex-col gap-4">
<SectionTitle>Параметры пула</SectionTitle>
<Field label="Имя пула" required hint="Например pool-gre-office или pool-gre-dc2">
<Input className="font-mono" placeholder="pool-gre-core" value={pForm.name}
onChange={(e) => setPForm((f) => ({ ...f, name: e.target.value }))} />
</Field>
<Field label="Диапазон CIDR" required hint="Блок, из которого будут нарезаться /30 на каждый туннель">
<Input className="font-mono" placeholder="10.200.0.0/24" value={pForm.cidr}
onChange={(e) => setPForm((f) => ({ ...f, cidr: e.target.value }))} />
</Field>
<Field label="Назначение / Комментарий">
<Input placeholder="Ядровые межузловые туннели" value={pForm.comment}
onChange={(e) => setPForm((f) => ({ ...f, comment: e.target.value }))} />
</Field>
{pForm.cidr && /\/\d+$/.test(pForm.cidr) && (() => {
const prefix = parseInt(pForm.cidr.split("/")[1] ?? "0")
const blocks = prefix <= 30 ? Math.pow(2, 30 - prefix) : 0
return blocks > 0 ? (
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3 text-sm">
<p className="text-muted-foreground">
Доступно <span className="font-semibold text-foreground font-mono">{blocks}</span> блоков /30
{" "}= до <span className="font-semibold text-foreground font-mono">{blocks}</span> туннелей
</p>
</div>
) : null
})()}
</div>
</div>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
<Button className="flex-1" onClick={() => setPoolOpen(false)}>Создать пул</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</div>
)
}
+84
View File
@@ -0,0 +1,84 @@
"use client"
import { PageHeader } from "@/components/page-header"
import { DataTable } from "@/components/data-table"
import { ipRanges } from "@/lib/data"
import { Button } from "@/components/ui/button"
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon } from "lucide-react"
export default function IpRangesPage() {
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Данные" }, { label: "IP-диапазоны" }]}
actions={
<>
<Button variant="outline" size="sm"><UploadIcon className="size-4" />Импорт</Button>
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
<Button size="sm"><PlusIcon className="size-4" />Добавить диапазон</Button>
</>
}
/>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">IP-диапазоны</h1>
<p className="text-sm text-muted-foreground mt-1">
CIDR-блоки, отправляемые в address-list MikroTik по всем настроенным фильтрам
</p>
</div>
<DataTable
data={ipRanges}
searchPlaceholder="Поиск по CIDR, ASN…"
searchKeys={["cidr", "asn", "country", "filter"]}
columns={[
{
key: "cidr",
label: "CIDR",
render: (d) => <span className="font-mono font-medium">{d.cidr}</span>,
},
{
key: "asn",
label: "ASN",
render: (d) => <span className="font-mono text-xs text-muted-foreground">{d.asn}</span>,
},
{
key: "country",
label: "Страна",
render: (d) => <span className="text-xs border border-border rounded px-2 py-0.5">{d.country}</span>,
},
{
key: "purpose",
label: "Назначение",
render: (d) => <span className="text-xs border border-border rounded px-2 py-0.5">{d.purpose}</span>,
},
{
key: "filter",
label: "Фильтр",
render: (d) => (
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
<FilterIcon className="size-3 text-muted-foreground" />{d.filter}
</span>
),
},
{
key: "updated",
label: "Обновлён",
render: (d) => <span className="text-xs text-muted-foreground">{d.updated}</span>,
},
{
key: "enabled",
label: "Статус",
render: (d) => (
<span className={`text-xs font-medium ${d.enabled ? "text-emerald-600" : "text-muted-foreground"}`}>
{d.enabled ? "Активен" : "Отключён"}
</span>
),
},
]}
/>
</div>
</div>
</div>
)
}
+16
View File
@@ -0,0 +1,16 @@
import { AppSidebar } from "@/components/app-sidebar"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
import { CommandPalette } from "@/components/command-palette"
import { DataSourceProvider } from "@/lib/data-source"
export default function MainLayout({ children }: { children: React.ReactNode }) {
return (
<DataSourceProvider>
<SidebarProvider>
<AppSidebar />
<SidebarInset className="h-svh overflow-hidden">{children}</SidebarInset>
<CommandPalette />
</SidebarProvider>
</DataSourceProvider>
)
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+802
View File
@@ -0,0 +1,802 @@
"use client"
import { useEffect, useRef, useState, useMemo, useCallback } from "react"
import { PageHeader } from "@/components/page-header"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { servers, greTunnels } from "@/lib/data"
import {
PlayIcon, SquareIcon, CopyIcon, Trash2Icon, PlusIcon,
ActivityIcon, RouteIcon, SearchIcon, NetworkIcon, RulerIcon,
ZapIcon, ClockIcon, CheckIcon, TerminalIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
// ─── types ────────────────────────────────────────────────────────────────────
type DiagTool = "ping" | "traceroute" | "bandwidth" | "dns" | "route" | "mtu"
type RunStatus = "running" | "done" | "error"
type TraceProto = "icmp" | "udp" | "tcp"
type DnsType = "A" | "AAAA" | "MX" | "NS" | "TXT" | "CNAME" | "PTR"
interface OutputLine { text: string; kind: "normal" | "ok" | "err" | "dim" | "header" | "cmd" }
interface DiagTest {
id: string
tool: DiagTool
status: RunStatus
srcServerId: string
srcServerName: string
target: string
command: string
startedAt: number
lines: OutputLine[]
totalLines: number // final line count — reveals progressively
}
type SchedType = "ping" | "bandwidth" | "both"
interface SchedRule {
id: string; srcId: string; tunnelId: string; type: SchedType
intervalMin: number; enabled: boolean
lastRun: string | null; nextRunMin: number | null
}
// ─── tool metadata ────────────────────────────────────────────────────────────
const TOOL_META: Record<DiagTool, {
label: string
ros: string // RouterOS tool path
Icon: React.FC<{ className?: string }>
color: string
description: string
}> = {
ping: { label: "Ping", ros: "/tool ping", Icon: ActivityIcon, color: "text-sky-500", description: "Проверка связи и RTT" },
traceroute: { label: "Traceroute", ros: "/tool traceroute", Icon: RouteIcon, color: "text-violet-500", description: "Трассировка маршрута" },
bandwidth: { label: "BW-тест", ros: "/tool bandwidth-test", Icon: ZapIcon, color: "text-emerald-500", description: "Пропускная способность" },
dns: { label: "DNS", ros: "/resolve", Icon: SearchIcon, color: "text-amber-500", description: "Разрешение DNS-имён" },
route: { label: "Маршрут", ros: "/ip route lookup", Icon: NetworkIcon, color: "text-blue-500", description: "Поиск активного маршрута" },
mtu: { label: "MTU-тест", ros: "/tool ping", Icon: RulerIcon, color: "text-orange-500", description: "Определение MTU пути" },
}
// ─── helpers ──────────────────────────────────────────────────────────────────
function jitter(base: number, pct = 0.15) {
return Math.max(1, Math.round(base + (Math.random() - 0.5) * base * pct * 2))
}
function seededRng(seed: string) {
let h = 0
for (let i = 0; i < seed.length; i++) h = (Math.imul(31, h) + seed.charCodeAt(i)) | 0
h = Math.abs(h)
return (n = 233280) => { h = (h * 9301 + 49297) % n; return h / n }
}
function baseRtt(srcId: string, _target: string): number {
const map: Record<string, number> = { srv1: 13, srv2: 11, srv3: 5, srv4: 8, srv5: 80, srv6: 8, srv7: 14 }
return map[srcId] ?? 20
}
function randomIp(rng: () => number, base = "10.") {
return `${base}${Math.floor(rng() * 255)}.${Math.floor(rng() * 255)}.${Math.floor(rng() * 255)}`
}
// ─── output generators ────────────────────────────────────────────────────────
function genPingOutput(target: string, srcId: string, count: number, size: number, _ttl: number): OutputLine[] {
const rng = seededRng(target + srcId)
const base = baseRtt(srcId, target)
const rtts: (number | null)[] = Array.from({ length: count }, () =>
rng() < 0.04 ? null : jitter(base, 0.2),
)
const lines: OutputLine[] = [
{ text: ` SEQ HOST SIZE TTL TIME STATUS`, kind: "header" },
]
rtts.forEach((rtt, i) => {
lines.push({
text: ` ${String(i).padStart(3)} ${target.padEnd(40)} ${String(size).padStart(4)} ${String(rtt ? 118 : 0).padStart(3)} ${rtt ? `${rtt}ms`.padStart(6) : " "} ${rtt ? "" : "timeout"}`,
kind: rtt ? "normal" : "err",
})
})
const recv = rtts.filter(r => r !== null) as number[]
const loss = Math.round((count - recv.length) / count * 100)
lines.push({ text: "", kind: "dim" })
lines.push({
text: ` sent=${count} received=${recv.length} packet-loss=${loss}% min-rtt=${Math.min(...recv)}ms avg-rtt=${Math.round(recv.reduce((a, b) => a + b, 0) / recv.length)}ms max-rtt=${Math.max(...recv)}ms`,
kind: loss === 0 ? "ok" : "err",
})
return lines
}
function genTraceOutput(target: string, srcId: string, proto: TraceProto, maxHops: number): OutputLine[] {
const rng = seededRng(target + srcId + proto)
const base = baseRtt(srcId, target)
const hopCount = Math.min(maxHops, 4 + Math.floor(rng() * 3))
const lines: OutputLine[] = [
{ text: ` # ADDRESS LOSS SENT LAST AVG BEST WORST`, kind: "header" },
]
for (let i = 1; i <= hopCount; i++) {
const addr = i === 1 ? "10.200.0.2" : i === hopCount ? target : randomIp(rng, "95.213.")
const frac = i / hopCount
const rtt1 = Math.round(base * frac * jitter(1, 0.1))
const rtt2 = Math.round(base * frac * jitter(1, 0.08))
const rtt3 = Math.round(base * frac * jitter(1, 0.12))
const avg = Math.round((rtt1 + rtt2 + rtt3) / 3)
lines.push({
text: ` ${String(i).padStart(2)} ${addr.padEnd(40)} 0% 3 ${`${rtt1}ms`.padStart(6)} ${`${avg}ms`.padStart(6)} ${`${Math.min(rtt1,rtt2,rtt3)}ms`.padStart(6)} ${`${Math.max(rtt1,rtt2,rtt3)}ms`.padStart(6)}`,
kind: i === hopCount ? "ok" : "normal",
})
}
return lines
}
function genDnsOutput(target: string, srcId: string, type: DnsType): OutputLine[] {
const rng = seededRng(target + srcId + type)
const ttl = 300 - Math.floor(rng() * 200)
const time = 5 + Math.floor(rng() * 20)
const lines: OutputLine[] = [
{ text: `;; QUESTION SECTION:`, kind: "dim" },
{ text: `;; ${target}. IN ${type}`, kind: "dim" },
{ text: "", kind: "dim" },
{ text: `;; ANSWER SECTION:`, kind: "header" },
]
const count = type === "A" ? 2 + Math.floor(rng() * 2) : 1
for (let i = 0; i < count; i++) {
let value = ""
if (type === "A") value = `142.250.${74 + Math.floor(rng() * 10)}.${Math.floor(rng() * 200)}`
if (type === "AAAA") value = `2001:db8::${Math.floor(rng() * 0xffff).toString(16)}`
if (type === "MX") value = `10 mail.${target}.`
if (type === "NS") value = `ns${i + 1}.${target}.`
if (type === "TXT") value = `"v=spf1 include:${target} ~all"`
if (type === "CNAME") value = `${target}.cdn.example.com.`
if (type === "PTR") value = `${target}.in-addr.arpa.`
lines.push({ text: `${target.padEnd(24)} ${String(ttl).padStart(5)} IN ${type.padEnd(5)} ${value}`, kind: "ok" })
}
lines.push({ text: "", kind: "dim" })
lines.push({ text: `;; Query time: ${time} msec`, kind: "dim" })
lines.push({ text: `;; SERVER: 1.1.1.1`, kind: "dim" })
return lines
}
function genRouteOutput(target: string, srcId: string): OutputLine[] {
const rng = seededRng(target + srcId)
const gw = `10.200.0.${2 + Math.floor(rng() * 14)}`
const iface = ["gre-msk-fra", "gre-msk-ams", "gre-msk-spb"][Math.floor(rng() * 3)]
const prefix = target.split(".").slice(0, 3).join(".") + ".0/24"
const asn = [15169, 32934, 20940][Math.floor(rng() * 3)]
const comm = `65001:${100 + Math.floor(rng() * 4) * 100}`
return [
{ text: `Flags: X - disabled, A - active, B - blackhole, U - unreachable`, kind: "dim" },
{ text: "", kind: "dim" },
{ text: ` # DST-ADDRESS PREF-SRC GATEWAY DISTANCE SCOPE TARGET-SCOPE`, kind: "header" },
{ text: ` 0 A ${prefix.padEnd(18)} ${gw.padEnd(15)} 200 30 10`, kind: "ok" },
{ text: ` routing-table: main`, kind: "dim" },
{ text: ` bgp-as-path: ${asn}`, kind: "normal" },
{ text: ` bgp-communities: ${comm} ${asn}:5003`, kind: "normal" },
{ text: ` bgp-local-pref: 100`, kind: "dim" },
{ text: ` bgp-med: 0`, kind: "dim" },
{ text: ` bgp-origin: igp`, kind: "dim" },
{ text: ` bgp-nexthop: ${gw}`, kind: "dim" },
{ text: ` bgp-ext-communities: (nothing)`, kind: "dim" },
{ text: ` interface: ${iface}`, kind: "normal" },
{ text: ` gateway: ${gw}`, kind: "normal" },
{ text: "", kind: "dim" },
{ text: `1 route found`, kind: "ok" },
]
}
function genMtuOutput(target: string, srcId: string): OutputLine[] {
const base = baseRtt(srcId, target)
const mtu = [1476, 1472, 1468, 1400][Math.floor(Math.random() * 2)] // common GRE MTUs
const sizes = [1500, 1492, mtu + 8, mtu + 4, mtu, mtu - 4]
const lines: OutputLine[] = [
{ text: `MTU path discovery → ${target} (do-not-fragment ping)`, kind: "header" },
{ text: "", kind: "dim" },
{ text: ` SIZE RESULT RTT`, kind: "dim" },
]
sizes.forEach(sz => {
const pass = sz <= mtu
const rtt = pass ? jitter(base, 0.1) : null
lines.push({
text: ` ${String(sz).padStart(4)} ${pass ? "✓ PASS" : "✗ FAIL (frag needed)"} ${pass ? `${rtt}ms` : "—"}`,
kind: pass ? "ok" : "err",
})
})
lines.push({ text: "", kind: "dim" })
lines.push({ text: `MTU discovered: ${mtu} bytes`, kind: "ok" })
const overhead = 1500 - mtu
lines.push({ text: `Overhead: ${overhead} bytes (GRE ${overhead >= 24 ? "+IPsec" : "no IPsec"})`, kind: "dim" })
return lines
}
function genBwOutput(target: string, srcId: string, proto: string, duration: number): OutputLine[] {
const base = [410, 580, 220, 680][Math.floor(Math.random() * 4)]
seededRng(target + srcId)
const lines: OutputLine[] = [
{ text: ` status: running`, kind: "dim" },
{ text: ` direction: both`, kind: "dim" },
{ text: ` protocol: ${proto.toUpperCase()}`, kind: "dim" },
{ text: ` duration: ${duration}s`, kind: "dim" },
{ text: "", kind: "dim" },
]
for (let t = 2; t <= duration; t += 2) {
const tx = jitter(base * 0.98, 0.08)
const rx = jitter(base * 0.95, 0.09)
lines.push({
text: ` [${String(t).padStart(2)}s] tx-current: ${tx}Mbps rx-current: ${rx}Mbps`,
kind: "normal",
})
}
const txAvg = jitter(base * 0.97, 0.04)
const rxAvg = jitter(base * 0.94, 0.04)
lines.push({ text: "", kind: "dim" })
lines.push({ text: ` status: done`, kind: "ok" })
lines.push({ text: ` tx-total-average: ${txAvg}Mbps`, kind: "ok" })
lines.push({ text: ` rx-total-average: ${rxAvg}Mbps`, kind: "ok" })
return lines
}
// ─── command builder ──────────────────────────────────────────────────────────
function buildCommand(
tool: DiagTool, src: string, target: string,
opts: { pingCount?: number; pingSize?: number; pingTtl?: number; traceProto?: TraceProto; traceMaxHops?: number; dnsType?: DnsType; mtuStart?: number; bwProto?: string; bwDuration?: number; bwTarget?: string },
): string {
const host = servers.find(s => s.id === src)?.host ?? "?"
switch (tool) {
case "ping":
return `/tool ping address=${target} count=${opts.pingCount ?? 5} size=${opts.pingSize ?? 64} ttl=${opts.pingTtl ?? 64} src-address=${host}`
case "traceroute":
return `/tool traceroute address=${target} max-hops=${opts.traceMaxHops ?? 30} protocol=${opts.traceProto ?? "icmp"} src-address=${host}`
case "bandwidth":
return `/tool bandwidth-test address=${opts.bwTarget ?? target} duration=${opts.bwDuration ?? 10}s protocol=${opts.bwProto ?? "tcp"} direction=both`
case "dns":
return `/resolve ${target} type=${opts.dnsType ?? "A"} server=1.1.1.1`
case "route":
return `/ip route lookup ip=${target}`
case "mtu":
return `/tool ping address=${target} do-not-fragment count=1 size=${opts.mtuStart ?? 1500} src-address=${host}`
}
}
// ─── small UI components ──────────────────────────────────────────────────────
function NativeSelect({ value, onChange, children, className }: {
value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string
}) {
return (
<select value={value} onChange={e => onChange(e.target.value)}
className={cn(
"h-8 min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-sm",
"text-foreground transition-colors outline-none",
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 dark:bg-input/30",
className,
)}>
{children}
</select>
)
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button onClick={() => onChange(!checked)}
className={cn("relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors",
checked ? "bg-primary" : "bg-muted-foreground/30")}>
<span className={cn("inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform",
checked ? "translate-x-4" : "translate-x-0.5")} />
</button>
)
}
function OptionLabel({ children }: { children: React.ReactNode }) {
return <p className="text-[11px] font-medium text-muted-foreground mb-1">{children}</p>
}
function SegBtn<T extends string | number>({ value, current, onClick, children }: {
value: T; current: T; onClick: (v: T) => void; children: React.ReactNode
}) {
return (
<button onClick={() => onClick(value)}
className={cn(
"px-2.5 py-1 text-xs font-mono border-r last:border-r-0 border-input transition-colors",
value === current ? "bg-muted text-foreground font-semibold" : "text-muted-foreground hover:bg-muted/50",
)}>
{children}
</button>
)
}
// ─── terminal output ──────────────────────────────────────────────────────────
function TerminalOutput({ test }: { test: DiagTest }) {
const ref = useRef<HTMLDivElement>(null)
const visible = test.lines.slice(0, test.totalLines)
useEffect(() => {
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight
}, [visible.length])
return (
<div ref={ref}
className="h-72 overflow-y-auto rounded-lg bg-zinc-950 dark:bg-zinc-900 border border-zinc-800 px-4 py-3 font-mono text-xs leading-relaxed">
{/* command line */}
<div className="mb-2 flex items-center gap-2">
<span className="text-zinc-500">[{test.srcServerName}]</span>
<span className="text-emerald-400">$</span>
<span className="text-zinc-300">{test.command}</span>
{test.status === "running" && (
<span className="inline-block size-1.5 rounded-full bg-emerald-400 animate-pulse ml-1" />
)}
</div>
{/* output */}
{visible.map((line, i) => (
<div key={i} className={cn(
"whitespace-pre leading-5",
line.kind === "ok" && "text-emerald-400",
line.kind === "err" && "text-red-400",
line.kind === "dim" && "text-zinc-500",
line.kind === "header" && "text-zinc-400 font-semibold",
line.kind === "cmd" && "text-amber-400",
line.kind === "normal" && "text-zinc-300",
)}>
{line.text || " "}
</div>
))}
{test.status === "running" && visible.length < test.lines.length && (
<div className="text-zinc-600 animate-pulse">…</div>
)}
</div>
)
}
// ─── schedule tab ─────────────────────────────────────────────────────────────
const INIT_RULES: SchedRule[] = [
{ id: "r1", srcId: "srv1", tunnelId: "gre1", type: "both", intervalMin: 15, enabled: true, lastRun: "3 мин назад", nextRunMin: 12 },
{ id: "r2", srcId: "srv1", tunnelId: "gre2", type: "ping", intervalMin: 5, enabled: true, lastRun: "1 мин назад", nextRunMin: 4 },
{ id: "r3", srcId: "srv7", tunnelId: "gre5", type: "bandwidth", intervalMin: 60, enabled: false, lastRun: "2ч назад", nextRunMin: null },
]
function ScheduleTab({ rules, setRules }: {
rules: SchedRule[]; setRules: React.Dispatch<React.SetStateAction<SchedRule[]>>
}) {
const [showAdd, setShowAdd] = useState(false)
const [addSrc, setAddSrc] = useState("srv1")
const [addTun, setAddTun] = useState("gre1")
const [addType, setAddType] = useState<SchedType>("ping")
const [addMin, setAddMin] = useState(10)
const addTunnels = useMemo(() => greTunnels.filter(t => t.serverId === addSrc), [addSrc])
const typeLabel: Record<SchedType, string> = { ping: "Ping", bandwidth: "BW-тест", both: "Ping + BW" }
return (
<div className="flex flex-col gap-3">
<Card className="overflow-hidden">
{rules.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 gap-2 text-muted-foreground">
<ClockIcon className="size-7 opacity-20" />
<p className="text-sm">Нет правил расписания</p>
</div>
) : (
<>
<div className="grid grid-cols-[40px_1fr_140px_80px_100px_1fr_auto] gap-2 items-center px-4 py-2 bg-muted/30 border-b text-[10px] font-semibold text-muted-foreground uppercase tracking-wide">
<span />
<span>Туннель</span>
<span>Сервер</span>
<span>Тип</span>
<span>Интервал</span>
<span>Последний / следующий</span>
<span />
</div>
<div className="divide-y divide-border/60">
{rules.map(rule => {
const src = servers.find(s => s.id === rule.srcId)
const tun = greTunnels.find(t => t.id === rule.tunnelId)
return (
<div key={rule.id} className={cn(
"grid grid-cols-[40px_1fr_140px_80px_100px_1fr_auto] gap-2 items-center px-4 py-2.5 hover:bg-muted/20 transition-colors",
!rule.enabled && "opacity-50",
)}>
<Toggle checked={rule.enabled}
onChange={v => setRules(p => p.map(r => r.id === rule.id ? { ...r, enabled: v } : r))} />
<code className="font-mono text-xs truncate">{tun?.name ?? rule.tunnelId}</code>
<span className="text-xs text-muted-foreground truncate">{src?.name ?? rule.srcId}</span>
<span className={cn("text-[10px] px-1.5 py-0.5 rounded border font-medium w-fit",
rule.type === "ping" ? "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20"
: rule.type === "bandwidth" ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20",
)}>{typeLabel[rule.type]}</span>
<span className="text-xs text-muted-foreground">каждые {rule.intervalMin} мин</span>
<div className="text-xs text-muted-foreground flex items-center gap-2 min-w-0">
{rule.lastRun && <span className="truncate">{rule.lastRun}</span>}
{rule.nextRunMin != null && rule.enabled && (
<span className="text-sky-600 dark:text-sky-400 shrink-0">· через {rule.nextRunMin} мин</span>
)}
</div>
<button onClick={() => setRules(p => p.filter(r => r.id !== rule.id))}
className="size-6 flex items-center justify-center rounded text-muted-foreground/40 hover:text-red-500 hover:bg-red-500/10 transition-colors">
<Trash2Icon className="size-3.5" />
</button>
</div>
)
})}
</div>
</>
)}
</Card>
{showAdd ? (
<Card className="overflow-hidden">
<div className="px-4 py-3 border-b flex items-center gap-2 text-sm font-medium">
<PlusIcon className="size-4 text-muted-foreground" />Новое правило
</div>
<div className="px-4 py-4 flex flex-wrap items-end gap-3">
<div><OptionLabel>Сервер</OptionLabel>
<NativeSelect value={addSrc} onChange={setAddSrc} className="min-w-[160px]">
{servers.filter(s => s.enabled).map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</NativeSelect>
</div>
<div><OptionLabel>GRE-туннель</OptionLabel>
<NativeSelect value={addTun} onChange={setAddTun} className="min-w-[150px]">
{addTunnels.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</NativeSelect>
</div>
<div><OptionLabel>Тип</OptionLabel>
<NativeSelect value={addType} onChange={v => setAddType(v as SchedType)}>
<option value="ping">Ping</option>
<option value="bandwidth">BW-тест</option>
<option value="both">Ping + BW</option>
</NativeSelect>
</div>
<div><OptionLabel>Интервал (мин)</OptionLabel>
<Input type="number" min={1} max={1440} value={addMin}
onChange={e => setAddMin(Math.max(1, parseInt(e.target.value) || 1))}
className="w-20 font-mono text-sm" />
</div>
<Button size="sm" disabled={!addTunnels.length}
onClick={() => {
setRules(p => [...p, { id: `r${Date.now()}`, srcId: addSrc, tunnelId: addTun, type: addType, intervalMin: addMin, enabled: true, lastRun: null, nextRunMin: addMin }])
setShowAdd(false)
}}>
<CheckIcon className="size-4" />Добавить
</Button>
<Button variant="outline" size="sm" onClick={() => setShowAdd(false)}>Отмена</Button>
</div>
</Card>
) : (
<Button variant="outline" size="sm" className="w-fit" onClick={() => setShowAdd(true)}>
<PlusIcon className="size-4" />Добавить правило
</Button>
)}
</div>
)
}
// ─── page ─────────────────────────────────────────────────────────────────────
export default function ProbesPage() {
// ── tool config ──
const [tool, setTool] = useState<DiagTool>("ping")
const [srcId, setSrcId] = useState(servers[0]?.id ?? "srv1")
const [target, setTarget] = useState("8.8.8.8")
const [pingCount, setPingCount] = useState(5)
const [pingSize, setPingSize] = useState(64)
const [pingTtl] = useState(64)
const [traceProto, setTraceProto] = useState<TraceProto>("icmp")
const [traceHops] = useState(30)
const [dnsType, setDnsType] = useState<DnsType>("A")
const [bwTunId, setBwTunId] = useState(greTunnels[0]?.id ?? "gre1")
const [bwProto, setBwProto] = useState<"tcp" | "udp">("tcp")
const [bwDuration, setBwDuration] = useState(10)
// ── run state ──
const [tests, setTests] = useState<DiagTest[]>([])
const [tab, setTab] = useState<"history" | "schedule">("history")
const [rules, setRules] = useState<SchedRule[]>(INIT_RULES)
const nextId = useRef(1)
const tickRef = useRef<ReturnType<typeof setInterval> | null>(null)
const bwTunnels = useMemo(() => greTunnels.filter(t => t.serverId === srcId), [srcId])
const srcServer = useMemo(() => servers.find(s => s.id === srcId), [srcId])
// current command preview
const cmdPreview = useMemo(() => buildCommand(tool, srcId, target, {
pingCount, pingSize, pingTtl, traceProto, traceMaxHops: traceHops, dnsType,
bwTarget: greTunnels.find(t => t.id === bwTunId)?.remoteAddress, bwProto, bwDuration,
}), [tool, srcId, target, pingCount, pingSize, pingTtl, traceProto, traceHops, dnsType, bwTunId, bwProto, bwDuration])
// ── progressive reveal tick ──
useEffect(() => {
tickRef.current = setInterval(() => {
setTests(prev => {
const hasRunning = prev.some(t => t.status === "running")
if (!hasRunning) return prev
return prev.map(t => {
if (t.status !== "running") return t
const speed = t.tool === "dns" || t.tool === "route" ? t.lines.length : 1
const nextTotal = Math.min(t.totalLines + speed, t.lines.length)
const done = nextTotal >= t.lines.length
return { ...t, totalLines: nextTotal, status: done ? "done" : "running" }
})
})
}, 400)
return () => { if (tickRef.current) clearInterval(tickRef.current) }
}, [])
// ── run test ──
const runTest = useCallback(() => {
const srv = servers.find(s => s.id === srcId)
if (!srv) return
const id = String(nextId.current++)
let lines: OutputLine[] = []
const bwTun = greTunnels.find(t => t.id === bwTunId)
switch (tool) {
case "ping": lines = genPingOutput(target, srcId, pingCount, pingSize, pingTtl); break
case "traceroute":lines = genTraceOutput(target, srcId, traceProto, traceHops); break
case "dns": lines = genDnsOutput(target, srcId, dnsType); break
case "route": lines = genRouteOutput(target, srcId); break
case "mtu": lines = genMtuOutput(target, srcId); break
case "bandwidth": lines = genBwOutput(bwTun?.remoteAddress ?? target, srcId, bwProto, bwDuration); break
}
const test: DiagTest = {
id, tool, status: "running",
srcServerId: srcId, srcServerName: srv.name,
target: tool === "bandwidth" ? (bwTun?.name ?? target) : target,
command: cmdPreview,
startedAt: Date.now(),
lines,
totalLines: 0,
}
setTests(p => [test, ...p.slice(0, 9)]) // keep last 10
setTab("history")
}, [tool, srcId, target, pingCount, pingSize, pingTtl, traceProto, traceHops, dnsType, bwTunId, bwProto, bwDuration, cmdPreview])
const stopTest = (id: string) => setTests(p => p.map(t => t.id === id ? { ...t, status: "done", totalLines: t.lines.length } : t))
const clearTest = (id: string) => setTests(p => p.filter(t => t.id !== id))
const running = tests.filter(t => t.status === "running")
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Инструменты" }, { label: "Диагностика" }]}
actions={
running.length > 0
? <Button variant="outline" size="sm" onClick={() => setTests(p => p.map(t => ({ ...t, status: "done" as const, totalLines: t.lines.length })))}>
<SquareIcon className="size-4" />Остановить все
</Button>
: undefined
}
/>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-4">
{/* ── tool selector + config ── */}
<Card>
<CardContent className="pt-4 pb-4 px-4 flex flex-col gap-4">
{/* tool chips */}
<div className="flex gap-2 flex-wrap">
{(Object.entries(TOOL_META) as [DiagTool, typeof TOOL_META[DiagTool]][]).map(([t, m]) => {
const active = tool === t
return (
<button key={t} onClick={() => setTool(t)}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-xs font-medium transition-colors",
active
? "border-foreground bg-foreground text-background"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
)}>
<m.Icon className={cn("size-3.5", active ? "" : m.color)} />
{m.label}
</button>
)
})}
</div>
{/* main config row */}
<div className="flex flex-wrap items-end gap-3">
{/* source server */}
<div>
<OptionLabel>Источник</OptionLabel>
<NativeSelect value={srcId} onChange={setSrcId} className="min-w-[175px]">
{servers.filter(s => s.enabled).map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</NativeSelect>
</div>
{/* target — all tools except bandwidth */}
{tool !== "bandwidth" && (
<div className="flex-1 min-w-[140px]">
<OptionLabel>
{tool === "dns" ? "Домен" : tool === "route" ? "Destination IP" : "Цель (IP или домен)"}
</OptionLabel>
<Input value={target} onChange={e => setTarget(e.target.value)}
placeholder={tool === "dns" ? "google.com" : tool === "route" ? "8.8.8.8" : "8.8.8.8"}
className="font-mono text-sm"
onKeyDown={e => e.key === "Enter" && runTest()}
/>
</div>
)}
{/* bandwidth: select GRE tunnel */}
{tool === "bandwidth" && (
<div>
<OptionLabel>GRE-туннель (цель)</OptionLabel>
<NativeSelect value={bwTunId} onChange={setBwTunId} className="min-w-[180px]">
{bwTunnels.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</NativeSelect>
</div>
)}
{/* inline quick options */}
{tool === "ping" && (
<>
<div>
<OptionLabel>Кол-во</OptionLabel>
<div className="flex rounded-lg border border-input overflow-hidden h-8">
{[5, 10, 25, 100].map(n => <SegBtn key={n} value={n} current={pingCount} onClick={setPingCount}>{n}</SegBtn>)}
</div>
</div>
<div>
<OptionLabel>Размер (байт)</OptionLabel>
<div className="flex rounded-lg border border-input overflow-hidden h-8">
{[64, 128, 512, 1472].map(n => <SegBtn key={n} value={n} current={pingSize} onClick={setPingSize}>{n}</SegBtn>)}
</div>
</div>
</>
)}
{tool === "traceroute" && (
<div>
<OptionLabel>Протокол</OptionLabel>
<div className="flex rounded-lg border border-input overflow-hidden h-8">
{(["icmp", "udp", "tcp"] as TraceProto[]).map(p => <SegBtn key={p} value={p} current={traceProto} onClick={setTraceProto}>{p.toUpperCase()}</SegBtn>)}
</div>
</div>
)}
{tool === "dns" && (
<div>
<OptionLabel>Тип записи</OptionLabel>
<div className="flex rounded-lg border border-input overflow-hidden h-8">
{(["A", "AAAA", "MX", "NS", "TXT", "PTR"] as DnsType[]).map(t => <SegBtn key={t} value={t} current={dnsType} onClick={setDnsType}>{t}</SegBtn>)}
</div>
</div>
)}
{tool === "bandwidth" && (
<>
<div>
<OptionLabel>Протокол</OptionLabel>
<div className="flex rounded-lg border border-input overflow-hidden h-8">
{(["tcp", "udp"] as const).map(p => <SegBtn key={p} value={p} current={bwProto} onClick={setBwProto}>{p.toUpperCase()}</SegBtn>)}
</div>
</div>
<div>
<OptionLabel>Длительность</OptionLabel>
<div className="flex rounded-lg border border-input overflow-hidden h-8">
{[5, 10, 30].map(n => <SegBtn key={n} value={n} current={bwDuration} onClick={setBwDuration}>{n}с</SegBtn>)}
</div>
</div>
</>
)}
<Button onClick={runTest} className="h-8 shrink-0 gap-1.5 self-end">
<PlayIcon className="size-3.5" />Запустить
</Button>
</div>
{/* RouterOS command preview */}
<div className="flex items-center gap-2 rounded-lg bg-zinc-950 dark:bg-zinc-900 px-3 py-2 border border-zinc-800">
<TerminalIcon className="size-3 text-zinc-500 shrink-0" />
<span className="text-zinc-500 text-xs font-mono shrink-0">[{srcServer?.name}]</span>
<span className="text-emerald-400 text-xs font-mono shrink-0">$</span>
<span className="text-zinc-300 text-xs font-mono truncate">{cmdPreview}</span>
<button onClick={() => navigator.clipboard.writeText(cmdPreview)}
className="shrink-0 text-zinc-600 hover:text-zinc-300 transition-colors ml-auto">
<CopyIcon className="size-3" />
</button>
</div>
</CardContent>
</Card>
{/* ── tabs ── */}
<div>
<div className="flex items-center gap-0 border-b">
{([
["history", "История тестов"],
["schedule", "Расписание"],
] as const).map(([t, label]) => (
<button key={t} onClick={() => setTab(t)}
className={cn(
"px-4 py-2 text-sm font-medium -mb-px border-b-2 transition-colors",
tab === t
? "border-foreground text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground",
)}>
{label}
{t === "history" && tests.length > 0 && (
<span className="ml-1.5 text-[10px] font-mono px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground">
{tests.length}
</span>
)}
</button>
))}
</div>
<div className="mt-4">
{/* history */}
{tab === "history" && (
tests.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground gap-2">
<TerminalIcon className="size-8 opacity-20" />
<p className="text-sm">Запустите тест — результаты появятся здесь</p>
</div>
) : (
<div className="flex flex-col gap-4">
{tests.map(test => {
const { Icon, color, label } = TOOL_META[test.tool]
return (
<Card key={test.id} className="overflow-hidden">
{/* header */}
<div className="flex items-center justify-between gap-3 px-4 py-2.5 border-b bg-muted/20">
<div className="flex items-center gap-2 min-w-0">
<Icon className={cn("size-4 shrink-0", color)} />
<span className="text-sm font-medium">{label}</span>
<span className="text-xs font-mono text-muted-foreground truncate">{test.target}</span>
<span className="text-[10px] text-muted-foreground">← {test.srcServerName}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
{test.status === "running" && (
<>
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
<span className="size-1.5 rounded-full bg-emerald-400 animate-pulse" />
запущен
</span>
<Button size="sm" variant="outline" className="h-6 px-2 text-xs"
onClick={() => stopTest(test.id)}>
<SquareIcon className="size-3" />Стоп
</Button>
</>
)}
{test.status === "done" && (
<span className="text-[11px] text-muted-foreground">
{((test.lines.length * 0.4)).toFixed(1)}с
</span>
)}
<button onClick={() => navigator.clipboard.writeText(test.lines.map(l => l.text).join("\n"))}
className="size-6 flex items-center justify-center rounded text-muted-foreground/40 hover:text-foreground hover:bg-muted transition-colors">
<CopyIcon className="size-3.5" />
</button>
<button onClick={() => clearTest(test.id)}
className="size-6 flex items-center justify-center rounded text-muted-foreground/40 hover:text-red-500 hover:bg-red-500/10 transition-colors">
<Trash2Icon className="size-3.5" />
</button>
</div>
</div>
{/* terminal output */}
<div className="p-3">
<TerminalOutput test={test} />
</div>
</Card>
)
})}
</div>
)
)}
{/* schedule */}
{tab === "schedule" && <ScheduleTab rules={rules} setRules={setRules} />}
</div>
</div>
</div>
</div>
</div>
)
}
+879
View File
@@ -0,0 +1,879 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { StatusDot } from "@/components/status-dot"
import { Flag } from "@/components/flag"
import { useDataSource } from "@/lib/data-source"
import { cn } from "@/lib/utils"
import { servers as mockServers, type Server } from "@/lib/data"
import { PlusIcon, SaveIcon, TrashIcon, SearchIcon, XIcon, PencilIcon, ChevronDownIcon, ChevronRightIcon, AlertCircleIcon, CheckIcon } from "lucide-react"
interface BackendServer {
id: number
name: string
host: string
type: "jump-host" | "exit-node" | "home-router"
site: string
country: string
asn: string
enabled: boolean
status: "online" | "offline" | null
latency: number | null
}
interface GatewayOption {
id: string
name: string
ip: string
status: "up" | "down"
}
const INFER_COUNTRIES = [
{ code: "RU", keys: ["MSK", "SPB", "RTK", "MTS", "VPSVILLE", "IHOR"] },
{ code: "SE", keys: ["SWE", "STO"] },
{ code: "FI", keys: ["HEL", "FIN"] },
{ code: "DE", keys: ["FRA", "GER", "DE"] },
{ code: "NL", keys: ["AMS", "NLD", "NL"] },
{ code: "SG", keys: ["SGP", "SIN", "SG"] },
{ code: "TR", keys: ["TUR", "TR"] },
{ code: "US", keys: ["USA", "US", "NYC", "LAX"] },
]
function inferCountry(name: string): string | null {
const upper = name.toUpperCase()
for (const c of INFER_COUNTRIES) {
if (c.keys.some(k => upper.includes(k))) return c.code
}
return null
}
const COUNTRY_OPTIONS = [
{ code: "RU", label: "Россия" }, { code: "DE", label: "Германия" },
{ code: "NL", label: "Нидерланды" }, { code: "SG", label: "Сингапур" },
{ code: "FI", label: "Финляндия" }, { code: "SE", label: "Швеция" },
{ code: "FR", label: "Франция" }, { code: "GB", label: "Великобритания" },
{ code: "PL", label: "Польша" }, { code: "US", label: "США" },
{ code: "UA", label: "Украина" }, { code: "TR", label: "Турция" },
{ code: "JP", label: "Япония" }, { code: "HK", label: "Гонконг" },
{ code: "KZ", label: "Казахстан" }, { code: "BY", label: "Беларусь" },
{ code: "LT", label: "Литва" }, { code: "LV", label: "Латвия" },
{ code: "EE", label: "Эстония" }, { code: "CZ", label: "Чехия" },
{ code: "AT", label: "Австрия" }, { code: "CH", label: "Швейцария" },
{ code: "NO", label: "Норвегия" },
]
interface RecursiveRouteRow {
id: string
dstAddress: string
gateway: string
distance: number
scope: number | null
targetScope: number | null
routingTable: string
checkGateway: string
comment: string
disabled: boolean
country: string
}
interface RouteGroup {
key: string
dstAddress: string
routingTable: string
comment: string
endpoints: RecursiveRouteRow[]
}
function makeApiFetch(backendUrl: string) {
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {}
const res = await fetch(backendUrl.replace(/\/$/, "") + path, {
...init,
headers: { ...headers, ...(init?.headers ?? {}) },
})
if (!res.ok) {
let message = res.statusText
try {
const err = await res.json() as { error?: string }
message = err.error ?? message
} catch {
const text = await res.text().catch(() => "")
if (text) message = text
}
throw new Error(message)
}
return res.json() as Promise<T>
}
}
function mapBackendServer(s: BackendServer): Server {
return {
id: String(s.id),
name: s.name || s.host,
host: s.host,
model: "—",
os: "—",
site: s.site,
country: s.country || "UN",
asn: s.asn,
type: s.type,
enabled: s.enabled,
status: (s.status ?? "offline") as Server["status"],
latency: s.latency != null ? Math.round(s.latency) : null,
sessions: 0,
}
}
interface RouteForm {
dstAddress: string
routingTable: string
comment: string
endpoints: RouteEndpointForm[]
}
interface RouteEndpointForm {
id: string
gateway: string
distance: number
scope: number | null
targetScope: number | null
checkGateway: string
country: string
}
const newEndpoint = (): RouteEndpointForm => ({
id: `ep-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
gateway: "",
distance: 1,
scope: null,
targetScope: null,
checkGateway: "ping",
country: "",
})
const emptyForm = (): RouteForm => ({
dstAddress: "",
routingTable: "main",
comment: "",
endpoints: [newEndpoint()],
})
function Field({ label, hint, required, children }: {
label: string
hint?: string
required?: boolean
children: React.ReactNode
}) {
return (
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">
{label}{required && <span className="text-destructive ml-0.5">*</span>}
</label>
{children}
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
</div>
)
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-2 py-0.5">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
<div className="flex-1 h-px bg-border" />
</div>
)
}
function RouteGroupRows({
group, expanded, onToggle, onEdit, onDelete,
}: {
group: RouteGroup
expanded: boolean
onToggle: () => void
onEdit: () => void
onDelete: () => void
}) {
const [confirmDel, setConfirmDel] = useState(false)
const bestDistance = Math.min(...group.endpoints.map(ep => ep.distance))
const sorted = [...group.endpoints].sort((a, b) => a.distance - b.distance)
return (
<>
<tr
className={cn(
"hover:bg-muted/40 transition-colors cursor-pointer group",
expanded && "bg-muted/30",
)}
onClick={onToggle}
>
<td className="px-5 py-3">
<div className="flex items-start gap-2">
{expanded
? <ChevronDownIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground" />
: <ChevronRightIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/40" />}
<div className="min-w-0">
<p className="font-medium truncate">{group.dstAddress}</p>
<p className="text-xs font-mono text-muted-foreground">{group.comment || "—"}</p>
</div>
</div>
</td>
<td className="px-4 py-3">
<div className="flex flex-col gap-0.5">
{sorted.map((ep, idx) => {
const code = ep.country || inferCountry(ep.gateway)
return (
<div key={ep.id} className="flex items-center gap-1.5 text-[11px] font-mono">
<span className={cn(
"size-1.5 rounded-full shrink-0",
idx === 0 ? "bg-emerald-500" : "bg-sky-500",
)} />
{code ? <Flag code={code} size={14} className="shrink-0" /> : <span className="text-[10px] text-muted-foreground w-3.5 text-center shrink-0">?</span>}
<span className="font-semibold text-sky-600 dark:text-sky-400 truncate min-w-0">{ep.gateway}</span>
</div>
)
})}
</div>
</td>
<td className="px-4 py-3 text-xs tabular-nums">{group.endpoints.length}</td>
<td className="px-4 py-3 font-mono text-xs">d{bestDistance}</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">{group.routingTable || "main"}</td>
<td className="px-3 py-3" onClick={e => e.stopPropagation()}>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<Button size="sm" variant="ghost" className="size-7 p-0 text-muted-foreground hover:text-foreground" onClick={onEdit}><PencilIcon className="size-3.5" /></Button>
<Button size="sm" variant="ghost" className={cn("size-7 p-0 transition-colors", confirmDel ? "text-destructive bg-destructive/10 hover:bg-destructive/20" : "text-muted-foreground hover:text-destructive")} onClick={() => { if (!confirmDel) setConfirmDel(true); else onDelete() }} onBlur={() => setConfirmDel(false)}>
{confirmDel ? <AlertCircleIcon className="size-3.5" /> : <TrashIcon className="size-3.5" />}
</Button>
</div>
</td>
</tr>
{expanded && (
<tr className="bg-muted/20">
<td colSpan={6} className="px-8 py-5 border-b border-border/50">
<div className="flex flex-col gap-4">
<div className="flex flex-wrap gap-x-6 gap-y-2 text-xs">
<span className="text-muted-foreground">Route: <span className="font-mono text-foreground">{group.dstAddress}</span></span>
<span className="text-muted-foreground">Table: <span className="font-mono text-foreground">{group.routingTable || "main"}</span></span>
{group.comment && <span className="text-muted-foreground italic">{group.comment}</span>}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2.5">
{sorted.map((ep, idx) => (
<div key={ep.id} className="rounded-lg border border-border bg-background px-4 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
{(ep.country || inferCountry(ep.gateway)) && (
<Flag code={ep.country || inferCountry(ep.gateway) || ""} size={16} />
)}
<span className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wide">Endpoint {idx + 1}</span>
</div>
<span className="text-[11px] font-mono">distance: {ep.distance}</span>
</div>
<p className="mt-1.5 font-mono text-sm break-all leading-tight">{ep.gateway}</p>
<div className="mt-1.5 text-[11px] text-muted-foreground flex items-center gap-3">
<span>scope: {ep.scope ?? "—"}</span>
<span>t.scope: {ep.targetScope ?? "—"}</span>
<span>check: {ep.checkGateway || "—"}</span>
</div>
</div>
))}
</div>
</div>
</td>
</tr>
)}
</>
)
}
function EndpointCountryField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const [query, setQuery] = useState("")
const q = query.trim().toUpperCase()
const visible = q.length === 0
? COUNTRY_OPTIONS
: COUNTRY_OPTIONS.filter(c => c.code.startsWith(q) || c.label.toLowerCase().includes(query.trim().toLowerCase()))
return (
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">Страна</label>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Input placeholder="Поиск или код (RU, DE…)" value={query}
onChange={e => {
const v = e.target.value.toUpperCase().slice(0, 3)
setQuery(v)
if (v.length === 2) {
const match = COUNTRY_OPTIONS.find(c => c.code === v)
if (match) onChange(match.code)
else onChange(v)
}
}}
className="font-mono pr-10 h-8 text-sm" />
{value && (
<span className="absolute right-2.5 top-1/2 -translate-y-1/2">
<Flag code={value} size={20} />
</span>
)}
</div>
{value && (
<span className="text-sm font-mono text-muted-foreground shrink-0">
{COUNTRY_OPTIONS.find(c => c.code === value)?.label ?? value}
</span>
)}
</div>
<div className="grid grid-cols-5 gap-1.5 max-h-40 overflow-y-auto pr-0.5">
{visible.map(c => (
<button key={c.code} type="button"
onClick={() => { onChange(c.code); setQuery("") }}
title={`${c.code} · ${c.label}`}
className={cn(
"flex flex-col items-center gap-1 px-1 py-2 rounded-lg border text-[10px] transition-all",
value === c.code
? "border-primary bg-primary/5 ring-1 ring-primary/30 font-semibold text-primary"
: "border-border hover:border-muted-foreground/40 hover:bg-muted/40 text-muted-foreground",
)}>
<Flag code={c.code} size={24} />
<span className="font-mono leading-none">{c.code}</span>
</button>
))}
</div>
</div>
)
}
function RouteSheet({
open, mode, initial, onSave, onClose, gateways,
}: {
open: boolean
mode: "create" | "edit"
initial: RouteForm
onSave: (v: RouteForm) => void
onClose: () => void
gateways: GatewayOption[]
}) {
const [form, setForm] = useState<RouteForm>(initial)
const [error, setError] = useState<string | null>(null)
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { setForm(initial); setError(null) }, [initial, open])
const set = <K extends keyof RouteForm>(k: K, v: RouteForm[K]) => setForm(f => ({ ...f, [k]: v }))
const setEp = <K extends keyof RouteEndpointForm>(id: string, k: K, v: RouteEndpointForm[K]) =>
setForm(f => ({ ...f, endpoints: f.endpoints.map(ep => ep.id === id ? { ...ep, [k]: v } : ep) }))
function handleSave() {
if (!form.dstAddress.trim()) { setError("Dst Address обязателен"); return }
if (form.endpoints.length === 0) { setError("Добавь хотя бы одну конечную точку"); return }
if (form.endpoints.some(ep => !ep.gateway.trim())) { setError("У каждой конечной точки должен быть Gateway"); return }
setError(null)
onSave(form)
}
return (
<Sheet open={open} onOpenChange={v => { if (!v) onClose() }}>
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0" showCloseButton={false}>
<SheetHeader className="px-5 pt-5 pb-4 border-b shrink-0">
<div className="flex items-start justify-between gap-2">
<div>
<SheetTitle className="text-base">{mode === "create" ? "Новый маршрут" : "Редактировать маршрут"}</SheetTitle>
<SheetDescription className="text-xs mt-0.5">Рекурсивный статический маршрут</SheetDescription>
</div>
<Button variant="ghost" size="icon-sm" onClick={onClose} className="shrink-0 mt-0.5"><XIcon className="size-4" /></Button>
</div>
</SheetHeader>
<div className="flex-1 overflow-y-auto overflow-x-hidden px-6 py-5 flex flex-col gap-5">
<div className="flex flex-col gap-4">
<SectionTitle>Основные</SectionTitle>
<Field label="Dst Address" required hint="Например 8.8.8.8/32 или 1.1.1.0/24">
<Input className="font-mono h-9" placeholder="8.8.8.8/32" value={form.dstAddress} onChange={(e) => set("dstAddress", e.target.value)} />
</Field>
</div>
<div className="flex flex-col gap-4">
<SectionTitle>Конечные точки</SectionTitle>
<div className="flex flex-col gap-3">
{form.endpoints.map((ep, idx) => (
<div key={ep.id} className="rounded-lg border border-border bg-muted/20 p-3 flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Endpoint {idx + 1}
</span>
<button
type="button"
onClick={() => setForm(f => ({ ...f, endpoints: f.endpoints.filter(x => x.id !== ep.id) }))}
className="text-muted-foreground hover:text-destructive transition-colors"
disabled={form.endpoints.length <= 1}
>
<TrashIcon className="size-3.5" />
</button>
</div>
<EndpointCountryField value={ep.country} onChange={(v) => setEp(ep.id, "country", v)} />
<Field label="Gateway" required hint="Можно выбрать карточкой ниже или ввести вручную в формате ip%gateway">
<Input className="font-mono h-9" placeholder="1.2.3.4%GW-NAME" value={ep.gateway} onChange={(e) => setEp(ep.id, "gateway", e.target.value)} />
</Field>
<div className="grid grid-cols-2 gap-2">
<Field label="Distance (приоритет)">
<Input type="number" className="h-9" value={ep.distance} onChange={(e) => setEp(ep.id, "distance", Number(e.target.value) || 1)} />
</Field>
<Field label="Check Gateway">
<Input className="h-9 font-mono" placeholder="ping" value={ep.checkGateway} onChange={(e) => setEp(ep.id, "checkGateway", e.target.value)} />
</Field>
</div>
<div className="grid grid-cols-2 gap-2">
<Field label="Scope">
<Input type="number" className="h-9" value={ep.scope ?? ""} onChange={(e) => setEp(ep.id, "scope", e.target.value ? Number(e.target.value) : null)} />
</Field>
<Field label="T.Scope">
<Input type="number" className="h-9" value={ep.targetScope ?? ""} onChange={(e) => setEp(ep.id, "targetScope", e.target.value ? Number(e.target.value) : null)} />
</Field>
</div>
<div className="flex flex-col gap-1.5 max-h-[180px] overflow-y-auto overflow-x-hidden pr-1">
{gateways.map((g) => {
const value = `${g.ip}%${g.name}`
const selected = ep.gateway === value
const country = inferCountry(g.name)
return (
<button
key={`${ep.id}-${g.id}`}
type="button"
onClick={() => setEp(ep.id, "gateway", value)}
className={cn(
"text-left rounded-lg border px-3 py-2.5 transition-all",
selected
? "border-primary bg-primary/5 ring-1 ring-primary/30"
: "border-border hover:border-muted-foreground/40 hover:bg-muted/40",
)}
>
<div className="flex items-center gap-2">
<span className={cn("size-1.5 rounded-full shrink-0", g.status === "up" ? "bg-emerald-500" : "bg-red-500")} />
{country && <Flag code={country} className="shrink-0" />}
<span className="font-mono text-xs font-semibold flex-1 truncate">{g.name}</span>
{selected && <CheckIcon className="size-3.5 text-primary shrink-0" />}
</div>
<div className="mt-1.5 grid grid-cols-[auto_auto_1fr] items-start gap-2 text-[11px] text-muted-foreground font-mono">
<span className="text-muted-foreground/50 break-all">{g.ip}</span>
<span className="text-muted-foreground/30">→</span>
<span className={cn("min-w-0 break-all whitespace-normal leading-tight", selected ? "text-primary font-medium" : "")}>
{value}
</span>
</div>
</button>
)
})}
</div>
</div>
))}
<Button type="button" variant="outline" size="sm" className="w-fit gap-1.5" onClick={() => setForm(f => ({ ...f, endpoints: [...f.endpoints, newEndpoint()] }))}>
<PlusIcon className="size-3.5" />Добавить endpoint
</Button>
{gateways.length === 0 && (
<div className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
Нет доступных шлюзов на выбранном роутере.
</div>
)}
</div>
</div>
<div className="flex flex-col gap-4">
<SectionTitle>Параметры</SectionTitle>
<Field label="Routing Table"><Input className="h-9 font-mono" value={form.routingTable} onChange={(e) => set("routingTable", e.target.value)} /></Field>
<Field label="Комментарий">
<Input className="h-9" value={form.comment} onChange={(e) => set("comment", e.target.value)} />
</Field>
</div>
{error && <div className="flex items-center gap-2 text-sm text-destructive bg-destructive/10 border border-destructive/20 px-3 py-2 rounded-md"><AlertCircleIcon className="size-4 shrink-0" />{error}</div>}
</div>
<SheetFooter className="px-6 py-4 border-t shrink-0 gap-2">
<Button variant="outline" onClick={onClose} className="flex-1">Отмена</Button>
<Button onClick={handleSave} className="flex-1"><CheckIcon className="size-4" />{mode === "create" ? "Добавить" : "Сохранить"}</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" }) {
return (
<span className={cn(
"inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold border shrink-0",
type === "home-router" ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20" :
type === "jump-host"
? "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20"
: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
)}>
{type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"}
</span>
)
}
export default function RecursiveRoutesPage() {
const { mode, backendUrl, backendStatus } = useDataSource()
const isLive = mode === "live" && backendStatus === true
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const [servers, setServers] = useState<Server[]>(mockServers)
const [selectedServerId, setSelectedServerId] = useState<string>(mockServers[0]?.id ?? "")
const [rows, setRows] = useState<RecursiveRouteRow[]>([])
const [busy, setBusy] = useState<"load" | "save" | "from" | "to" | null>(null)
const [search, setSearch] = useState("")
const [sheetOpen, setSheetOpen] = useState(false)
const [sheetMode, setSheetMode] = useState<"create" | "edit">("create")
const [sheetInitial, setSheetInitial] = useState<RouteForm>(emptyForm())
const [editingGroupKey, setEditingGroupKey] = useState<string | null>(null)
const [gatewayOptions, setGatewayOptions] = useState<GatewayOption[]>([])
const [expandedGroupKey, setExpandedGroupKey] = useState<string | null>(null)
const [opError, setOpError] = useState<string | null>(null)
useEffect(() => {
if (!isLive) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setServers(mockServers)
setSelectedServerId(mockServers[0]?.id ?? "")
setRows([])
return
}
apiFetch<BackendServer[]>("/api/servers")
.then((data) => {
const mapped = data.map(mapBackendServer)
setServers(mapped)
setSelectedServerId(prev => (mapped.some(s => s.id === prev) ? prev : (mapped[0]?.id ?? "")))
})
.catch(() => {
setServers([])
setSelectedServerId("")
})
}, [isLive, apiFetch])
const loadRoutes = useCallback(async () => {
if (!isLive || !selectedServerId) return
setOpError(null)
setBusy("load")
try {
const res = await apiFetch<{ routes: RecursiveRouteRow[] }>(`/api/recursive-routes?serverId=${selectedServerId}`)
setRows(res.routes)
} catch (e) {
setRows([])
setOpError(e instanceof Error ? e.message : "Не удалось загрузить маршруты")
} finally {
setBusy(null)
}
}, [isLive, selectedServerId, apiFetch])
const loadGateways = useCallback(async () => {
if (!isLive || !selectedServerId) return
try {
const res = await apiFetch<{ gateways: GatewayOption[] }>(`/api/recursive-routes/gateways?serverId=${selectedServerId}`)
setGatewayOptions(res.gateways)
} catch {
setGatewayOptions([])
}
}, [isLive, selectedServerId, apiFetch])
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
void loadRoutes()
}, [loadRoutes])
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
void loadGateways()
}, [loadGateways])
const saveToDb = useCallback(async () => {
if (!isLive || !selectedServerId) return
setOpError(null)
setBusy("save")
try {
await apiFetch<{ ok: boolean }>("/api/recursive-routes", {
method: "PUT",
body: JSON.stringify({ serverId: selectedServerId, routes: rows }),
})
await loadRoutes()
} catch (e) {
setOpError(e instanceof Error ? e.message : "Не удалось сохранить маршруты в БД")
} finally {
setBusy(null)
}
}, [isLive, selectedServerId, rows, apiFetch, loadRoutes])
const syncFromRouter = useCallback(async () => {
if (!isLive || !selectedServerId) return
setOpError(null)
setBusy("from")
try {
await apiFetch<{ ok: boolean }>("/api/recursive-routes/sync/from-router", {
method: "POST",
body: JSON.stringify({ serverId: selectedServerId }),
})
await loadRoutes()
} catch (e) {
setOpError(e instanceof Error ? e.message : "Не удалось синхронизировать маршруты с роутера")
} finally {
setBusy(null)
}
}, [isLive, selectedServerId, apiFetch, loadRoutes])
const syncToRouter = useCallback(async () => {
if (!isLive || !selectedServerId) return
setOpError(null)
setBusy("to")
try {
await apiFetch<{ ok: boolean }>("/api/recursive-routes/sync/to-router", {
method: "POST",
body: JSON.stringify({ serverId: selectedServerId }),
})
} catch (e) {
setOpError(e instanceof Error ? e.message : "Не удалось применить маршруты на роутер")
} finally {
setBusy(null)
}
}, [isLive, selectedServerId, apiFetch])
function groupKeyOf(row: RecursiveRouteRow): string {
return row.dstAddress.trim().toLowerCase()
}
function openCreate() {
setSheetMode("create")
setEditingGroupKey(null)
setSheetInitial(emptyForm())
setSheetOpen(true)
}
function openEdit(group: RouteGroup) {
setSheetMode("edit")
setEditingGroupKey(group.key)
setSheetInitial({
dstAddress: group.dstAddress,
routingTable: group.routingTable,
comment: group.comment,
endpoints: group.endpoints.map(ep => ({
id: ep.id,
gateway: ep.gateway,
distance: ep.distance,
scope: ep.scope,
targetScope: ep.targetScope,
checkGateway: ep.checkGateway,
country: ep.country || "",
})),
})
setSheetOpen(true)
}
function handleSaveSheet(v: RouteForm) {
const toRow = (ep: RouteEndpointForm, id: string): RecursiveRouteRow => ({
id,
dstAddress: v.dstAddress,
gateway: ep.gateway,
distance: ep.distance,
scope: ep.scope,
targetScope: ep.targetScope,
routingTable: v.routingTable,
checkGateway: ep.checkGateway,
comment: v.comment,
disabled: false,
country: ep.country || inferCountry(ep.gateway) || "",
})
if (sheetMode === "create") {
const base = `new-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`))
setRows(prev => [...prev, ...expanded])
} else if (editingGroupKey) {
setRows(prev => {
const kept = prev.filter(r => groupKeyOf(r) !== editingGroupKey)
const base = `edit-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`))
return [...kept, ...expanded]
})
}
setSheetOpen(false)
}
const currentServer = servers.find(s => s.id === selectedServerId)
const filteredRows = useMemo(() => {
const q = search.trim().toLowerCase()
if (!q) return rows
return rows.filter(r =>
r.dstAddress.toLowerCase().includes(q) ||
r.gateway.toLowerCase().includes(q) ||
r.routingTable.toLowerCase().includes(q) ||
r.comment.toLowerCase().includes(q),
)
}, [rows, search])
const groupedRoutes = useMemo(() => {
const map = new Map<string, RouteGroup>()
for (const r of filteredRows) {
const key = groupKeyOf(r)
const ex = map.get(key)
if (ex) {
ex.endpoints.push(r)
if (!ex.comment && r.comment) ex.comment = r.comment
}
else map.set(key, {
key,
dstAddress: r.dstAddress,
routingTable: r.routingTable,
comment: r.comment,
endpoints: [r],
})
}
return [...map.values()]
}, [filteredRows])
return (
<div className="flex h-full flex-col">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Рекурсивные маршруты" }]}
actions={
<>
<Button variant="outline" size="sm" onClick={syncFromRouter} disabled={!isLive || busy !== null}>
{busy === "from" ? "Синхронизация..." : "Router => DB"}
</Button>
<Button variant="outline" size="sm" onClick={syncToRouter} disabled={!isLive || busy !== null}>
{busy === "to" ? "Применение..." : "DB => Router"}
</Button>
<Button variant="outline" size="sm" onClick={saveToDb} disabled={!isLive || busy !== null}>
<SaveIcon className="size-4" />Сохранить в БД
</Button>
<Button size="sm" onClick={openCreate} disabled={!isLive || busy !== null}>
<PlusIcon className="size-4" />Добавить
</Button>
</>
}
/>
<div className="border-b bg-muted/20 px-6 py-3 flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground">Всего маршрутов</span>
<span className="font-semibold tabular-nums">{rows.length}</span>
</div>
<div className="w-px h-4 bg-border mx-1 shrink-0" />
{servers.map((s) => {
const count = s.id === selectedServerId ? rows.length : 0
const active = selectedServerId === s.id
return (
<button key={s.id}
onClick={() => setSelectedServerId(s.id)}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all",
active
? "bg-foreground text-background border-foreground"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
!s.enabled && !active && "opacity-40",
)}>
<StatusDot status={s.status} />
<Flag code={s.country} size={12} />
<span className="font-mono">{s.name}</span>
<TypeChip type={s.type} />
<span className={cn(
"tabular-nums font-semibold",
active ? "" : count > 0 ? "text-foreground" : "opacity-40",
)}>{count}</span>
</button>
)
})}
</div>
<div className="px-6 py-3 flex items-center gap-3 border-b flex-wrap shrink-0">
<div className="relative min-w-[200px] max-w-xs flex-1">
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<Input className="pl-8 h-8 text-sm" placeholder="Dst, gateway, table, comment…"
value={search} onChange={(e) => setSearch(e.target.value)} />
{search && (
<button onClick={() => setSearch("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
<XIcon className="size-3.5" />
</button>
)}
</div>
<p className="text-xs text-muted-foreground ml-auto">
{filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`}
</p>
{opError && (
<div className="w-full text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
{opError}
</div>
)}
</div>
<div className="flex-1 overflow-y-auto p-6">
{!isLive ? (
<Card className="p-6 text-sm text-muted-foreground">
Раздел работает в режиме &quot;Живые данные&quot;. Переключи источник данных в настройках.
</Card>
) : (
<Card className="overflow-hidden py-0 gap-0">
{currentServer && (
<div className="flex items-center gap-2.5 px-4 py-3 border-b bg-muted/10">
<StatusDot status={currentServer.status} pulse={currentServer.status === "online"} />
<Flag code={currentServer.country} size={16} />
<span className="font-mono text-sm font-semibold">{currentServer.name}</span>
<TypeChip type={currentServer.type} />
<code className="text-[11px] font-mono text-muted-foreground">{currentServer.host}</code>
<span className="text-xs text-muted-foreground">{currentServer.asn}</span>
{currentServer.latency !== null && (
<span className={cn(
"text-xs font-mono",
currentServer.latency > 60 ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground",
)}>{currentServer.latency}мс</span>
)}
<div className="ml-auto flex items-center gap-1.5 text-xs text-muted-foreground">
<span>{rows.length} маршрутов</span>
</div>
</div>
)}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
<th className="text-left font-medium px-5 py-3">Route / Comment</th>
<th className="text-left font-medium px-4 py-3">Gateways</th>
<th className="text-left font-medium px-4 py-3">EP</th>
<th className="text-left font-medium px-4 py-3">Priority</th>
<th className="text-left font-medium px-4 py-3">Table</th>
<th className="w-10 px-3 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{groupedRoutes.map((g) => (
<RouteGroupRows
key={g.key}
group={g}
expanded={expandedGroupKey === g.key}
onToggle={() => setExpandedGroupKey(prev => prev === g.key ? null : g.key)}
onEdit={() => openEdit(g)}
onDelete={() => setRows(prev => prev.filter(r => groupKeyOf(r) !== g.key))}
/>
))}
</tbody>
</table>
{groupedRoutes.length === 0 && (
<div className="p-8 text-center text-sm text-muted-foreground">
Нет маршрутов в БД для этого сервера. Нажми &quot;Router =&gt; DB&quot; для загрузки.
</div>
)}
</div>
<button onClick={openCreate}
className="w-full flex items-center gap-2 px-4 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t">
<PlusIcon className="size-3.5" />
Добавить маршрут
</button>
</Card>
)}
</div>
<RouteSheet
open={sheetOpen}
mode={sheetMode}
initial={sheetInitial}
onSave={handleSaveSheet}
onClose={() => setSheetOpen(false)}
gateways={gatewayOptions}
/>
</div>
)
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+624
View File
@@ -0,0 +1,624 @@
"use client"
import { useState, useRef, useEffect, useCallback, useMemo } from "react"
import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button"
import { servers as mockServers } from "@/lib/data"
import { Flag } from "@/components/flag"
import { useDataSource } from "@/lib/data-source"
import {
TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon,
} from "lucide-react"
import { cn } from "@/lib/utils"
// ─── types ────────────────────────────────────────────────────────────────────
/** Unified server representation for the terminal sidebar */
interface TermServer {
uid: string // unique key (mock id or String(backend id))
backendId: number | null // null in mock mode
name: string
host: string
country: string
status: "online" | "offline" | "degraded" | null
enabled: boolean
rosVersion: string | null
identityName: string | null
}
interface TermLine {
id: number
kind: "output" | "prompt" | "error" | "info"
text: string
}
// ─── mock CLI responses ───────────────────────────────────────────────────────
type CmdFn = (args: string[], serverName: string) => string
const MOCK_COMMANDS: Record<string, CmdFn> = {
"ip address print": () =>
"Flags: X - disabled, I - invalid, D - dynamic\n" +
" # ADDRESS NETWORK INTERFACE\n" +
" 0 10.0.0.1/24 10.0.0.0 ether1-wan\n" +
" 1 192.168.88.1/24 192.168.88.0 bridge-lan\n" +
" 2 10.200.0.1/30 10.200.0.0 gre-msk-spb\n" +
" 3 10.200.0.5/30 10.200.0.4 gre-msk-fra\n" +
" 4 D 169.254.0.0/16 169.254.0.0 lo0",
"ip route print": () =>
"Flags: A - active, D - dynamic, C - connect, S - static, b - bgp, o - ospf\n" +
" # DST-ADDRESS PREF-SRC GATEWAY DIST\n" +
" 0 ADS 0.0.0.0/0 10.0.0.254 1\n" +
" 1 Ab 1.1.1.0/24 10.0.1.1 20\n" +
" 2 Ab 8.8.8.0/24 10.0.1.1 20\n" +
" 3 ADC 10.0.0.0/24 10.0.0.1 0\n" +
" 4 ADC 10.200.0.0/30 10.200.0.1 0",
"interface print": () =>
"Flags: X - disabled, D - dynamic, R - running\n" +
" # NAME TYPE MTU\n" +
" 0 R ether1-wan ether 1500\n" +
" 1 R ether2-lan ether 1500\n" +
" 2 R bridge-lan bridge 1500\n" +
" 3 R gre-msk-spb gre 1476\n" +
" 4 R gre-msk-fra gre 1476\n" +
" 5 gre-msk-ams gre 1476",
"system resource print": () =>
" uptime: 12d 4h 22m 18s\n" +
" version: 7.14.3 (stable)\n" +
" free-memory: 512.0 MiB\n" +
" total-memory: 2048.0 MiB\n" +
" cpu: Intel(R) Xeon(R)\n" +
" cpu-count: 4\n" +
" cpu-frequency: 3200 MHz\n" +
" cpu-load: 12%\n" +
" free-hdd-space: 8192.0 MiB\n" +
" total-hdd-space: 16384.0 MiB\n" +
" architecture-name: x86\n" +
" board-name: CHR\n" +
" platform: MikroTik",
"system identity print": (_, n) => ` name: ${n}`,
"ip firewall filter print": () =>
"Flags: X - disabled, I - invalid, D - dynamic\n\n" +
" 0 \n ;;; Accept established connections\n" +
" chain=input action=accept connection-state=established,related\n\n" +
" 1 \n ;;; YouTube bypass traffic\n" +
" chain=forward action=accept src-address-list=youtube-bypass protocol=tcp dst-port=443\n" +
" packets=188421 bytes=14283698176\n\n" +
" 2 \n ;;; Block SSH from WAN\n" +
" chain=input action=drop in-interface=ether1-wan protocol=tcp dst-port=22\n" +
" packets=882412 bytes=54618112",
"routing bgp session print": () =>
"Flags: E - established\n" +
" # NAME REMOTE-AS REMOTE-ADDRESS STATE UPTIME\n" +
" 0 E peer-spb-01 65002 10.0.1.1 established 12d 4h 22m\n" +
" 1 E peer-fra-01 65003 10.0.2.1 established 9d 12h 11m\n" +
" 2 peer-ams-01 65004 10.0.3.1 active —",
"routing ospf neighbor print": () =>
"Flags: V - virtual\n" +
" # ROUTER-ID STATE CHANGES ADJACENCY INTERFACE\n" +
" 0 10.0.0.7 Full 8 12d 4h 18m gre-msk-spb\n" +
" 1 10.0.1.1 Full 3 9d 11h 42m gre-msk-fra",
"routing bfd session print": () =>
" # LOCAL-ADDRESS REMOTE-ADDRESS STATE UPTIME\n" +
" 0 10.200.0.1%gre-msk-spb 10.200.0.2%gre-msk-spb up 12d 4h\n" +
" 1 10.200.1.1%gre-msk-fra 10.200.1.2%gre-msk-fra up 9d 11h",
"log print": () =>
"may/01 08:14:22 ospf,debug,packet GRE-MSK-FRA: hello received, RouterID: 10.0.2.1\n" +
"may/01 08:14:21 bgp,debug peer-spb-01 sending UPDATE\n" +
"may/01 08:14:18 system,info user admin logged from 10.10.0.5\n" +
"may/01 08:12:44 firewall,info forward: in:ether2-lan out:gre-msk-fra proto TCP\n" +
"may/01 08:11:03 script,info backup-script: backup saved to /backup/mt.rsc\n" +
"may/01 08:09:17 system,warning cpu load is 85% on cpu0",
"ping": (args) => {
const host = args[0] || "8.8.8.8"
const count = parseInt(args.find(a => a.startsWith("count="))?.split("=")[1] || "4")
const rtt = Math.round(10 + Math.random() * 50)
const lines = [`PING ${host}`]
for (let i = 0; i < Math.min(count, 5); i++) {
const t = rtt + Math.round((Math.random() - 0.5) * 8)
lines.push(` seq=${i} ttl=56 time=${t}ms`)
}
lines.push(` sent=${count} received=${count} packet-loss=0%`)
return lines.join("\n")
},
}
function mockResolve(raw: string, serverName: string): string {
const trimmed = raw.trim()
if (!trimmed) return ""
if (trimmed === "?" || trimmed === "help")
return "ip address print | ip route print | interface print\n" +
"system resource print | ip firewall filter print\n" +
"routing bgp session print | routing ospf neighbor print\n" +
"routing bfd session print | log print | ping <host>"
if (trimmed === "clear" || trimmed === "/clear") return "__CLEAR__"
if (trimmed === "quit" || trimmed === "exit") return "Connection closed."
const key = Object.keys(MOCK_COMMANDS).find(k =>
trimmed === k || trimmed.toLowerCase().startsWith(k + " ")
)
if (key) {
const rest = trimmed.slice(key.length).trim().split(/\s+/)
return MOCK_COMMANDS[key](rest, serverName)
}
return `bad command name ${trimmed.split(" ")[0]} (line 1 column 1)`
}
// ─── MOTD builders ────────────────────────────────────────────────────────────
function mockMotd(server: TermServer): string {
return [
"",
" MMM MMM KKK TTTTTTTTTTT KKK",
" MMM MMMM MMM III KKK KKK RRR OOOOOO TTT TTT II KKK KKK",
" MMM MMM III KKK KKK RRR RRROOOOO TTT TTT II KKK KKK",
"",
` MikroTik RouterOS ${server.rosVersion ?? "7.14.3"} (c) 1999-${new Date().getFullYear()} https://www.mikrotik.com/`,
"",
"[?] Gives the list of available commands",
"[Tab] Completes the command/word",
"[/] Move up to base level",
"[..] Move up one level",
"",
].join("\n")
}
function liveMotd(server: TermServer): string {
return [
"",
` MikroTik RouterOS ${server.rosVersion ?? "7.x"} (c) 1999-${new Date().getFullYear()} https://www.mikrotik.com/`,
"",
` Connected to ${server.name} (${server.host})`,
"",
"[?] Gives the list of available commands — type help or ?",
"[Tab] Completes the command/word",
"[/] Move up to base level",
"",
].join("\n")
}
// ─── terminal component ───────────────────────────────────────────────────────
function Terminal({
server,
isLive,
backendUrl,
}: {
server: TermServer
isLive: boolean
backendUrl: string
}) {
const [lines, setLines] = useState<TermLine[]>([])
const [input, setInput] = useState("")
const [history, setHistory] = useState<string[]>([])
const [histIdx, setHistIdx] = useState(-1)
const [idCtr, setIdCtr] = useState(0)
const [executing, setExecuting] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const nextId = useCallback((): number => {
let id = 0
setIdCtr(prev => { id = prev + 1; return prev + 1 })
return id
}, [])
const addLines = useCallback((texts: string[], kind: TermLine["kind"] = "output") => {
setLines(prev => [
...prev,
...texts.map(text => ({ id: 0, kind, text })),
].map((l, i, arr) => ({ ...l, id: arr.length - texts.length + i }))
// Note: IDs don't need to be perfect unique here since we append
)
setIdCtr(prev => prev + texts.length)
}, [])
// Initialize MOTD on mount / server change
useEffect(() => {
const motd = isLive ? liveMotd(server) : mockMotd(server)
const init = motd.split("\n").map((text, i) => ({ id: i, kind: "output" as const, text }))
setLines(init)
setIdCtr(init.length)
setInput("")
setHistory([])
setHistIdx(-1)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [server.uid, isLive])
// Auto-scroll
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" })
}, [lines])
const prompt = `[admin@${server.identityName ?? server.name}] > `
const submit = useCallback(async () => {
if (executing) return
const cmd = input.trim()
setLines(prev => [...prev, { id: nextId(), kind: "prompt", text: prompt + input }])
setInput("")
setHistIdx(-1)
if (!cmd) return
setHistory(h => [cmd, ...h.filter(x => x !== cmd)].slice(0, 100))
// local always-available commands
const lower = cmd.toLowerCase()
if (lower === "clear" || lower === "/clear") { setLines([]); return }
if (lower === "quit" || lower === "exit") {
setLines(prev => [...prev, { id: nextId(), kind: "info", text: "Connection closed." }])
return
}
if (isLive && server.backendId !== null) {
setExecuting(true)
try {
const res = await fetch(`${backendUrl}/api/servers/${server.backendId}/exec`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ command: cmd }),
})
const data = await res.json() as { output?: string; error?: string }
const text = data.output ?? data.error ?? "(empty response)"
const kind: TermLine["kind"] = text.startsWith("error:") ? "error" : "output"
text.split("\n").forEach(line =>
setLines(prev => [...prev, { id: nextId(), kind, text: line }])
)
} catch (err) {
setLines(prev => [...prev, { id: nextId(), kind: "error", text: `network error: ${String(err)}` }])
} finally {
setExecuting(false)
}
} else {
// mock mode
const result = mockResolve(cmd, server.name)
if (result === "__CLEAR__") { setLines([]); return }
if (result) {
result.split("\n").forEach(line =>
setLines(prev => [...prev, { id: nextId(), kind: "output", text: line }])
)
}
}
}, [executing, input, nextId, prompt, isLive, server, backendUrl])
const handleKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault(); void submit()
} else if (e.key === "ArrowUp") {
e.preventDefault()
setHistIdx(i => {
const next = Math.min(i + 1, history.length - 1)
if (history[next] !== undefined) setInput(history[next])
return next
})
} else if (e.key === "ArrowDown") {
e.preventDefault()
setHistIdx(i => {
const next = i - 1
if (next < 0) { setInput(""); return -1 }
if (history[next] !== undefined) setInput(history[next])
return next
})
} else if (e.key === "l" && e.ctrlKey) {
e.preventDefault(); setLines([])
}
}
const lineColor = (kind: TermLine["kind"]) => {
if (kind === "error") return "text-red-400"
if (kind === "prompt") return "text-emerald-400"
if (kind === "info") return "text-sky-400"
return "text-[#c9d1d9]"
}
return (
<div
className="flex flex-col bg-[#0d1117] rounded-lg border border-[#30363d] overflow-hidden font-mono text-xs h-full"
onClick={() => inputRef.current?.focus()}
>
{/* title bar */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-[#30363d] bg-[#161b22] shrink-0">
<CircleIcon className="size-3 text-red-500 fill-red-500" />
<CircleIcon className="size-3 text-amber-400 fill-amber-400" />
<CircleIcon className="size-3 text-emerald-500 fill-emerald-500" />
<span className="mx-auto text-[#8b949e] text-[11px]">
{server.name} — {isLive ? "RouterOS REST" : "SSH Terminal (mock)"}
</span>
{isLive && (
<span className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium border border-emerald-500/30 bg-emerald-500/10 text-emerald-400">
<span className="size-1 rounded-full bg-emerald-400" />LIVE
</span>
)}
<button
onClick={e => { e.stopPropagation(); setLines([]) }}
className="text-[#8b949e] hover:text-white transition-colors ml-1"
title="Очистить"
>
<TrashIcon className="size-3.5" />
</button>
</div>
{/* output */}
<div ref={scrollRef} className="flex-1 overflow-y-auto p-3 space-y-0.5 min-h-0">
{lines.map((line, i) => (
<div key={`${line.id}-${i}`} className={cn("whitespace-pre-wrap break-all leading-5", lineColor(line.kind))}>
{line.text}
</div>
))}
{/* executing indicator */}
{executing && (
<div className="flex items-center gap-2 text-amber-400 opacity-80">
<Loader2Icon className="size-3 animate-spin" />
<span>executing…</span>
</div>
)}
{/* input line */}
{!executing && (
<div className="flex items-center gap-0">
<span className="text-emerald-400 select-none shrink-0">{prompt}</span>
<input
ref={inputRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKey}
className="terminal-input-active flex-1 bg-transparent outline-none text-[#c9d1d9] caret-emerald-400"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
/>
</div>
)}
</div>
</div>
)
}
// ─── quick commands ───────────────────────────────────────────────────────────
const QUICK_CMDS = [
{ cmd: "system resource print", label: "system resource print" },
{ cmd: "interface print", label: "interface print" },
{ cmd: "ip address print", label: "ip address print" },
{ cmd: "ip route print", label: "ip route print" },
{ cmd: "ip firewall filter print", label: "ip firewall filter print" },
{ cmd: "routing bgp session print", label: "routing bgp session print" },
{ cmd: "routing ospf neighbor print", label: "routing ospf neighbor print" },
{ cmd: "routing ospf area print", label: "routing ospf area print" },
{ cmd: "routing bfd session print", label: "routing bfd session print" },
{ cmd: "log print", label: "log print" },
]
// ─── page ─────────────────────────────────────────────────────────────────────
/** Convert mock server list to TermServer[] */
function mockServersToTermServers(): TermServer[] {
return mockServers.map(s => ({
uid: s.id,
backendId: null,
name: s.name,
host: s.host,
country: s.country,
status: s.status as TermServer["status"],
enabled: s.enabled,
rosVersion: s.os ?? null,
identityName: s.name,
}))
}
/** Backend ServerRead shape (only fields we need) */
interface BackendServer {
id: number
name: string
host: string
country: string
status: "online" | "offline" | null
enabled: boolean
os: string | null
identityName: string | null
}
export default function TerminalPage() {
const { mode, backendUrl, backendStatus } = useDataSource()
const isLive = mode === "live" && backendStatus === true
// Server list state
const [liveServers, setLiveServers] = useState<TermServer[]>([])
const [serversLoading, setServersLoading] = useState(false)
const [refreshKey, setRefreshKey] = useState(0) // force terminal remount on reconnect
// Load servers from backend when in live mode
useEffect(() => {
if (!isLive) { setLiveServers([]); return }
let cancelled = false
setServersLoading(true)
fetch(`${backendUrl}/api/servers`)
.then(r => r.json() as Promise<BackendServer[]>)
.then(data => {
if (cancelled) return
setLiveServers(data.map(s => ({
uid: String(s.id),
backendId: s.id,
name: s.name || s.host,
host: s.host,
country: s.country || "",
status: s.status,
enabled: s.enabled,
rosVersion: s.os,
identityName: s.identityName,
})))
setServersLoading(false)
})
.catch(() => { if (!cancelled) setServersLoading(false) })
return () => { cancelled = true }
}, [isLive, backendUrl, refreshKey])
const termServers: TermServer[] = isLive ? liveServers : mockServersToTermServers()
// Select first available server by default
const [selectedUid, setSelectedUid] = useState<string>("")
const defaultUid = useMemo(() => {
const first = termServers.find(s => s.status !== "offline" && s.enabled)
?? termServers[0]
return first?.uid ?? ""
}, [termServers])
useEffect(() => {
if (!selectedUid && defaultUid) setSelectedUid(defaultUid)
}, [defaultUid, selectedUid])
// Reset selection when switching modes
useEffect(() => { setSelectedUid("") }, [isLive])
const selected = termServers.find(s => s.uid === selectedUid) ?? termServers[0]
const termKey = `${selectedUid}-${refreshKey}-${isLive ? "live" : "mock"}`
function injectCommand(cmd: string) {
const el = document.querySelector<HTMLInputElement>(".terminal-input-active")
if (!el) return
const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set
nativeSetter?.call(el, cmd)
el.dispatchEvent(new Event("input", { bubbles: true }))
el.focus()
}
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Инструменты" }, { label: "Терминал" }]}
actions={
<Button variant="outline" size="sm" onClick={() => {
setRefreshKey(k => k + 1)
if (isLive) setSelectedUid("")
}}>
<RefreshCwIcon className="size-4" />Переподключить
</Button>
}
/>
<div className="flex-1 overflow-hidden p-6">
<div className="grid grid-cols-[220px_1fr] gap-5 h-full">
{/* ── sidebar ── */}
<div className="flex flex-col gap-4 overflow-y-auto min-h-0">
{/* server picker */}
<div>
<div className="flex items-center gap-2 mb-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Узел</p>
{isLive && serversLoading && (
<Loader2Icon className="size-3 animate-spin text-muted-foreground" />
)}
{isLive && !serversLoading && (
<span className="inline-flex items-center gap-1 text-[10px] font-medium rounded border border-emerald-500/30 bg-emerald-500/10 text-emerald-400 px-1.5 py-0.5">
<span className="size-1 rounded-full bg-emerald-400" />LIVE
</span>
)}
</div>
{isLive && !serversLoading && liveServers.length === 0 && (
<p className="text-xs text-muted-foreground px-2.5">
Нет доступных серверов
</p>
)}
<div className="space-y-1">
{termServers.map(s => {
const isOffline = s.status === "offline"
const isSelected = s.uid === selectedUid
return (
<button
key={s.uid}
disabled={isOffline || !s.enabled}
onClick={() => { setSelectedUid(s.uid); setRefreshKey(k => k + 1) }}
className={cn(
"w-full text-left rounded-md px-2.5 py-2 text-xs transition-colors",
"flex items-center gap-2",
isSelected
? "bg-primary text-primary-foreground"
: "hover:bg-muted",
(isOffline || !s.enabled) && "opacity-40 cursor-not-allowed",
)}
>
<span className={cn(
"inline-block size-1.5 rounded-full shrink-0",
s.status === "online" ? "bg-emerald-500" :
s.status === "degraded" ? "bg-amber-400" :
s.status === null ? "bg-sky-400" : "bg-red-500",
)} />
{s.country && <Flag code={s.country} />}
<span className="truncate font-mono">{s.name}</span>
</button>
)
})}
</div>
</div>
{/* quick commands */}
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Быстрые команды
</p>
<div className="space-y-1">
{QUICK_CMDS.map(({ cmd, label }) => (
<button
key={cmd}
className="w-full text-left rounded-md px-2.5 py-1.5 text-[11px] font-mono text-muted-foreground hover:bg-muted hover:text-foreground transition-colors truncate block"
onClick={() => injectCommand(cmd)}
title={cmd}
>
{label}
</button>
))}
</div>
</div>
{/* hints */}
<div className="mt-auto text-[10px] text-muted-foreground/50 space-y-0.5 px-0.5">
<p>↑↓ — история команд</p>
<p>Ctrl+L — очистить экран</p>
{isLive
? <p className="text-sky-400/60">Команды выполняются на роутере</p>
: <p>Режим: mock-данные</p>
}
</div>
</div>
{/* ── terminal ── */}
{selected ? (
<Terminal
key={termKey}
server={selected}
isLive={isLive}
backendUrl={backendUrl}
/>
) : (
<div className="flex items-center justify-center bg-[#0d1117] rounded-lg border border-[#30363d] text-[#8b949e] text-sm font-mono">
{serversLoading ? "Загрузка серверов…" : "Выберите сервер"}
</div>
)}
</div>
</div>
</div>
)
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+395
View File
@@ -0,0 +1,395 @@
"use client"
import { useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { vxlanTunnels, servers } from "@/lib/data"
import type { VxlanTunnel } from "@/lib/data"
import { Flag } from "@/components/flag"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
DropdownMenuItem, DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu"
import {
SearchIcon, NetworkIcon, PlusIcon, MoreHorizontalIcon,
Trash2Icon, PencilIcon, PowerIcon, CopyIcon, CheckIcon,
CodeXmlIcon, LayersIcon,
} from "lucide-react"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
// ─── helpers ──────────────────────────────────────────────────────────────────
function serverFor(id: string) {
return servers.find((s) => s.id === id)
}
// ─── RSC generator ───────────────────────────────────────────────────────────
function generateVxlanRsc(t: VxlanTunnel): string {
const srv = serverFor(t.serverId)
const lines: string[] = []
lines.push(`# VXLAN — ${t.name} · VNI ${t.vni}`)
if (srv) lines.push(`# Сервер: ${srv.name} (${srv.host})`)
lines.push(`# RouterOS 7.x · /interface/vxlan`)
lines.push(``)
lines.push(`/interface/vxlan/add \\`)
lines.push(` name=${t.name} \\`)
lines.push(` vni=${t.vni} \\`)
lines.push(` port=${t.dstPort} \\`)
lines.push(` vtep-mac-address=auto \\`)
lines.push(` arp-proxy=${t.arpProxy ? "yes" : "no"} \\`)
lines.push(` mac-learning=${t.macLearning ? "yes" : "no"} \\`)
lines.push(` l2mtu=${t.l2mtu} \\`)
if (t.comment) lines.push(` comment="${t.comment}" \\`)
if (!t.enabled) lines.push(` disabled=yes \\`)
lines.push(``)
// FDB entries for remote VTEPs
for (const vtep of t.remoteVteps) {
lines.push(`/interface/vxlan/vteps/add \\`)
lines.push(` interface=${t.name} \\`)
lines.push(` remote-ip=${vtep}`)
lines.push(``)
}
// Bridge
lines.push(`# Добавить в bridge:`)
lines.push(`/interface/bridge/port/add \\`)
lines.push(` bridge=bridge-overlay \\`)
lines.push(` interface=${t.name}`)
return lines.join("\n")
}
// ─── Export Sheet ─────────────────────────────────────────────────────────────
function ExportSheet({ open, tunnel, onClose }: {
open: boolean; tunnel: VxlanTunnel | null; onClose: () => void
}) {
const [copied, setCopied] = useState(false)
const code = useMemo(() => tunnel ? generateVxlanRsc(tunnel) : "", [tunnel])
function handleCopy() {
navigator.clipboard.writeText(code).then(() => {
setCopied(true); setTimeout(() => setCopied(false), 2000)
})
}
return (
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl">
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
<div className="flex items-start justify-between gap-4">
<div>
<SheetTitle>Экспорт VXLAN</SheetTitle>
<SheetDescription>RouterOS 7.x · /interface/vxlan + vteps</SheetDescription>
</div>
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}>
{copied ? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</> : <><CopyIcon className="size-3.5" />Копировать</>}
</Button>
</div>
</SheetHeader>
<div className="flex-1 overflow-y-auto">
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all">
{code.split("\n").map((line, i) => {
const isComment = line.startsWith("#")
const isCmd = /^\//.test(line.trimStart())
const isParam = /^\s+[a-z]/.test(line)
return (
<span key={i} className={
isComment ? "text-muted-foreground"
: isCmd ? "text-sky-400"
: isParam ? "text-violet-300"
: "text-foreground"
}>
{line}{"\n"}
</span>
)
})}
</pre>
</div>
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
<Button className="flex-1" onClick={handleCopy}>
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
{copied ? "Скопировано" : "Копировать .rsc"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
// ─── Tunnel row ───────────────────────────────────────────────────────────────
function TunnelRow({
tunnel,
onExport,
}: {
tunnel: VxlanTunnel
onExport: () => void
}) {
const srv = serverFor(tunnel.serverId)
return (
<div className={cn(
"grid grid-cols-[10px_1fr_1fr_auto_auto_auto_auto_auto_auto_auto_auto] gap-3 px-4 py-3 items-center border-b last:border-b-0 hover:bg-muted/30 transition-colors",
!tunnel.enabled && "opacity-50",
)}>
{/* status dot */}
<span className={cn(
"size-2 rounded-full shrink-0",
tunnel.status === "up" ? "bg-emerald-500" : "bg-red-500",
)} />
{/* name */}
<div className="min-w-0">
<p className="font-mono font-medium text-sm truncate">{tunnel.name}</p>
<p className="text-[11px] text-muted-foreground font-mono">VTEP: {tunnel.vtepIp}</p>
</div>
{/* server */}
<div className="flex items-center gap-1.5 text-xs text-muted-foreground min-w-0">
{srv && <><Flag code={srv.country} size={12} /><span className="font-mono truncate">{srv.name}</span></>}
</div>
{/* VNI */}
<div className="text-center">
<p className="text-[10px] text-muted-foreground">VNI</p>
<p className="font-mono text-sm">{tunnel.vni}</p>
</div>
{/* Port */}
<div className="text-center">
<p className="text-[10px] text-muted-foreground">Port</p>
<p className="font-mono text-sm">{tunnel.dstPort}</p>
</div>
{/* Remote VTEPs */}
<div className="text-center">
<p className="text-[10px] text-muted-foreground">Remote VTEP</p>
<p className="font-mono text-sm">{tunnel.remoteVteps.length}</p>
</div>
{/* ARP Proxy */}
<span className={cn("text-[10px] font-mono px-1.5 py-0.5 rounded",
tunnel.arpProxy ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400" : "bg-muted text-muted-foreground")}>
ARP {tunnel.arpProxy ? "✓" : "✗"}
</span>
{/* MAC learning */}
<span className={cn("text-[10px] font-mono px-1.5 py-0.5 rounded",
tunnel.macLearning ? "bg-sky-500/10 text-sky-600 dark:text-sky-400" : "bg-muted text-muted-foreground")}>
MAC {tunnel.macLearning ? "✓" : "✗"}
</span>
{/* Status badge */}
<span className={cn(
"text-[11px] font-mono px-2 py-0.5 rounded border whitespace-nowrap",
tunnel.status === "up"
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-red-500/10 text-red-500 border-red-500/20",
)}>
{tunnel.status === "up" ? "UP" : "DOWN"}
</span>
{/* menu */}
<DropdownMenu>
<DropdownMenuTrigger render={
<Button variant="ghost" size="icon" className="size-7">
<MoreHorizontalIcon className="size-4" />
</Button>
} />
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem onClick={onExport}><CodeXmlIcon className="size-4" />Экспорт .rsc</DropdownMenuItem>
<DropdownMenuItem><PencilIcon className="size-4" />Редактировать</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem><PowerIcon className="size-4" />{tunnel.enabled ? "Отключить" : "Включить"}</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive"><Trash2Icon className="size-4" />Удалить</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
// ════════════════════════════════════════════════════════════════════════════
export default function VxlanPage() {
const [search, setSearch] = useState("")
const [exportTunnel, setExportTunnel] = useState<VxlanTunnel | null>(null)
const filtered = useMemo(() => {
if (!search) return vxlanTunnels
const q = search.toLowerCase()
return vxlanTunnels.filter((t) =>
t.name.includes(q) ||
String(t.vni).includes(q) ||
t.vtepIp.includes(q) ||
(serverFor(t.serverId)?.name.toLowerCase().includes(q) ?? false)
)
}, [search])
const upCount = vxlanTunnels.filter((t) => t.status === "up").length
const vnis = new Set(vxlanTunnels.map((t) => t.vni)).size
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "VXLAN" }]}
actions={
<Button size="sm">
<PlusIcon className="size-4" />Новый VXLAN
</Button>
}
/>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
{/* KPI */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: "Туннелей", value: vxlanTunnels.length, icon: <NetworkIcon className="size-4 text-muted-foreground" /> },
{ label: "Активных", value: upCount, icon: <LayersIcon className="size-4 text-emerald-500" /> },
{ label: "Уникальных VNI", value: vnis, icon: <LayersIcon className="size-4 text-sky-400" /> },
{ label: "Серверов", value: new Set(vxlanTunnels.map((t) => t.serverId)).size, icon: <NetworkIcon className="size-4 text-violet-400" /> },
].map((s) => (
<Card key={s.label}>
<CardContent className="px-5 py-4 flex items-start justify-between">
<div>
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
</div>
<div className="mt-0.5">{s.icon}</div>
</CardContent>
</Card>
))}
</div>
{/* Info banner */}
<div className="flex items-start gap-3 rounded-lg bg-sky-500/5 border border-sky-500/20 px-4 py-3 text-sm">
<NetworkIcon className="size-5 text-sky-500 shrink-0 mt-0.5" />
<div>
<p className="font-medium text-sky-600 dark:text-sky-400">VXLAN — L2-over-L3 оверлей для RouterOS 7.x</p>
<p className="text-muted-foreground text-xs mt-0.5">
Доступен с RouterOS 7.1+. VNI (Virtual Network Identifier) — уникальный идентификатор сегмента (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>
)
}
+492
View File
@@ -0,0 +1,492 @@
"use client"
import { useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { servers } from "@/lib/data"
import type { WireGuardInterface, WireGuardPeer } from "@/lib/data"
import { Flag } from "@/components/flag"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
DropdownMenuItem, DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu"
import {
ShieldCheckIcon, PlusIcon, SearchIcon, KeyRoundIcon,
ChevronDownIcon, ChevronRightIcon, MoreHorizontalIcon,
PencilIcon, Trash2Icon, PowerIcon, CopyIcon, CheckIcon,
CodeXmlIcon, UsersIcon, ActivityIcon, ArrowDownIcon, ArrowUpIcon,
} from "lucide-react"
// ─── collect all WireGuard interfaces from all servers ────────────────────────
interface WgIfaceWithServer extends WireGuardInterface {
serverId: string
serverName: string
serverCountry: string
}
function collectInterfaces(): WgIfaceWithServer[] {
const result: WgIfaceWithServer[] = []
for (const srv of servers) {
for (const wg of srv.wireGuardIfaces ?? []) {
result.push({
...wg,
serverId: srv.id,
serverName: srv.name,
serverCountry: srv.country,
})
}
}
return result
}
// ─── helpers ──────────────────────────────────────────────────────────────────
function fmtBytes(n: number | undefined): string {
if (!n) return "—"
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)} ГБ`
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} МБ`
if (n >= 1_000) return `${(n / 1_000).toFixed(0)} КБ`
return `${n} Б`
}
function truncKey(key: string): string {
if (key.length <= 20) return key
return `${key.slice(0, 8)}…${key.slice(-8)}`
}
// ─── RSC generator ────────────────────────────────────────────────────────────
function generateWgRsc(iface: WgIfaceWithServer): string {
const lines: string[] = []
lines.push(`# WireGuard — ${iface.name} · ${iface.serverName}`)
lines.push(`# RouterOS 7.x`)
lines.push(``)
lines.push(`/interface wireguard add \\`)
lines.push(` name=${iface.name} \\`)
lines.push(` listen-port=${iface.listenPort} \\`)
lines.push(` mtu=${iface.mtu} \\`)
if (iface.comment) lines.push(` comment="${iface.comment}" \\`)
if (!iface.enabled) lines.push(` disabled=yes \\`)
lines.push(``)
for (const p of iface.peers) {
lines.push(`/interface wireguard peers add \\`)
lines.push(` interface=${iface.name} \\`)
lines.push(` public-key="${p.publicKey}" \\`)
lines.push(` allowed-address=${p.allowedIps.join(",")} \\`)
if (p.endpoint) lines.push(` endpoint-address=${p.endpoint.split(":")[0]} \\`)
if (p.endpoint) lines.push(` endpoint-port=${p.endpoint.split(":")[1] ?? "13231"} \\`)
if (p.persistent) lines.push(` persistent-keepalive=25 \\`)
if (p.comment) lines.push(` comment="${p.comment}" \\`)
lines.push(``)
}
return lines.join("\n")
}
// ─── Peer row ─────────────────────────────────────────────────────────────────
function PeerRow({ peer }: { peer: WireGuardPeer }) {
return (
<div className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-3 px-4 py-2.5 items-center text-xs border-t border-border/50 bg-muted/20">
{/* public key */}
<div className="flex items-center gap-1.5 min-w-0">
<KeyRoundIcon className="size-3 text-muted-foreground shrink-0" />
<span className="font-mono text-muted-foreground truncate" title={peer.publicKey}>
{truncKey(peer.publicKey)}
</span>
</div>
{/* allowed IPs */}
<div className="font-mono text-muted-foreground truncate">
{peer.allowedIps.join(", ")}
</div>
{/* handshake */}
<span className={cn(
"font-mono text-[11px] whitespace-nowrap",
peer.latestHandshake ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground",
)}>
{peer.latestHandshake ?? "нет рукопожатия"}
</span>
{/* rx / tx */}
<div className="flex items-center gap-2 text-muted-foreground whitespace-nowrap">
<span className="flex items-center gap-0.5">
<ArrowDownIcon className="size-3 text-emerald-500" />{fmtBytes(peer.transferRx)}
</span>
<span className="flex items-center gap-0.5">
<ArrowUpIcon className="size-3 text-blue-400" />{fmtBytes(peer.transferTx)}
</span>
</div>
{/* endpoint */}
<span className="font-mono text-muted-foreground/60 text-[11px]">{peer.endpoint ?? "—"}</span>
</div>
)
}
// ─── Interface card ───────────────────────────────────────────────────────────
function IfaceRow({
iface,
expanded,
onToggleExpand,
onExport,
}: {
iface: WgIfaceWithServer
expanded: boolean
onToggleExpand: () => void
onExport: () => void
}) {
const onlinePeers = iface.peers.filter((p) => !!p.latestHandshake).length
return (
<div className={cn("border-b last:border-b-0", !iface.enabled && "opacity-50")}>
<div
className="grid grid-cols-[20px_1fr_auto_auto_auto_auto_auto_auto] gap-3 px-4 py-3 items-center hover:bg-muted/30 transition-colors cursor-pointer"
onClick={onToggleExpand}
>
{/* expand */}
<button className="text-muted-foreground" onClick={(e) => { e.stopPropagation(); onToggleExpand() }}>
{expanded
? <ChevronDownIcon className="size-3.5" />
: <ChevronRightIcon className="size-3.5" />}
</button>
{/* name + server */}
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className={cn(
"size-2 rounded-full shrink-0",
iface.status === "up" ? "bg-emerald-500 animate-pulse" : "bg-red-500",
)} />
<span className="font-mono font-semibold text-sm">{iface.name}</span>
</div>
<div className="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground font-mono">
<Flag code={iface.serverCountry} size={12} />
{iface.serverName}
</div>
</div>
{/* port */}
<div className="text-center">
<p className="text-[10px] text-muted-foreground">Порт</p>
<p className="font-mono text-sm">{iface.listenPort}</p>
</div>
{/* MTU */}
<div className="text-center">
<p className="text-[10px] text-muted-foreground">MTU</p>
<p className="font-mono text-sm">{iface.mtu}</p>
</div>
{/* peers */}
<div className="text-center">
<p className="text-[10px] text-muted-foreground">Пиров</p>
<p className="font-mono text-sm">
<span className="text-emerald-600 dark:text-emerald-400">{onlinePeers}</span>
<span className="text-muted-foreground">/{iface.peers.length}</span>
</p>
</div>
{/* status badge */}
<span className={cn(
"text-[11px] font-mono px-2 py-0.5 rounded border",
iface.status === "up"
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-red-500/10 text-red-500 border-red-500/20",
)}>
{iface.status === "up" ? "UP" : "DOWN"}
</span>
{/* menu */}
<DropdownMenu>
<DropdownMenuTrigger render={
<Button variant="ghost" size="icon" className="size-7" onClick={(e) => e.stopPropagation()}>
<MoreHorizontalIcon className="size-4" />
</Button>
} />
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onExport() }}>
<CodeXmlIcon className="size-4" />Экспорт .rsc
</DropdownMenuItem>
<DropdownMenuItem><PencilIcon className="size-4" />Редактировать</DropdownMenuItem>
<DropdownMenuItem><PlusIcon className="size-4" />Добавить пира</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem><PowerIcon className="size-4" />{iface.enabled ? "Отключить" : "Включить"}</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive"><Trash2Icon className="size-4" />Удалить</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* expanded peers */}
{expanded && iface.peers.length > 0 && (
<div>
<div className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-3 px-4 py-1.5 bg-muted/10 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground border-t border-border/50">
<span>Public Key</span>
<span>Allowed IPs</span>
<span>Последнее рукопожатие</span>
<span>RX / TX</span>
<span>Endpoint</span>
</div>
{iface.peers.map((p) => <PeerRow key={p.publicKey} peer={p} />)}
</div>
)}
{expanded && iface.peers.length === 0 && (
<div className="px-4 py-4 text-xs text-muted-foreground text-center border-t border-border/50">
Нет пиров
</div>
)}
</div>
)
}
// ─── Export Sheet ─────────────────────────────────────────────────────────────
function ExportSheet({ open, iface, onClose }: {
open: boolean; iface: WgIfaceWithServer | null; onClose: () => void
}) {
const [copied, setCopied] = useState(false)
const code = useMemo(() => iface ? generateWgRsc(iface) : "", [iface])
function handleCopy() {
navigator.clipboard.writeText(code).then(() => {
setCopied(true); setTimeout(() => setCopied(false), 2000)
})
}
return (
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl">
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
<div className="flex items-start justify-between gap-4">
<div>
<SheetTitle>Экспорт WireGuard</SheetTitle>
<SheetDescription>RouterOS 7.x · /interface wireguard + peers</SheetDescription>
</div>
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}>
{copied
? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</>
: <><CopyIcon className="size-3.5" />Копировать</>}
</Button>
</div>
</SheetHeader>
<div className="flex-1 overflow-y-auto">
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all">
{code.split("\n").map((line, i) => {
const isComment = line.startsWith("#")
const isCmd = line.trimStart().startsWith("/interface")
const isParam = /^\s+[a-z]/.test(line)
return (
<span key={i} className={
isComment ? "text-muted-foreground"
: isCmd ? "text-sky-400"
: isParam ? "text-violet-300"
: "text-foreground"
}>
{line}{"\n"}
</span>
)
})}
</pre>
</div>
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
<Button className="flex-1" onClick={handleCopy}>
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
{copied ? "Скопировано" : "Копировать .rsc"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
// ════════════════════════════════════════════════════════════════════════════
export default function WireGuardPage() {
const allIfaces = useMemo(() => collectInterfaces(), [])
const [search, setSearch] = useState("")
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
const [exportIface, setExportIface] = useState<WgIfaceWithServer | null>(null)
const filtered = useMemo(() => {
if (!search) return allIfaces
const q = search.toLowerCase()
return allIfaces.filter((i) =>
i.name.includes(q) ||
i.serverName.toLowerCase().includes(q) ||
i.peers.some((p) => p.allowedIps.some((a) => a.includes(q)) || (p.endpoint ?? "").includes(q))
)
}, [allIfaces, search])
const totalPeers = allIfaces.reduce((s, i) => s + i.peers.length, 0)
const onlinePeers = allIfaces.reduce((s, i) => s + i.peers.filter((p) => !!p.latestHandshake).length, 0)
const upIfaces = allIfaces.filter((i) => i.status === "up").length
function toggleExpand(id: string) {
setExpandedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
}
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "WireGuard" }]}
actions={
<>
<Button size="sm">
<PlusIcon className="size-4" />Новый интерфейс
</Button>
</>
}
/>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
{/* KPI */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: "Интерфейсов", value: allIfaces.length, icon: <ShieldCheckIcon className="size-4 text-muted-foreground" /> },
{ label: "Активных (UP)", value: upIfaces, icon: <ActivityIcon className="size-4 text-emerald-500" /> },
{ label: "Всего пиров", value: totalPeers, icon: <UsersIcon className="size-4 text-sky-400" /> },
{ label: "Пиров онлайн", value: `${onlinePeers}/${totalPeers}`, icon: <KeyRoundIcon className="size-4 text-violet-400" /> },
].map((s) => (
<Card key={s.label}>
<CardContent className="px-5 py-4 flex items-start justify-between">
<div>
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
</div>
<div className="mt-0.5">{s.icon}</div>
</CardContent>
</Card>
))}
</div>
{/* Info banner */}
<div className="flex items-start gap-3 rounded-lg bg-sky-500/5 border border-sky-500/20 px-4 py-3 text-sm">
<ShieldCheckIcon className="size-5 text-sky-500 shrink-0 mt-0.5" />
<div>
<p className="font-medium text-sky-600 dark:text-sky-400">WireGuard — рекомендуемый туннельный протокол в RouterOS 7.x</p>
<p className="text-muted-foreground text-xs mt-0.5">
Доступен с RouterOS 7.1+. Более высокая производительность и безопасность по сравнению с GRE+IPsec.
Ключи генерируются командой <code className="font-mono bg-muted px-1 rounded">/interface/wireguard/print</code>.
</p>
</div>
</div>
{/* Search + table */}
<Card>
<div className="flex items-center gap-3 px-4 py-3 border-b">
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[260px]">
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
<input
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
placeholder="Поиск по имени, серверу, IP…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} интерфейсов</span>
</div>
{/* table header */}
<div className="grid grid-cols-[20px_1fr_auto_auto_auto_auto_auto_auto] gap-3 px-4 py-2 border-b text-[10px] font-semibold uppercase tracking-widest text-muted-foreground bg-muted/20">
<span />
<span>Интерфейс / Сервер</span>
<span>Порт</span>
<span>MTU</span>
<span>Пиры</span>
<span>Статус</span>
<span />
</div>
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
<ShieldCheckIcon className="size-10 mb-3 opacity-20" />
<p className="text-sm font-medium">Нет WireGuard интерфейсов</p>
<p className="text-xs mt-1">Добавьте первый интерфейс или проверьте поиск</p>
</div>
) : (
filtered.map((iface) => (
<IfaceRow
key={iface.id}
iface={iface}
expanded={expandedIds.has(iface.id)}
onToggleExpand={() => toggleExpand(iface.id)}
onExport={() => setExportIface(iface)}
/>
))
)}
</Card>
{/* RouterOS reference */}
<Card>
<CardContent className="px-5 py-4">
<p className="text-xs font-medium text-muted-foreground mb-3">
RouterOS 7 · /interface wireguard — быстрые команды
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
{[
{
title: "Создать интерфейс",
lines: [
"/interface wireguard add \\",
" name=wg0 \\",
" listen-port=13231 \\",
" mtu=1420",
],
},
{
title: "Добавить пира",
lines: [
"/interface wireguard peers add \\",
" interface=wg0 \\",
' public-key="<ключ>" \\',
" allowed-address=10.0.0.2/32 \\",
" endpoint-address=1.2.3.4 \\",
" persistent-keepalive=25",
],
},
{
title: "Назначить IP",
lines: [
"/ip address add \\",
" address=10.210.0.1/30 \\",
" interface=wg0",
"",
"# Статус:",
"/interface wireguard print",
],
},
].map((b) => (
<div key={b.title}>
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">{b.title}</p>
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto">
{b.lines.join("\n")}
</pre>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
<ExportSheet
open={!!exportIface}
iface={exportIface}
onClose={() => setExportIface(null)}
/>
</div>
)
}
+190
View File
@@ -0,0 +1,190 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
/* ─── Light theme ─────────────────────────────────────────────────────────── */
:root {
/* Base — subtle blue-gray tint so cards (white) float above the page */
--background: oklch(0.973 0.004 247.858);
--foreground: oklch(0.141 0.006 264.0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.006 264.0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.006 264.0);
/* Primary — dark charcoal for buttons / key UI */
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
/* Secondary / muted — very light blue-gray */
--secondary: oklch(0.958 0.005 247.858);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.958 0.005 247.858);
--muted-foreground: oklch(0.520 0.010 264.0);
/* Accent — soft blue highlight */
--accent: oklch(0.942 0.016 264.376);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
/* Borders + inputs */
--border: oklch(0.904 0.006 264.0);
--input: oklch(0.904 0.006 264.0);
/* Focus ring — blue, matching the brand accent */
--ring: oklch(0.488 0.243 264.376);
/* ── Colorful chart palette (light) ── */
--chart-1: oklch(0.546 0.215 264.376); /* blue */
--chart-2: oklch(0.527 0.154 150.069); /* emerald */
--chart-3: oklch(0.625 0.162 41.500); /* orange */
--chart-4: oklch(0.591 0.224 296.400); /* violet */
--chart-5: oklch(0.577 0.245 27.325); /* red */
/* ── Semantic line colors for charts (light) ── */
--chart-line-1: oklch(0.527 0.154 150.069); /* green — MSK / RX */
--chart-line-2: oklch(0.546 0.215 264.376); /* blue — SPB / TX */
--chart-line-3: oklch(0.625 0.162 41.500); /* orange — FRA */
--chart-line-4: oklch(0.591 0.224 296.400); /* violet — AMS */
--chart-rx: oklch(0.527 0.154 150.069);
--chart-tx: oklch(0.546 0.215 264.376);
--radius: 0.625rem;
/* ── Sidebar — intentionally dark even in light mode ── */
--sidebar: oklch(0.152 0.010 264.376);
--sidebar-foreground: oklch(0.920 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.240 0.012 264.376);
--sidebar-accent-foreground: oklch(0.950 0 0);
--sidebar-border: oklch(1 0 0 / 8%);
--sidebar-ring: oklch(0.488 0.243 264.376);
/* ── Status ── */
--status-online: oklch(0.527 0.154 150.069);
--status-offline: oklch(0.577 0.245 27.325);
--status-degraded: oklch(0.666 0.179 58.318);
}
/* ─── Dark theme ──────────────────────────────────────────────────────────── */
.dark {
/* Base — blue-tinted dark for modern network terminal aesthetic */
--background: oklch(0.133 0.011 264.376);
--foreground: oklch(0.985 0 0);
--card: oklch(0.191 0.011 264.376);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.191 0.011 264.376);
--popover-foreground: oklch(0.985 0 0);
/* Primary — bright white for dark backgrounds */
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
/* Secondary / muted — dark blue-gray */
--secondary: oklch(0.252 0.010 264.376);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.252 0.010 264.376);
--muted-foreground: oklch(0.648 0.008 264.376);
/* Accent — slightly lighter blue-tinted dark */
--accent: oklch(0.285 0.022 264.376);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.488 0.243 264.376);
/* ── Colorful chart palette (dark — brighter for visibility) ── */
--chart-1: oklch(0.637 0.222 264.376); /* bright blue */
--chart-2: oklch(0.696 0.170 151.328); /* bright emerald */
--chart-3: oklch(0.713 0.176 47.600); /* bright orange */
--chart-4: oklch(0.714 0.173 303.900); /* bright violet */
--chart-5: oklch(0.704 0.191 22.216); /* bright red */
/* ── Semantic line colors (dark) ── */
--chart-line-1: oklch(0.696 0.170 151.328); /* green */
--chart-line-2: oklch(0.637 0.222 264.376); /* blue */
--chart-line-3: oklch(0.713 0.176 47.600); /* orange */
--chart-line-4: oklch(0.714 0.173 303.900); /* violet */
--chart-rx: oklch(0.696 0.170 151.328);
--chart-tx: oklch(0.637 0.222 264.376);
/* ── Sidebar — slightly lighter than bg in dark mode ── */
--sidebar: oklch(0.191 0.011 264.376);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.262 0.014 264.376);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.488 0.243 264.376);
/* Status stays the same */
--status-online: oklch(0.527 0.154 150.069);
--status-offline: oklch(0.577 0.245 27.325);
--status-degraded: oklch(0.666 0.179 58.318);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
+58
View File
@@ -0,0 +1,58 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import Script from "next/script";
import { TooltipProvider } from "@/components/ui/tooltip";
import { ThemeProvider } from "@/components/theme-provider";
import "./globals.css";
const geistSans = Geist({
variable: "--font-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "RouterLists",
description: "MikroTik network management platform",
};
// Runs before React hydration — prevents flash of wrong theme (FOUC).
const ANTI_FOUC = `
try {
var t = localStorage.getItem('rl-theme') || 'dark';
var d = t === 'system'
? window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
: t;
document.documentElement.classList.toggle('dark', d === 'dark');
document.documentElement.classList.toggle('light', d === 'light');
} catch(e) {}
`.trim()
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="ru"
suppressHydrationWarning
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<head>
<Script id="anti-fouc" strategy="beforeInteractive">
{ANTI_FOUC}
</Script>
</head>
<body className="min-h-full flex flex-col">
<ThemeProvider defaultTheme="dark" disableTransitionOnChange>
<TooltipProvider>{children}</TooltipProvider>
</ThemeProvider>
</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function Home() {
redirect("/dashboard");
}
+8
View File
@@ -0,0 +1,8 @@
# Path to SQLite database file
DATABASE_PATH=./mikrotik.db
# Port for the Fastify server
PORT=8000
# Allowed CORS origin (Next.js frontend)
CORS_ORIGIN=http://localhost:3000
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
*.db
*.db-shm
*.db-wal
.env
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from "drizzle-kit"
import { config } from "dotenv"
config()
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "sqlite",
dbCredentials: {
url: process.env.DATABASE_PATH ?? "./mikrotik.db",
},
verbose: true,
strict: true,
})
+36
View File
@@ -0,0 +1,36 @@
CREATE TABLE `server_snapshots` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`server_id` integer NOT NULL,
`polled_at` text NOT NULL,
`status` text NOT NULL,
`latency_ms` real,
`ros_version` text,
`board_name` text,
`uptime` text,
`cpu_load` integer,
`free_memory` integer,
`total_memory` integer,
`identity_name` text,
`raw_interfaces` text,
`raw_ip_addresses` text,
FOREIGN KEY (`server_id`) REFERENCES `servers`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `servers` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text DEFAULT '' NOT NULL,
`host` text NOT NULL,
`port` integer DEFAULT 443 NOT NULL,
`username` text DEFAULT 'admin' NOT NULL,
`password` text DEFAULT '' NOT NULL,
`use_ssl` integer DEFAULT true NOT NULL,
`verify_ssl` integer DEFAULT false NOT NULL,
`type` text DEFAULT 'home-router' NOT NULL,
`site` text DEFAULT '' NOT NULL,
`country` text DEFAULT '' NOT NULL,
`asn` text DEFAULT '' NOT NULL,
`comment` text DEFAULT '' NOT NULL,
`enabled` integer DEFAULT true NOT NULL,
`created_at` text DEFAULT (datetime('now')) NOT NULL,
`updated_at` text DEFAULT (datetime('now')) NOT NULL
);
+276
View File
@@ -0,0 +1,276 @@
{
"version": "6",
"dialect": "sqlite",
"id": "0b2f7dde-98dc-44e9-84a9-fd04e004299d",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"server_snapshots": {
"name": "server_snapshots",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"server_id": {
"name": "server_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"polled_at": {
"name": "polled_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"latency_ms": {
"name": "latency_ms",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ros_version": {
"name": "ros_version",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"board_name": {
"name": "board_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"uptime": {
"name": "uptime",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cpu_load": {
"name": "cpu_load",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"free_memory": {
"name": "free_memory",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"total_memory": {
"name": "total_memory",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"identity_name": {
"name": "identity_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"raw_interfaces": {
"name": "raw_interfaces",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"raw_ip_addresses": {
"name": "raw_ip_addresses",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"server_snapshots_server_id_servers_id_fk": {
"name": "server_snapshots_server_id_servers_id_fk",
"tableFrom": "server_snapshots",
"tableTo": "servers",
"columnsFrom": [
"server_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"servers": {
"name": "servers",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"host": {
"name": "host",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"port": {
"name": "port",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 443
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'admin'"
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"use_ssl": {
"name": "use_ssl",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"verify_ssl": {
"name": "verify_ssl",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'home-router'"
},
"site": {
"name": "site",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"country": {
"name": "country",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"asn": {
"name": "asn",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"comment": {
"name": "comment",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1777572014210,
"tag": "0000_living_xorn",
"breakpoints": true
}
]
}
+3008
View File
File diff suppressed because it is too large. Load diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "mikrotik-manager-backend",
"version": "0.1.0",
"description": "MikroTik Manager backend — Fastify + Drizzle + SQLite",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
"@fastify/type-provider-zod": "^1.0.0",
"better-sqlite3": "^12.9.0",
"dotenv": "^16.4.7",
"drizzle-orm": "^0.45.2",
"fastify": "^5.8.5",
"pino-pretty": "^13.1.3",
"undici": "^8.1.0",
"zod": "^4.4.1"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.15.3",
"drizzle-kit": "^0.31.10",
"tsx": "^4.19.3",
"typescript": "^5.8.3"
}
}
+19
View File
@@ -0,0 +1,19 @@
import { config } from "dotenv"
import { z } from "zod"
config()
const envSchema = z.object({
DATABASE_PATH: z.string().default("./mikrotik.db"),
PORT: z.coerce.number().int().positive().default(8000),
CORS_ORIGIN: z.string().default("http://localhost:3000"),
})
const parsed = envSchema.safeParse(process.env)
if (!parsed.success) {
console.error("❌ Invalid environment variables:", parsed.error.flatten().fieldErrors)
process.exit(1)
}
export const env = parsed.data
+164
View File
@@ -0,0 +1,164 @@
import Database from "better-sqlite3"
import { drizzle } from "drizzle-orm/better-sqlite3"
import { env } from "../config.js"
import * as schema from "./schema.js"
const sqlite = new Database(env.DATABASE_PATH)
// WAL mode for better concurrent read performance
sqlite.pragma("journal_mode = WAL")
sqlite.pragma("foreign_keys = ON")
sqlite.exec(`
CREATE TABLE IF NOT EXISTS filter_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
community TEXT NOT NULL,
community_name TEXT,
action TEXT NOT NULL DEFAULT 'route',
gateway TEXT NOT NULL DEFAULT '',
gateway_tunnel_id TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_filter_rules_server_sort
ON filter_rules(server_id, sort_order);
CREATE TABLE IF NOT EXISTS recursive_routes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
dst_address TEXT NOT NULL,
gateway TEXT NOT NULL,
distance INTEGER NOT NULL DEFAULT 1,
scope INTEGER,
target_scope INTEGER,
routing_table TEXT NOT NULL DEFAULT 'main',
check_gateway TEXT NOT NULL DEFAULT '',
country TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT '',
disabled INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_recursive_routes_server_sort
ON recursive_routes(server_id, sort_order);
CREATE TABLE IF NOT EXISTS traffic_settings (
id INTEGER PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1,
interval_sec INTEGER NOT NULL DEFAULT 30,
retention_days INTEGER NOT NULL DEFAULT 14,
last_collected_at TEXT,
last_duration_ms INTEGER,
last_error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS traffic_samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL,
interface_name TEXT NOT NULL,
sampled_at TEXT NOT NULL,
rx_bytes INTEGER NOT NULL DEFAULT 0,
tx_bytes INTEGER NOT NULL DEFAULT 0,
rx_bps INTEGER NOT NULL DEFAULT 0,
tx_bps INTEGER NOT NULL DEFAULT 0,
running INTEGER NOT NULL DEFAULT 0,
disabled INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_time
ON traffic_samples(server_id, sampled_at);
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_iface_time
ON traffic_samples(server_id, interface_name, sampled_at);
CREATE TABLE IF NOT EXISTS uptime_settings (
id INTEGER PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1,
interval_sec INTEGER NOT NULL DEFAULT 15,
retention_days INTEGER NOT NULL DEFAULT 14,
last_collected_at TEXT,
last_duration_ms INTEGER,
last_error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS uptime_probes (
id TEXT PRIMARY KEY,
src_server_id INTEGER NOT NULL,
src_interface TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
target TEXT NOT NULL,
probe_filter TEXT NOT NULL DEFAULT '—',
enabled INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (src_server_id) REFERENCES servers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_uptime_probes_server_sort
ON uptime_probes(src_server_id, sort_order);
CREATE TABLE IF NOT EXISTS uptime_probe_samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
probe_id TEXT NOT NULL,
sampled_at TEXT NOT NULL,
rtt_ms INTEGER,
loss_pct INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'down',
FOREIGN KEY (probe_id) REFERENCES uptime_probes(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_uptime_probe_samples_probe_time
ON uptime_probe_samples(probe_id, sampled_at);
CREATE TABLE IF NOT EXISTS uptime_resource_samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL,
sampled_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'offline',
cpu_load INTEGER NOT NULL DEFAULT 0,
free_memory INTEGER NOT NULL DEFAULT 0,
total_memory INTEGER NOT NULL DEFAULT 0,
free_hdd_space INTEGER NOT NULL DEFAULT 0,
total_hdd_space INTEGER NOT NULL DEFAULT 0,
uptime_seconds INTEGER NOT NULL DEFAULT 0,
board_name TEXT NOT NULL DEFAULT '',
ros_version TEXT NOT NULL DEFAULT '',
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_uptime_resource_samples_server_time
ON uptime_resource_samples(server_id, sampled_at);
`)
// Lightweight schema evolution for existing databases without migrations
const recursiveCols = sqlite.prepare(`PRAGMA table_info('recursive_routes')`).all() as Array<{ name?: string }>
const hasCountryColumn = recursiveCols.some((c) => c.name === "country")
if (!hasCountryColumn) {
sqlite.exec(`ALTER TABLE recursive_routes ADD COLUMN country TEXT NOT NULL DEFAULT ''`)
}
const uptimeProbeCols = sqlite.prepare(`PRAGMA table_info('uptime_probes')`).all() as Array<{ name?: string }>
const hasSrcInterfaceColumn = uptimeProbeCols.some((c) => c.name === "src_interface")
if (!hasSrcInterfaceColumn) {
sqlite.exec(`ALTER TABLE uptime_probes ADD COLUMN src_interface TEXT NOT NULL DEFAULT ''`)
}
sqlite.exec(`
INSERT INTO traffic_settings (id, enabled, interval_sec, retention_days)
SELECT 1, 1, 30, 14
WHERE NOT EXISTS (SELECT 1 FROM traffic_settings WHERE id = 1);
`)
sqlite.exec(`
INSERT INTO uptime_settings (id, enabled, interval_sec, retention_days)
SELECT 1, 1, 15, 14
WHERE NOT EXISTS (SELECT 1 FROM uptime_settings WHERE id = 1);
`)
export const db = drizzle(sqlite, { schema })
+200
View File
@@ -0,0 +1,200 @@
import { sql } from "drizzle-orm"
import {
integer,
real,
sqliteTable,
text,
} from "drizzle-orm/sqlite-core"
// ── servers ────────────────────────────────────────────────────────────────────
export const servers = sqliteTable("servers", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull().default(""),
host: text("host").notNull(),
port: integer("port").notNull().default(443),
username: text("username").notNull().default("admin"),
password: text("password").notNull().default(""),
useSsl: integer("use_ssl", { mode: "boolean" }).notNull().default(true),
verifySsl: integer("verify_ssl", { mode: "boolean" }).notNull().default(false),
// metadata set manually by user
type: text("type", { enum: ["jump-host", "exit-node", "home-router"] })
.notNull().default("home-router"),
site: text("site").notNull().default(""),
country: text("country").notNull().default(""),
asn: text("asn").notNull().default(""),
comment: text("comment").notNull().default(""),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
// ── server_snapshots ───────────────────────────────────────────────────────────
export const serverSnapshots = sqliteTable("server_snapshots", {
id: integer("id").primaryKey({ autoIncrement: true }),
serverId: integer("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
polledAt: text("polled_at").notNull(),
status: text("status", { enum: ["online", "offline"] }).notNull(),
latencyMs: real("latency_ms"),
// data from RouterOS
rosVersion: text("ros_version"),
boardName: text("board_name"),
uptime: text("uptime"),
cpuLoad: integer("cpu_load"),
freeMemory: integer("free_memory"),
totalMemory: integer("total_memory"),
identityName: text("identity_name"),
// raw JSON payloads for future use
rawInterfaces: text("raw_interfaces"),
rawIpAddresses: text("raw_ip_addresses"),
})
// ── filter_rules ───────────────────────────────────────────────────────────────
export const filterRules = sqliteTable("filter_rules", {
id: integer("id").primaryKey({ autoIncrement: true }),
serverId: integer("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
sortOrder: integer("sort_order").notNull().default(0),
community: text("community").notNull(),
communityName: text("community_name"),
action: text("action", { enum: ["route", "blackhole"] }).notNull().default("route"),
gateway: text("gateway").notNull().default(""),
gatewayTunnelId: text("gateway_tunnel_id").notNull().default(""),
description: text("description").notNull().default(""),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
// ── recursive_routes ───────────────────────────────────────────────────────────
export const recursiveRoutes = sqliteTable("recursive_routes", {
id: integer("id").primaryKey({ autoIncrement: true }),
serverId: integer("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
sortOrder: integer("sort_order").notNull().default(0),
dstAddress: text("dst_address").notNull(),
gateway: text("gateway").notNull(),
distance: integer("distance").notNull().default(1),
scope: integer("scope"),
targetScope: integer("target_scope"),
routingTable: text("routing_table").notNull().default("main"),
checkGateway: text("check_gateway").notNull().default(""),
country: text("country").notNull().default(""),
comment: text("comment").notNull().default(""),
disabled: integer("disabled", { mode: "boolean" }).notNull().default(false),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
// ── traffic collection settings ────────────────────────────────────────────────
export const trafficSettings = sqliteTable("traffic_settings", {
id: integer("id").primaryKey(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
intervalSec: integer("interval_sec").notNull().default(30),
retentionDays: integer("retention_days").notNull().default(14),
lastCollectedAt: text("last_collected_at"),
lastDurationMs: integer("last_duration_ms"),
lastError: text("last_error"),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
// ── raw traffic samples (per server/interface/timepoint) ──────────────────────
export const trafficSamples = sqliteTable("traffic_samples", {
id: integer("id").primaryKey({ autoIncrement: true }),
serverId: integer("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
interfaceName: text("interface_name").notNull(),
sampledAt: text("sampled_at").notNull(),
rxBytes: integer("rx_bytes").notNull().default(0),
txBytes: integer("tx_bytes").notNull().default(0),
rxBps: integer("rx_bps").notNull().default(0),
txBps: integer("tx_bps").notNull().default(0),
running: integer("running", { mode: "boolean" }).notNull().default(false),
disabled: integer("disabled", { mode: "boolean" }).notNull().default(false),
})
// ── uptime monitor settings ────────────────────────────────────────────────────
export const uptimeSettings = sqliteTable("uptime_settings", {
id: integer("id").primaryKey(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
intervalSec: integer("interval_sec").notNull().default(15),
retentionDays: integer("retention_days").notNull().default(14),
lastCollectedAt: text("last_collected_at"),
lastDurationMs: integer("last_duration_ms"),
lastError: text("last_error"),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
export const uptimeProbes = sqliteTable("uptime_probes", {
id: text("id").primaryKey(),
srcServerId: integer("src_server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
srcInterface: text("src_interface").notNull().default(""),
name: text("name").notNull(),
target: text("target").notNull(),
probeFilter: text("probe_filter").notNull().default("—"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
sortOrder: integer("sort_order").notNull().default(0),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
})
export const uptimeProbeSamples = sqliteTable("uptime_probe_samples", {
id: integer("id").primaryKey({ autoIncrement: true }),
probeId: text("probe_id")
.notNull()
.references(() => uptimeProbes.id, { onDelete: "cascade" }),
sampledAt: text("sampled_at").notNull(),
rttMs: integer("rtt_ms"),
lossPct: integer("loss_pct").notNull().default(0),
status: text("status", { enum: ["up", "warn", "down"] }).notNull().default("down"),
})
export const uptimeResourceSamples = sqliteTable("uptime_resource_samples", {
id: integer("id").primaryKey({ autoIncrement: true }),
serverId: integer("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
sampledAt: text("sampled_at").notNull(),
status: text("status", { enum: ["online", "offline"] }).notNull().default("offline"),
cpuLoad: integer("cpu_load").notNull().default(0),
freeMemory: integer("free_memory").notNull().default(0),
totalMemory: integer("total_memory").notNull().default(0),
freeHddSpace: integer("free_hdd_space").notNull().default(0),
totalHddSpace: integer("total_hdd_space").notNull().default(0),
uptimeSeconds: integer("uptime_seconds").notNull().default(0),
boardName: text("board_name").notNull().default(""),
rosVersion: text("ros_version").notNull().default(""),
})
// ── inferred types ─────────────────────────────────────────────────────────────
export type Server = typeof servers.$inferSelect
export type ServerInsert = typeof servers.$inferInsert
export type Snapshot = typeof serverSnapshots.$inferSelect
export type SnapshotInsert = typeof serverSnapshots.$inferInsert
export type FilterRuleRow = typeof filterRules.$inferSelect
export type RecursiveRouteRow = typeof recursiveRoutes.$inferSelect
export type TrafficSettingsRow = typeof trafficSettings.$inferSelect
export type TrafficSampleRow = typeof trafficSamples.$inferSelect
export type UptimeSettingsRow = typeof uptimeSettings.$inferSelect
export type UptimeProbeRow = typeof uptimeProbes.$inferSelect
export type UptimeProbeSampleRow = typeof uptimeProbeSamples.$inferSelect
export type UptimeResourceSampleRow = typeof uptimeResourceSamples.$inferSelect
+68
View File
@@ -0,0 +1,68 @@
import Fastify from "fastify"
import cors from "@fastify/cors"
import { serializerCompiler, validatorCompiler } from "@fastify/type-provider-zod"
import { env } from "./config.js"
import serversRoutes from "./routes/servers.js"
import bgpRoutes from "./routes/bgp.js"
import ospfRoutes from "./routes/ospf.js"
import execRoutes from "./routes/exec.js"
import filtersRoutes from "./routes/filters.js"
import recursiveRoutes from "./routes/recursive-routes.js"
import trafficRoutes from "./routes/traffic.js"
import uptimeRoutes from "./routes/uptime.js"
import { collectTrafficOnce, restartTrafficCollector, stopTrafficCollector } from "./services/traffic-collector.js"
import { collectUptimeOnce, restartUptimeCollector, stopUptimeCollector } from "./services/uptime-collector.js"
// ── app factory ────────────────────────────────────────────────────────────────
const app = Fastify({
logger: {
transport: {
target: "pino-pretty",
options: { colorize: true, translateTime: "HH:MM:ss", ignore: "pid,hostname" },
},
},
})
// Use Zod for request validation and response serialization
app.setValidatorCompiler(validatorCompiler)
app.setSerializerCompiler(serializerCompiler)
// CORS — allow Next.js frontend
await app.register(cors, {
origin: env.CORS_ORIGIN,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
})
// ── routes ─────────────────────────────────────────────────────────────────────
app.get("/health", async () => ({ status: "ok", timestamp: new Date().toISOString() }))
await app.register(serversRoutes, { prefix: "/api/servers" })
await app.register(bgpRoutes, { prefix: "/api" })
await app.register(ospfRoutes, { prefix: "/api" })
await app.register(execRoutes, { prefix: "/api" })
await app.register(filtersRoutes, { prefix: "/api" })
await app.register(recursiveRoutes, { prefix: "/api" })
await app.register(trafficRoutes, { prefix: "/api" })
await app.register(uptimeRoutes, { prefix: "/api" })
restartTrafficCollector()
void collectTrafficOnce()
restartUptimeCollector()
void collectUptimeOnce()
app.addHook("onClose", async () => {
stopTrafficCollector()
stopUptimeCollector()
})
// ── start ──────────────────────────────────────────────────────────────────────
try {
await app.listen({ port: env.PORT, host: "0.0.0.0" })
console.log(`\n🚀 MikroTik Manager Backend running at http://localhost:${env.PORT}`)
console.log(` Docs / test: http://localhost:${env.PORT}/health`)
} catch (err) {
app.log.error(err)
process.exit(1)
}
+208
View File
@@ -0,0 +1,208 @@
import { eq } from "drizzle-orm"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { MikrotikClient } from "../services/mikrotik.js"
import { ServerIdParamSchema } from "../types/server.js"
import type { RosBgpSession, BgpSessionRead } from "../types/server.js"
type ServerRow = typeof servers.$inferSelect
/** Parse RouterOS duration like "1m30s", "1d5h13m23s580ms", "30s" → seconds */
function parseDuration(s: string | undefined): number {
if (!s) return 0
let total = 0
const matches = s.matchAll(/(\d+)(w|d|h|m(?!s)|s|ms)/g)
for (const m of matches) {
const n = parseInt(m[1])
switch (m[2]) {
case "w": total += n * 604800; break
case "d": total += n * 86400; break
case "h": total += n * 3600; break
case "m": total += n * 60; break
case "s": total += n; break
// ms ignored for seconds precision
}
}
return total
}
/**
* Map RouterOS 7.x short capability codes to human-readable names.
* Both local and remote capability strings are comma-separated short codes.
*/
function parseCapabilities(capStr: string | undefined): string[] {
if (!capStr) return []
const codeMap: Record<string, string> = {
mp: "MP-BGP",
rr: "Route Refresh",
gr: "Graceful Restart",
as4: "4-byte-AS",
enhe: "Extended Next-Hop",
role: "BGP Role",
err: "Extended Route Refresh",
llgr: "Long-Lived GR",
"add-path": "ADD-PATH",
}
return capStr.split(",")
.map(c => c.trim().toLowerCase())
.filter(Boolean)
.map(c => codeMap[c] ?? c)
.filter((v, i, a) => a.indexOf(v) === i) // deduplicate
}
/**
* Determine BGP session state from a RouterOS 7.x session object.
*
* RouterOS 7.x does NOT include a "state" field.
* Instead: "established": "true" means the session is up.
* For non-established sessions the "state" field may appear (RouterOS 6.x compat),
* or we fall back by checking other indicators.
*/
function detectState(s: RosBgpSession): string {
// RouterOS 7.x: explicit established flag
if (s["established"] === "true") return "Established"
// RouterOS 6.x / some 7.x builds: "state" field
if (s["state"]) {
const map: Record<string, string> = {
established: "Established",
active: "Active",
idle: "Idle",
connect: "Connect",
opensent: "OpenSent",
openconfirm: "OpenConfirm",
"open sent": "OpenSent",
"open confirm": "OpenConfirm",
}
const key = s["state"].trim().toLowerCase()
if (map[key]) return map[key]
// partial match
for (const [k, v] of Object.entries(map)) {
if (key.startsWith(k)) return v
}
return key.charAt(0).toUpperCase() + key.slice(1)
}
// Has uptime but no established flag → assume degraded Active
if (s["uptime"]) return "Active"
return "Idle"
}
function parseSessions(server: ServerRow, raw: RosBgpSession[]): BgpSessionRead[] {
return raw.map((s, idx) => {
const remoteAs = parseInt(s["remote.as"] ?? "0") || 0
const localAs = parseInt(s["local.as"] ?? "0") || 0
const type: "eBGP" | "iBGP" = (localAs > 0 && localAs === remoteAs) ? "iBGP" : "eBGP"
// Capabilities: prefer local caps (what we negotiated); merge with remote
const localCaps = parseCapabilities(s["local.capabilities"])
const remoteCaps = parseCapabilities(s["remote.capabilities"])
// Old-style boolean capability fields (RouterOS 6.x)
const legacyCaps: string[] = []
if (s["4-octet-as-capability"] === "true") legacyCaps.push("4-byte-AS")
if (s["as4-capability"] === "true") legacyCaps.push("4-byte-AS")
if (s["refresh-capability"] === "true") legacyCaps.push("Route Refresh")
if (s["add-path-capability"] === "true") legacyCaps.push("ADD-PATH")
if (s["graceful-restart-capability"] === "true") legacyCaps.push("Graceful Restart")
if (s["extended-message-capability"] === "true") legacyCaps.push("Extended Messages")
const caps = [...new Set([...localCaps, ...legacyCaps])]
// Hold time: RouterOS 7 uses "hold-time" ("1m30s"), RouterOS 6 uses "active-holdtime" (seconds)
const holdTime = parseDuration(s["hold-time"])
|| parseInt(s["active-holdtime"] ?? "0")
|| 90
// Keepalive: may have "s" suffix
const keepalive = parseDuration(s["keepalive-time"])
|| 30
// Message counts: RouterOS 7 uses "remote.messages" / "local.messages"
const inputMessages = parseInt(s["remote.messages"] ?? s["total-messages-received"] ?? "0") || 0
const outputMessages = parseInt(s["local.messages"] ?? s["total-messages-sent"] ?? "0") || 0
const peerIp = (s["remote.address"] ?? "").replace(/\/\d+$/, "")
return {
id: s[".id"] ?? String(idx),
serverId: server.id,
serverName: server.name || server.host,
serverSite: server.site,
serverCountry: server.country,
name: s["name"] ?? peerIp,
peerIp,
remoteAs,
localAs,
localId: s["local.id"] ?? "",
remoteId: s["remote.id"] ?? "",
state: detectState(s),
type,
uptime: s["uptime"] || null,
holdTime,
keepalive,
prefixesRx: parseInt(s["prefix-count"] ?? s["total-updates-received"] ?? "0") || 0,
prefixesTx: parseInt(s["total-updates-sent"] ?? "0") || 0,
inputMessages,
outputMessages,
capabilities: caps,
lastError: s["last-notification"] || null,
}
})
}
const bgpRoutes: FastifyPluginAsyncZod = async (app) => {
// GET /api/bgp/sessions/raw/:id — raw RouterOS response for a single server (debug)
app.get("/bgp/sessions/raw/:id", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const server = db.select().from(servers).where(eq(servers.id, req.params.id)).limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
try {
const client = MikrotikClient.fromServer(server)
const raw = await client.getBgpSessionsRaw()
app.log.info({ serverId: server.id, host: server.host, raw }, "BGP raw response")
return reply.send({ server: { id: server.id, host: server.host, name: server.name }, raw })
} catch (err) {
return reply.status(500).send({ error: String(err) })
}
})
// GET /api/bgp/sessions — aggregate from ALL enabled servers
app.get("/bgp/sessions", async (_req, reply) => {
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const results: BgpSessionRead[][] = await Promise.all(
allServers.map(async (server) => {
try {
const client = MikrotikClient.fromServer(server)
const raw = await client.getBgpSessions()
return parseSessions(server, raw)
} catch {
return []
}
}),
)
return reply.send(results.flat())
})
// GET /api/servers/:id/bgp/sessions — single server
app.get("/servers/:id/bgp/sessions", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const server = db
.select().from(servers)
.where(eq(servers.id, req.params.id))
.limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
try {
const client = MikrotikClient.fromServer(server)
const raw = await client.getBgpSessions()
return reply.send(parseSessions(server, raw))
} catch {
return reply.send([])
}
})
}
export default bgpRoutes
+336
View File
@@ -0,0 +1,336 @@
import { eq } from "drizzle-orm"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { z } from "zod"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { MikrotikClient } from "../services/mikrotik.js"
import { ServerIdParamSchema } from "../types/server.js"
import type {
RosIpAddress, RosInterface, RosResource, RosIdentity, RosIpRoute,
RosFirewallFilter, RosLogEntry, RosBgpSession, RosPingResult,
RosOspfNeighbor, RosOspfArea, RosOspfInstance, RosOspfInterfaceTemplate,
RosBfdSession,
} from "../types/server.js"
// ── string helpers ─────────────────────────────────────────────────────────────
function p(s: string | undefined, len: number): string {
return (s ?? "").padEnd(len)
}
function flag(val: string | undefined, char: string): string {
return val === "true" ? char : " "
}
// ── formatters ─────────────────────────────────────────────────────────────────
function fmtIpAddresses(addrs: RosIpAddress[]): string {
const hdr = [
"Flags: X - disabled, I - invalid, D - dynamic",
" # ADDRESS NETWORK INTERFACE",
]
const rows = addrs.map((a, i) => {
const f = `${flag(a.disabled,"X")}${flag(a.invalid,"I")}${flag(a.dynamic,"D")}`
return ` ${String(i).padStart(2)} ${f} ${p(a.address,18)} ${p(a.network,15)} ${a.interface}${a.comment ? ` ; ${a.comment}` : ""}`
})
return [...hdr, ...rows].join("\n")
}
function fmtInterfaces(ifaces: RosInterface[]): string {
const hdr = [
"Flags: X - disabled, D - dynamic, R - running",
" # NAME TYPE MTU MAC-ADDRESS",
]
const rows = ifaces.map((f, i) => {
const flags = `${flag(f.disabled,"X")} ${flag(f.running,"R")}`
const name = p(f.name, 20)
const type = p(f.type, 12)
const mtu = p(f["actual-mtu"] ?? f.mtu, 6)
const mac = f["mac-address"] ?? ""
return ` ${String(i).padStart(2)} ${flags} ${name} ${type} ${mtu} ${mac}${f.comment ? ` ; ${f.comment}` : ""}`
})
return [...hdr, ...rows].join("\n")
}
function fmtResource(r: RosResource, id: RosIdentity): string {
const kv = (k: string, v: string) => `${k.padStart(30)}: ${v}`
const mib = (b: string | undefined) => b ? `${(parseInt(b) / 1024 / 1024).toFixed(1)} MiB` : "?"
return [
kv("uptime", r["uptime"] ?? "?"),
kv("version", r["version"] ?? "?"),
kv("build-time", r["build-time"] ?? "?"),
kv("free-memory", mib(r["free-memory"])),
kv("total-memory", mib(r["total-memory"])),
kv("cpu", r["cpu"] ?? "?"),
kv("cpu-count", r["cpu-count"] ?? "?"),
kv("cpu-frequency", r["cpu-frequency"] ? `${r["cpu-frequency"]} MHz` : "?"),
kv("cpu-load", r["cpu-load"] ? `${r["cpu-load"]}%` : "?"),
kv("free-hdd-space", mib(r["free-hdd-space"])),
kv("total-hdd-space", mib(r["total-hdd-space"])),
kv("architecture-name",r["architecture-name"] ?? "?"),
kv("board-name", r["board-name"] ?? "?"),
kv("platform", r["platform"] ?? "MikroTik"),
kv("identity", id.name),
].join("\n")
}
function fmtIpRoutes(routes: RosIpRoute[]): string {
const hdr = [
"Flags: X - disabled, A - active, D - dynamic, C - connect, S - static,",
" r - rip, b - bgp, o - ospf, B - blackhole, U - unreachable, P - prohibit",
" # DST-ADDRESS PREF-SRC GATEWAY DIST",
]
const rows = routes.map((r, i) => {
const f = [
flag(r.active, "A"),
flag(r.dynamic, "D"),
flag(r.connect, "C"),
flag(r.static, "S"),
flag(r.bgp, "b"),
flag(r.ospf, "o"),
flag(r.blackhole, "B"),
flag(r.unreachable, "U"),
flag(r.prohibit, "P"),
].join("")
const dst = p(r["dst-address"] ?? "", 18)
const pref = p(r["pref-src"] ?? "", 15)
const gw = p(r["gateway"] ?? r["interface"] ?? "", 16)
const dist = r["distance"] ?? "?"
return ` ${String(i).padStart(2)} ${f} ${dst} ${pref} ${gw} ${dist}`
})
return [...hdr, ...rows].join("\n")
}
function fmtBgpSessions(sessions: RosBgpSession[]): string {
const hdr = [
"Flags: E - established",
" # NAME REMOTE-AS REMOTE-ADDRESS STATE UPTIME",
]
const rows = sessions.map((s, i) => {
const est = s["established"] === "true" || s["state"] === "established"
const fl = est ? "E" : " "
const name = p(s["name"] ?? s[".id"], 23)
const remAs = p(s["remote.as"], 10)
const remIp = p((s["remote.address"] ?? "").split("/")[0], 22)
const state = est ? "established" : (s["state"] ?? "active")
const up = s["uptime"] ?? "—"
return ` ${String(i).padStart(2)} ${fl} ${name} ${remAs} ${remIp} ${p(state, 12)} ${up}`
})
return [...hdr, ...rows].join("\n")
}
function fmtOspfNeighbors(neighbors: RosOspfNeighbor[]): string {
const hdr = [
"Flags: V - virtual",
" # ROUTER-ID STATE CHANGES ADJACENCY INTERFACE",
]
const rows = neighbors.map((n, i) => {
return ` ${String(i).padStart(2)} ${p(n["router-id"],16)} ${p(n["state"],8)} ${p(n["state-changes"] ?? "0",8)} ${p(n["adjacency"] ?? "—",15)} ${n["interface"] ?? ""}`
})
return [...hdr, ...rows].join("\n")
}
function fmtOspfAreas(areas: RosOspfArea[]): string {
const hdr = [" # NAME AREA-ID TYPE INSTANCE"]
const rows = areas.map((a, i) =>
` ${String(i).padStart(2)} ${p(a.name,20)} ${p(a["area-id"] ?? "0.0.0.0",15)} ${p(a.type ?? "default",12)} ${a.instance}`
)
return [...hdr, ...rows].join("\n")
}
function fmtOspfInstances(insts: RosOspfInstance[]): string {
const hdr = [" # NAME ROUTER-ID VER DISTRIBUTE"]
const rows = insts.map((inst, i) =>
` ${String(i).padStart(2)} ${p(inst.name,15)} ${p(inst["router-id"],18)} ${p(inst.version ?? "2",4)} ${inst["redistribute"] ?? "—"}`
)
return [...hdr, ...rows].join("\n")
}
function fmtOspfIfaceTemplates(templates: RosOspfInterfaceTemplate[]): string {
const hdr = [" # INTERFACES AREA COST TYPE BFD"]
const rows = templates.map((t, i) =>
` ${String(i).padStart(2)} ${p(t.interfaces ?? "—",21)} ${p(t.area,19)} ${p(t.cost ?? "10",5)} ${p(t.type ?? "broadcast",10)} ${t["use-bfd"] === "true" ? "yes" : "no"}`
)
return [...hdr, ...rows].join("\n")
}
function fmtBfdSessions(sessions: RosBfdSession[]): string {
const hdr = [" # LOCAL-ADDRESS REMOTE-ADDRESS STATE UPTIME"]
const rows = sessions.map((s, i) =>
` ${String(i).padStart(2)} ${p(s["local-address"],27)} ${p(s["remote-address"],27)} ${p(s["state"] ?? "down",8)} ${s["uptime"] ?? "—"}`
)
return [...hdr, ...rows].join("\n")
}
function fmtFirewallFilter(rules: RosFirewallFilter[]): string {
const lines: string[] = ["Flags: X - disabled, I - invalid, D - dynamic"]
rules.forEach((r, i) => {
const f = `${flag(r.disabled,"X")}${flag(r.invalid,"I")}${flag(r.dynamic,"D")}`
lines.push(``)
lines.push(` ${String(i).padStart(2)} ${f}`)
if (r.comment) lines.push(` ;;; ${r.comment}`)
const parts = [`chain=${r.chain}`, `action=${r.action}`]
if (r.protocol) parts.push(`protocol=${r.protocol}`)
if (r["connection-state"])parts.push(`connection-state=${r["connection-state"]}`)
if (r["src-address"]) parts.push(`src-address=${r["src-address"]}`)
if (r["dst-address"]) parts.push(`dst-address=${r["dst-address"]}`)
if (r["src-address-list"])parts.push(`src-address-list=${r["src-address-list"]}`)
if (r["dst-address-list"])parts.push(`dst-address-list=${r["dst-address-list"]}`)
if (r["src-port"]) parts.push(`src-port=${r["src-port"]}`)
if (r["dst-port"]) parts.push(`dst-port=${r["dst-port"]}`)
if (r["in-interface"]) parts.push(`in-interface=${r["in-interface"]}`)
if (r["out-interface"]) parts.push(`out-interface=${r["out-interface"]}`)
if (r["tls-host"]) parts.push(`tls-host=${r["tls-host"]}`)
lines.push(` ${parts.join(" ")}`)
if (r.packets || r.bytes) lines.push(` packets=${r.packets ?? "0"} bytes=${r.bytes ?? "0"}`)
})
return lines.join("\n")
}
function fmtLogs(entries: RosLogEntry[]): string {
if (entries.length === 0) return "(no log entries)"
return entries.map(e =>
`${(e.time ?? "").padEnd(20)} ${(e.topics ?? "").padEnd(28)} ${e.message ?? ""}`
).join("\n")
}
function fmtPing(results: RosPingResult[], host: string): string {
const lines = [`PING ${host}`]
// individual replies
for (const r of results) {
if (!r.time && !r.ttl) continue // skip summary-only items
if (r.status === "timeout") {
lines.push(` seq=${r.seq} timeout`)
} else {
lines.push(` seq=${r.seq} ttl=${r.ttl ?? "?"} time=${r.time ?? "?"}`)
}
}
// summary — last item with sent/received fields
const sum = [...results].reverse().find(r => r.sent)
if (sum) {
const loss = sum["packet-loss"] ?? "0%"
lines.push(` sent=${sum.sent} received=${sum.received} packet-loss=${loss}`)
if (sum.time) lines.push(` avg-rtt=${sum.time}`)
}
return lines.join("\n")
}
// ── help text ──────────────────────────────────────────────────────────────────
const HELP_TEXT = [
"Доступные команды (RouterOS REST API):",
" ip address print — IP-адреса",
" ip route print — таблица маршрутов",
" interface print — интерфейсы",
" system resource print — ресурсы системы",
" system identity print — имя роутера",
" ip firewall filter print — правила firewall",
" routing bgp session print — BGP-сессии",
" routing ospf neighbor print — OSPF-соседи",
" routing ospf area print — OSPF-зоны",
" routing ospf instance print — OSPF-инстансы",
" routing ospf interface print — OSPF-интерфейсы",
" routing bfd session print — BFD-сессии",
" log print [limit=N] — системный лог",
" ping <host> [count=N] — пинг",
].join("\n")
// ── command dispatcher ─────────────────────────────────────────────────────────
async function dispatch(client: MikrotikClient, raw: string): Promise<string> {
// strip leading slashes, normalize whitespace
const cmd = raw.trim().replace(/^\/+/, "")
const lower = cmd.toLowerCase().replace(/\s+/g, " ")
if (lower === "?" || lower === "help") return HELP_TEXT
if (lower === "ip address print")
return fmtIpAddresses(await client.getIpAddresses())
if (lower === "interface print")
return fmtInterfaces(await client.getInterfaces())
if (lower === "system resource print") {
const [res, id] = await Promise.all([client.getResource(), client.getIdentity()])
return fmtResource(res, id)
}
if (lower === "system identity print")
return ` name: ${(await client.getIdentity()).name}`
if (lower === "ip route print")
return fmtIpRoutes(await client.getIpRoutes())
if (lower === "ip firewall filter print")
return fmtFirewallFilter(await client.getFirewallFilters())
if (lower === "routing bgp session print" || lower === "bgp session print" || lower === "bgp peer print")
return fmtBgpSessions(await client.getBgpSessions())
if (lower === "routing ospf neighbor print" || lower === "ospf neighbor print")
return fmtOspfNeighbors(await client.getOspfNeighbors())
if (lower === "routing ospf area print" || lower === "ospf area print")
return fmtOspfAreas(await client.getOspfAreas())
if (lower === "routing ospf instance print" || lower === "ospf instance print")
return fmtOspfInstances(await client.getOspfInstances())
if (lower === "routing ospf interface print" || lower === "routing ospf interface-template print")
return fmtOspfIfaceTemplates(await client.getOspfInterfaceTemplates())
if (lower === "routing bfd session print" || lower === "bfd session print")
return fmtBfdSessions(await client.getBfdSessions().catch(() => []))
if (lower.startsWith("log print")) {
const limitArg = lower.match(/limit=(\d+)/)
const limit = limitArg ? parseInt(limitArg[1]) : 50
return fmtLogs(await client.getLogs(Math.min(limit, 200)))
}
if (lower.startsWith("ping")) {
const parts = cmd.split(/\s+/)
const host = parts[1]
if (!host) return "Usage: ping <address> [count=N]"
const countArg = parts.find(p => p.toLowerCase().startsWith("count="))
const count = countArg ? parseInt(countArg.split("=")[1]) : 4
const results = await client.ping(host, Math.min(count, 20))
return fmtPing(results, host)
}
const firstToken = cmd.split(" ")[0]
return `bad command name ${firstToken} (line 1 column 1)`
}
// ── route plugin ───────────────────────────────────────────────────────────────
const ExecBodySchema = z.object({
command: z.string().min(1).max(1024),
})
const execRoutes: FastifyPluginAsyncZod = async (app) => {
// POST /api/servers/:id/exec — execute a RouterOS CLI-style command via REST API
app.post(
"/servers/:id/exec",
{ schema: { params: ServerIdParamSchema, body: ExecBodySchema } },
async (req, reply) => {
const server = db
.select().from(servers)
.where(eq(servers.id, req.params.id))
.limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
const client = MikrotikClient.fromServer(server)
try {
const output = await dispatch(client, req.body.command)
return reply.send({ output })
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
// Return as output (not HTTP error) so the terminal can display it
return reply.send({ output: `error: ${msg}` })
}
}
)
}
export default execRoutes
+336
View File
@@ -0,0 +1,336 @@
import { and, asc, eq, inArray } from "drizzle-orm"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { db } from "../db/index.js"
import { filterRules, servers } from "../db/schema.js"
import { MikrotikClient } from "../services/mikrotik.js"
type ServerRow = typeof servers.$inferSelect
function parseServerId(raw: unknown): number | null {
const direct = Number.parseInt(String(raw ?? ""), 10)
if (Number.isFinite(direct)) return direct
const fromLegacy = String(raw ?? "").match(/(\d+)/)?.[1]
if (!fromLegacy) return null
const parsed = Number.parseInt(fromLegacy, 10)
return Number.isFinite(parsed) ? parsed : null
}
interface RosGre {
".id"?: string
name?: string
"local-address"?: string
"remote-address"?: string
"allow-fast-path"?: string
"clamp-tcp-mss"?: string
mtu?: string
"keepalive"?: string
dscp?: string
running?: string
disabled?: string
comment?: string
}
interface RosFilterRule {
".id"?: string
chain?: string
rule?: string
comment?: string
disabled?: string
}
interface LiveGreTunnel {
id: string
name: string
serverId: string
localAddress: string
remoteAddress: string
localInnerIp: string
remoteInnerIp: string
poolId: string
ipsec: null
mtu: number
keepaliveInterval: number
keepaliveRetries: number
dscp: "inherit" | number
clampTcpMss: boolean
allowFastPath: boolean
comment: string
enabled: boolean
status: "up" | "down" | "degraded"
}
interface ApiFilterRule {
id: string
community: string
communityName?: string
action?: "route" | "blackhole"
gateway: string
gatewayTunnelId: string
description: string
}
function parseKeepalive(value: string | undefined): { interval: number; retries: number } {
if (!value || value.toLowerCase() === "none") return { interval: 0, retries: 0 }
const [intervalRaw, retriesRaw] = value.split(",")
const interval = Number.parseInt((intervalRaw ?? "").trim(), 10)
const retries = Number.parseInt((retriesRaw ?? "").trim(), 10)
return {
interval: Number.isFinite(interval) ? interval : 0,
retries: Number.isFinite(retries) ? retries : 0,
}
}
function parseDscp(value: string | undefined): "inherit" | number {
if (!value || value === "inherit") return "inherit"
const n = Number.parseInt(value, 10)
return Number.isFinite(n) ? n : "inherit"
}
function parseInnerIps(rule: string | undefined): { localInnerIp: string; remoteInnerIp: string } {
if (!rule) return { localInnerIp: "", remoteInnerIp: "" }
const local = rule.match(/address\s*=\s*([0-9.]+\/\d+)/)?.[1] ?? ""
const remote = rule.match(/(?:network|gateway)\s*=\s*([0-9.]+\/\d+)/)?.[1] ?? ""
return { localInnerIp: local, remoteInnerIp: remote }
}
function parseFilterRule(raw: RosFilterRule): ApiFilterRule[] {
const text = raw.rule ?? ""
// RouterOS rule formats vary across versions/config styles:
// - bgp-communities.has("AS:NNN")
// - bgp-communities includes AS:NNN
const communities = [
...text.matchAll(/bgp-communities\.has\("([^"]+)"\)/gi),
...text.matchAll(/bgp-communities\s+includes\s+([0-9]+:[0-9]+)/gi),
].map(m => m[1]).filter(Boolean)
if (communities.length === 0) return []
const uniqueCommunities = [...new Set(communities)]
const isBlackhole = /set\s+type\s+blackhole/i.test(text)
const gwToken = text.match(/set\s+gw(?:ateway)?\s+([^\s;]+)/i)?.[1] ?? ""
const outIface = text.match(/set\s+out-interface\s+([^\s;]+)/i)?.[1] ?? ""
const inlineComment = text.match(/#\s*([^\n]+)/)?.[1]?.trim()
return uniqueCommunities.map((community, idx) => ({
id: `${raw[".id"] ?? "live"}-${idx}`,
community,
action: isBlackhole ? "blackhole" : "route",
gateway: isBlackhole ? "" : gwToken,
gatewayTunnelId: isBlackhole ? "" : outIface,
description: inlineComment || raw.comment?.trim() || "",
}))
}
async function fetchServerFilters(server: ServerRow) {
const client = MikrotikClient.fromServer(server)
const [greRaw, filterRaw] = await Promise.all([
client.get<RosGre[]>("/interface/gre"),
client.get<RosFilterRule[]>("/routing/filter/rule"),
])
const tunnels: LiveGreTunnel[] = greRaw.map((g, idx) => {
const keepalive = parseKeepalive(g["keepalive"])
const inner = parseInnerIps(g.comment)
const running = g.running === "true"
const disabled = g.disabled === "true"
return {
id: g.name || g[".id"] || `gre-${server.id}-${idx}`,
name: g.name || `gre-${idx + 1}`,
serverId: String(server.id),
localAddress: g["local-address"] ?? "",
remoteAddress: g["remote-address"] ?? "",
localInnerIp: inner.localInnerIp,
remoteInnerIp: inner.remoteInnerIp,
poolId: "live",
ipsec: null,
mtu: Number.parseInt(g.mtu ?? "1476", 10) || 1476,
keepaliveInterval: keepalive.interval,
keepaliveRetries: keepalive.retries,
dscp: parseDscp(g.dscp),
clampTcpMss: g["clamp-tcp-mss"] !== "false",
allowFastPath: g["allow-fast-path"] !== "false",
comment: g.comment ?? "",
enabled: !disabled,
status: disabled ? "down" : (running ? "up" : "degraded"),
}
})
const rules = filterRaw
.filter(r => (r.chain ?? "").trim().toLowerCase() === "bgp-in")
.flatMap(parseFilterRule)
return {
serverId: String(server.id),
rules,
tunnels,
}
}
function toApiRulesets(serverRows: ServerRow[]) {
const dbRules = db.select().from(filterRules).orderBy(asc(filterRules.serverId), asc(filterRules.sortOrder)).all()
return serverRows.map(s => ({
serverId: String(s.id),
rules: dbRules
.filter(r => r.serverId === s.id)
.map((r): ApiFilterRule => ({
id: String(r.id),
community: r.community,
communityName: r.communityName ?? undefined,
action: r.action,
gateway: r.gateway,
gatewayTunnelId: r.gatewayTunnelId,
description: r.description,
})),
}))
}
function toRouterRuleBody(rules: ApiFilterRule[]): string {
return rules.map((rule, i) => {
const kw = i === 0 ? "if" : "} else if"
const comment = rule.description ? ` # ${rule.description}` : ""
if (rule.action === "blackhole") {
return [
` ${kw} (bgp-communities.has("${rule.community}")) {`,
comment,
" set type blackhole;",
" accept;",
].filter(Boolean).join("\n")
}
return [
` ${kw} (bgp-communities.has("${rule.community}")) {`,
comment,
` set gateway ${rule.gateway};`,
` set out-interface ${rule.gatewayTunnelId};`,
" accept;",
].filter(Boolean).join("\n")
}).join("\n")
}
async function replaceDbRules(serverId: number, rules: ApiFilterRule[]) {
db.delete(filterRules).where(eq(filterRules.serverId, serverId)).run()
if (rules.length === 0) return
const now = new Date().toISOString()
db.insert(filterRules).values(
rules.map((r, i) => ({
serverId,
sortOrder: i,
community: r.community,
communityName: r.communityName ?? null,
action: r.action ?? "route",
gateway: r.gateway,
gatewayTunnelId: r.gatewayTunnelId,
description: r.description,
createdAt: now,
updatedAt: now,
})),
).run()
}
const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/filters/rules", async (_req, reply) => {
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const dbRulesets = toApiRulesets(allServers)
const results = await Promise.all(allServers.map(async (server) => {
try {
const remote = await fetchServerFilters(server)
return { serverId: String(server.id), tunnels: remote.tunnels }
} catch {
return { serverId: String(server.id), tunnels: [] as LiveGreTunnel[] }
}
}))
return reply.send({
rulesets: dbRulesets,
greTunnels: results.flatMap(r => r.tunnels),
})
})
app.put("/filters/rules", async (req, reply) => {
const body = req.body as { rulesets?: Array<{ serverId: string; rules: ApiFilterRule[] }> }
const payload = body.rulesets ?? []
const serverIds = payload.map(r => Number.parseInt(r.serverId, 10)).filter(Number.isFinite)
if (serverIds.length > 0) {
db.delete(filterRules).where(inArray(filterRules.serverId, serverIds)).run()
}
for (const rs of payload) {
const sid = Number.parseInt(rs.serverId, 10)
if (!Number.isFinite(sid)) continue
await replaceDbRules(sid, rs.rules ?? [])
}
return reply.send({ ok: true })
})
app.post("/filters/sync/from-router", async (_req, reply) => {
const body = _req.body as { serverId?: string | number } | undefined
const rawServerId = body?.serverId
const serverId = parseServerId(rawServerId)
if (serverId === null) {
return reply.status(400).send({ error: "serverId is required" })
}
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
try {
app.log.info({ serverId, host: server.host }, "Filters sync from router started")
const remote = await fetchServerFilters(server)
await replaceDbRules(server.id, remote.rules)
app.log.info({ serverId, totalRules: remote.rules.length }, "Filters sync from router completed")
return reply.send({ ok: true, updatedServers: 1, totalRules: remote.rules.length, serverId })
} catch (err) {
app.log.error({ serverId, err: String(err) }, "Filters sync from router failed")
return reply.status(500).send({ error: String(err) })
}
})
app.post("/filters/sync/to-router", async (_req, reply) => {
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
let updatedServers = 0
let pushedRules = 0
for (const server of allServers) {
try {
const client = MikrotikClient.fromServer(server)
const existing = await client.get<RosFilterRule[]>("/routing/filter/rule")
const managed = existing.filter(r => (r.comment ?? "").startsWith("RouterLists:"))
for (const r of managed) {
if (!r[".id"]) continue
await client.delete(`/routing/filter/rule/${encodeURIComponent(r[".id"])}`)
}
const rows = db.select().from(filterRules)
.where(and(eq(filterRules.serverId, server.id)))
.orderBy(asc(filterRules.sortOrder))
.all()
const rules: ApiFilterRule[] = rows.map(r => ({
id: String(r.id),
community: r.community,
communityName: r.communityName ?? undefined,
action: r.action,
gateway: r.gateway,
gatewayTunnelId: r.gatewayTunnelId,
description: r.description,
}))
if (rules.length > 0) {
const ruleBody = toRouterRuleBody(rules)
await client.post("/routing/filter/rule", {
chain: "bgp-in",
comment: `RouterLists: ${server.name || server.host}`,
rule: ruleBody,
})
pushedRules += rules.length
}
updatedServers += 1
} catch {
// ignore failed server push
}
}
return reply.send({ ok: true, updatedServers, pushedRules })
})
}
export default filtersRoutes
+327
View File
@@ -0,0 +1,327 @@
import { eq } from "drizzle-orm"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { MikrotikClient } from "../services/mikrotik.js"
import { ServerIdParamSchema } from "../types/server.js"
import type {
RosOspfNeighbor, RosOspfArea, RosOspfInterfaceTemplate, RosOspfInstance,
RosBfdSession,
OspfNeighborRead, OspfInterfaceRead, OspfInstanceRead, BfdSessionRead,
} from "../types/server.js"
type ServerRow = typeof servers.$inferSelect
// ── helpers ───────────────────────────────────────────────────────────────────
/** Parse RouterOS duration string like "1h30m17s", "40s", "10m" → seconds */
function parseDuration(s: string | undefined): number {
if (!s) return 0
let total = 0
for (const m of s.matchAll(/(\d+)(w|d|h|m(?!s)|s)/g)) {
const n = parseInt(m[1])
switch (m[2]) {
case "w": total += n * 604800; break
case "d": total += n * 86400; break
case "h": total += n * 3600; break
case "m": total += n * 60; break
case "s": total += n; break
}
}
return total
}
/** Build area-name → area-id lookup from a list of raw areas */
function buildAreaMap(areas: RosOspfArea[]): Map<string, string> {
const map = new Map<string, string>()
for (const a of areas) {
map.set(a.name, a["area-id"] ?? "0.0.0.0")
}
return map
}
/** Parse "200ms" | "1s" | "500ms" → milliseconds */
function parseMs(s: string | undefined): number {
if (!s) return 0
const ms = s.match(/^(\d+)ms$/)
if (ms) return parseInt(ms[1])
const sec = s.match(/^(\d+)s$/)
if (sec) return parseInt(sec[1]) * 1000
return parseInt(s) || 0
}
/** Normalize BFD state: "up" → "Up", "down" → "Down", etc. */
function normalizeBfdState(state: string | undefined): string {
const map: Record<string, string> = {
up: "Up", down: "Down", init: "Init", admindown: "AdminDown",
}
return map[(state ?? "").toLowerCase()] ?? (state ?? "Down")
}
/** Extract IP and interface from RouterOS address format "10.0.0.1%eth0" */
function parseAddrIface(addr: string): { ip: string; iface: string } {
const [ip, iface = ""] = addr.split("%")
return { ip, iface }
}
/** Fetch all OSPF + BFD data for one server */
async function fetchServerOspf(server: ServerRow) {
const client = MikrotikClient.fromServer(server)
const [neighbors, areas, ifaceTemplates, instances, bfdSessions] = await Promise.all([
client.getOspfNeighbors(),
client.getOspfAreas(),
client.getOspfInterfaceTemplates(),
client.getOspfInstances(),
client.getBfdSessions().catch(() => [] as RosBfdSession[]), // BFD is optional
])
return { neighbors, areas, ifaceTemplates, instances, bfdSessions }
}
// ── BFD parser ────────────────────────────────────────────────────────────────
function parseBfdSessions(server: ServerRow, raw: RosBfdSession[]): BfdSessionRead[] {
return raw.map((s, idx) => {
const local = parseAddrIface(s["local-address"])
const remote = parseAddrIface(s["remote-address"])
// Prefer interface from local-address; fallback to remote-address suffix
const iface = local.iface || remote.iface
return {
id: s[".id"] ?? String(idx),
serverId: server.id,
serverName: server.name || server.host,
serverSite: server.site,
serverCountry: server.country,
localAddr: local.ip,
remoteAddr: remote.ip,
interface: iface,
state: normalizeBfdState(s["state"]),
uptime: s["uptime"] ?? null,
multihop: s["multihop"] === "true",
multiplier: parseInt(s["multiplier"] ?? "3") || 3,
txInterval: parseMs(s["actual-tx-interval"] ?? s["desired-tx-interval"]),
rxInterval: parseMs(s["required-min-rx"]),
holdTime: parseMs(s["hold-time"]),
packetsRx: parseInt(s["packets-rx"] ?? "0") || 0,
packetsTx: parseInt(s["packets-tx"] ?? "0") || 0,
stateChanges: parseInt(s["state-changes"] ?? "0") || 0,
}
})
}
// ── parsers ───────────────────────────────────────────────────────────────────
function parseNeighbors(
server: ServerRow,
neighbors: RosOspfNeighbor[],
areaMap: Map<string, string>,
): OspfNeighborRead[] {
return neighbors.map((n, idx) => ({
id: n[".id"] ?? String(idx),
serverId: server.id,
serverName: server.name || server.host,
serverSite: server.site,
serverCountry: server.country,
address: n.address,
routerId: n["router-id"],
instance: n.instance,
area: n.area,
areaId: areaMap.get(n.area) ?? n.area,
interface: n.interface,
state: n.state,
uptime: n.adjacency ?? null,
stateChanges: parseInt(n["state-changes"] ?? "0") || 0,
priority: parseInt(n.priority ?? "1") || 1,
}))
}
function parseInterfaces(
server: ServerRow,
templates: RosOspfInterfaceTemplate[],
areas: RosOspfArea[],
instances: RosOspfInstance[],
areaMap: Map<string, string>,
): OspfInterfaceRead[] {
// Build instance-id → instance name map (for interface-template instance-id field)
const instanceIdMap = new Map<string, string>()
instances.forEach((inst, i) => { instanceIdMap.set(String(i), inst.name) })
return templates.map((t, idx) => {
// Resolve interface name: may be a "*ID" reference (RouterOS internal ID)
const ifaceName = (t.interfaces ?? "").startsWith("*")
? `(ref ${t.interfaces})`
: (t.interfaces ?? "—")
return {
id: t[".id"] ?? String(idx),
serverId: server.id,
serverName: server.name || server.host,
serverSite: server.site,
serverCountry: server.country,
instance: instanceIdMap.get(t["instance-id"] ?? "") ?? t["instance-id"] ?? "",
area: t.area,
areaId: areaMap.get(t.area) ?? t.area,
interface: ifaceName,
cost: parseInt(t.cost ?? "10") || 10,
type: t.type ?? "broadcast",
disabled: t.disabled === "true",
inactive: t.inactive === "true",
priority: parseInt(t.priority ?? "1") || 1,
helloInterval: parseDuration(t["hello-interval"]),
deadInterval: parseDuration(t["dead-interval"]),
useBfd: t["use-bfd"] === "true",
}
})
}
function parseInstances(
server: ServerRow,
instances: RosOspfInstance[],
): OspfInstanceRead[] {
return instances.map((inst, idx) => ({
id: inst[".id"] ?? String(idx),
serverId: server.id,
serverName: server.name || server.host,
serverSite: server.site,
serverCountry: server.country,
name: inst.name,
routerId: inst["router-id"],
version: parseInt(inst.version ?? "2") || 2,
disabled: inst.disabled === "true",
inactive: inst.inactive === "true",
redistribute: inst.redistribute ?? "",
}))
}
// ── route plugin ──────────────────────────────────────────────────────────────
const ospfRoutes: FastifyPluginAsyncZod = async (app) => {
// GET /api/ospf/neighbors — aggregate OSPF neighbors from ALL enabled servers
app.get("/ospf/neighbors", async (_req, reply) => {
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const results = await Promise.all(
allServers.map(async (server) => {
try {
const { neighbors, areas } = await fetchServerOspf(server)
const areaMap = buildAreaMap(areas)
return parseNeighbors(server, neighbors, areaMap)
} catch {
return []
}
}),
)
return reply.send(results.flat())
})
// GET /api/ospf/interfaces — aggregate OSPF interface templates from ALL enabled servers
app.get("/ospf/interfaces", async (_req, reply) => {
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const results = await Promise.all(
allServers.map(async (server) => {
try {
const { ifaceTemplates, areas, instances } = await fetchServerOspf(server)
const areaMap = buildAreaMap(areas)
return parseInterfaces(server, ifaceTemplates, areas, instances, areaMap)
} catch {
return []
}
}),
)
return reply.send(results.flat())
})
// GET /api/ospf/instances — aggregate OSPF instances from ALL enabled servers
app.get("/ospf/instances", async (_req, reply) => {
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const results = await Promise.all(
allServers.map(async (server) => {
try {
const { instances } = await fetchServerOspf(server)
return parseInstances(server, instances)
} catch {
return []
}
}),
)
return reply.send(results.flat())
})
// GET /api/ospf/all — single round-trip: neighbors + interfaces + instances + BFD
app.get("/ospf/all", async (_req, reply) => {
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const perServer = await Promise.all(
allServers.map(async (server) => {
try {
const { neighbors, areas, ifaceTemplates, instances, bfdSessions } = await fetchServerOspf(server)
const areaMap = buildAreaMap(areas)
return {
neighbors: parseNeighbors(server, neighbors, areaMap),
interfaces: parseInterfaces(server, ifaceTemplates, areas, instances, areaMap),
instances: parseInstances(server, instances),
bfdSessions: parseBfdSessions(server, bfdSessions),
}
} catch {
return { neighbors: [], interfaces: [], instances: [], bfdSessions: [] }
}
}),
)
return reply.send({
neighbors: perServer.flatMap(r => r.neighbors),
interfaces: perServer.flatMap(r => r.interfaces),
instances: perServer.flatMap(r => r.instances),
bfdSessions: perServer.flatMap(r => r.bfdSessions),
})
})
// GET /api/bfd/sessions — BFD sessions only (for direct access)
app.get("/bfd/sessions", async (_req, reply) => {
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const results = await Promise.all(
allServers.map(async (server) => {
try {
const client = MikrotikClient.fromServer(server)
const raw = await client.getBfdSessions()
return parseBfdSessions(server, raw)
} catch {
return []
}
}),
)
return reply.send(results.flat())
})
// GET /api/servers/:id/ospf — single server OSPF + BFD data
app.get("/servers/:id/ospf", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const server = db
.select().from(servers)
.where(eq(servers.id, req.params.id))
.limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
try {
const { neighbors, areas, ifaceTemplates, instances, bfdSessions } = await fetchServerOspf(server)
const areaMap = buildAreaMap(areas)
return reply.send({
neighbors: parseNeighbors(server, neighbors, areaMap),
interfaces: parseInterfaces(server, ifaceTemplates, areas, instances, areaMap),
instances: parseInstances(server, instances),
bfdSessions: parseBfdSessions(server, bfdSessions),
areas: areas.map(a => ({ name: a.name, areaId: a["area-id"] ?? "0.0.0.0", type: a.type, disabled: a.disabled === "true", inactive: a.inactive === "true", instance: a.instance })),
})
} catch (err) {
return reply.status(500).send({ error: String(err) })
}
})
}
export default ospfRoutes
+264
View File
@@ -0,0 +1,264 @@
import { asc, eq } from "drizzle-orm"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { db } from "../db/index.js"
import { recursiveRoutes, servers } from "../db/schema.js"
import { MikrotikClient } from "../services/mikrotik.js"
type ServerRow = typeof servers.$inferSelect
interface RosRoute {
".id"?: string
"dst-address"?: string
gateway?: string
distance?: string
scope?: string
"target-scope"?: string
"routing-table"?: string
"check-gateway"?: string
comment?: string
disabled?: string
static?: string
dynamic?: string
blackhole?: string
unreachable?: string
prohibit?: string
active?: string
}
interface GatewayOptionDto {
id: string
name: string
ip: string
status: "up" | "down"
}
interface RecursiveRouteDto {
id: string
dstAddress: string
gateway: string
distance: number
scope: number | null
targetScope: number | null
routingTable: string
checkGateway: string
country: string
comment: string
disabled: boolean
}
function parseServerId(raw: unknown): number | null {
const direct = Number.parseInt(String(raw ?? ""), 10)
if (Number.isFinite(direct)) return direct
const legacy = String(raw ?? "").match(/(\d+)/)?.[1]
if (!legacy) return null
const parsed = Number.parseInt(legacy, 10)
return Number.isFinite(parsed) ? parsed : null
}
function isIpGateway(gw: string): boolean {
return /^\d{1,3}(\.\d{1,3}){3}(?:%\S+)?$/.test(gw.trim())
}
function isRecursiveRoute(r: RosRoute): boolean {
if ((r.static ?? "false") !== "true") return false
if ((r.dynamic ?? "false") === "true") return false
if ((r.blackhole ?? "false") === "true") return false
if ((r.unreachable ?? "false") === "true") return false
if ((r.prohibit ?? "false") === "true") return false
const dst = r["dst-address"] ?? ""
const gw = r.gateway ?? ""
if (!dst || !gw) return false
return isIpGateway(gw)
}
function hasRecursiveCommentMask(comment: string | undefined): boolean {
if (!comment) return false
return /^recursive:\s*/i.test(comment.trim())
}
function splitGateway(raw: string): { ip: string; name: string } | null {
const v = raw.trim()
if (!v) return null
const [ip, name] = v.split("%")
if (!ip || !/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) return null
return { ip, name: name || ip }
}
function mapDbRoutes(serverId: number): RecursiveRouteDto[] {
const rows = db
.select()
.from(recursiveRoutes)
.where(eq(recursiveRoutes.serverId, serverId))
.orderBy(asc(recursiveRoutes.sortOrder))
.all()
return rows.map((r) => ({
id: String(r.id),
dstAddress: r.dstAddress,
gateway: r.gateway,
distance: r.distance,
scope: r.scope ?? null,
targetScope: r.targetScope ?? null,
routingTable: r.routingTable,
checkGateway: r.checkGateway,
country: r.country ?? "",
comment: r.comment,
disabled: r.disabled,
}))
}
function toRouterPayload(route: RecursiveRouteDto): Record<string, string> {
return {
"dst-address": route.dstAddress,
gateway: route.gateway,
distance: String(route.distance),
...(route.scope != null ? { scope: String(route.scope) } : {}),
...(route.targetScope != null ? { "target-scope": String(route.targetScope) } : {}),
...(route.routingTable ? { "routing-table": route.routingTable } : {}),
...(route.checkGateway ? { "check-gateway": route.checkGateway } : {}),
comment: route.comment
? `RouterLists:recursive ${route.comment}`
: "RouterLists:recursive",
disabled: route.disabled ? "true" : "false",
}
}
async function replaceDbRoutes(serverId: number, routes: RecursiveRouteDto[]) {
db.delete(recursiveRoutes).where(eq(recursiveRoutes.serverId, serverId)).run()
if (routes.length === 0) return
const now = new Date().toISOString()
db.insert(recursiveRoutes).values(
routes.map((r, i) => ({
serverId,
sortOrder: i,
dstAddress: r.dstAddress,
gateway: r.gateway,
distance: r.distance,
scope: r.scope,
targetScope: r.targetScope,
routingTable: r.routingTable || "main",
checkGateway: r.checkGateway ?? "",
country: r.country ?? "",
comment: r.comment ?? "",
disabled: r.disabled,
createdAt: now,
updatedAt: now,
})),
).run()
}
const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => {
app.get("/recursive-routes/gateways", async (req, reply) => {
const q = req.query as { serverId?: string | number }
const serverId = parseServerId(q.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
try {
const client = MikrotikClient.fromServer(server)
const rosRoutes = await client.get<RosRoute[]>("/ip/route")
const map = new Map<string, GatewayOptionDto>()
for (const r of rosRoutes) {
const g = splitGateway(r.gateway ?? "")
if (!g) continue
const id = `${g.ip}%${g.name}`
if (!map.has(id)) {
map.set(id, {
id,
name: g.name,
ip: g.ip,
status: r.active === "true" ? "up" : "down",
})
}
}
return reply.send({ gateways: [...map.values()] })
} catch (err) {
return reply.status(500).send({ error: String(err) })
}
})
app.get("/recursive-routes", async (req, reply) => {
const q = req.query as { serverId?: string | number }
const serverId = parseServerId(q.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
return reply.send({ routes: mapDbRoutes(serverId) })
})
app.put("/recursive-routes", async (req, reply) => {
const body = req.body as { serverId?: string | number; routes?: RecursiveRouteDto[] }
const serverId = parseServerId(body.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
await replaceDbRoutes(serverId, body.routes ?? [])
return reply.send({ ok: true })
})
app.post("/recursive-routes/sync/from-router", async (req, reply) => {
const body = req.body as { serverId?: string | number } | undefined
const serverId = parseServerId(body?.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const server: ServerRow | undefined = db
.select().from(servers)
.where(eq(servers.id, serverId))
.limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
try {
const client = MikrotikClient.fromServer(server)
const rosRoutes = await client.get<RosRoute[]>("/ip/route")
const rec = rosRoutes.filter(r =>
isRecursiveRoute(r) && hasRecursiveCommentMask(r.comment),
)
const mapped: RecursiveRouteDto[] = rec.map((r, i) => ({
id: r[".id"] ?? `ros-${i}`,
dstAddress: r["dst-address"] ?? "",
gateway: r.gateway ?? "",
distance: Number.parseInt(r.distance ?? "1", 10) || 1,
scope: r.scope ? (Number.parseInt(r.scope, 10) || null) : null,
targetScope: r["target-scope"] ? (Number.parseInt(r["target-scope"], 10) || null) : null,
routingTable: r["routing-table"] ?? "main",
checkGateway: r["check-gateway"] ?? "",
country: "",
comment: r.comment ?? "",
disabled: r.disabled === "true",
}))
await replaceDbRoutes(serverId, mapped)
return reply.send({ ok: true, serverId, totalRoutes: mapped.length })
} catch (err) {
return reply.status(500).send({ error: String(err) })
}
})
app.post("/recursive-routes/sync/to-router", async (req, reply) => {
const body = req.body as { serverId?: string | number } | undefined
const serverId = parseServerId(body?.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
try {
const client = MikrotikClient.fromServer(server)
const existing = await client.get<RosRoute[]>("/ip/route")
const managed = existing.filter(r => (r.comment ?? "").startsWith("RouterLists:recursive"))
for (const r of managed) {
if (!r[".id"]) continue
await client.delete(`/ip/route/${encodeURIComponent(r[".id"])}`)
}
const dbRows = mapDbRoutes(serverId)
for (const route of dbRows) {
await client.post("/ip/route", toRouterPayload(route))
}
return reply.send({ ok: true, serverId, pushedRoutes: dbRows.length })
} catch (err) {
return reply.status(500).send({ error: String(err) })
}
})
}
export default recursiveRoutesPlugin
+224
View File
@@ -0,0 +1,224 @@
import { desc, eq } from "drizzle-orm"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { db } from "../db/index.js"
import { servers, serverSnapshots } from "../db/schema.js"
import type { ServerRead } from "../types/server.js"
import {
ServerCreateSchema,
ServerUpdateSchema,
ServerIdParamSchema,
SnapshotsQuerySchema,
TestConnectionSchema,
} from "../types/server.js"
import { pollServer, toSnapshotRead } from "../services/poller.js"
import { MikrotikClient, MikrotikError } from "../services/mikrotik.js"
// ── helpers ────────────────────────────────────────────────────────────────────
type ServerRow = typeof servers.$inferSelect
type SnapshotRow = typeof serverSnapshots.$inferSelect
/** Merge a server row with its latest snapshot into the frontend-compatible shape */
function toServerRead(server: ServerRow, snap: SnapshotRow | undefined): ServerRead {
return {
id: server.id,
name: server.name || server.host,
host: server.host,
port: server.port,
useSsl: server.useSsl,
verifySsl: server.verifySsl,
username: server.username,
password: server.password,
type: server.type,
site: server.site,
country: server.country,
asn: server.asn,
comment: server.comment,
enabled: server.enabled,
createdAt: server.createdAt,
updatedAt: server.updatedAt,
// snapshot fields (null if never polled)
status: snap ? snap.status : null,
latency: snap?.latencyMs ?? null,
os: snap?.rosVersion ?? null,
model: snap?.boardName ?? null,
uptime: snap?.uptime ?? null,
cpuLoad: snap?.cpuLoad ?? null,
freeMemory: snap?.freeMemory ?? null,
totalMemory: snap?.totalMemory ?? null,
identityName: snap?.identityName ?? null,
sessions: 0,
polledAt: snap?.polledAt ?? null,
}
}
/** Get latest snapshot for a single server */
function getLatestSnapshot(serverId: number): SnapshotRow | undefined {
return db
.select()
.from(serverSnapshots)
.where(eq(serverSnapshots.serverId, serverId))
.orderBy(desc(serverSnapshots.polledAt))
.limit(1)
.all()[0]
}
// ── plugin ─────────────────────────────────────────────────────────────────────
const serversRoutes: FastifyPluginAsyncZod = async (app) => {
// POST /api/servers/test-connection — check credentials before saving
app.post("/test-connection", { schema: { body: TestConnectionSchema } }, async (req, reply) => {
const { host, port, useSsl, verifySsl, apiPath, username, password } = req.body
const client = new MikrotikClient({ host, port, useSsl, verifySsl, apiPath, username, password })
try {
const t0 = performance.now()
const [identity, resource] = await Promise.all([
client.getIdentity(),
client.getResource(),
])
const latencyMs = Math.round(performance.now() - t0)
return reply.send({
success: true,
latencyMs,
identity: identity.name,
version: resource["version"],
boardName: resource["board-name"],
uptime: resource["uptime"],
message: `Подключено · RouterOS ${resource["version"]} · ${resource["board-name"]}`,
})
} catch (err) {
let message = "Не удалось подключиться"
if (err instanceof MikrotikError) {
if (err.statusCode === 401) message = "Неверный логин или пароль (401 Unauthorized)"
else if (err.statusCode === 403) message = "Нет прав доступа к REST API (403 Forbidden)"
else message = `Ошибка RouterOS API: ${err.statusCode}`
} else if (err instanceof Error) {
const msg = err.message.toLowerCase()
if (msg.includes("timeout") || msg.includes("abort"))
message = `Нет ответа от ${host}:${port} — проверьте IP и что сервис www/www-ssl включён`
else if (msg.includes("econnrefused"))
message = `Соединение отклонено — порт ${port} закрыт`
else if (msg.includes("cert") || msg.includes("ssl") || msg.includes("tls"))
message = "Ошибка SSL-сертификата — отключите «Проверять SSL» для self-signed"
else if (msg.includes("enotfound") || msg.includes("getaddrinfo"))
message = `Хост не найден: ${host}`
else
message = err.message
}
return reply.status(502).send({ success: false, message })
}
})
// GET /api/servers
app.get("/", async (_req, reply) => {
const all = db.select().from(servers).all()
const result: ServerRead[] = all.map(s => toServerRead(s, getLatestSnapshot(s.id)))
return reply.send(result)
})
// POST /api/servers
app.post("/", { schema: { body: ServerCreateSchema } }, async (req, reply) => {
const now = new Date().toISOString()
const [inserted] = db
.insert(servers)
.values({ ...req.body, createdAt: now, updatedAt: now })
.returning()
.all()
return reply.status(201).send(toServerRead(inserted, undefined))
})
// GET /api/servers/:id
app.get("/:id", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const server = db
.select().from(servers)
.where(eq(servers.id, req.params.id))
.limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
return reply.send(toServerRead(server, getLatestSnapshot(server.id)))
})
// PUT /api/servers/:id
app.put(
"/:id",
{ schema: { params: ServerIdParamSchema, body: ServerUpdateSchema } },
async (req, reply) => {
const existing = db
.select().from(servers)
.where(eq(servers.id, req.params.id))
.limit(1).all()[0]
if (!existing) return reply.status(404).send({ error: "Server not found" })
const [updated] = db
.update(servers)
.set({ ...req.body, updatedAt: new Date().toISOString() })
.where(eq(servers.id, req.params.id))
.returning()
.all()
return reply.send(toServerRead(updated, getLatestSnapshot(updated.id)))
},
)
// DELETE /api/servers/:id
app.delete("/:id", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const existing = db
.select().from(servers)
.where(eq(servers.id, req.params.id))
.limit(1).all()[0]
if (!existing) return reply.status(404).send({ error: "Server not found" })
db.delete(servers).where(eq(servers.id, req.params.id)).run()
return reply.status(204).send()
})
// POST /api/servers/:id/poll
app.post("/:id/poll", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
const existing = db
.select().from(servers)
.where(eq(servers.id, req.params.id))
.limit(1).all()[0]
if (!existing) return reply.status(404).send({ error: "Server not found" })
try {
const snap = await pollServer(req.params.id)
return reply.send(snap)
} catch (err) {
return reply.status(500).send({ error: (err as Error).message })
}
})
// GET /api/servers/:id/snapshots
app.get(
"/:id/snapshots",
{ schema: { params: ServerIdParamSchema, querystring: SnapshotsQuerySchema } },
async (req, reply) => {
const existing = db
.select().from(servers)
.where(eq(servers.id, req.params.id))
.limit(1).all()[0]
if (!existing) return reply.status(404).send({ error: "Server not found" })
const snaps = db
.select()
.from(serverSnapshots)
.where(eq(serverSnapshots.serverId, req.params.id))
.orderBy(desc(serverSnapshots.polledAt))
.limit(req.query.limit)
.all()
return reply.send(snaps.map(toSnapshotRead))
},
)
}
export default serversRoutes
+285
View File
@@ -0,0 +1,285 @@
import { desc, eq } from "drizzle-orm"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { db } from "../db/index.js"
import { serverSnapshots, servers } from "../db/schema.js"
import {
collectTrafficOnce,
getTrafficCollectorState,
getTrafficSettings,
readServerSamplesInRange,
updateTrafficSettings,
} from "../services/traffic-collector.js"
type SnapshotRow = typeof serverSnapshots.$inferSelect
interface TrafficServerDto {
id: string
name: string
site: string
country: string
status: "online" | "offline" | "degraded"
rxNow: number
txNow: number
rxPeak: number
txPeak: number
rxTotal: number
txTotal: number
sessions: number
rxSeries: number[]
txSeries: number[]
}
interface TrafficInterfaceDto {
name: string
running: boolean
disabled: boolean
rxNow: number
txNow: number
}
function latestSnapshot(serverId: number): SnapshotRow | undefined {
return db
.select()
.from(serverSnapshots)
.where(eq(serverSnapshots.serverId, serverId))
.orderBy(desc(serverSnapshots.polledAt))
.limit(1)
.all()[0]
}
function rangeToMinutes(range: string | undefined): number {
switch ((range ?? "1h").toLowerCase()) {
case "5m": return 5
case "15m": return 15
case "1h": return 60
case "4h": return 240
case "24h": return 1440
default: return 60
}
}
function toSeries(values: number[], target = 60): number[] {
if (values.length === 0) return Array(target).fill(0)
if (values.length === target) return values
if (values.length > target) return values.slice(values.length - target)
const head = Array(target - values.length).fill(values[0] ?? 0)
return [...head, ...values]
}
function buildServerTraffic(
s: typeof servers.$inferSelect,
status: TrafficServerDto["status"],
rows: Array<{
interfaceName: string
sampledAt: string
rxBps: number
txBps: number
rxBytes: number
txBytes: number
running: boolean
disabled: boolean
}>,
onlyInterface?: string,
): TrafficServerDto {
const filteredRows = onlyInterface
? rows.filter((r) => r.interfaceName === onlyInterface)
: rows
if (filteredRows.length === 0) {
return {
id: String(s.id),
name: s.name || s.host,
site: s.site || "—",
country: s.country || "UN",
status,
rxNow: 0,
txNow: 0,
rxPeak: 0,
txPeak: 0,
rxTotal: 0,
txTotal: 0,
sessions: 0,
rxSeries: Array(60).fill(0),
txSeries: Array(60).fill(0),
}
}
const bySampleTs = new Map<string, { rx: number; tx: number }>()
const byIface = new Map<string, typeof filteredRows>()
for (const r of filteredRows) {
const ts = r.sampledAt
const cur = bySampleTs.get(ts) ?? { rx: 0, tx: 0 }
cur.rx += Math.max(0, r.rxBps) / 1_000_000
cur.tx += Math.max(0, r.txBps) / 1_000_000
bySampleTs.set(ts, cur)
const arr = byIface.get(r.interfaceName) ?? []
arr.push(r)
byIface.set(r.interfaceName, arr)
}
const seriesPoints = [...bySampleTs.entries()]
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([, v]) => ({ rx: Math.round(v.rx), tx: Math.round(v.tx) }))
const rxSeries = toSeries(seriesPoints.map((p) => p.rx))
const txSeries = toSeries(seriesPoints.map((p) => p.tx))
const rxNow = rxSeries[rxSeries.length - 1] ?? 0
const txNow = txSeries[txSeries.length - 1] ?? 0
const rxPeak = rxSeries.reduce((m, v) => Math.max(m, v), 0)
const txPeak = txSeries.reduce((m, v) => Math.max(m, v), 0)
let rxBytesDelta = 0
let txBytesDelta = 0
let sessions = 0
for (const arr of byIface.values()) {
const sorted = [...arr].sort((a, b) => a.sampledAt.localeCompare(b.sampledAt))
const first = sorted[0]
const last = sorted[sorted.length - 1]
if (first && last) {
const dRx = last.rxBytes - first.rxBytes
const dTx = last.txBytes - first.txBytes
rxBytesDelta += dRx >= 0 ? dRx : last.rxBytes
txBytesDelta += dTx >= 0 ? dTx : last.txBytes
if (last.running && !last.disabled) sessions += 1
}
}
return {
id: String(s.id),
name: s.name || s.host,
site: s.site || "—",
country: s.country || "UN",
status,
rxNow,
txNow,
rxPeak,
txPeak,
rxTotal: Number((rxBytesDelta / (1024 ** 3)).toFixed(1)),
txTotal: Number((txBytesDelta / (1024 ** 3)).toFixed(1)),
sessions,
rxSeries,
txSeries,
}
}
const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/traffic/settings", async (_req, reply) => {
const settings = getTrafficSettings()
const state = getTrafficCollectorState()
return reply.send({
enabled: settings.enabled,
intervalSec: settings.intervalSec,
retentionDays: settings.retentionDays,
lastCollectedAt: settings.lastCollectedAt ?? null,
lastDurationMs: settings.lastDurationMs ?? null,
lastError: settings.lastError || null,
collectorRunning: state.running,
})
})
app.put("/traffic/settings", async (req, reply) => {
const body = req.body as {
enabled?: boolean
intervalSec?: number | string
retentionDays?: number | string
}
const intervalSec = body.intervalSec == null ? undefined : Math.max(5, Number.parseInt(String(body.intervalSec), 10) || 30)
const retentionDays = body.retentionDays == null ? undefined : Math.max(1, Number.parseInt(String(body.retentionDays), 10) || 14)
const updated = updateTrafficSettings({
enabled: body.enabled,
intervalSec,
retentionDays,
})
return reply.send({
ok: true,
settings: {
enabled: updated.enabled,
intervalSec: updated.intervalSec,
retentionDays: updated.retentionDays,
lastCollectedAt: updated.lastCollectedAt ?? null,
lastDurationMs: updated.lastDurationMs ?? null,
lastError: updated.lastError || null,
},
})
})
app.post("/traffic/collect-now", async (_req, reply) => {
await collectTrafficOnce()
const updated = getTrafficSettings()
return reply.send({
ok: true,
lastCollectedAt: updated.lastCollectedAt ?? null,
lastDurationMs: updated.lastDurationMs ?? null,
lastError: updated.lastError || null,
})
})
app.get("/traffic/servers", async (req, reply) => {
const q = req.query as { range?: string }
const minutes = rangeToMinutes(q.range)
const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString()
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
const data = allServers.map((s): TrafficServerDto => {
const snap = latestSnapshot(s.id)
const status: TrafficServerDto["status"] =
snap?.status === "offline" ? "offline" : (snap?.status === "online" ? "online" : "degraded")
const rows = readServerSamplesInRange(s.id, sinceIso)
return buildServerTraffic(s, status, rows)
})
return reply.send({ servers: data })
})
app.get("/traffic/servers/:id/interfaces", async (req, reply) => {
const p = req.params as { id?: string | number }
const serverId = Number.parseInt(String(p.id ?? ""), 10)
if (!Number.isFinite(serverId)) return reply.status(400).send({ error: "id is required" })
const allRows = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()
const server = allRows[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
const sinceIso = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()
const rows = readServerSamplesInRange(serverId, sinceIso)
const byIface = new Map<string, typeof rows>()
for (const r of rows) {
const arr = byIface.get(r.interfaceName) ?? []
arr.push(r)
byIface.set(r.interfaceName, arr)
}
const interfaces: TrafficInterfaceDto[] = [...byIface.entries()].map(([name, arr]) => {
const sorted = arr.sort((a, b) => a.sampledAt.localeCompare(b.sampledAt))
const last = sorted[sorted.length - 1]
return {
name,
running: Boolean(last?.running),
disabled: Boolean(last?.disabled),
rxNow: Math.round((last?.rxBps ?? 0) / 1_000_000),
txNow: Math.round((last?.txBps ?? 0) / 1_000_000),
}
}).sort((a, b) => (b.rxNow + b.txNow) - (a.rxNow + a.txNow))
return reply.send({ interfaces })
})
app.get("/traffic/servers/:id", async (req, reply) => {
const p = req.params as { id?: string | number }
const q = req.query as { range?: string; iface?: string }
const serverId = Number.parseInt(String(p.id ?? ""), 10)
if (!Number.isFinite(serverId)) return reply.status(400).send({ error: "id is required" })
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
const minutes = rangeToMinutes(q.range)
const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString()
const rows = readServerSamplesInRange(server.id, sinceIso)
const snap = latestSnapshot(server.id)
const status: TrafficServerDto["status"] =
snap?.status === "offline" ? "offline" : (snap?.status === "online" ? "online" : "degraded")
const data = buildServerTraffic(server, status, rows, q.iface && q.iface !== "__all__" ? q.iface : undefined)
return reply.send({ server: data })
})
}
export default trafficRoutes
+317
View File
@@ -0,0 +1,317 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { MikrotikClient } from "../services/mikrotik.js"
import {
collectUptimeOnce,
readProbeRows,
readProbeSamplesSince,
readResourceSamplesSince,
readUptimeSettings,
replaceProbes,
updateUptimeSettings,
} from "../services/uptime-collector.js"
function rangeToMinutes(range: string | undefined): number {
switch ((range ?? "1h").toLowerCase()) {
case "5m": return 5
case "15m": return 15
case "1h": return 60
case "4h": return 240
case "24h": return 1440
default: return 60
}
}
function toSeries(values: number[], target = 40): number[] {
if (values.length === 0) return Array(target).fill(0)
if (values.length === target) return values
if (values.length > target) return values.slice(values.length - target)
return [...Array(target - values.length).fill(values[0] ?? 0), ...values]
}
function parseRateToMbps(raw: unknown): number {
const txt = String(raw ?? "").trim().toLowerCase()
if (!txt) return 0
const m = txt.match(/^(\d+(?:\.\d+)?)\s*(g|m|k)?(?:bps|bit\/s|bits\/s)?$/)
if (m) {
const n = Number.parseFloat(m[1])
const u = m[2] ?? ""
if (!Number.isFinite(n)) return 0
if (u === "g") return n * 1000
if (u === "m") return n
if (u === "k") return n / 1000
return n / 1_000_000
}
const n = Number.parseFloat(txt.replace(/[^\d.]/g, ""))
if (!Number.isFinite(n)) return 0
return n > 10000 ? n / 1_000_000 : n
}
const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/uptime/settings", async (_req, reply) => {
const s = readUptimeSettings()
return reply.send({
enabled: s.enabled,
intervalSec: s.intervalSec,
retentionDays: s.retentionDays,
lastCollectedAt: s.lastCollectedAt ?? null,
lastDurationMs: s.lastDurationMs ?? null,
lastError: s.lastError || null,
})
})
app.put("/uptime/settings", async (req, reply) => {
const body = req.body as { enabled?: boolean; intervalSec?: number | string; retentionDays?: number | string }
const updated = updateUptimeSettings({
enabled: body.enabled,
intervalSec: body.intervalSec == null ? undefined : Math.max(5, Number.parseInt(String(body.intervalSec), 10) || 15),
retentionDays: body.retentionDays == null ? undefined : Math.max(1, Number.parseInt(String(body.retentionDays), 10) || 14),
})
return reply.send({
ok: true,
settings: {
enabled: updated.enabled,
intervalSec: updated.intervalSec,
retentionDays: updated.retentionDays,
lastCollectedAt: updated.lastCollectedAt ?? null,
lastDurationMs: updated.lastDurationMs ?? null,
lastError: updated.lastError || null,
},
})
})
app.post("/uptime/collect-now", async (_req, reply) => {
await collectUptimeOnce()
const s = readUptimeSettings()
return reply.send({
ok: true,
lastCollectedAt: s.lastCollectedAt ?? null,
lastDurationMs: s.lastDurationMs ?? null,
lastError: s.lastError || null,
})
})
app.get("/uptime/probes", async (_req, reply) => {
return reply.send({ probes: readProbeRows() })
})
app.put("/uptime/probes", async (req, reply) => {
const body = req.body as {
probes?: Array<{
id?: string
srcServerId?: string | number
srcInterface?: string
name?: string
target?: string
filter?: string
enabled?: boolean
}>
}
const normalized = (body.probes ?? [])
.map((p, i) => ({
id: p.id || `p-${Date.now()}-${i}`,
srcServerId: Number.parseInt(String(p.srcServerId ?? ""), 10),
srcInterface: String(p.srcInterface ?? "").trim(),
name: String(p.name ?? "").trim(),
target: String(p.target ?? "").trim(),
probeFilter: String(p.filter ?? "—"),
enabled: p.enabled !== false,
}))
.filter((p) => Number.isFinite(p.srcServerId) && p.name && p.target)
replaceProbes(normalized)
return reply.send({ ok: true })
})
app.get("/uptime/sources/:id/interfaces", async (req, reply) => {
const params = req.params as { id?: string | number }
const serverId = Number.parseInt(String(params.id ?? ""), 10)
if (!Number.isFinite(serverId)) return reply.status(400).send({ error: "Invalid server id" })
const srv = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
if (!srv) return reply.status(404).send({ error: "Server not found" })
try {
const rows = await MikrotikClient.fromServer(srv).getInterfaces()
const interfaces = rows
.map((r) => ({
name: String(r.name ?? "").trim(),
running: String(r.running ?? "false").toLowerCase() === "true",
disabled: String(r.disabled ?? "false").toLowerCase() === "true",
}))
.filter((r) => r.name.length > 0)
.sort((a, b) => a.name.localeCompare(b.name))
return reply.send({ interfaces })
} catch (e) {
const message = e instanceof Error ? e.message : "Failed to fetch interfaces"
return reply.status(502).send({ error: message })
}
})
app.post("/uptime/speed-test", async (req, reply) => {
const body = req.body as {
srcServerId?: string | number
dstServerId?: string | number
srcInterface?: string
dstInterface?: string
protocol?: "tcp" | "udp"
direction?: "transmit" | "receive" | "both"
durationSec?: string | number
}
const srcId = Number.parseInt(String(body.srcServerId ?? ""), 10)
const dstId = Number.parseInt(String(body.dstServerId ?? ""), 10)
if (!Number.isFinite(srcId) || !Number.isFinite(dstId)) {
return reply.status(400).send({ error: "Invalid src/dst server id" })
}
if (srcId === dstId) return reply.status(400).send({ error: "Source and destination must be different" })
const src = db.select().from(servers).where(eq(servers.id, srcId)).limit(1).all()[0]
const dst = db.select().from(servers).where(eq(servers.id, dstId)).limit(1).all()[0]
if (!src || !dst) return reply.status(404).send({ error: "Server not found" })
try {
const protocol = body.protocol === "udp" ? "udp" : "tcp"
const direction = body.direction === "transmit" || body.direction === "receive" ? body.direction : "both"
const durationSec = Math.max(3, Number.parseInt(String(body.durationSec ?? ""), 10) || 10)
const srcInterface = String(body.srcInterface ?? "").trim()
const dstInterface = String(body.dstInterface ?? "").trim()
const srcClient = MikrotikClient.fromServer(src)
const dstAddressRows = dstInterface
? await MikrotikClient.fromServer(dst).getIpAddresses().catch(() => [])
: []
const dstAddress = dstInterface
? String(
dstAddressRows.find((a) => String(a.interface ?? "") === dstInterface && String(a.address ?? "").includes("/"))?.address ??
"",
).split("/")[0]
: dst.host
if (dstInterface && !dstAddress) {
return reply.status(400).send({ error: `На интерфейсе назначения '${dstInterface}' нет IP-адреса` })
}
const initialAddress = dstAddress || dst.host
const rows = await srcClient.bandwidthTest({
address: initialAddress,
user: dst.username,
password: dst.password,
protocol,
direction,
durationSec,
})
const txSeries = rows
.map((r) => parseRateToMbps(r["tx-current"] ?? r["tx-10-second-average"] ?? r["tx-total-average"]))
.filter((v) => Number.isFinite(v) && v >= 0)
const rxSeries = rows
.map((r) => parseRateToMbps(r["rx-current"] ?? r["rx-10-second-average"] ?? r["rx-total-average"]))
.filter((v) => Number.isFinite(v) && v >= 0)
const last = rows[rows.length - 1] ?? {}
const txAvg = parseRateToMbps(last["tx-total-average"] ?? last["tx-10-second-average"])
const rxAvg = parseRateToMbps(last["rx-total-average"] ?? last["rx-10-second-average"])
return reply.send({
ok: true,
result: {
srcInterface: srcInterface || null,
dstInterface: dstInterface || null,
address: initialAddress,
protocol,
direction,
durationSec,
txAvgMbps: txAvg || (txSeries.length ? txSeries.reduce((a, b) => a + b, 0) / txSeries.length : 0),
rxAvgMbps: rxAvg || (rxSeries.length ? rxSeries.reduce((a, b) => a + b, 0) / rxSeries.length : 0),
txSeries,
rxSeries,
raw: rows,
},
})
} catch (e) {
const raw = e instanceof Error ? e.message : "Bandwidth test failed"
const message = /timed out|socket hang up|ECONNRESET/i.test(raw)
? "BTTest timeout/socket hang up: проверь доступность destination, /tool bandwidth-server, firewall и корректность интерфейсов"
: raw
return reply.status(502).send({ error: message })
}
})
app.get("/uptime/speed-test/endpoints/:id/interfaces", async (req, reply) => {
const params = req.params as { id?: string | number }
const serverId = Number.parseInt(String(params.id ?? ""), 10)
if (!Number.isFinite(serverId)) return reply.status(400).send({ error: "Invalid server id" })
const srv = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
if (!srv) return reply.status(404).send({ error: "Server not found" })
try {
const client = MikrotikClient.fromServer(srv)
const [ifaces, ips] = await Promise.all([
client.getInterfaces(),
client.getIpAddresses(),
])
const interfaces = ifaces
.map((i) => {
const name = String(i.name ?? "").trim()
const addresses = ips
.filter((a) => String(a.interface ?? "") === name)
.map((a) => String(a.address ?? "").split("/")[0])
.filter((x) => x.length > 0)
return {
name,
running: String(i.running ?? "false").toLowerCase() === "true",
disabled: String(i.disabled ?? "false").toLowerCase() === "true",
addresses,
}
})
.filter((i) => i.name.length > 0)
return reply.send({ interfaces })
} catch (e) {
const message = e instanceof Error ? e.message : "Failed to fetch speed-test interfaces"
return reply.status(502).send({ error: message })
}
})
app.get("/uptime/overview", async (req, reply) => {
const q = req.query as { range?: string }
const minutes = rangeToMinutes(q.range)
const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString()
const allServers = db.select().from(servers).all()
const probeRows = readProbeRows()
const probeSamples = readProbeSamplesSince(sinceIso)
const probes = probeRows.map((p) => {
const rows = probeSamples.filter((r) => r.probeId === p.id)
const last = rows[rows.length - 1]
const series = toSeries(rows.map((r) => r.rttMs ?? 0))
return {
id: p.id,
srcServerId: String(p.srcServerId),
srcInterface: p.srcInterface || "",
name: p.name,
target: p.target,
filter: p.probeFilter || "—",
rtt: last?.rttMs ?? null,
loss: last?.lossPct ?? 100,
status: (last?.status ?? "down") as "up" | "warn" | "down",
series,
enabled: p.enabled,
}
})
const resources = allServers.map((s) => {
const rows = readResourceSamplesSince(sinceIso, s.id)
const last = rows[rows.length - 1]
const cpuHistory = toSeries(rows.map((r) => r.cpuLoad), 40)
return {
serverId: String(s.id),
cpu: last?.cpuLoad ?? 0,
cpuHistory,
ramUsed: Math.max(0, ((last?.totalMemory ?? 0) - (last?.freeMemory ?? 0)) / (1024 * 1024)),
ramTotal: Math.max(0, (last?.totalMemory ?? 0) / (1024 * 1024)),
hddUsed: Math.max(0, ((last?.totalHddSpace ?? 0) - (last?.freeHddSpace ?? 0)) / (1024 * 1024)),
hddTotal: Math.max(0, (last?.totalHddSpace ?? 0) / (1024 * 1024)),
uptimeSeconds: last?.uptimeSeconds ?? 0,
boardName: last?.boardName || "RouterBOARD",
temp: undefined as number | undefined,
}
})
return reply.send({ probes, resources })
})
}
export default uptimeRoutes
+304
View File
@@ -0,0 +1,304 @@
import http from "node:http"
import https from "node:https"
import type { Server } from "../db/schema.js"
import type {
RosIdentity, RosInterface, RosIpAddress, RosResource,
RosBgpSession,
RosOspfNeighbor, RosOspfArea, RosOspfInterfaceTemplate, RosOspfInstance,
RosBfdSession,
RosIpRoute, RosFirewallFilter, RosLogEntry, RosPingResult,
} from "../types/server.js"
// ── connection params ─────────────────────────────────────────────────────────
export interface MikrotikConnectParams {
host: string
port: number
useSsl: boolean
verifySsl: boolean
username: string
password: string
apiPath?: string // defaults to "/rest"
}
// ── low-level HTTP helpers ────────────────────────────────────────────────────
function rosRequest(
params: MikrotikConnectParams,
path: string,
timeoutMs: number,
): Promise<unknown> {
return new Promise((resolve, reject) => {
const basePath = params.apiPath ?? "/rest"
const authHeader = "Basic " + Buffer.from(`${params.username}:${params.password}`).toString("base64")
const options: https.RequestOptions = {
hostname: params.host,
port: params.port,
path: basePath + path,
method: "GET",
headers: { Authorization: authHeader, "Content-Type": "application/json" },
rejectUnauthorized: params.useSsl ? params.verifySsl : undefined,
}
const lib = params.useSsl ? https : http
const timer = setTimeout(() => {
req.destroy(new Error(`Connection to ${params.host}:${params.port} timed out after ${timeoutMs / 1000}s`))
}, timeoutMs)
const req = lib.request(options, (res) => {
let body = ""
res.setEncoding("utf8")
res.on("data", (chunk: string) => { body += chunk })
res.on("end", () => {
clearTimeout(timer)
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
reject(new MikrotikError(res.statusCode ?? 0, path, body))
return
}
try {
resolve(JSON.parse(body))
} catch {
reject(new Error(`Invalid JSON from RouterOS: ${body.slice(0, 200)}`))
}
})
})
req.on("error", (err) => {
clearTimeout(timer)
reject(err)
})
req.end()
})
}
function rosPost(
params: MikrotikConnectParams,
path: string,
body: Record<string, string>,
timeoutMs: number,
): Promise<unknown> {
return new Promise((resolve, reject) => {
const basePath = params.apiPath ?? "/rest"
const authHeader = "Basic " + Buffer.from(`${params.username}:${params.password}`).toString("base64")
const payload = JSON.stringify(body)
const options: https.RequestOptions = {
hostname: params.host,
port: params.port,
path: basePath + path,
method: "POST",
headers: {
Authorization: authHeader,
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(payload),
},
rejectUnauthorized: params.useSsl ? params.verifySsl : undefined,
}
const lib = params.useSsl ? https : http
const timer = setTimeout(() => {
req.destroy(new Error(`Connection to ${params.host}:${params.port} timed out after ${timeoutMs / 1000}s`))
}, timeoutMs)
const req = lib.request(options, (res) => {
let buf = ""
res.setEncoding("utf8")
res.on("data", (chunk: string) => { buf += chunk })
res.on("end", () => {
clearTimeout(timer)
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
reject(new MikrotikError(res.statusCode ?? 0, path, buf)); return
}
try { resolve(JSON.parse(buf)) } catch {
reject(new Error(`Invalid JSON from RouterOS: ${buf.slice(0, 200)}`))
}
})
})
req.on("error", (err) => { clearTimeout(timer); reject(err) })
req.write(payload)
req.end()
})
}
function rosDelete(
params: MikrotikConnectParams,
path: string,
timeoutMs: number,
): Promise<void> {
return new Promise((resolve, reject) => {
const basePath = params.apiPath ?? "/rest"
const authHeader = "Basic " + Buffer.from(`${params.username}:${params.password}`).toString("base64")
const options: https.RequestOptions = {
hostname: params.host,
port: params.port,
path: basePath + path,
method: "DELETE",
headers: { Authorization: authHeader, "Content-Type": "application/json" },
rejectUnauthorized: params.useSsl ? params.verifySsl : undefined,
}
const lib = params.useSsl ? https : http
const timer = setTimeout(() => {
req.destroy(new Error(`Connection to ${params.host}:${params.port} timed out after ${timeoutMs / 1000}s`))
}, timeoutMs)
const req = lib.request(options, (res) => {
let body = ""
res.setEncoding("utf8")
res.on("data", (chunk: string) => { body += chunk })
res.on("end", () => {
clearTimeout(timer)
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
reject(new MikrotikError(res.statusCode ?? 0, path, body)); return
}
resolve()
})
})
req.on("error", (err) => {
clearTimeout(timer)
reject(err)
})
req.end()
})
}
// ── MikrotikClient ─────────────────────────────────────────────────────────────
export class MikrotikClient {
constructor(private readonly params: MikrotikConnectParams) {}
/** Convenience factory from a DB Server row */
static fromServer(server: Server): MikrotikClient {
return new MikrotikClient({
host: server.host,
port: server.port,
useSsl: server.useSsl,
verifySsl: server.verifySsl,
username: server.username,
password: server.password,
})
}
async get<T>(path: string, timeoutMs = 10_000): Promise<T> {
return rosRequest(this.params, path, timeoutMs) as Promise<T>
}
async post<T>(path: string, body: Record<string, string>, timeoutMs = 15_000): Promise<T> {
return rosPost(this.params, path, body, timeoutMs) as Promise<T>
}
async delete(path: string, timeoutMs = 10_000): Promise<void> {
return rosDelete(this.params, path, timeoutMs)
}
// ── typed helpers ──────────────────────────────────────────────────────────
async getIdentity(): Promise<RosIdentity> {
return this.get<RosIdentity>("/system/identity")
}
async getResource(): Promise<RosResource> {
return this.get<RosResource>("/system/resource")
}
async getInterfaces(): Promise<RosInterface[]> {
return this.get<RosInterface[]>("/interface")
}
async getIpAddresses(): Promise<RosIpAddress[]> {
return this.get<RosIpAddress[]>("/ip/address")
}
async getBgpSessions(): Promise<RosBgpSession[]> {
return this.get<RosBgpSession[]>("/routing/bgp/session")
}
/** Returns the raw (un-typed) BGP session objects — used for debugging */
async getBgpSessionsRaw(): Promise<unknown[]> {
return this.get<unknown[]>("/routing/bgp/session")
}
// ── OSPF ──────────────────────────────────────────────────────────────────
async getOspfNeighbors(): Promise<RosOspfNeighbor[]> {
return this.get<RosOspfNeighbor[]>("/routing/ospf/neighbor")
}
async getOspfAreas(): Promise<RosOspfArea[]> {
return this.get<RosOspfArea[]>("/routing/ospf/area")
}
async getOspfInterfaceTemplates(): Promise<RosOspfInterfaceTemplate[]> {
return this.get<RosOspfInterfaceTemplate[]>("/routing/ospf/interface-template")
}
async getOspfInstances(): Promise<RosOspfInstance[]> {
return this.get<RosOspfInstance[]>("/routing/ospf/instance")
}
async getBfdSessions(): Promise<RosBfdSession[]> {
return this.get<RosBfdSession[]>("/routing/bfd/session")
}
// ── extra endpoints for exec route ────────────────────────────────────────
async getIpRoutes(): Promise<RosIpRoute[]> {
return this.get<RosIpRoute[]>("/ip/route")
}
async getFirewallFilters(): Promise<RosFirewallFilter[]> {
return this.get<RosFirewallFilter[]>("/ip/firewall/filter")
}
async getLogs(limit = 50): Promise<RosLogEntry[]> {
return this.get<RosLogEntry[]>(`/log?limit=${limit}`)
}
async ping(address: string, count = 4, interfaceName?: string): Promise<RosPingResult[]> {
const body: Record<string, string> = {
address,
count: String(count),
interval: "0.2s",
}
if (interfaceName && interfaceName.trim()) body.interface = interfaceName.trim()
return this.post<RosPingResult[]>("/tool/ping", body, 20_000)
}
async bandwidthTest(params: {
address: string
user: string
password: string
protocol?: "tcp" | "udp"
direction?: "transmit" | "receive" | "both"
durationSec?: number
}): Promise<Array<Record<string, string>>> {
const body: Record<string, string> = {
address: params.address,
user: params.user,
password: params.password,
protocol: params.protocol ?? "tcp",
direction: params.direction ?? "both",
duration: `${Math.max(3, params.durationSec ?? 10)}s`,
}
return this.post<Array<Record<string, string>>>("/tool/bandwidth-test", body, 30_000)
}
}
// ── Error type ─────────────────────────────────────────────────────────────────
export class MikrotikError extends Error {
constructor(
public readonly statusCode: number,
public readonly path: string,
public readonly body: string,
) {
super(`RouterOS API error ${statusCode} on ${path}: ${body}`)
this.name = "MikrotikError"
}
}
+104
View File
@@ -0,0 +1,104 @@
import { desc, eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers, serverSnapshots } from "../db/schema.js"
import type { SnapshotInsert } from "../db/schema.js"
import type { SnapshotRead } from "../types/server.js"
import { MikrotikClient } from "./mikrotik.js"
// ── pollServer ─────────────────────────────────────────────────────────────────
/**
* Connect to a RouterOS device, collect system info, persist a snapshot,
* and sync the server's display name from system/identity.
*/
export async function pollServer(serverId: number): Promise<SnapshotRead> {
const server = db
.select()
.from(servers)
.where(eq(servers.id, serverId))
.limit(1)
.all()[0]
if (!server) {
throw new Error(`Server with id=${serverId} not found`)
}
const now = new Date().toISOString()
const client = MikrotikClient.fromServer(server)
const t0 = performance.now()
const partialSnap: Partial<SnapshotInsert> = {
serverId,
polledAt: now,
status: "offline",
latencyMs: null,
}
try {
// Fire all requests in parallel for speed
const [identity, resource, ifaces, addresses] = await Promise.all([
client.getIdentity(),
client.getResource(),
client.getInterfaces(),
client.getIpAddresses(),
])
const latencyMs = performance.now() - t0
// Parse RouterOS string values (RouterOS REST API returns everything as strings)
const cpuLoad = parseInt(resource["cpu-load"], 10)
const freeMem = parseInt(resource["free-memory"], 10)
const totalMem = parseInt(resource["total-memory"], 10)
Object.assign(partialSnap, {
status: "online",
latencyMs,
identityName: identity.name,
rosVersion: resource["version"],
boardName: resource["board-name"],
uptime: resource["uptime"],
cpuLoad: isNaN(cpuLoad) ? null : cpuLoad,
freeMemory: isNaN(freeMem) ? null : freeMem,
totalMemory: isNaN(totalMem) ? null : totalMem,
rawInterfaces: JSON.stringify(ifaces),
rawIpAddresses: JSON.stringify(addresses),
} satisfies Partial<SnapshotInsert>)
// Keep server.name in sync with RouterOS identity
db.update(servers)
.set({ name: identity.name, updatedAt: now })
.where(eq(servers.id, serverId))
.run()
} catch (err) {
// Log but don't throw — we still persist the offline snapshot
console.warn(`[poller] server id=${serverId} unreachable:`, (err as Error).message)
}
const [inserted] = db
.insert(serverSnapshots)
.values(partialSnap as SnapshotInsert)
.returning()
.all()
return toSnapshotRead(inserted)
}
// ── helper ─────────────────────────────────────────────────────────────────────
export function toSnapshotRead(s: typeof serverSnapshots.$inferSelect): SnapshotRead {
return {
id: s.id,
serverId: s.serverId,
polledAt: s.polledAt,
status: s.status,
latencyMs: s.latencyMs ?? null,
rosVersion: s.rosVersion ?? null,
boardName: s.boardName ?? null,
uptime: s.uptime ?? null,
cpuLoad: s.cpuLoad ?? null,
freeMemory: s.freeMemory ?? null,
totalMemory: s.totalMemory ?? null,
identityName: s.identityName ?? null,
}
}
+169
View File
@@ -0,0 +1,169 @@
import { and, asc, eq, gte, lt } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers, trafficSamples, trafficSettings } from "../db/schema.js"
import { MikrotikClient } from "./mikrotik.js"
interface RosIfaceTraffic {
name?: string
running?: string
disabled?: string
"rx-byte"?: string
"tx-byte"?: string
"rx-bits-per-second"?: string
"tx-bits-per-second"?: string
}
export interface TrafficCollectorState {
running: boolean
lastRunAt: string | null
lastError: string | null
}
let timer: ReturnType<typeof setInterval> | null = null
let collecting = false
const state: TrafficCollectorState = {
running: false,
lastRunAt: null,
lastError: null,
}
function toNum(raw: unknown): number {
const n = Number.parseFloat(String(raw ?? "0"))
return Number.isFinite(n) ? Math.max(0, Math.round(n)) : 0
}
function getSettingsRow() {
const row = db.select().from(trafficSettings).where(eq(trafficSettings.id, 1)).limit(1).all()[0]
if (row) return row
const now = new Date().toISOString()
db.insert(trafficSettings).values({
id: 1,
enabled: true,
intervalSec: 30,
retentionDays: 14,
createdAt: now,
updatedAt: now,
}).run()
return db.select().from(trafficSettings).where(eq(trafficSettings.id, 1)).limit(1).all()[0]
}
function cleanupOldSamples(retentionDays: number) {
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString()
db.delete(trafficSamples)
.where(lt(trafficSamples.sampledAt, cutoff))
.run()
}
export async function collectTrafficOnce(): Promise<void> {
if (collecting) return
collecting = true
const startedAt = Date.now()
const now = new Date().toISOString()
const settings = getSettingsRow()
try {
const enabledServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
for (const srv of enabledServers) {
try {
const client = MikrotikClient.fromServer(srv)
const ifaces = await client.get<RosIfaceTraffic[]>("/interface")
if (ifaces.length === 0) continue
db.insert(trafficSamples).values(
ifaces.map((i) => ({
serverId: srv.id,
interfaceName: i.name ?? "unknown",
sampledAt: now,
rxBytes: toNum(i["rx-byte"]),
txBytes: toNum(i["tx-byte"]),
rxBps: toNum(i["rx-bits-per-second"]),
txBps: toNum(i["tx-bits-per-second"]),
running: (i.running ?? "false") === "true",
disabled: (i.disabled ?? "false") === "true",
})),
).run()
} catch {
// Continue collecting from remaining servers
}
}
cleanupOldSamples(Math.max(1, settings.retentionDays))
db.update(trafficSettings).set({
lastCollectedAt: now,
lastDurationMs: Date.now() - startedAt,
lastError: "",
updatedAt: now,
}).where(eq(trafficSettings.id, 1)).run()
state.lastRunAt = now
state.lastError = null
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
db.update(trafficSettings).set({
lastCollectedAt: now,
lastDurationMs: Date.now() - startedAt,
lastError: msg,
updatedAt: now,
}).where(eq(trafficSettings.id, 1)).run()
state.lastRunAt = now
state.lastError = msg
} finally {
collecting = false
}
}
export function stopTrafficCollector() {
if (timer) {
clearInterval(timer)
timer = null
}
state.running = false
}
export function restartTrafficCollector() {
stopTrafficCollector()
const settings = getSettingsRow()
if (!settings.enabled) return
const intervalMs = Math.max(5, settings.intervalSec) * 1000
timer = setInterval(() => {
void collectTrafficOnce()
}, intervalMs)
state.running = true
}
export function getTrafficCollectorState(): TrafficCollectorState {
return { ...state }
}
export function getTrafficSettings() {
return getSettingsRow()
}
export function updateTrafficSettings(patch: {
enabled?: boolean
intervalSec?: number
retentionDays?: number
}) {
const prev = getSettingsRow()
const next = {
enabled: patch.enabled ?? prev.enabled,
intervalSec: patch.intervalSec ?? prev.intervalSec,
retentionDays: patch.retentionDays ?? prev.retentionDays,
updatedAt: new Date().toISOString(),
}
db.update(trafficSettings).set(next).where(eq(trafficSettings.id, 1)).run()
restartTrafficCollector()
return getSettingsRow()
}
export function readServerSamplesInRange(serverId: number, sinceIso: string) {
return db.select()
.from(trafficSamples)
.where(and(
eq(trafficSamples.serverId, serverId),
gte(trafficSamples.sampledAt, sinceIso),
))
.orderBy(asc(trafficSamples.sampledAt))
.all()
}
+267
View File
@@ -0,0 +1,267 @@
import { and, asc, eq, gte, lt } from "drizzle-orm"
import { db } from "../db/index.js"
import {
servers,
uptimeProbeSamples,
uptimeProbes,
uptimeResourceSamples,
uptimeSettings,
} from "../db/schema.js"
import { MikrotikClient } from "./mikrotik.js"
let timer: ReturnType<typeof setInterval> | null = null
let collecting = false
function parseDurationToSeconds(raw: string | undefined): number {
if (!raw) return 0
let total = 0
for (const m of raw.matchAll(/(\d+)(w|d|h|m(?!s)|s)/g)) {
const n = Number.parseInt(m[1], 10)
if (!Number.isFinite(n)) continue
switch (m[2]) {
case "w": total += n * 604800; break
case "d": total += n * 86400; break
case "h": total += n * 3600; break
case "m": total += n * 60; break
case "s": total += n; break
}
}
return total
}
function toNum(raw: unknown): number {
const n = Number.parseInt(String(raw ?? "0"), 10)
return Number.isFinite(n) ? Math.max(0, n) : 0
}
function parsePingTimeMs(raw: string | undefined): number | null {
if (!raw) return null
const s = String(raw).trim().toLowerCase().replace(",", ".")
const us = s.match(/^(\d+(?:\.\d+)?)\s*us$/)
if (us) return Number.parseFloat(us[1]) / 1000
const ms = s.match(/^(\d+(?:\.\d+)?)\s*ms$/)
if (ms) return Number.parseFloat(ms[1])
const sec = s.match(/^(\d+(?:\.\d+)?)\s*s$/)
if (sec) return Number.parseFloat(sec[1]) * 1000
// HH:MM:SS(.sss) from some RouterOS outputs
const clock = s.match(/^(\d+):(\d+):(\d+(?:\.\d+)?)$/)
if (clock) {
const h = Number.parseFloat(clock[1])
const m = Number.parseFloat(clock[2])
const sc = Number.parseFloat(clock[3])
return (h * 3600 + m * 60 + sc) * 1000
}
const n = Number.parseFloat(s)
return Number.isFinite(n) ? n : null
}
function getSettings() {
const row = db.select().from(uptimeSettings).where(eq(uptimeSettings.id, 1)).limit(1).all()[0]
if (row) return row
const now = new Date().toISOString()
db.insert(uptimeSettings).values({
id: 1,
enabled: true,
intervalSec: 15,
retentionDays: 14,
createdAt: now,
updatedAt: now,
}).run()
return db.select().from(uptimeSettings).where(eq(uptimeSettings.id, 1)).limit(1).all()[0]
}
function cleanup(retentionDays: number) {
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString()
db.delete(uptimeProbeSamples).where(lt(uptimeProbeSamples.sampledAt, cutoff)).run()
db.delete(uptimeResourceSamples).where(lt(uptimeResourceSamples.sampledAt, cutoff)).run()
}
function parsePing(results: Array<{ time?: string; status?: string; sent?: string; received?: string; "packet-loss"?: string }>) {
const sum = [...results].reverse().find((r) => r.sent || r.received || r["packet-loss"])
const lossRaw = sum?.["packet-loss"] ?? "100%"
const lossPct = Number.parseInt(String(lossRaw).replace("%", ""), 10)
const okReplies = results.filter((r) => r.time && r.status !== "timeout")
const rtts = okReplies
.map((r) => parsePingTimeMs(r.time))
.filter((v): v is number => v != null && Number.isFinite(v))
const avgFromReplies = rtts.length ? Math.round(rtts.reduce((a, b) => a + b, 0) / rtts.length) : null
const avgFromSummary = parsePingTimeMs(sum?.time)
const avgRtt = avgFromReplies ?? (avgFromSummary != null ? Math.round(avgFromSummary) : null)
const loss = Number.isFinite(lossPct) ? Math.max(0, Math.min(100, lossPct)) : 100
const status: "up" | "warn" | "down" = loss >= 100 ? "down" : (loss > 1 || (avgRtt ?? 0) > 60 ? "warn" : "up")
return { avgRtt, loss, status }
}
export async function collectUptimeOnce(): Promise<void> {
if (collecting) return
collecting = true
const started = Date.now()
const now = new Date().toISOString()
const settings = getSettings()
try {
const enabledServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
for (const s of enabledServers) {
try {
const client = MikrotikClient.fromServer(s)
const resource = await client.getResource()
db.insert(uptimeResourceSamples).values({
serverId: s.id,
sampledAt: now,
status: "online",
cpuLoad: toNum(resource["cpu-load"]),
freeMemory: toNum(resource["free-memory"]),
totalMemory: toNum(resource["total-memory"]),
freeHddSpace: toNum(resource["free-hdd-space"]),
totalHddSpace: toNum(resource["total-hdd-space"]),
uptimeSeconds: parseDurationToSeconds(resource["uptime"]),
boardName: String(resource["board-name"] ?? ""),
rosVersion: String(resource["version"] ?? ""),
}).run()
} catch {
db.insert(uptimeResourceSamples).values({
serverId: s.id,
sampledAt: now,
status: "offline",
cpuLoad: 0,
freeMemory: 0,
totalMemory: 0,
freeHddSpace: 0,
totalHddSpace: 0,
uptimeSeconds: 0,
boardName: "",
rosVersion: "",
}).run()
}
}
const probes = db.select().from(uptimeProbes).where(eq(uptimeProbes.enabled, true)).orderBy(asc(uptimeProbes.sortOrder)).all()
for (const p of probes) {
const src = enabledServers.find((s) => s.id === p.srcServerId)
if (!src) continue
try {
const client = MikrotikClient.fromServer(src)
const results = await client.ping(p.target, 4, p.srcInterface || undefined)
const parsed = parsePing(results)
db.insert(uptimeProbeSamples).values({
probeId: p.id,
sampledAt: now,
rttMs: parsed.avgRtt,
lossPct: parsed.loss,
status: parsed.status,
}).run()
} catch {
db.insert(uptimeProbeSamples).values({
probeId: p.id,
sampledAt: now,
rttMs: null,
lossPct: 100,
status: "down",
}).run()
}
}
cleanup(Math.max(1, settings.retentionDays))
db.update(uptimeSettings).set({
lastCollectedAt: now,
lastDurationMs: Date.now() - started,
lastError: "",
updatedAt: now,
}).where(eq(uptimeSettings.id, 1)).run()
} catch (e) {
db.update(uptimeSettings).set({
lastCollectedAt: now,
lastDurationMs: Date.now() - started,
lastError: e instanceof Error ? e.message : String(e),
updatedAt: now,
}).where(eq(uptimeSettings.id, 1)).run()
} finally {
collecting = false
}
}
export function restartUptimeCollector() {
const s = getSettings()
if (timer) clearInterval(timer)
timer = null
if (!s.enabled) return
const intervalMs = Math.max(5, s.intervalSec) * 1000
timer = setInterval(() => { void collectUptimeOnce() }, intervalMs)
}
export function stopUptimeCollector() {
if (timer) clearInterval(timer)
timer = null
}
export function readUptimeSettings() {
return getSettings()
}
export function updateUptimeSettings(patch: {
enabled?: boolean
intervalSec?: number
retentionDays?: number
}) {
const prev = getSettings()
const next = {
enabled: patch.enabled ?? prev.enabled,
intervalSec: patch.intervalSec ?? prev.intervalSec,
retentionDays: patch.retentionDays ?? prev.retentionDays,
updatedAt: new Date().toISOString(),
}
db.update(uptimeSettings).set(next).where(eq(uptimeSettings.id, 1)).run()
restartUptimeCollector()
return readUptimeSettings()
}
export function readProbeRows() {
return db.select().from(uptimeProbes).orderBy(asc(uptimeProbes.sortOrder)).all()
}
export function replaceProbes(rows: Array<{
id: string
srcServerId: number
srcInterface: string
name: string
target: string
probeFilter: string
enabled: boolean
}>) {
db.delete(uptimeProbes).run()
if (rows.length === 0) return
const now = new Date().toISOString()
db.insert(uptimeProbes).values(rows.map((r, i) => ({
id: r.id,
srcServerId: r.srcServerId,
srcInterface: r.srcInterface || "",
name: r.name,
target: r.target,
probeFilter: r.probeFilter || "—",
enabled: r.enabled,
sortOrder: i,
createdAt: now,
updatedAt: now,
}))).run()
}
export function readProbeSamplesSince(sinceIso: string) {
return db.select().from(uptimeProbeSamples)
.where(gte(uptimeProbeSamples.sampledAt, sinceIso))
.orderBy(asc(uptimeProbeSamples.sampledAt))
.all()
}
export function readResourceSamplesSince(sinceIso: string, serverId: number) {
return db.select().from(uptimeResourceSamples)
.where(and(
eq(uptimeResourceSamples.serverId, serverId),
gte(uptimeResourceSamples.sampledAt, sinceIso),
))
.orderBy(asc(uptimeResourceSamples.sampledAt))
.all()
}
+465
View File
@@ -0,0 +1,465 @@
import { z } from "zod"
// ── RouterOS raw response types ────────────────────────────────────────────────
export interface RosIdentity {
name: string
}
export interface RosResource {
"uptime": string
"version": string
"build-time": string
"factory-software": string
"free-memory": string
"total-memory": string
"cpu": string
"cpu-count": string
"cpu-frequency": string
"cpu-load": string
"free-hdd-space": string
"total-hdd-space": string
"architecture-name": string
"board-name": string
"platform": string
}
export interface RosInterface {
".id": string
name: string
type: string
mtu?: string
"actual-mtu"?: string
"mac-address"?: string
running: string // "true" | "false" (RouterOS returns strings)
disabled: string
comment?: string
}
export interface RosIpAddress {
".id": string
address: string // "192.168.1.1/24"
network: string // "192.168.1.0"
interface: string
disabled: string
dynamic?: string
invalid?: string
comment?: string
}
// ── Zod schemas for API validation ────────────────────────────────────────────
export const ServerCreateSchema = z.object({
host: z.string().min(1, "Host is required"),
port: z.number().int().positive().default(443),
username: z.string().default("admin"),
password: z.string().default(""),
useSsl: z.boolean().default(true),
verifySsl: z.boolean().default(false),
name: z.string().default(""),
type: z.enum(["jump-host", "exit-node", "home-router"]).default("home-router"),
site: z.string().default(""),
country: z.string().default(""),
asn: z.string().default(""),
comment: z.string().default(""),
enabled: z.boolean().default(true),
})
export const ServerUpdateSchema = ServerCreateSchema.partial().omit({ host: true }).extend({
host: z.string().min(1).optional(),
})
export const TestConnectionSchema = z.object({
host: z.string().min(1),
port: z.number().int().positive().default(443),
useSsl: z.boolean().default(true),
verifySsl: z.boolean().default(false),
apiPath: z.string().default("/rest"),
username: z.string().default("admin"),
password: z.string().default(""),
})
export const ServerIdParamSchema = z.object({
id: z.coerce.number().int().positive(),
})
export const SnapshotsQuerySchema = z.object({
limit: z.coerce.number().int().positive().max(500).default(50),
})
// ── Response types (what the API returns) ─────────────────────────────────────
/** Flat server response — mirrors the frontend's Server interface from lib/data.ts */
export interface ServerRead {
id: number
name: string
host: string
port: number
useSsl: boolean
verifySsl: boolean
username: string
password: string
type: "jump-host" | "exit-node" | "home-router"
site: string
country: string
asn: string
comment: string
enabled: boolean
createdAt: string
updatedAt: string
// from latest snapshot (null if never polled)
status: "online" | "offline" | null
latency: number | null // ms
os: string | null // RouterOS version
model: string | null // board-name
uptime: string | null
cpuLoad: number | null
freeMemory: number | null
totalMemory: number | null
identityName: string | null
sessions: number // always 0 for now
polledAt: string | null
}
export interface SnapshotRead {
id: number
serverId: number
polledAt: string
status: "online" | "offline"
latencyMs: number | null
rosVersion: string | null
boardName: string | null
uptime: string | null
cpuLoad: number | null
freeMemory: number | null
totalMemory: number | null
identityName: string | null
}
// ── BGP types ────────────────────────────────────────────────────────────────
/**
* Raw BGP session object returned by RouterOS 7.x REST API
* (/rest/routing/bgp/session).
*
* NOTE: RouterOS 7.x does NOT have a "state" field.
* - "established": "true" → session is Established
* - Field absence → session is not established (Active/Idle/Connect…)
* - "ebgp": "" → session role is eBGP
* Capabilities are comma-separated short codes in "local.capabilities" /
* "remote.capabilities" (e.g. "mp,rr,gr,as4,enhe,role").
*/
export interface RosBgpSession {
".id": string
"name"?: string
// RouterOS 7.x state flags (no "state" field)
"established"?: string // "true" when session is up
"ebgp"?: string // "" when eBGP
// Addressing
"remote.address"?: string // "10.0.0.1" or "10.0.0.1/32"
"remote.as"?: string
"remote.id"?: string
"remote.messages"?: string // total received messages
"remote.hold-time"?: string // e.g. "1m30s"
"remote.capabilities"?: string // "mp,rr,gr,as4"
"remote.bytes"?: string
"remote.eor"?: string
"remote.afi"?: string
"remote.gr-time"?: string
"local.address"?: string
"local.as"?: string
"local.id"?: string
"local.messages"?: string // total sent messages
"local.capabilities"?: string // "mp,rr,enhe,role,gr,as4"
"local.bytes"?: string
"local.eor"?: string
"local.afi"?: string
"local.role"?: string // "ebgp-customer" | "ebgp-provider" | "ibgp" …
"local.cluster-id"?: string
// Timers
"hold-time"?: string // "1m30s" (active hold time, RouterOS 7.x)
"keepalive-time"?: string // "30s"
// Routing
"prefix-count"?: string
"uptime"?: string // "1d5h13m23s580ms"
"instance"?: string
"multihop"?: string // "true" | "false"
// Filters
"input.filter"?: string
"output.filter-chain"?: string
"input.procid"?: string
"output.procid"?: string
// Timestamps
"last-started"?: string
"last-notification"?: string
// Old RouterOS 6.x / some 7.x fields (kept for compatibility)
"state"?: string
"active-holdtime"?: string
"total-messages-sent"?: string
"total-messages-received"?: string
"total-updates-sent"?: string
"total-updates-received"?: string
"4-octet-as-capability"?: string
"refresh-capability"?: string
"as4-capability"?: string
"add-path-capability"?: string
"graceful-restart-capability"?: string
"extended-message-capability"?: string
}
export interface BgpSessionRead {
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
}
// ── OSPF types ────────────────────────────────────────────────────────────────
/** Raw RouterOS 7.x /rest/routing/ospf/neighbor */
export interface RosOspfNeighbor {
".id": string
"address": string // neighbor IP
"router-id": string
"instance": string // OSPF instance name
"area": string // area NAME (not area-id)
"interface": string // local interface
"state": string // "Full" | "2-Way" | "ExStart" | "Exchange" | "Loading" | "Down"
"adjacency"?: string // uptime of adjacency, e.g. "1h30m17s"
"state-changes"?: string
"timeout"?: string
"dynamic"?: string
"priority"?: string
}
/** Raw RouterOS 7.x /rest/routing/ospf/area */
export interface RosOspfArea {
".id": string
"name": string
"area-id"?: string // "0.0.0.1" — may be absent for backbone when using ROS defaults
"instance": string
"type": string // "default" | "stub" | "nssa" | "totally-stub"
"disabled": string
"inactive": string
".about"?: string // error note when area is inactive
"nssa-translator"?: string
}
/** Raw RouterOS 7.x /rest/routing/ospf/interface-template */
export interface RosOspfInterfaceTemplate {
".id": string
"area": string // area NAME (not area-id)
"interfaces"?: string // interface name or "*ID" ref
"cost"?: string
"priority"?: string
"type"?: string // "ptp" | "broadcast" | "nbma" | "ptmp" | "ptmp-broadcast"
"disabled": string
"inactive": string
"hello-interval"?: string // e.g. "10s"
"dead-interval"?: string // e.g. "40s"
"retransmit-interval"?: string
"transmit-delay"?: string
"use-bfd"?: string
"instance-id"?: string
".about"?: string
}
/** Raw RouterOS 7.x /rest/routing/ospf/instance */
export interface RosOspfInstance {
".id": string
"name": string
"router-id": string // may be "main" to use main routing table router-id
"version": string // "2" | "3"
"disabled": string
"inactive": string
"redistribute"?: string
"routing-table"?: string
"vrf"?: string
"in-filter-chain"?: string
"out-filter-chain"?: string
}
// ── Normalized OSPF read types (API response) ─────────────────────────────────
export interface OspfNeighborRead {
id: string
serverId: number
serverName: string
serverSite: string
serverCountry: string
address: string
routerId: string
instance: string
area: string // area name
areaId: string // area-id like "0.0.0.1"
interface: string
state: string
uptime: string | null
stateChanges: number
priority: number
}
export interface OspfInterfaceRead {
id: string
serverId: number
serverName: string
serverSite: string
serverCountry: string
instance: string
area: string // area name
areaId: string // area-id
interface: string // interface name
cost: number
type: string // "ptp" | "broadcast" | ...
disabled: boolean
inactive: boolean
priority: number
helloInterval: number // seconds
deadInterval: number // seconds
useBfd: boolean
}
export interface OspfInstanceRead {
id: string
serverId: number
serverName: string
serverSite: string
serverCountry: string
name: string
routerId: string
version: number
disabled: boolean
inactive: boolean
redistribute: string
}
// ── IP Route / Firewall / Log / Ping ─────────────────────────────────────────
export interface RosIpRoute {
".id": string
"dst-address": string
"pref-src"?: string
"gateway"?: string
"distance"?: string
"scope"?: string
"active"?: string // "true"
"dynamic"?: string
"static"?: string
"connect"?: string
"bgp"?: string
"ospf"?: string
"rip"?: string
"blackhole"?: string
"unreachable"?: string
"prohibit"?: string
"interface"?: string
"routing-mark"?: string
"type"?: string
}
export interface RosFirewallFilter {
".id": string
"chain": string
"action": string
"protocol"?: string
"src-address"?: string
"dst-address"?: string
"src-port"?: string
"dst-port"?: string
"in-interface"?: string
"out-interface"?: string
"connection-state"?: string
"src-address-list"?: string
"dst-address-list"?: string
"layer7-protocol"?: string
"tls-host"?: string
"comment"?: string
"disabled"?: string
"invalid"?: string
"dynamic"?: string
"bytes"?: string
"packets"?: string
}
export interface RosLogEntry {
".id": string
"time": string
"message": string
"topics": string
}
/** One reply item from POST /rest/tool/ping */
export interface RosPingResult {
"host"?: string
"seq": string
"size"?: string
"ttl"?: string
"time"?: string
"sent"?: string
"received"?: string
"packet-loss"?: string
"status"?: string // "timeout" when lost
}
// ── BFD types ─────────────────────────────────────────────────────────────────
/** Raw RouterOS 7.x /rest/routing/bfd/session */
export interface RosBfdSession {
".id": string
"state": string // "up" | "down" | "init" | "admindown"
"up"?: string // "true" when up
"uptime"?: string // e.g. "6h15m29s"
"local-address": string // "10.100.2.13%NSK-SERVHOST-MTS" (IP%iface)
"remote-address": string // "10.100.2.14%NSK-SERVHOST-MTS"
"multihop"?: string // "true" | "false"
"multiplier"?: string // detection multiplier
"desired-tx-interval"?: string // "200ms"
"required-min-rx"?: string // "200ms"
"actual-tx-interval"?: string // "200ms"
"remote-min-rx"?: string // "200ms"
"remote-min-tx"?: string // "200ms"
"hold-time"?: string // "1s"
"packets-rx"?: string
"packets-tx"?: string
"state-changes"?: string
"vrf"?: string
}
export interface BfdSessionRead {
id: string
serverId: number
serverName: string
serverSite: string
serverCountry: string
localAddr: string // IP only, without %iface
remoteAddr: string // IP only
interface: string // extracted from %iface suffix
state: string // "Up" | "Down" | "Init" | "AdminDown"
uptime: string | null
multihop: boolean
multiplier: number
txInterval: number // ms
rxInterval: number // ms
holdTime: number // ms
packetsRx: number
packetsTx: number
stateChanges: number
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+164
View File
@@ -0,0 +1,164 @@
"use client"
import * as React from "react"
import { useTheme } from "@/components/theme-provider"
import { NavMain, type NavGroup } from "@/components/nav-main"
import { SearchIcon } from "lucide-react"
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarRail,
} from "@/components/ui/sidebar"
import {
RouterIcon,
LayoutDashboardIcon,
ActivityIcon,
MapIcon,
HeartPulseIcon,
GlobeIcon,
NetworkIcon,
LayersIcon,
TagIcon,
ServerIcon,
FilterIcon,
ShieldIcon,
HardDriveIcon,
RouteIcon,
GitForkIcon,
GitMergeIcon,
TerminalIcon,
SettingsIcon,
CableIcon,
ScanLineIcon,
BellIcon,
SunIcon,
MoonIcon,
ShieldCheckIcon,
BoxIcon,
BadgeCheckIcon,
} from "lucide-react"
// ─── Command palette trigger button ──────────────────────────────────────────
function CommandPaletteButton() {
function trigger() {
window.dispatchEvent(new KeyboardEvent("keydown", { key: "k", ctrlKey: true, bubbles: true }))
}
return (
<button
onClick={trigger}
className="group-data-[collapsible=icon]:hidden mx-2 mb-1 flex items-center gap-2 px-2.5 py-1.5 rounded-md border border-sidebar-border/60 bg-sidebar-accent/30 hover:bg-sidebar-accent text-sidebar-foreground/50 hover:text-sidebar-foreground text-xs transition-colors w-[calc(100%-1rem)]"
>
<SearchIcon className="size-3 shrink-0" />
<span className="flex-1 text-left">Поиск…</span>
<span className="font-mono text-[10px] border border-sidebar-border/60 rounded px-1 py-0.5">⌘K</span>
</button>
)
}
// ─────────────────────────────────────────────────────────────────────────────
const navGroups: NavGroup[] = [
{
label: "Обзор",
items: [
{ title: "Дашборд", url: "/dashboard", icon: <LayoutDashboardIcon /> },
{ title: "Трафик", url: "/traffic", icon: <ActivityIcon /> },
{ title: "Карта сети", url: "/network-map", icon: <MapIcon /> },
{ title: "Мониторинг", url: "/uptime", icon: <HeartPulseIcon />, badge: "8" },
],
},
{
label: "Данные",
items: [
{ title: "Домены", url: "/domains", icon: <GlobeIcon />, badge: "1.8к" },
{ title: "IP-диапазоны", url: "/ip-ranges", icon: <NetworkIcon />, badge: "4.2к" },
{ title: "ASN", url: "/asns", icon: <LayersIcon /> },
{ title: "Communities", url: "/communities", icon: <TagIcon /> },
],
},
{
label: "Управление",
items: [
{ title: "Серверы", url: "/servers", icon: <ServerIcon />, badge: "9" },
{ title: "Фильтры", url: "/filters", icon: <FilterIcon />, badge: "6" },
{ title: "Рекурсивные маршруты", url: "/recursive-routes", icon: <RouteIcon /> },
{ title: "Firewall", url: "/firewall", icon: <ShieldIcon /> },
{ title: "WireGuard", url: "/wireguard", icon: <ShieldCheckIcon />, badge: "4" },
{ title: "GRE-туннели", url: "/gre", icon: <CableIcon />, badge: "6" },
{ title: "VXLAN", url: "/vxlan", icon: <NetworkIcon /> },
{ title: "Контейнеры", url: "/containers", icon: <BoxIcon />, badge: "5" },
{ title: "Сертификаты", url: "/certificates", icon: <BadgeCheckIcon />, badge: "2" },
{ title: "Бэкапы", url: "/backups", icon: <HardDriveIcon /> },
],
},
{
label: "Инструменты",
items: [
{ title: "Оптимизатор маршрутов", url: "/route-optimizer", icon: <RouteIcon /> },
{ title: "OSPF", url: "/ospf", icon: <GitForkIcon /> },
{ title: "BGP", url: "/bgp", icon: <GitMergeIcon />, badge: "12" },
{ title: "Диагностика", url: "/probes", icon: <ScanLineIcon /> },
{ title: "Терминал", url: "/terminal", icon: <TerminalIcon /> },
],
},
{
label: "Система",
items: [
{ title: "Оповещения", url: "/alerts", icon: <BellIcon /> },
{ title: "Настройки", url: "/settings", icon: <SettingsIcon /> },
],
},
]
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { resolvedTheme, setTheme } = useTheme()
const isDark = resolvedTheme === "dark"
return (
<Sidebar collapsible="icon" {...props}>
<SidebarHeader>
<div className="flex items-center gap-2.5 px-2 py-1.5 overflow-hidden">
<div className="flex aspect-square size-8 shrink-0 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<RouterIcon className="size-4" />
</div>
<div className="flex flex-col leading-tight group-data-[collapsible=icon]:hidden">
<span className="font-semibold text-sidebar-foreground text-sm tracking-tight">RouterLists</span>
<span className="text-[10px] font-mono text-sidebar-foreground/40">v3.4.1</span>
</div>
</div>
{/* Command palette trigger */}
<CommandPaletteButton />
</SidebarHeader>
<SidebarContent>
<NavMain groups={navGroups} />
</SidebarContent>
<SidebarFooter>
<div className="flex items-center gap-2 px-2 py-2 group-data-[collapsible=icon]:justify-center">
{/* Avatar */}
<div className="size-7 rounded-full bg-gradient-to-br from-blue-500 to-violet-600 flex items-center justify-center text-white text-[10px] font-semibold shrink-0">
АК
</div>
{/* Name / email */}
<div className="flex flex-col leading-tight min-w-0 flex-1 group-data-[collapsible=icon]:hidden">
<span className="text-[13px] font-medium text-sidebar-foreground truncate">а.коротаев</span>
<span className="text-[11px] text-sidebar-foreground/50 truncate">admin@routerlists.io</span>
</div>
{/* Theme toggle */}
<button
onClick={() => setTheme(isDark ? "light" : "dark")}
title={isDark ? "Светлая тема" : "Тёмная тема"}
className="shrink-0 size-6 flex items-center justify-center rounded-md text-sidebar-foreground/50 hover:text-sidebar-foreground hover:bg-sidebar-accent transition-colors group-data-[collapsible=icon]:hidden"
>
{isDark
? <SunIcon className="size-3.5" />
: <MoonIcon className="size-3.5" />}
</button>
</div>
</SidebarFooter>
<SidebarRail />
</Sidebar>
)
}
+269
View File
@@ -0,0 +1,269 @@
"use client"
import { useEffect, useState, useRef, useCallback, useMemo } from "react"
import { useRouter, usePathname } from "next/navigation"
import { cn } from "@/lib/utils"
import {
SearchIcon, LayoutDashboardIcon, ActivityIcon, MapIcon, HeartPulseIcon,
GlobeIcon, NetworkIcon, LayersIcon, TagIcon, ServerIcon, FilterIcon,
ShieldIcon, ShieldCheckIcon, CableIcon, BoxIcon, BadgeCheckIcon,
HardDriveIcon, RouteIcon, GitForkIcon, GitMergeIcon, ScanLineIcon,
TerminalIcon, BellIcon, SettingsIcon,
} from "lucide-react"
// ─── Command item definition ──────────────────────────────────────────────────
interface CommandItem {
id: string
title: string
subtitle?: string
group: string
url: string
icon: React.ReactNode
keywords?: string[]
}
const ALL_ITEMS: CommandItem[] = [
// Обзор
{ id: "dashboard", title: "Дашборд", group: "Обзор", url: "/dashboard", icon: <LayoutDashboardIcon />, keywords: ["главная","home","overview"] },
{ id: "traffic", title: "Трафик", group: "Обзор", url: "/traffic", icon: <ActivityIcon />, keywords: ["bandwidth","traffic","клиенты","интерфейсы"] },
{ id: "network-map", title: "Карта сети", group: "Обзор", url: "/network-map", icon: <MapIcon />, keywords: ["topology","топология","map"] },
{ id: "uptime", title: "Мониторинг / Uptime", group: "Обзор", url: "/uptime", icon: <HeartPulseIcon />, keywords: ["ping","uptime","мониторинг","проверка"] },
// Данные
{ id: "domains", title: "Домены", group: "Данные", url: "/domains", icon: <GlobeIcon />, keywords: ["domain","dns","сайты"] },
{ id: "ip-ranges", title: "IP-диапазоны", group: "Данные", url: "/ip-ranges", icon: <NetworkIcon />, keywords: ["cidr","subnet","подсети","префиксы"] },
{ id: "asns", title: "ASN", group: "Данные", url: "/asns", icon: <LayersIcon />, keywords: ["autonomous system","автономная система"] },
{ id: "communities", title: "BGP Communities", group: "Данные", url: "/communities", icon: <TagIcon />, keywords: ["community","bgp","теги"] },
// Управление
{ id: "servers", title: "Серверы", group: "Управление", url: "/servers", icon: <ServerIcon />, keywords: ["router","mikrotik","сервер","routeros"] },
{ id: "filters", title: "Фильтры", group: "Управление", url: "/filters", icon: <FilterIcon />, keywords: ["filter","routing","маршрутизация"] },
{ id: "recursive-routes", title: "Рекурсивные маршруты", group: "Управление", url: "/recursive-routes", icon: <RouteIcon />, keywords: ["recursive","route","static","маршруты"] },
{ id: "firewall", title: "Firewall", group: "Управление", url: "/firewall", icon: <ShieldIcon />, keywords: ["rules","правила","брандмауэр","acl"] },
{ id: "wireguard", title: "WireGuard", group: "Управление", url: "/wireguard", icon: <ShieldCheckIcon />, keywords: ["vpn","tunnel","туннель","wg"] },
{ id: "gre", title: "GRE-туннели", group: "Управление", url: "/gre", icon: <CableIcon />, keywords: ["gre","ipsec","tunnel","туннель"] },
{ id: "vxlan", title: "VXLAN", group: "Управление", url: "/vxlan", icon: <NetworkIcon />, keywords: ["overlay","vni","vtep","l2"] },
{ id: "containers", title: "Контейнеры", group: "Управление", url: "/containers", icon: <BoxIcon />, keywords: ["docker","container","образ","image"] },
{ id: "certificates", title: "Сертификаты", group: "Управление", url: "/certificates", icon: <BadgeCheckIcon />, keywords: ["ssl","tls","cert","pki","x509"] },
{ id: "backups", title: "Бэкапы", group: "Управление", url: "/backups", icon: <HardDriveIcon />, keywords: ["backup","backup","резервная копия"] },
// Инструменты
{ id: "route-optimizer", title: "Оптимизатор маршрутов", group: "Инструменты", url: "/route-optimizer", icon: <RouteIcon />, keywords: ["routing","wan","failover","переключение"] },
{ id: "ospf", title: "OSPF", group: "Инструменты", url: "/ospf", icon: <GitForkIcon />, keywords: ["ospf","igp","neighbors","lsa"] },
{ id: "bgp", title: "BGP", group: "Инструменты", url: "/bgp", icon: <GitMergeIcon />, keywords: ["bgp","ebgp","ibgp","сессии","peers"] },
{ id: "probes", title: "Диагностика", group: "Инструменты", url: "/probes", icon: <ScanLineIcon />, keywords: ["probe","ping","traceroute","диагностика"] },
{ id: "terminal", title: "Терминал", group: "Инструменты", url: "/terminal", icon: <TerminalIcon />, keywords: ["terminal","cli","ssh","консоль"] },
// Система
{ id: "alerts", title: "Оповещения", group: "Система", url: "/alerts", icon: <BellIcon />, keywords: ["alert","notification","уведомление"] },
{ id: "settings", title: "Настройки", group: "Система", url: "/settings", icon: <SettingsIcon />, keywords: ["settings","config","конфигурация"] },
]
// ─── Scoring / filtering ──────────────────────────────────────────────────────
function score(item: CommandItem, q: string): number {
const t = item.title.toLowerCase()
const kws = (item.keywords ?? []).join(" ").toLowerCase()
if (t === q) return 100
if (t.startsWith(q)) return 90
if (t.includes(q)) return 70
if (kws.includes(q)) return 50
return 0
}
function filterItems(q: string): CommandItem[] {
if (!q.trim()) return ALL_ITEMS
const lq = q.trim().toLowerCase()
return ALL_ITEMS
.map(item => ({ item, s: score(item, lq) }))
.filter(x => x.s > 0)
.sort((a, b) => b.s - a.s)
.map(x => x.item)
}
// ─── Component ────────────────────────────────────────────────────────────────
export function CommandPalette() {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState("")
const [activeIdx, setActiveIdx] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const router = useRouter()
const pathname = usePathname()
const results = useMemo(() => filterItems(query), [query])
// ── Keyboard shortcut to open ─────────────────────────────────────────────
useEffect(() => {
function onKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault()
setOpen(prev => {
if (!prev) { setQuery(""); setActiveIdx(0) }
return !prev
})
}
if (e.key === "Escape") setOpen(false)
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [])
// ── Focus input on open ───────────────────────────────────────────────────
useEffect(() => {
if (open) setTimeout(() => inputRef.current?.focus(), 10)
}, [open])
// ── Close on route change ─────────────────────────────────────────────────
useEffect(() => { setOpen(false) }, [pathname])
// ── Arrow key + Enter navigation ──────────────────────────────────────────
function onInputKey(e: React.KeyboardEvent) {
if (e.key === "ArrowDown") {
e.preventDefault()
setActiveIdx(i => Math.min(i + 1, results.length - 1))
} else if (e.key === "ArrowUp") {
e.preventDefault()
setActiveIdx(i => Math.max(i - 1, 0))
} else if (e.key === "Enter") {
e.preventDefault()
const item = results[activeIdx]
if (item) navigate(item)
}
}
function navigate(item: CommandItem) {
router.push(item.url)
setOpen(false)
}
// ── Scroll active item into view ──────────────────────────────────────────
useEffect(() => {
const el = listRef.current?.querySelector(`[data-idx="${activeIdx}"]`) as HTMLElement | null
el?.scrollIntoView({ block: "nearest" })
}, [activeIdx])
// ── Reset active idx when query changes ───────────────────────────────────
useEffect(() => { setActiveIdx(0) }, [query])
// ── Grouped results for display ───────────────────────────────────────────
const grouped = useMemo(() => {
const map = new Map<string, CommandItem[]>()
for (const item of results) {
if (!map.has(item.group)) map.set(item.group, [])
map.get(item.group)!.push(item)
}
return Array.from(map.entries())
}, [results])
// ── Running index across groups ───────────────────────────────────────────
let runningIdx = 0
if (!open) return null
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center pt-[12vh]"
onClick={() => setOpen(false)}
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
{/* Dialog */}
<div
className="relative z-10 w-full max-w-[620px] mx-4 rounded-xl border border-white/[0.08] shadow-2xl overflow-hidden"
style={{ background: "#0c1526" }}
onClick={e => e.stopPropagation()}
>
{/* Search input row */}
<div className="flex items-center gap-3 px-4 py-3.5 border-b border-white/[0.07]">
<SearchIcon className="size-4 text-muted-foreground shrink-0" />
<input
ref={inputRef}
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={onInputKey}
placeholder="Поиск страниц и функций…"
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
/>
{query && (
<button
onClick={() => setQuery("")}
className="text-muted-foreground hover:text-foreground transition-colors text-xs px-1.5 py-0.5 rounded border border-white/10 hover:border-white/20"
>
✕
</button>
)}
<kbd className="text-[10px] font-mono text-muted-foreground/50 border border-white/10 rounded px-1.5 py-0.5 hidden sm:block">
ESC
</kbd>
</div>
{/* Results */}
<div ref={listRef} className="max-h-[420px] overflow-y-auto py-2">
{results.length === 0 ? (
<div className="py-12 text-center text-sm text-muted-foreground">
Ничего не найдено по &ldquo;{query}&rdquo;
</div>
) : grouped.map(([group, items]) => (
<div key={group}>
{/* Group header */}
<div className="px-4 py-1.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/50">
{group}
</div>
{items.map(item => {
const idx = runningIdx++
const isActive = idx === activeIdx
return (
<button
key={item.id}
data-idx={idx}
onClick={() => navigate(item)}
onMouseEnter={() => setActiveIdx(idx)}
className={cn(
"w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors",
isActive
? "bg-white/[0.07] text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
<span className={cn(
"size-7 flex items-center justify-center rounded-md shrink-0 transition-colors [&_svg]:size-3.5",
isActive ? "bg-primary/20 text-primary" : "bg-white/[0.04] text-muted-foreground",
)}>
{item.icon}
</span>
<span className="flex-1 min-w-0">
<span className="text-sm font-medium text-foreground">{item.title}</span>
{item.subtitle && (
<span className="text-xs text-muted-foreground ml-2">{item.subtitle}</span>
)}
</span>
{isActive && (
<span className="text-[10px] font-mono text-muted-foreground/50 border border-white/10 rounded px-1.5 py-0.5 shrink-0">
↵
</span>
)}
</button>
)
})}
</div>
))}
</div>
{/* Footer */}
<div className="border-t border-white/[0.07] px-4 py-2 flex items-center gap-4 text-[10px] text-muted-foreground/50">
<span><kbd className="font-mono border border-white/10 rounded px-1 py-0.5">↑↓</kbd> навигация</span>
<span><kbd className="font-mono border border-white/10 rounded px-1 py-0.5">↵</kbd> открыть</span>
<span><kbd className="font-mono border border-white/10 rounded px-1 py-0.5">Esc</kbd> закрыть</span>
<span className="ml-auto">
<kbd className="font-mono border border-white/10 rounded px-1 py-0.5">Ctrl</kbd>
{" + "}
<kbd className="font-mono border border-white/10 rounded px-1 py-0.5">K</kbd>
</span>
</div>
</div>
</div>
)
}
+94
View File
@@ -0,0 +1,94 @@
"use client"
interface BandwidthChartProps {
rx: number[]
tx: number[]
}
export function BandwidthChart({ rx, tx }: BandwidthChartProps) {
const W = 600, H = 200
const pad = { l: 44, r: 12, t: 12, b: 28 }
const iw = W - pad.l - pad.r
const ih = H - pad.t - pad.b
const maxVal = Math.max(...rx, ...tx) * 1.15
const xAt = (i: number, n: number) => pad.l + (i / (n - 1)) * iw
const yAt = (v: number) => pad.t + (1 - v / maxVal) * ih
const areaPath = (arr: number[]) => {
const top = arr.map((v, i) => `${xAt(i, arr.length).toFixed(1)},${yAt(v).toFixed(1)}`).join(" L ")
return `M ${pad.l},${pad.t + ih} L ${top} L ${pad.l + iw},${pad.t + ih} Z`
}
const linePath = (arr: number[]) =>
arr.map((v, i) => `${xAt(i, arr.length).toFixed(1)},${yAt(v).toFixed(1)}`).join(" ")
return (
<div>
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height: 200, display: "block" }}>
<defs>
<linearGradient id="rx-grad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" style={{ stopColor: "var(--chart-rx)", stopOpacity: 0.25 }} />
<stop offset="100%" style={{ stopColor: "var(--chart-rx)", stopOpacity: 0 }} />
</linearGradient>
<linearGradient id="tx-grad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" style={{ stopColor: "var(--chart-tx)", stopOpacity: 0.20 }} />
<stop offset="100%" style={{ stopColor: "var(--chart-tx)", stopOpacity: 0 }} />
</linearGradient>
</defs>
{[0, 0.25, 0.5, 0.75, 1].map((p, i) => (
<g key={i}>
<line
x1={pad.l} x2={W - pad.r}
y1={pad.t + ih * p} y2={pad.t + ih * p}
style={{ stroke: "var(--border)" }}
strokeDasharray={p === 1 ? "0" : "2 4"}
/>
<text
x={pad.l - 8} y={pad.t + ih * p + 4}
textAnchor="end" fontSize="10"
style={{ fill: "var(--muted-foreground)" }}
fontFamily="var(--font-mono, monospace)"
>
{Math.round(maxVal * (1 - p))}
</text>
</g>
))}
<path d={areaPath(rx)} fill="url(#rx-grad)" />
<polyline points={linePath(rx)} fill="none"
style={{ stroke: "var(--chart-rx)" }}
strokeWidth="1.6" strokeLinejoin="round" />
<path d={areaPath(tx)} fill="url(#tx-grad)" />
<polyline points={linePath(tx)} fill="none"
style={{ stroke: "var(--chart-tx)" }}
strokeWidth="1.6" strokeLinejoin="round" />
{[0, 15, 30, 45, 59].map((i) => (
<text
key={i} x={xAt(i, 60)} y={H - 6}
textAnchor="middle" fontSize="10"
style={{ fill: "var(--muted-foreground)" }}
fontFamily="var(--font-mono, monospace)"
>
-{60 - i}м
</text>
))}
</svg>
{/* Legend */}
<div className="flex gap-4 pt-2 pl-11">
{[
{ key: "rx", label: "RX ↓", color: "var(--chart-rx)" },
{ key: "tx", label: "TX ↑", color: "var(--chart-tx)" },
].map(({ key, label, color }) => (
<div key={key} className="flex items-center gap-1.5 text-xs">
<span className="w-3 h-0.5 rounded" style={{ background: color }} />
<span className="font-mono text-foreground">{label}</span>
</div>
))}
</div>
</div>
)
}
+91
View File
@@ -0,0 +1,91 @@
"use client"
interface LatencyChartProps {
series: Record<string, number[]>
}
// Maps to CSS variables defined in globals.css
const COLORS: Record<string, string> = {
msk: "var(--chart-line-1)",
spb: "var(--chart-line-2)",
fra: "var(--chart-line-3)",
ams: "var(--chart-line-4)",
}
const LABELS: Record<string, string> = {
msk: "MSK",
spb: "SPB",
fra: "FRA",
ams: "AMS",
}
export function LatencyChart({ series }: LatencyChartProps) {
const W = 800, H = 220
const pad = { l: 44, r: 12, t: 12, b: 28 }
const iw = W - pad.l - pad.r
const ih = H - pad.t - pad.b
const allVals = Object.values(series).flat()
const maxVal = Math.ceil(Math.max(...allVals) / 20) * 20 + 10
const xAt = (i: number, n: number) => pad.l + (i / (n - 1)) * iw
const yAt = (v: number) => pad.t + (1 - v / maxVal) * ih
const gridLines = [0, 0.25, 0.5, 0.75, 1]
return (
<div>
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height: 220, display: "block" }}>
{gridLines.map((p, i) => (
<g key={i}>
<line
x1={pad.l} x2={W - pad.r}
y1={pad.t + ih * p} y2={pad.t + ih * p}
style={{ stroke: "var(--border)" }}
strokeDasharray={p === 1 ? "0" : "2 4"}
/>
<text
x={pad.l - 8} y={pad.t + ih * p + 4}
textAnchor="end" fontSize="10"
style={{ fill: "var(--muted-foreground)" }}
fontFamily="var(--font-mono, monospace)"
>
{Math.round(maxVal * (1 - p))}мс
</text>
</g>
))}
{[0, 15, 30, 45, 59].map((i) => (
<text
key={i}
x={xAt(i, 60)} y={H - 6}
textAnchor="middle" fontSize="10"
style={{ fill: "var(--muted-foreground)" }}
fontFamily="var(--font-mono, monospace)"
>
-{60 - i}м
</text>
))}
{Object.entries(series).map(([key, arr]) => {
const pts = arr.map((v, i) => `${xAt(i, arr.length).toFixed(1)},${yAt(v).toFixed(1)}`).join(" ")
const color = COLORS[key] ?? "var(--chart-line-1)"
return (
<polyline key={key} points={pts} fill="none"
style={{ stroke: color }}
strokeWidth="1.6" strokeLinejoin="round" />
)
})}
</svg>
<div className="flex gap-4 pt-2 pl-11">
{Object.keys(series).map((k) => {
const color = COLORS[k] ?? "var(--chart-line-1)"
return (
<div key={k} className="flex items-center gap-1.5 text-xs">
<span className="w-3 h-0.5 rounded" style={{ background: color }} />
<span className="font-mono text-foreground">{LABELS[k]}</span>
</div>
)
})}
</div>
</div>
)
}
+76
View File
@@ -0,0 +1,76 @@
"use client"
import { useMemo, useState } from "react"
import { Card } from "@/components/ui/card"
import { SearchIcon } from "lucide-react"
export interface Column<T> {
key: string
label: string
render: (row: T) => React.ReactNode
}
interface DataTableProps<T extends { id: string }> {
data: T[]
columns: Column<T>[]
searchPlaceholder?: string
searchKeys?: (keyof T)[]
}
export function DataTable<T extends { id: string }>({
data,
columns,
searchPlaceholder = "Поиск…",
searchKeys = [],
}: DataTableProps<T>) {
const [search, setSearch] = useState("")
const filtered = useMemo(() => {
if (!search || searchKeys.length === 0) return data
const s = search.toLowerCase()
return data.filter((row) =>
searchKeys.some((k) => String(row[k]).toLowerCase().includes(s)),
)
}, [data, search, searchKeys])
return (
<Card>
<div className="flex items-center gap-2 px-5 py-3 border-b">
<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={searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<span className="text-sm text-muted-foreground ml-1">{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">
{columns.map((col) => (
<th key={col.key} className="text-left font-medium px-5 py-3">
{col.label}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{filtered.map((row) => (
<tr key={row.id} className="hover:bg-muted/40 transition-colors">
{columns.map((col) => (
<td key={col.key} className="px-5 py-3">
{col.render(row)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</Card>
)
}
+64
View File
@@ -0,0 +1,64 @@
/**
* Flag — renders a country flag image from an ISO 3166-1 alpha-2 code.
* Images served from flagcdn.com (free, no auth required).
*/
const COUNTRY_NAMES: Record<string, string> = {
RU: "Россия",
DE: "Германия",
NL: "Нидерланды",
SG: "Сингапур",
US: "США",
GB: "Великобритания",
FR: "Франция",
FI: "Финляндия",
SE: "Швеция",
PL: "Польша",
UA: "Украина",
TR: "Турция",
JP: "Япония",
HK: "Гонконг",
}
/** Country name in Russian (fallback to code) */
export function countryName(code: string): string {
return COUNTRY_NAMES[code.toUpperCase()] ?? code
}
interface FlagProps {
code: string
/** px size — width of the flag image (height auto-scales 3:2 ratio) */
size?: number
className?: string
}
// flagcdn.com supports only these widths
const CDN_SIZES = [20, 40, 80, 160, 320, 640, 1280, 2560]
function nearestCdnSize(px: number): number {
return CDN_SIZES.find(s => s >= px) ?? CDN_SIZES[CDN_SIZES.length - 1]
}
/**
* Renders a flag <img> for a given ISO 3166-1 alpha-2 country code.
* Source: https://flagcdn.com — free CDN, no API key needed.
*/
export function Flag({ code, size = 20, className }: FlagProps) {
if (!code) return null
const lower = code.toLowerCase()
const name = countryName(code.toUpperCase())
const cdnSrc = nearestCdnSize(size)
const cdnSrc2x = nearestCdnSize(size * 2)
return (
<img
src={`https://flagcdn.com/w${cdnSrc}/${lower}.png`}
srcSet={`https://flagcdn.com/w${cdnSrc2x}/${lower}.png 2x`}
width={size}
height={Math.round(size * 0.75)}
alt={name}
title={name}
className={className}
style={{ display: "inline-block", verticalAlign: "middle", borderRadius: 2 }}
/>
)
}
+56
View File
@@ -0,0 +1,56 @@
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
export interface NavItem {
title: string
url: string
icon?: React.ReactNode
badge?: string
}
export interface NavGroup {
label: string
items: NavItem[]
}
export function NavMain({ groups }: { groups: NavGroup[] }) {
const pathname = usePathname()
return (
<>
{groups.map((group) => (
<SidebarGroup key={group.label}>
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
<SidebarMenu>
{group.items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
isActive={pathname === item.url}
tooltip={item.title}
render={<Link href={item.url} />}
>
{item.icon}
<span>{item.title}</span>
{item.badge && (
<span className="ml-auto text-[10.5px] font-mono tabular-nums text-sidebar-foreground/50 group-data-[collapsible=icon]:hidden">
{item.badge}
</span>
)}
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroup>
))}
</>
)
}
+84
View File
@@ -0,0 +1,84 @@
"use client"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar"
import { MoreHorizontalIcon, FolderIcon, ArrowRightIcon, Trash2Icon } from "lucide-react"
export function NavProjects({
projects,
}: {
projects: {
name: string
url: string
icon: React.ReactNode
}[]
}) {
const { isMobile } = useSidebar()
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Projects</SidebarGroupLabel>
<SidebarMenu>
{projects.map((item) => (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton render={<a href={item.url} />}>
{item.icon}
<span>{item.name}</span>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuAction
showOnHover
className="aria-expanded:bg-muted"
/>
}
>
<MoreHorizontalIcon />
<span className="sr-only">More</span>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-48 rounded-lg"
side={isMobile ? "bottom" : "right"}
align={isMobile ? "end" : "start"}
>
<DropdownMenuItem>
<FolderIcon className="text-muted-foreground" />
<span>View Project</span>
</DropdownMenuItem>
<DropdownMenuItem>
<ArrowRightIcon className="text-muted-foreground" />
<span>Share Project</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Trash2Icon className="text-muted-foreground" />
<span>Delete Project</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
<SidebarMenuItem>
<SidebarMenuButton className="text-sidebar-foreground/70">
<MoreHorizontalIcon className="text-sidebar-foreground/70" />
<span>More</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
)
}
+113
View File
@@ -0,0 +1,113 @@
"use client"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar"
import {
ChevronsUpDownIcon,
SparklesIcon,
BadgeCheckIcon,
CreditCardIcon,
BellIcon,
LogOutIcon,
} from "lucide-react"
export function NavUser({
user,
}: {
user: {
name: string
email: string
avatar: string
}
}) {
const { isMobile } = useSidebar()
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton size="lg" className="aria-expanded:bg-muted" />
}
>
<Avatar>
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback>CN</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="truncate text-xs">{user.email}</span>
</div>
<ChevronsUpDownIcon className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-56 rounded-lg"
side={isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenuGroup>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar>
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback>CN</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="truncate text-xs">{user.email}</span>
</div>
</div>
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<SparklesIcon />
Upgrade to Pro
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<BadgeCheckIcon />
Account
</DropdownMenuItem>
<DropdownMenuItem>
<CreditCardIcon />
Billing
</DropdownMenuItem>
<DropdownMenuItem>
<BellIcon />
Notifications
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem>
<LogOutIcon />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}
+48
View File
@@ -0,0 +1,48 @@
import { Separator } from "@/components/ui/separator"
import { SidebarTrigger } from "@/components/ui/sidebar"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
interface Crumb {
label: string
href?: string
}
interface PageHeaderProps {
crumbs: Crumb[]
actions?: React.ReactNode
}
export function PageHeader({ crumbs, actions }: PageHeaderProps) {
return (
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-4 bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="h-4" />
<Breadcrumb>
<BreadcrumbList>
{crumbs.map((crumb, i) => (
<span key={i} className="flex items-center gap-1.5">
{i > 0 && <BreadcrumbSeparator />}
<BreadcrumbItem>
{i < crumbs.length - 1 && crumb.href ? (
<BreadcrumbLink href={crumb.href}>{crumb.label}</BreadcrumbLink>
) : i < crumbs.length - 1 ? (
<span className="text-muted-foreground text-sm">{crumb.label}</span>
) : (
<BreadcrumbPage>{crumb.label}</BreadcrumbPage>
)}
</BreadcrumbItem>
</span>
))}
</BreadcrumbList>
</Breadcrumb>
{actions && <div className="ml-auto flex items-center gap-2">{actions}</div>}
</header>
)
}
+35
View File
@@ -0,0 +1,35 @@
import { PageHeader } from "@/components/page-header"
import { Card, CardContent } from "@/components/ui/card"
import type { LucideIcon } from "lucide-react"
interface PlaceholderPageProps {
section: string
title: string
description: string
Icon: LucideIcon
}
export function PlaceholderPage({ section, title, description, Icon }: PlaceholderPageProps) {
return (
<div className="flex flex-col h-full">
<PageHeader crumbs={[{ label: section }, { label: title }]} />
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-4 max-w-[1400px]">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
<p className="text-sm text-muted-foreground mt-1">{description}</p>
</div>
<Card>
<CardContent className="flex flex-col items-center justify-center py-20 gap-3 text-center">
<Icon className="size-10 text-muted-foreground/30" />
<h3 className="text-base font-medium">Скоро</h3>
<p className="text-sm text-muted-foreground max-w-xs">
Этот раздел будет подключён следующим. Слой данных и эндпоинты бэкенда уже готовы.
</p>
</CardContent>
</Card>
</div>
</div>
</div>
)
}
+31
View File
@@ -0,0 +1,31 @@
interface SparklineProps {
data: number[]
width?: number
height?: number
color?: string
filled?: boolean
}
export function Sparkline({ data, width = 120, height = 28, color = "currentColor", filled = false }: SparklineProps) {
const valid = data.filter((v) => v != null && isFinite(v))
if (valid.length < 2) return <span className="text-muted-foreground/50 text-xs font-mono">—</span>
const max = Math.max(...valid)
const min = Math.min(...valid)
const span = Math.max(1, max - min)
const pts = data.map((v, i) => [
(i / (data.length - 1)) * width,
height - ((v - min) / span) * (height - 2) - 1,
])
const lineD = pts.map(([x, y], i) => `${i === 0 ? "M" : "L"}${x.toFixed(1)},${y.toFixed(1)}`).join(" ")
const areaD = `${lineD} L${width},${height} L0,${height} Z`
return (
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} style={{ display: "block" }}>
{filled && <path d={areaD} style={{ fill: color, fillOpacity: 0.15 }} />}
<path d={lineD} fill="none" style={{ stroke: color }} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
+25
View File
@@ -0,0 +1,25 @@
import { cn } from "@/lib/utils"
import { StatusDot } from "@/components/status-dot"
import type { ServerStatus } from "@/lib/data"
const labels: Record<ServerStatus, string> = {
online: "Онлайн",
offline: "Оффлайн",
degraded: "Внимание",
}
export function StatusBadge({ status }: { status: ServerStatus }) {
return (
<span
className={cn(
"inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium",
status === "online" && "bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400",
status === "offline" && "bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-400",
status === "degraded" && "bg-amber-50 text-amber-700 dark:bg-amber-950/40 dark:text-amber-400",
)}
>
<StatusDot status={status} />
{labels[status]}
</span>
)
}
+16
View File
@@ -0,0 +1,16 @@
import { cn } from "@/lib/utils"
import type { ServerStatus } from "@/lib/data"
export function StatusDot({ status, pulse }: { status: ServerStatus; pulse?: boolean }) {
return (
<span
className={cn(
"inline-block size-2 rounded-full shrink-0",
status === "online" && "bg-emerald-500",
status === "offline" && "bg-red-500",
status === "degraded" && "bg-amber-400",
pulse && status === "online" && "animate-pulse",
)}
/>
)
}
+98
View File
@@ -0,0 +1,98 @@
"use client"
import * as React from "react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar"
import { ChevronsUpDownIcon, PlusIcon } from "lucide-react"
export function TeamSwitcher({
teams,
}: {
teams: {
name: string
logo: React.ReactNode
plan: string
}[]
}) {
const { isMobile } = useSidebar()
const [activeTeam, setActiveTeam] = React.useState(teams[0])
if (!activeTeam) {
return null
}
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton
size="lg"
className="data-open:bg-sidebar-accent data-open:text-sidebar-accent-foreground"
/>
}
>
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
{activeTeam.logo}
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{activeTeam.name}</span>
<span className="truncate text-xs">{activeTeam.plan}</span>
</div>
<ChevronsUpDownIcon className="ml-auto" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-56 rounded-lg"
align="start"
side={isMobile ? "bottom" : "right"}
sideOffset={4}
>
<DropdownMenuGroup>
<DropdownMenuLabel className="text-xs text-muted-foreground">
Teams
</DropdownMenuLabel>
{teams.map((team, index) => (
<DropdownMenuItem
key={team.name}
onClick={() => setActiveTeam(team)}
className="gap-2 p-2"
>
<div className="flex size-6 items-center justify-center rounded-md border">
{team.logo}
</div>
{team.name}
<DropdownMenuShortcut>⌘{index + 1}</DropdownMenuShortcut>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem className="gap-2 p-2">
<div className="flex size-6 items-center justify-center rounded-md border bg-transparent">
<PlusIcon className="size-4" />
</div>
<div className="font-medium text-muted-foreground">
Add team
</div>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}
+90
View File
@@ -0,0 +1,90 @@
"use client"
import { createContext, useContext, useEffect, useState } from "react"
// ── types ──────────────────────────────────────────────────────────────────────
type ThemeValue = "dark" | "light" | "system"
type ResolvedTheme = "dark" | "light"
interface ThemeContextValue {
theme: ThemeValue
resolvedTheme: ResolvedTheme
setTheme: (t: ThemeValue) => void
}
// ── context ────────────────────────────────────────────────────────────────────
const ThemeContext = createContext<ThemeContextValue>({
theme: "dark",
resolvedTheme: "dark",
setTheme: () => {},
})
// ── provider ───────────────────────────────────────────────────────────────────
export interface ThemeProviderProps {
children: React.ReactNode
defaultTheme?: ThemeValue
/** kept for API compat with next-themes — unused, class is always used */
attribute?: string
disableTransitionOnChange?: boolean
}
export function ThemeProvider({
children,
defaultTheme = "dark",
disableTransitionOnChange = false,
}: ThemeProviderProps) {
const [theme, setThemeState] = useState<ThemeValue>(() => {
if (typeof window === "undefined") return defaultTheme
return (localStorage.getItem("rl-theme") as ThemeValue | null) ?? defaultTheme
})
const [systemDark, setSystemDark] = useState<boolean>(() => {
if (typeof window === "undefined") return true
return window.matchMedia("(prefers-color-scheme: dark)").matches
})
// Track system preference
useEffect(() => {
const mq = window.matchMedia("(prefers-color-scheme: dark)")
const handler = (e: MediaQueryListEvent) => setSystemDark(e.matches)
mq.addEventListener("change", handler)
return () => mq.removeEventListener("change", handler)
}, [])
const resolvedTheme: ResolvedTheme =
theme === "system" ? (systemDark ? "dark" : "light") : theme
// Apply class to <html>
useEffect(() => {
const root = document.documentElement
if (disableTransitionOnChange) {
root.style.setProperty("transition", "none")
void root.offsetHeight // force reflow
setTimeout(() => root.style.removeProperty("transition"), 0)
}
root.classList.toggle("dark", resolvedTheme === "dark")
root.classList.toggle("light", resolvedTheme === "light")
}, [resolvedTheme, disableTransitionOnChange])
const setTheme = (t: ThemeValue) => {
setThemeState(t)
try { localStorage.setItem("rl-theme", t) } catch {}
}
return (
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
{children}
</ThemeContext.Provider>
)
}
// ── hook (drop-in replacement for next-themes useTheme) ───────────────────────
export function useTheme(): ThemeContextValue {
return useContext(ThemeContext)
}
+109
View File
@@ -0,0 +1,109 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}
+40
View File
@@ -0,0 +1,40 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
...props
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return (
<span
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+122
View File
@@ -0,0 +1,122 @@
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
aria-label="breadcrumb"
data-slot="breadcrumb"
className={cn(className)}
{...props}
/>
)
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
function BreadcrumbLink({
className,
render,
...props
}: useRender.ComponentProps<"a">) {
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn("transition-colors hover:text-foreground", className),
},
props
),
render,
state: {
slot: "breadcrumb-link",
},
})
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRightIcon />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn(
"flex size-5 items-center justify-center [&>svg]:size-4",
className
)}
{...props}
>
<MoreHorizontalIcon />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
+58
View File
@@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+103
View File
@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+21
View File
@@ -0,0 +1,21 @@
"use client"
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
return (
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
)
}
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
return (
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
+266
View File
@@ -0,0 +1,266 @@
"use client"
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className)}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className)}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon />
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon />
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+25
View File
@@ -0,0 +1,25 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }
+137
View File
@@ -0,0 +1,137 @@
"use client"
import * as React from "react"
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
/>
}
>
<XIcon />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+713
View File
@@ -0,0 +1,713 @@
"use client"
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { PanelLeftIcon } from "lucide-react"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
className
)}
{...props}
>
{children}
</div>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
dir,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
dir={dir}
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
data-side={side}
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon-sm"
className={cn(className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("h-8 w-full bg-background shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
render,
...props
}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-group-label",
sidebar: "group-label",
},
})
}
function SidebarGroupAction({
className,
render,
...props
}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-group-action",
sidebar: "group-action",
},
})
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
render,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const { isMobile, state } = useSidebar()
const comp = useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
},
props
),
render: !tooltip ? render : <TooltipTrigger render={render} />,
state: {
slot: "sidebar-menu-button",
sidebar: "menu-button",
size,
active: isActive,
},
})
if (!tooltip) {
return comp
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
{comp}
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
render,
showOnHover = false,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
showOnHover?: boolean
}) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-menu-action",
sidebar: "menu-action",
},
})
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
})
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
render,
size = "md",
isActive = false,
className,
...props
}: useRender.ComponentProps<"a"> &
React.ComponentProps<"a"> & {
size?: "sm" | "md"
isActive?: boolean
}) {
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
className
),
},
props
),
render,
state: {
slot: "sidebar-menu-sub-button",
sidebar: "menu-sub-button",
size,
active: isActive,
},
})
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }
+28
View File
@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Switch } from "@base-ui/react/switch"
import { cn } from "@/lib/utils"
function SwitchComponent({
className,
...props
}: React.ComponentProps<typeof Switch.Root>) {
return (
<Switch.Root
data-slot="switch"
className={cn(
"peer data-[checked]:bg-primary data-[unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<Switch.Thumb
data-slot="switch-thumb"
className="bg-background dark:data-[unchecked]:bg-foreground dark:data-[checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[checked]:translate-x-[calc(100%-2px)] data-[unchecked]:translate-x-0"
/>
</Switch.Root>
)
}
export { SwitchComponent as Switch }
+82
View File
@@ -0,0 +1,82 @@
"use client"
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
+66
View File
@@ -0,0 +1,66 @@
"use client"
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+28
View File
@@ -0,0 +1,28 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
globalIgnores([
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
{
rules: {
"@typescript-eslint/no-unused-vars": [
"warn",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
},
],
},
},
]);
export default eslintConfig;
+23
View File
@@ -0,0 +1,23 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean>(() => {
if (typeof window === "undefined") {
return false
}
return window.innerWidth < MOBILE_BREAKPOINT
})
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}
+36
View File
@@ -0,0 +1,36 @@
/**
* API mode feature-flag.
*
* Set NEXT_PUBLIC_API_MODE=live in .env.local to switch from static mock data
* to real RouterOS REST API calls.
*
* Default: "mock"
*/
export const API_MODE = (
typeof process !== "undefined"
? (process.env.NEXT_PUBLIC_API_MODE ?? "mock")
: "mock"
) as "mock" | "live"
export const IS_MOCK = API_MODE !== "live"
/**
* Wrap an async data-fetch so it transparently returns mock data in "mock" mode
* or calls the live resolver in "live" mode.
*
* Example:
* export const fetchServers = apiOr(
* () => servers, // mock
* async (cfg) => apiClient(cfg).getAll() // live
* )
*/
export function apiOr<T, Args extends unknown[]>(
mock: (...args: Args) => T | Promise<T>,
live: (...args: Args) => T | Promise<T>,
): (...args: Args) => Promise<T> {
return async (...args: Args): Promise<T> => {
if (IS_MOCK) return mock(...args)
return live(...args)
}
}
+256
View File
@@ -0,0 +1,256 @@
/**
* RouterOS 7.x REST API client
*
* RouterOS 7.1+ exposes a REST API at https://<router>/rest/
* All responses are JSON; authentication is HTTP Basic or Bearer token.
*
* Usage (browser / server-component):
* const client = new RouterOsApiClient({ host: "10.0.0.1", port: 443, ... })
* const ifaces = await client.getInterfaces()
*/
export interface ApiClientConfig {
host: string // "10.0.0.1"
port: number // 443 (HTTPS) or 80 (HTTP)
tls: boolean // use HTTPS
username: string
password: string
tlsVerify: boolean // verify TLS certificate (false for self-signed)
timeout: number // ms, default 8000
}
// ─── RouterOS REST response shapes ───────────────────────────────────────────
export interface RosSystemIdentity {
name: string
}
export interface RosSystemResource {
uptime: string
version: string
"build-time": string
"free-memory": string
"total-memory": string
"cpu": string
"cpu-count": string
"cpu-load": string
"board-name": string
"platform": string
"architecture-name": string
"bad-blocks": string
}
export interface RosInterface {
".id": string
name: string
type: string
mtu: string
"actual-mtu": string
"mac-address": string
running: string
disabled: string
comment?: string
}
export interface RosBgpSession {
".id": string
name: string
"remote.address": string
"remote.as": string
state: string
"uptime": string
"prefix-count": string
disabled: string
}
export interface RosWireGuardInterface {
".id": string
name: string
"listen-port": string
mtu: string
"public-key": string
running: string
disabled: string
comment?: string
}
export interface RosWireGuardPeer {
".id": string
interface: string
"public-key": string
"endpoint-address"?: string
"endpoint-port"?: string
"allowed-address": string
"last-handshake"?: string
"rx": string
"tx": string
disabled: string
comment?: string
}
// ─── Error class ─────────────────────────────────────────────────────────────
export class RouterOsApiError extends Error {
constructor(
public readonly status: number,
public readonly detail: string,
message: string,
) {
super(message)
this.name = "RouterOsApiError"
}
}
// ─── Client class ─────────────────────────────────────────────────────────────
export class RouterOsApiClient {
private readonly baseUrl: string
private readonly headers: Record<string, string>
private readonly timeout: number
constructor(private readonly cfg: ApiClientConfig) {
const scheme = cfg.tls ? "https" : "http"
this.baseUrl = `${scheme}://${cfg.host}:${cfg.port}/rest`
const creds = btoa(`${cfg.username}:${cfg.password}`)
this.headers = {
"Authorization": `Basic ${creds}`,
"Content-Type": "application/json",
}
this.timeout = cfg.timeout ?? 8000
}
// ── low-level fetch ──────────────────────────────────────────────────────
private async request<T>(
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE",
path: string,
body?: unknown,
): Promise<T> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), this.timeout)
try {
const res = await fetch(`${this.baseUrl}${path}`, {
method,
headers: this.headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: controller.signal,
})
clearTimeout(timer)
if (!res.ok) {
let detail = ""
try { detail = (await res.json())?.message ?? "" } catch { /* ignore */ }
throw new RouterOsApiError(res.status, detail, `RouterOS API ${method} ${path} → ${res.status}: ${detail}`)
}
if (res.status === 204) return undefined as T
return res.json() as Promise<T>
} catch (e) {
clearTimeout(timer)
throw e
}
}
async get<T>(path: string): Promise<T> {
return this.request<T>("GET", path)
}
async post<T>(path: string, body: unknown): Promise<T> {
return this.request<T>("POST", path, body)
}
/** RouterOS uses PUT for updates on a specific item by /.id */
async put<T>(path: string, id: string, body: unknown): Promise<T> {
return this.request<T>("PUT", `${path}/${encodeURIComponent(id)}`, body)
}
async patch<T>(path: string, id: string, body: unknown): Promise<T> {
return this.request<T>("PATCH", `${path}/${encodeURIComponent(id)}`, body)
}
async delete(path: string, id: string): Promise<void> {
return this.request<void>("DELETE", `${path}/${encodeURIComponent(id)}`)
}
// ── high-level helpers ───────────────────────────────────────────────────
async getSystemIdentity(): Promise<RosSystemIdentity> {
return this.get<RosSystemIdentity>("/system/identity")
}
async getSystemResource(): Promise<RosSystemResource> {
return this.get<RosSystemResource>("/system/resource")
}
async getInterfaces(): Promise<RosInterface[]> {
return this.get<RosInterface[]>("/interface")
}
async getBgpSessions(): Promise<RosBgpSession[]> {
return this.get<RosBgpSession[]>("/routing/bgp/session")
}
async getWireGuardInterfaces(): Promise<RosWireGuardInterface[]> {
return this.get<RosWireGuardInterface[]>("/interface/wireguard")
}
async getWireGuardPeers(): Promise<RosWireGuardPeer[]> {
return this.get<RosWireGuardPeer[]>("/interface/wireguard/peers")
}
/** Add a WireGuard interface */
async addWireGuardInterface(params: {
name: string
listenPort: number
mtu?: number
comment?: string
}): Promise<RosWireGuardInterface> {
return this.post<RosWireGuardInterface>("/interface/wireguard", {
name: params.name,
"listen-port": String(params.listenPort),
mtu: String(params.mtu ?? 1420),
comment: params.comment ?? "",
})
}
/** Add a WireGuard peer */
async addWireGuardPeer(params: {
iface: string
publicKey: string
allowedAddresses: string[]
endpoint?: string
endpointPort?: number
persistentKeepalive?: number
comment?: string
}): Promise<RosWireGuardPeer> {
return this.post<RosWireGuardPeer>("/interface/wireguard/peers", {
interface: params.iface,
"public-key": params.publicKey,
"allowed-address": params.allowedAddresses.join(","),
...(params.endpoint ? { "endpoint-address": params.endpoint } : {}),
...(params.endpointPort ? { "endpoint-port": String(params.endpointPort) } : {}),
...(params.persistentKeepalive ? { "persistent-keepalive": String(params.persistentKeepalive) } : {}),
comment: params.comment ?? "",
})
}
/** Ping test from the router — uses /tool/ping */
async ping(address: string, count = 4): Promise<{ "avg-rtt": string; "packet-loss": string }[]> {
return this.post("/tool/ping", { address, count: String(count) })
}
}
// ─── Factory helper ───────────────────────────────────────────────────────────
export function createClient(overrides: Partial<ApiClientConfig> & { host: string }): RouterOsApiClient {
return new RouterOsApiClient({
port: 443,
tls: true,
username: "admin",
password: "",
tlsVerify: false,
timeout: 8000,
...overrides,
})
}
+76
View File
@@ -0,0 +1,76 @@
"use client"
import { createContext, useContext, useEffect, useState, useCallback } from "react"
// ── types ─────────────────────────────────────────────────────────────────────
export type DataSourceMode = "mock" | "live"
interface DataSourceContextValue {
mode: DataSourceMode
setMode: (m: DataSourceMode) => void
backendUrl: string
setBackendUrl: (url: string) => void
/** undefined = не проверялось, true = OK, false = недоступен */
backendStatus: boolean | undefined
checkBackend: () => Promise<void>
}
// ── context ───────────────────────────────────────────────────────────────────
const DataSourceContext = createContext<DataSourceContextValue | null>(null)
const LS_MODE = "routerlists:data-source"
const LS_BACKEND = "routerlists:backend-url"
const DEFAULT_URL = "http://localhost:8000"
export function DataSourceProvider({ children }: { children: React.ReactNode }) {
const [mode, setModeState] = useState<DataSourceMode>("mock")
const [backendUrl, setBackendUrlState] = useState(DEFAULT_URL)
const [backendStatus, setBackendStatus] = useState<boolean | undefined>(undefined)
// Restore from localStorage on mount
useEffect(() => {
const storedMode = localStorage.getItem(LS_MODE) as DataSourceMode | null
const storedUrl = localStorage.getItem(LS_BACKEND)
if (storedMode === "mock" || storedMode === "live") setModeState(storedMode)
if (storedUrl) setBackendUrlState(storedUrl)
}, [])
const setMode = useCallback((m: DataSourceMode) => {
setModeState(m)
localStorage.setItem(LS_MODE, m)
}, [])
const setBackendUrl = useCallback((url: string) => {
setBackendUrlState(url)
localStorage.setItem(LS_BACKEND, url)
}, [])
const checkBackend = useCallback(async () => {
const url = (localStorage.getItem(LS_BACKEND) ?? DEFAULT_URL).replace(/\/$/, "")
try {
const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(3000) })
setBackendStatus(res.ok)
} catch {
setBackendStatus(false)
}
}, [])
// Auto-check when mode switches to live
useEffect(() => {
if (mode === "live") checkBackend()
}, [mode, checkBackend])
return (
<DataSourceContext.Provider value={{ mode, setMode, backendUrl, setBackendUrl, backendStatus, checkBackend }}>
{children}
</DataSourceContext.Provider>
)
}
export function useDataSource(): DataSourceContextValue {
const ctx = useContext(DataSourceContext)
if (!ctx) throw new Error("useDataSource must be used inside <DataSourceProvider>")
return ctx
}
+807
View File
@@ -0,0 +1,807 @@
export type ServerStatus = "online" | "offline" | "degraded"
export type ServerType = "jump-host" | "exit-node" | "home-router"
export interface WanUplink {
id: string
name: string // "WAN1-RT"
isp: string // "Rostelecom"
iface: string // "ether1"
ip: string // external IP
maxDl: number // Mbps
maxUl: number // Mbps
}
// ─── WireGuard ───────────────────────────────────────────────────────────────
export interface WireGuardPeer {
publicKey: string
allowedIps: string[]
endpoint?: string // "1.2.3.4:13231"
latestHandshake?: string // "2 минуты назад"
transferRx?: number // bytes
transferTx?: number // bytes
persistent?: boolean
comment?: string
}
export interface WireGuardInterface {
id: string
name: string // e.g. "wg-msk-spb"
listenPort: number // default 13231
mtu: number // 1420 default in ROS 7.x
peers: WireGuardPeer[]
comment: string
enabled: boolean
status: "up" | "down"
}
export interface Server {
id: string
name: string
host: string // management IP / API address
model: string
os: string
site: string
country: string // ISO 3166-1 alpha-2, e.g. "RU", "DE"
asn: string
type: ServerType
enabled: boolean
status: ServerStatus
latency: number | null
sessions: number
comment?: string
// RouterOS 7.x extras
ipv6Address?: string // global or link-local
wireGuardIfaces?: WireGuardInterface[]
vrfNames?: string[] // VRF instances on this router
rpkiEnabled?: boolean
// home-router specific
wanUplinks?: WanUplink[]
lanSubnet?: string // e.g. "192.168.10.0/24"
// snapshot / poll data
uptime?: string
cpuLoad?: number
polledAt?: string
}
export interface FlowspecRule {
id: string
dstPrefix?: string
srcPrefix?: string
ipProto?: string
dstPort?: string
action: "rate-limit" | "discard" | "redirect"
rate?: number // Mbps, for rate-limit
comment?: string
}
export interface Filter {
id: string
name: string
domains: number
ips: number
asns: number
servers: string[]
enabled: boolean
updated: string
communities: string[]
gateway: string
// RouterOS 7.x extras
largeCommunities?: string[] // RFC 8092 format "AS:VALUE1:VALUE2"
rpkiValid?: boolean // filter only RPKI-valid routes
flowspecRules?: FlowspecRule[]
}
export interface Domain {
id: string
domain: string
resolvedIp: string
asn: string
purpose: string
filter: string
updated: string
enabled: boolean
}
export interface IpRange {
id: string
cidr: string
asn: string
country: string
purpose: string
filter: string
updated: string
enabled: boolean
}
export interface Asn {
id: string
asn: string
org: string
country: string
prefixes: number
filter: string
updated: string
enabled: boolean
}
export type FirewallAction =
| "accept" | "drop" | "reject" | "mark-routing" | "mark-conn"
| "masquerade" | "src-nat" | "dst-nat"
| "fasttrack-connection" | "nfqueue" | "passthrough" | "return"
| "add-src-to-address-list" | "add-dst-to-address-list"
export interface FirewallRule {
id: string
chain: string
action: FirewallAction | string
proto: string
src: string
dst: string
port: string
iface: string
comment: string
enabled: boolean
hits: number
// RouterOS 7.x extras
family?: "ip" | "ip6" | "any"
tlsHost?: string // tls-host matcher (SNI)
connRateLimit?: string // connection-rate e.g. "100/s"
layer7Proto?: string // /ip firewall layer7-protocol
nfqueueId?: number
log?: boolean
logPrefix?: string
}
export interface Backup {
id: string
server: string
filename: string
size: string
created: string
kind: "auto" | "manual"
notes: string
}
// ─── VXLAN ───────────────────────────────────────────────────────────────────
export interface VxlanTunnel {
id: string
name: string // "vxlan-overlay-1"
vni: number // 0–16777215
port: number // source port (default 0 = auto)
dstPort: number // destination port (default 8472)
serverId: string
vtepIp: string // VTEP address on the server
remoteVteps: string[] // remote VTEP IPs (unicast-flood)
l2mtu: number
arpProxy: boolean
macLearning: boolean
comment: string
enabled: boolean
status: "up" | "down"
}
// ─── VRF ─────────────────────────────────────────────────────────────────────
export interface VrfInstance {
id: string
name: string // VRF name
serverId: string
interfaces: string[] // list of interfaces in this VRF
routingTable: string // associated routing table
comment: string
}
// ─── Container (RouterOS 7.4+) ───────────────────────────────────────────────
export interface RouterContainer {
id: string
name: string
serverId: string
image: string // e.g. "nginx:alpine"
tag: string
status: "running" | "stopped" | "error"
envs: { key: string; value: string }[]
mounts: { dst: string; src?: string }[]
interfaces: string[] // veth interface names
cmd?: string
startOnBoot: boolean
comment: string
uptime?: string
cpu?: number // CPU % 0-100
memMb?: number // MB
}
// ─── Certificate ─────────────────────────────────────────────────────────────
export type CertUsage = "server" | "client" | "ca" | "crl-sign"
export type CertStatus = "valid" | "expired" | "revoked"
export interface RouterCertificate {
id: string
name: string
serverId: string
commonName: string
sans: string[]
issuedBy: string
validFrom: string
validUntil: string
daysLeft: number
keySize: number
usage: CertUsage[]
trusted: boolean
status: CertStatus
}
export interface PingProbe {
id: string
srcServerId: string // which server sends the ping
srcInterface?: string // optional interface name on source router
name: string
target: string
filter: string
rtt: number | null
loss: number
status: "up" | "warn" | "down"
series: number[]
enabled: boolean
}
export interface SystemEvent {
id: string
sev: "destructive" | "warning" | "info"
title: string
message: string
when: string
}
function spark(n: number, base: number, range: number): number[] {
let s = base * 1000 + range
return Array.from({ length: n }, () => {
s = (s * 9301 + 49297) % 233280
const r = s / 233280
return Math.max(1, Math.round(base + (r - 0.5) * range * 2))
})
}
export const servers: Server[] = [
{
id: "srv1", name: "mt-msk-core-01", host: "10.0.0.1", model: "CCR2004-1G-12S+2XS", os: "7.20.1",
site: "MSK", country: "RU", asn: "AS65001", type: "jump-host", enabled: true, status: "online", latency: 12, sessions: 4,
ipv6Address: "2a01:4f8:1c1c:a1b2::1",
rpkiEnabled: true,
vrfNames: ["mgmt", "bypass"],
wireGuardIfaces: [
{
id: "wg1", name: "wg-msk-spb", listenPort: 13231, mtu: 1420, comment: "MSK → SPB overlay", enabled: true, status: "up",
peers: [
{ publicKey: "SPBPublicKeyBase64AAAAAAAAAAAAAAAAAAAAAA=", allowedIps: ["10.210.0.2/32","192.168.20.0/24"], endpoint: "10.0.1.1:13231", latestHandshake: "2 мин назад", transferRx: 182_400_000, transferTx: 64_800_000, persistent: true },
],
},
{
id: "wg2", name: "wg-msk-fra", listenPort: 13232, mtu: 1420, comment: "MSK → FRA overlay", enabled: true, status: "up",
peers: [
{ publicKey: "FRAPublicKeyBase64AAAAAAAAAAAAAAAAAAAAAA=", allowedIps: ["10.210.0.6/32"], endpoint: "10.0.2.1:13231", latestHandshake: "1 мин назад", transferRx: 421_000_000, transferTx: 210_000_000, persistent: true },
],
},
],
},
{
id: "srv2", name: "mt-spb-edge-01", host: "10.0.1.1", model: "CCR2116-12G-4S+", os: "7.20.1",
site: "SPB", country: "RU", asn: "AS65002", type: "exit-node", enabled: true, status: "online", latency: 28, sessions: 6,
ipv6Address: "2a01:4f8:1c2c:b2c3::1",
rpkiEnabled: true,
wireGuardIfaces: [
{
id: "wg3", name: "wg-spb-msk", listenPort: 13231, mtu: 1420, comment: "SPB → MSK overlay", enabled: true, status: "up",
peers: [
{ publicKey: "MSKPublicKeyBase64AAAAAAAAAAAAAAAAAAAAAA=", allowedIps: ["10.210.0.1/32"], endpoint: "10.0.0.1:13231", latestHandshake: "2 мин назад", transferRx: 64_800_000, transferTx: 182_400_000, persistent: true },
],
},
],
},
{
id: "srv3", name: "mt-fra-edge-01", host: "10.0.2.1", model: "CCR2004-16G-2S+", os: "7.20.1",
site: "FRA", country: "DE", asn: "AS65003", type: "exit-node", enabled: true, status: "online", latency: 41, sessions: 3,
ipv6Address: "2a01:4f8:1c3c:c3d4::1",
rpkiEnabled: true,
wireGuardIfaces: [
{
id: "wg4", name: "wg-fra-msk", listenPort: 13231, mtu: 1420, comment: "FRA → MSK overlay", enabled: true, status: "up",
peers: [
{ publicKey: "MSKPublicKeyBase64AAAAAAAAAAAAAAAAAAAAAA=", allowedIps: ["10.210.0.1/32"], endpoint: "10.0.0.1:13232", latestHandshake: "1 мин назад", transferRx: 210_000_000, transferTx: 421_000_000, persistent: true },
],
},
],
},
{
id: "srv4", name: "mt-ams-edge-01", host: "10.0.3.1", model: "RB5009UG+S+IN", os: "7.19.3",
site: "AMS", country: "NL", asn: "AS65004", type: "exit-node", enabled: true, status: "degraded", latency: 88, sessions: 2,
},
{
id: "srv5", name: "mt-sgp-edge-01", host: "10.0.4.1", model: "CCR2004-1G-12S+2XS", os: "7.14.2",
site: "SGP", country: "SG", asn: "AS65005", type: "exit-node", enabled: false, status: "offline", latency: null, sessions: 0,
},
{
id: "srv6", name: "mt-ams-test-01", host: "10.0.3.2", model: "hEX S", os: "7.20.1",
site: "AMS", country: "NL", asn: "AS65006", type: "jump-host", enabled: true, status: "online", latency: 92, sessions: 1,
},
{
id: "srv7", name: "mt-msk-lab-01", host: "10.0.0.7", model: "RB5009UG+S+IN", os: "7.20.1",
site: "MSK", country: "RU", asn: "AS65007", type: "jump-host", enabled: true, status: "online", latency: 14, sessions: 2,
vrfNames: ["lab"],
},
{
id: "home1", name: "home-msk-01", host: "192.168.10.1", model: "hEX S (RB760iGS)", os: "7.20.1",
site: "MSK", country: "RU", asn: "", type: "home-router", enabled: true, status: "online", latency: 3, sessions: 0,
comment: "MSK офис — основной роутер",
lanSubnet: "192.168.10.0/24",
wanUplinks: [
{ id: "w1a", name: "WAN1-RT", isp: "Rostelecom", iface: "ether1", ip: "94.25.168.1", maxDl: 500, maxUl: 500 },
{ id: "w1b", name: "WAN2-BL", isp: "Beeline", iface: "ether2", ip: "85.174.12.1", maxDl: 100, maxUl: 100 },
],
},
{
id: "home2", name: "home-spb-01", host: "192.168.20.1", model: "RB5009UG+S+IN", os: "7.20.1",
site: "SPB", country: "RU", asn: "", type: "home-router", enabled: true, status: "online", latency: 5, sessions: 0,
comment: "SPB офис — основной роутер",
lanSubnet: "192.168.20.0/24",
wanUplinks: [
{ id: "w2a", name: "WAN1-MTS", isp: "МТС", iface: "ether1", ip: "178.214.4.1", maxDl: 300, maxUl: 300 },
{ id: "w2b", name: "WAN2-ERT", isp: "ЭР-Телеком", iface: "ether2", ip: "95.31.14.1", maxDl: 200, maxUl: 150 },
],
},
]
export const filters: Filter[] = [
{
id: "f1", name: "youtube-bypass", domains: 1842, ips: 4218, asns: 12,
servers: ["srv1","srv2","srv3","srv4"], enabled: true, updated: "12 мин назад",
communities: ["65001:100","65001:200"], gateway: "10.0.0.1",
largeCommunities: ["65001:100:1","65001:100:2"],
rpkiValid: true,
},
{
id: "f2", name: "streaming-eu", domains: 612, ips: 2104, asns: 8,
servers: ["srv2","srv3","srv4"], enabled: true, updated: "2 ч назад",
communities: ["65001:200"], gateway: "10.0.1.1",
largeCommunities: ["65001:200:1"],
rpkiValid: true,
},
{
id: "f3", name: "cdn-bypass", domains: 4218, ips: 18211, asns: 22,
servers: ["srv1","srv2","srv3","srv4","srv6"], enabled: true, updated: "1 ч назад",
communities: ["65001:300","65002:100"], gateway: "10.0.0.1",
flowspecRules: [
{ id: "fs1", dstPrefix: "104.16.0.0/12", ipProto: "tcp", dstPort: "443", action: "rate-limit", rate: 100, comment: "CF rate-limit" },
],
},
{ id: "f4", name: "social-block-school", domains: 84, ips: 412, asns: 4, servers: ["srv1","srv7"], enabled: false, updated: "3 дн назад", communities: ["65007:999"], gateway: "" },
{ id: "f5", name: "office-direct", domains: 142, ips: 884, asns: 6, servers: ["srv1","srv2","srv3","srv4","srv6","srv7"], enabled: true, updated: "4 ч назад", communities: ["65001:400"], gateway: "10.0.0.1" },
{ id: "f6", name: "gaming-low-latency", domains: 88, ips: 1212, asns: 14, servers: ["srv1","srv2","srv3","srv7"], enabled: true, updated: "24 мин назад", communities: ["65001:500","65002:200"], gateway: "10.0.2.1" },
]
export const domains: Domain[] = [
{ id: "d1", domain: "youtube.com", purpose: "streaming", filter: "youtube-bypass", resolvedIp: "142.250.74.110", asn: "AS15169", updated: "12 мин", enabled: true },
{ id: "d2", domain: "googlevideo.com", purpose: "streaming", filter: "youtube-bypass", resolvedIp: "142.250.74.142", asn: "AS15169", updated: "12 мин", enabled: true },
{ id: "d3", domain: "ytimg.com", purpose: "streaming", filter: "youtube-bypass", resolvedIp: "172.217.16.46", asn: "AS15169", updated: "12 мин", enabled: true },
{ id: "d4", domain: "netflix.com", purpose: "streaming", filter: "streaming-eu", resolvedIp: "54.246.79.9", asn: "AS2906", updated: "2 ч", enabled: true },
{ id: "d5", domain: "nflxvideo.net", purpose: "streaming", filter: "streaming-eu", resolvedIp: "45.57.32.18", asn: "AS2906", updated: "2 ч", enabled: true },
{ id: "d6", domain: "cloudflare.com", purpose: "cdn", filter: "cdn-bypass", resolvedIp: "104.16.132.229", asn: "AS13335", updated: "1 ч", enabled: true },
{ id: "d7", domain: "akamaized.net", purpose: "cdn", filter: "cdn-bypass", resolvedIp: "23.46.18.94", asn: "AS20940", updated: "1 ч", enabled: true },
{ id: "d8", domain: "fastly.net", purpose: "cdn", filter: "cdn-bypass", resolvedIp: "151.101.1.95", asn: "AS54113", updated: "1 ч", enabled: true },
{ id: "d9", domain: "twitch.tv", purpose: "streaming", filter: "streaming-eu", resolvedIp: "52.223.241.72", asn: "AS16509", updated: "2 ч", enabled: false },
{ id: "d10", domain: "discord.com", purpose: "voip", filter: "gaming-low-latency", resolvedIp: "162.159.135.232", asn: "AS13335", updated: "24 мин", enabled: true },
{ id: "d11", domain: "steam.com", purpose: "gaming", filter: "gaming-low-latency", resolvedIp: "23.45.139.40", asn: "AS20940", updated: "24 мин", enabled: true },
{ id: "d12", domain: "epicgames.com", purpose: "gaming", filter: "gaming-low-latency", resolvedIp: "34.205.247.51", asn: "AS16509", updated: "24 мин", enabled: true },
]
export const ipRanges: IpRange[] = [
{ id: "i1", cidr: "142.250.0.0/15", purpose: "streaming", filter: "youtube-bypass", asn: "AS15169", country: "US", updated: "12 мин", enabled: true },
{ id: "i2", cidr: "172.217.0.0/16", purpose: "streaming", filter: "youtube-bypass", asn: "AS15169", country: "US", updated: "12 мин", enabled: true },
{ id: "i3", cidr: "104.16.0.0/12", purpose: "cdn", filter: "cdn-bypass", asn: "AS13335", country: "US", updated: "1 ч", enabled: true },
{ id: "i4", cidr: "23.32.0.0/11", purpose: "cdn", filter: "cdn-bypass", asn: "AS20940", country: "US", updated: "1 ч", enabled: true },
{ id: "i5", cidr: "151.101.0.0/16", purpose: "cdn", filter: "cdn-bypass", asn: "AS54113", country: "US", updated: "1 ч", enabled: true },
{ id: "i6", cidr: "54.246.0.0/16", purpose: "streaming", filter: "streaming-eu", asn: "AS2906", country: "IE", updated: "2 ч", enabled: true },
{ id: "i7", cidr: "45.57.0.0/16", purpose: "streaming", filter: "streaming-eu", asn: "AS2906", country: "NL", updated: "2 ч", enabled: true },
{ id: "i8", cidr: "162.159.128.0/19", purpose: "voip", filter: "gaming-low-latency", asn: "AS13335", country: "US", updated: "24 мин", enabled: true },
{ id: "i9", cidr: "52.223.240.0/20", purpose: "streaming", filter: "streaming-eu", asn: "AS16509", country: "IE", updated: "2 ч", enabled: false },
]
export const asns: Asn[] = [
{ id: "a1", asn: "AS15169", org: "GOOGLE", country: "US", prefixes: 842, filter: "youtube-bypass", updated: "12 мин", enabled: true },
{ id: "a2", asn: "AS13335", org: "CLOUDFLARENET", country: "US", prefixes: 1284, filter: "cdn-bypass", updated: "1 ч", enabled: true },
{ id: "a3", asn: "AS16509", org: "AMAZON-02", country: "US", prefixes: 4128, filter: "streaming-eu", updated: "2 ч", enabled: true },
{ id: "a4", asn: "AS20940", org: "AKAMAI-ASN1", country: "US", prefixes: 614, filter: "cdn-bypass", updated: "1 ч", enabled: true },
{ id: "a5", asn: "AS54113", org: "FASTLY", country: "US", prefixes: 122, filter: "cdn-bypass", updated: "1 ч", enabled: true },
{ id: "a6", asn: "AS2906", org: "AS-SSI (Netflix)", country: "US", prefixes: 88, filter: "streaming-eu", updated: "2 ч", enabled: true },
{ id: "a7", asn: "AS32934", org: "FACEBOOK", country: "US", prefixes: 412, filter: "social-block-school",updated: "3 дн", enabled: false },
{ id: "a8", asn: "AS8075", org: "MICROSOFT-CORP", country: "US", prefixes: 882, filter: "office-direct", updated: "4 ч", enabled: true },
]
export const firewallRules: FirewallRule[] = [
{ id: "fw1", chain: "forward", action: "accept", proto: "tcp", src: "youtube-bypass", dst: "0.0.0.0/0", port: "443", iface: "wan-bgp", comment: "YouTube bypass traffic", enabled: true, hits: 188421 },
{ id: "fw2", chain: "forward", action: "mark-routing", proto: "tcp", src: "10.10.0.0/24", dst: "cdn-bypass", port: "—", iface: "lan", comment: "CDN routing mark", enabled: true, hits: 4128821 },
{ id: "fw3", chain: "srcnat", action: "masquerade", proto: "all", src: "10.10.0.0/16", dst: "0.0.0.0/0", port: "—", iface: "wan-msk", comment: "Main NAT rule", enabled: true, hits: 12889241 },
{ id: "fw4", chain: "forward", action: "drop", proto: "tcp", src: "0.0.0.0/0", dst: "social-block-school", port: "—", iface: "lan-school", comment: "School social block", enabled: true, hits: 4218 },
{ id: "fw5", chain: "input", action: "accept", proto: "tcp", src: "10.10.0.0/16", dst: "—", port: "22,443,8291",iface: "—", comment: "Management access", enabled: true, hits: 18412 },
{ id: "fw6", chain: "input", action: "drop", proto: "tcp", src: "0.0.0.0/0", dst: "—", port: "22", iface: "wan-msk", comment: "Block SSH from WAN", enabled: true, hits: 882412 },
]
export const backups: Backup[] = [
{ id: "b1", server: "mt-msk-core-01", filename: "mt-msk-core-01_2026-04-26_03-00.rsc", size: "124 КБ", created: "Сегодня, 03:00", kind: "auto", notes: "снапшот перед обновлением" },
{ id: "b2", server: "mt-msk-core-01", filename: "mt-msk-core-01_2026-04-25_03-00.rsc", size: "124 КБ", created: "Вчера, 03:00", kind: "auto", notes: "" },
{ id: "b3", server: "mt-spb-edge-01", filename: "mt-spb-edge-01_2026-04-26_03-00.rsc", size: "88 КБ", created: "Сегодня, 03:00", kind: "auto", notes: "" },
{ id: "b4", server: "mt-fra-edge-01", filename: "mt-fra-edge-01_2026-04-26_manual.rsc",size: "92 КБ", created: "Сегодня, 14:18", kind: "manual", notes: "перед изменением BGP" },
{ id: "b5", server: "mt-ams-edge-01", filename: "mt-ams-edge-01_2026-04-25_03-00.rsc", size: "64 КБ", created: "Вчера, 03:00", kind: "auto", notes: "" },
]
export const pingProbes: PingProbe[] = [
// srv1 — mt-msk-core-01 (JH, MSK) → основной мониторинг
{ id: "p1", srcServerId: "srv1", name: "youtube.com", target: "142.250.74.110", filter: "youtube-bypass", rtt: 18, loss: 0, status: "up", series: spark(40, 18, 4), enabled: true },
{ id: "p2", srcServerId: "srv1", name: "cloudflare.com", target: "104.16.132.229", filter: "cdn-bypass", rtt: 4, loss: 0, status: "up", series: spark(40, 4, 1), enabled: true },
{ id: "p3", srcServerId: "srv1", name: "netflix-eu", target: "54.246.79.9", filter: "streaming-eu", rtt: 28, loss: 0, status: "up", series: spark(40, 28, 5), enabled: true },
{ id: "p4", srcServerId: "srv1", name: "fastly-edge", target: "151.101.1.95", filter: "cdn-bypass", rtt: 8, loss: 0, status: "up", series: spark(40, 8, 2), enabled: true },
// srv7 — mt-msk-lab-01 (JH, MSK) → лаборатория
{ id: "p5", srcServerId: "srv7", name: "discord-voip", target: "162.159.135.232", filter: "gaming-low-latency", rtt: 12, loss: 0, status: "up", series: spark(40, 12, 2), enabled: true },
{ id: "p6", srcServerId: "srv7", name: "steam-cdn", target: "23.45.139.40", filter: "gaming-low-latency", rtt: 38, loss: 1.2, status: "warn", series: spark(40, 38, 12), enabled: true },
{ id: "p7", srcServerId: "srv7", name: "akamai-edge", target: "23.46.18.94", filter: "cdn-bypass", rtt: 22, loss: 0, status: "up", series: spark(40, 22, 4), enabled: true },
// srv2 — mt-spb-edge-01 (EN, SPB) → исходящий трафик SPB
{ id: "p8", srcServerId: "srv2", name: "youtube.com", target: "142.250.74.110", filter: "youtube-bypass", rtt: 22, loss: 0, status: "up", series: spark(40, 22, 5), enabled: true },
{ id: "p9", srcServerId: "srv2", name: "office-365", target: "104.47.0.22", filter: "office-direct", rtt: 31, loss: 0, status: "up", series: spark(40, 31, 6), enabled: false },
// srv3 — mt-fra-edge-01 (EN, FRA) → исходящий трафик Frankfurt
{ id: "p10", srcServerId: "srv3", name: "youtube.com", target: "142.250.74.110", filter: "youtube-bypass", rtt: 12, loss: 0, status: "up", series: spark(40, 12, 3), enabled: true },
{ id: "p11", srcServerId: "srv3", name: "netflix-eu", target: "54.246.79.9", filter: "streaming-eu", rtt: 9, loss: 0, status: "up", series: spark(40, 9, 2), enabled: true },
// srv4 — mt-ams-edge-01 (EN, AMS, degraded) → деградировавший узел
{ id: "p12", srcServerId: "srv4", name: "cloudflare.com", target: "104.16.132.229", filter: "cdn-bypass", rtt: 88, loss: 3.4, status: "warn", series: spark(40, 88, 22), enabled: true },
{ id: "p13", srcServerId: "srv4", name: "sgp-probe", target: "139.180.22.4", filter: "—", rtt: null,loss: 100, status: "down", series: Array(40).fill(0), enabled: true },
]
export const systemEvents: SystemEvent[] = [
{ id: "e1", sev: "destructive", title: "BGP-сессия упала", message: "mt-sgp-edge-01 · пир 139.180.22.4 · простой 2ч", when: "2ч" },
{ id: "e2", sev: "destructive", title: "Ошибка синхронизации фильтра", message: "cdn-bypass → mt-ams-edge-01 · таймаут", when: "14м" },
{ id: "e3", sev: "warning", title: "Высокая задержка на AMS", message: "среднее 88мс · порог 60мс", when: "8м" },
{ id: "e4", sev: "warning", title: "Бэкап просрочен", message: "mt-msk-lab-01 · последний 5д назад", when: "1ч" },
{ id: "e5", sev: "info", title: "Обновление BGP завершено", message: "+412 префиксов · −188 отозвано", when: "12м" },
{ id: "e6", sev: "info", title: "Фильтр youtube-bypass обновлён", message: "+88 доменов · ↑ 24 сервера", when: "12м" },
]
export const dashLatency = {
msk: spark(60, 12, 4),
spb: spark(60, 28, 6),
fra: spark(60, 41, 9),
ams: spark(60, 88, 18),
}
export const traffic = {
rx: Array.from({ length: 60 }, (_, i) => Math.round(280 + Math.sin(i / 6) * 60 + ((i * 7919) % 31))),
tx: Array.from({ length: 60 }, (_, i) => Math.round(220 + Math.sin(i / 5) * 50 + ((i * 6271) % 21))),
}
// ─── GRE ────────────────────────────────────────────────────────────────────
export type GreStatus = "up" | "down" | "degraded"
export type IpsecEncAlg = "aes-128" | "aes-192" | "aes-256"
export type IpsecAuthAlg = "sha1" | "sha256" | "sha512"
export type IpsecDhGroup = "modp1024" | "modp2048" | "modp4096" | "ecp256" | "ecp384" | "ecp521"
export type IkeVersion = "ikev1" | "ikev2"
export type DscpMode = "inherit" | number
export interface GreIpsec {
secret: string // ipsec-secret → auto-creates peer+policy+proposal
encAlg: IpsecEncAlg // proposal enc-algorithms
authAlg: IpsecAuthAlg // proposal auth-algorithms
dhGroup: IpsecDhGroup // proposal pfs-group / peer dh-group
ikeVersion: IkeVersion // peer exchange-mode
lifetime: string // proposal lifetime (e.g. "1d 00:00:00")
pfs: boolean // perfect forward secrecy
}
export interface GreTunnel {
id: string
name: string // interface name, e.g. gre-msk-spb
serverId: string // which MikroTik runs this tunnel
localAddress: string // local GRE endpoint ("0.0.0.0" = auto)
remoteAddress: string // remote GRE endpoint (required)
localInnerIp: string // /ip address on this side, e.g. 10.200.0.1/30
remoteInnerIp: string // /ip address on remote side, e.g. 10.200.0.2/30
poolId: string // which IP pool the inner IPs come from
ipsec: GreIpsec | null // null = no IPsec (plain GRE)
mtu: number // default 1476
keepaliveInterval: number // seconds, 0 = disabled
keepaliveRetries: number
dscp: DscpMode // "inherit" or 0-63
clampTcpMss: boolean
allowFastPath: boolean
comment: string
enabled: boolean
status: GreStatus
}
export interface GrePool {
id: string
name: string
cidr: string
allocated: number
total: number
comment: string
}
const defaultIpsec = (secret: string, ikeVersion: IkeVersion = "ikev2"): GreIpsec => ({
secret,
encAlg: "aes-256",
authAlg: "sha256",
dhGroup: "modp2048",
ikeVersion,
lifetime: "1d 00:00:00",
pfs: true,
})
export const grePools: GrePool[] = [
{ id: "gp1", name: "pool-gre-core", cidr: "10.200.0.0/24", allocated: 16, total: 64, comment: "Ядровые межузловые туннели" },
{ id: "gp2", name: "pool-gre-mgmt", cidr: "10.201.0.0/24", allocated: 8, total: 64, comment: "Управляющие туннели (lab, test)" },
]
export const greTunnels: GreTunnel[] = [
{
id: "gre1", name: "gre-msk-spb", serverId: "srv1",
localAddress: "10.0.0.1", remoteAddress: "10.0.1.1",
localInnerIp: "10.200.0.1/30", remoteInnerIp: "10.200.0.2/30", poolId: "gp1",
ipsec: defaultIpsec("xK9#mQ2$vLp8nR4"),
mtu: 1476, keepaliveInterval: 10, keepaliveRetries: 10,
dscp: "inherit", clampTcpMss: true, allowFastPath: true,
comment: "MSK → SPB core interconnect", enabled: true, status: "up",
},
{
id: "gre2", name: "gre-msk-fra", serverId: "srv1",
localAddress: "10.0.0.1", remoteAddress: "10.0.2.1",
localInnerIp: "10.200.0.5/30", remoteInnerIp: "10.200.0.6/30", poolId: "gp1",
ipsec: defaultIpsec("pW3@jT7!hD5kYe1"),
mtu: 1476, keepaliveInterval: 10, keepaliveRetries: 10,
dscp: "inherit", clampTcpMss: true, allowFastPath: true,
comment: "MSK → FRA core interconnect", enabled: true, status: "up",
},
{
id: "gre3", name: "gre-msk-ams", serverId: "srv1",
localAddress: "10.0.0.1", remoteAddress: "10.0.3.1",
localInnerIp: "10.200.0.9/30", remoteInnerIp: "10.200.0.10/30", poolId: "gp1",
ipsec: defaultIpsec("rN6%bF2*cM8sZa0"),
mtu: 1476, keepaliveInterval: 10, keepaliveRetries: 10,
dscp: "inherit", clampTcpMss: true, allowFastPath: false,
comment: "MSK → AMS core interconnect", enabled: true, status: "degraded",
},
{
id: "gre4", name: "gre-msk-sgp", serverId: "srv1",
localAddress: "10.0.0.1", remoteAddress: "10.0.4.1",
localInnerIp: "10.200.0.13/30", remoteInnerIp: "10.200.0.14/30", poolId: "gp1",
ipsec: defaultIpsec("qA4^eU9#wG1oXi6"),
mtu: 1476, keepaliveInterval: 10, keepaliveRetries: 10,
dscp: "inherit", clampTcpMss: true, allowFastPath: true,
comment: "MSK → SGP core interconnect", enabled: false, status: "down",
},
{
id: "gre5", name: "gre-lab-core", serverId: "srv7",
localAddress: "10.0.0.7", remoteAddress: "10.0.0.1",
localInnerIp: "10.201.0.1/30", remoteInnerIp: "10.201.0.2/30", poolId: "gp2",
ipsec: null,
mtu: 1500, keepaliveInterval: 0, keepaliveRetries: 0,
dscp: "inherit", clampTcpMss: false, allowFastPath: true,
comment: "Lab → core (internal, no IPsec)", enabled: true, status: "up",
},
{
id: "gre6", name: "gre-ams-test", serverId: "srv6",
localAddress: "10.0.3.2", remoteAddress: "10.0.3.1",
localInnerIp: "10.201.0.5/30", remoteInnerIp: "10.201.0.6/30", poolId: "gp2",
ipsec: null,
mtu: 1476, keepaliveInterval: 5, keepaliveRetries: 5,
dscp: 0, clampTcpMss: true, allowFastPath: true,
comment: "AMS test → AMS edge", enabled: true, status: "up",
},
]
// ─── Filter Rules (Community → Gateway per server) ────────────────────────────
export interface FilterRule {
id: string
community: string // BGP community, e.g. "65001:100"
communityName?: string // optional human-readable name
action?: "route" | "blackhole" // default "route"; "blackhole" → null-route (RTBH)
gateway: string // remote inner IP of GRE tunnel; empty for blackhole
gatewayTunnelId: string // reference to greTunnel.id; empty for blackhole
description: string
}
export interface ServerFilterRuleset {
serverId: string
rules: FilterRule[]
}
export const serverFilterRulesets: ServerFilterRuleset[] = [
// srv1 — mt-msk-core-01 (jump-host, MSK): gre1=SPB, gre2=FRA, gre3=AMS(deg), gre4=SGP(down)
{
serverId: "srv1",
rules: [
{ id: "r1", community: "65001:100", communityName: "youtube-bypass", gateway: "10.200.0.6", gatewayTunnelId: "gre2", description: "YouTube/Google Video → Frankfurt (IX)" },
{ id: "r2", community: "65001:200", communityName: "streaming-eu", gateway: "10.200.0.6", gatewayTunnelId: "gre2", description: "Netflix, Twitch EU → Frankfurt" },
{ id: "r3", community: "65001:300", communityName: "cdn-bypass", gateway: "10.200.0.2", gatewayTunnelId: "gre1", description: "Cloudflare / Akamai / Fastly → SPB" },
{ id: "r4", community: "65002:100", communityName: "cdn-secondary", gateway: "10.200.0.10", gatewayTunnelId: "gre3", description: "CDN резерв → Amsterdam (degraded)" },
{ id: "r5", community: "65001:400", communityName: "office-direct", gateway: "10.200.0.2", gatewayTunnelId: "gre1", description: "MS Office 365 / Teams → SPB direct" },
{ id: "r6", community: "65001:500", communityName: "gaming-ll", gateway: "10.200.0.2", gatewayTunnelId: "gre1", description: "Discord / Steam / Epic — низкая задержка → SPB" },
{ id: "r10", community: "65535:666", communityName: "BLACKHOLE (RFC 7999)", action: "blackhole", gateway: "", gatewayTunnelId: "", description: "RTBH — null-route входящих маршрутов" },
{ id: "r11", community: "65001:666", communityName: "blackhole-local", action: "blackhole", gateway: "", gatewayTunnelId: "", description: "Локальная блокировка — сброс трафика" },
],
},
// srv7 — mt-msk-lab-01 (jump-host, MSK lab): gre5=core
{
serverId: "srv7",
rules: [
{ id: "r7", community: "65001:100", communityName: "youtube-bypass", gateway: "10.201.0.2", gatewayTunnelId: "gre5", description: "Lab: YouTube → core" },
{ id: "r8", community: "65007:999", communityName: "school-block", gateway: "10.201.0.2", gatewayTunnelId: "gre5", description: "Lab: школьная блокировка" },
],
},
// srv6 — mt-ams-test-01 (jump-host, AMS test): gre6=AMS edge
{
serverId: "srv6",
rules: [
{ id: "r9", community: "65001:300", communityName: "cdn-bypass", gateway: "10.201.0.6", gatewayTunnelId: "gre6", description: "AMS test: CDN → AMS edge" },
],
},
// Exit nodes — нет правил по умолчанию
{ serverId: "srv2", rules: [] },
{ serverId: "srv3", rules: [] },
{ serverId: "srv4", rules: [] },
{ serverId: "srv5", rules: [] },
]
// ─── VXLAN mock data ─────────────────────────────────────────────────────────
export const vxlanTunnels: VxlanTunnel[] = [
{
id: "vx1", name: "vxlan-overlay-msk", vni: 10010, port: 0, dstPort: 8472,
serverId: "srv1", vtepIp: "10.0.0.1", remoteVteps: ["10.0.1.1","10.0.2.1"],
l2mtu: 1500, arpProxy: true, macLearning: true,
comment: "MSK overlay — сегмент bypass", enabled: true, status: "up",
},
{
id: "vx2", name: "vxlan-overlay-spb", vni: 10010, port: 0, dstPort: 8472,
serverId: "srv2", vtepIp: "10.0.1.1", remoteVteps: ["10.0.0.1","10.0.2.1"],
l2mtu: 1500, arpProxy: true, macLearning: true,
comment: "SPB overlay — сегмент bypass", enabled: true, status: "up",
},
{
id: "vx3", name: "vxlan-overlay-fra", vni: 10010, port: 0, dstPort: 8472,
serverId: "srv3", vtepIp: "10.0.2.1", remoteVteps: ["10.0.0.1","10.0.1.1"],
l2mtu: 1500, arpProxy: true, macLearning: false,
comment: "FRA overlay — сегмент bypass", enabled: true, status: "up",
},
{
id: "vx4", name: "vxlan-mgmt-lab", vni: 20001, port: 0, dstPort: 4789,
serverId: "srv7", vtepIp: "10.0.0.7", remoteVteps: ["10.0.0.1"],
l2mtu: 1500, arpProxy: false, macLearning: true,
comment: "Lab management VXLAN", enabled: true, status: "up",
},
{
id: "vx5", name: "vxlan-overlay-ams", vni: 10010, port: 0, dstPort: 8472,
serverId: "srv4", vtepIp: "10.0.3.1", remoteVteps: ["10.0.0.1"],
l2mtu: 1500, arpProxy: true, macLearning: true,
comment: "AMS overlay (degraded)", enabled: true, status: "down",
},
]
// ─── Container mock data ─────────────────────────────────────────────────────
export const routerContainers: RouterContainer[] = [
{
id: "c1", name: "adguard-msk", serverId: "srv1",
image: "adguard/adguardhome", tag: "latest",
status: "running",
envs: [{ key: "AGH_CONFIG", value: "/opt/adguardhome/conf/AdGuardHome.yaml" }],
mounts: [{ dst: "/opt/adguardhome/conf", src: "/disk1/adguard" }, { dst: "/opt/adguardhome/work", src: "/disk1/adguard-work" }],
interfaces: ["veth-adguard"],
startOnBoot: true, comment: "AdGuard Home — DNS фильтрация",
uptime: "12д 4ч", cpu: 3, memMb: 64,
},
{
id: "c2", name: "prometheus-msk", serverId: "srv1",
image: "prom/prometheus", tag: "v2.52.0",
status: "running",
envs: [],
mounts: [{ dst: "/prometheus", src: "/disk1/prom-data" }],
interfaces: ["veth-prom"],
cmd: "--config.file=/etc/prometheus/prometheus.yml --storage.tsdb.retention.time=30d",
startOnBoot: true, comment: "Prometheus метрики",
uptime: "5д 11ч", cpu: 8, memMb: 256,
},
{
id: "c3", name: "grafana-msk", serverId: "srv1",
image: "grafana/grafana", tag: "10.4.2",
status: "running",
envs: [{ key: "GF_SECURITY_ADMIN_PASSWORD", value: "*** (hidden)" }],
mounts: [{ dst: "/var/lib/grafana", src: "/disk1/grafana" }],
interfaces: ["veth-grafana"],
startOnBoot: true, comment: "Grafana — дашборды мониторинга",
uptime: "5д 11ч", cpu: 5, memMb: 128,
},
{
id: "c4", name: "wg-portal-spb", serverId: "srv2",
image: "wgportal/wg-portal", tag: "v1.3.1",
status: "running",
envs: [{ key: "WG_PORTAL_ADMIN_USER", value: "admin" }],
mounts: [{ dst: "/app/data", src: "/disk1/wgportal" }],
interfaces: ["veth-wgportal"],
startOnBoot: true, comment: "WireGuard Portal — управление клиентами",
uptime: "3д 7ч", cpu: 1, memMb: 48,
},
{
id: "c5", name: "unbound-fra", serverId: "srv3",
image: "mvance/unbound", tag: "1.19.3",
status: "stopped",
envs: [],
mounts: [],
interfaces: ["veth-unbound"],
startOnBoot: false, comment: "Unbound DNS резолвер (в резерве)",
cpu: 0, memMb: 0,
},
]
// ─── Certificate mock data ────────────────────────────────────────────────────
export const routerCertificates: RouterCertificate[] = [
{
id: "cert1", name: "mt-msk-core-01-server", serverId: "srv1",
commonName: "mt-msk-core-01.routerlists.io",
sans: ["mt-msk-core-01.routerlists.io","10.0.0.1"],
issuedBy: "RouterLists Internal CA", validFrom: "2024-01-15",
validUntil: "2026-01-15", daysLeft: 261,
keySize: 4096, usage: ["server"], trusted: true, status: "valid",
},
{
id: "cert2", name: "routerlists-internal-ca", serverId: "srv1",
commonName: "RouterLists Internal CA",
sans: [],
issuedBy: "RouterLists Internal CA (self-signed)", validFrom: "2023-06-01",
validUntil: "2033-06-01", daysLeft: 2589,
keySize: 4096, usage: ["ca","crl-sign"], trusted: true, status: "valid",
},
{
id: "cert3", name: "mt-spb-edge-01-server", serverId: "srv2",
commonName: "mt-spb-edge-01.routerlists.io",
sans: ["mt-spb-edge-01.routerlists.io","10.0.1.1"],
issuedBy: "RouterLists Internal CA", validFrom: "2024-01-15",
validUntil: "2026-01-15", daysLeft: 261,
keySize: 4096, usage: ["server"], trusted: true, status: "valid",
},
{
id: "cert4", name: "mt-fra-edge-01-server", serverId: "srv3",
commonName: "mt-fra-edge-01.routerlists.io",
sans: ["mt-fra-edge-01.routerlists.io","10.0.2.1"],
issuedBy: "Let's Encrypt", validFrom: "2026-01-10",
validUntil: "2026-04-10", daysLeft: -19,
keySize: 2048, usage: ["server"], trusted: true, status: "expired",
},
{
id: "cert5", name: "wg-client-admin", serverId: "srv1",
commonName: "admin.routerlists.io",
sans: ["admin.routerlists.io"],
issuedBy: "RouterLists Internal CA", validFrom: "2025-06-01",
validUntil: "2026-06-01", daysLeft: 33,
keySize: 2048, usage: ["client"], trusted: true, status: "valid",
},
{
id: "cert6", name: "mt-ams-edge-01-server", serverId: "srv4",
commonName: "mt-ams-edge-01.routerlists.io",
sans: ["mt-ams-edge-01.routerlists.io"],
issuedBy: "RouterLists Internal CA", validFrom: "2024-04-01",
validUntil: "2026-05-15", daysLeft: 16,
keySize: 2048, usage: ["server"], trusted: false, status: "valid",
},
{
id: "cert7", name: "mt-msk-lab-01-server", serverId: "srv7",
commonName: "mt-msk-lab-01.routerlists.io",
sans: ["mt-msk-lab-01.routerlists.io","10.0.0.7"],
issuedBy: "RouterLists Internal CA", validFrom: "2025-09-01",
validUntil: "2026-09-01", daysLeft: 125,
keySize: 4096, usage: ["server"], trusted: true, status: "valid",
},
]
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+9833
View File
File diff suppressed because it is too large. Load diff
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff. Show more