Init commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
{"version":"0.0.1","configurations":[{"name":"MikrotikManager","runtimeExecutable":"npm","runtimeArgs":["run","dev"],"port":3000,"autoPort":true}]}
|
||||
@@ -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\\)\\)\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
node_modules
|
||||
@@ -0,0 +1,823 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useMemo } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter,
|
||||
} from "@/components/ui/sheet"
|
||||
import {
|
||||
BellIcon, BellOffIcon, RefreshCwIcon, PlusIcon,
|
||||
CableIcon, NetworkIcon, RouteIcon, UserIcon, ServerIcon,
|
||||
TimerIcon, WifiOffIcon, TrendingDownIcon,
|
||||
EyeIcon, EyeOffIcon, CopyIcon, SendIcon, CheckIcon, XIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// ─── types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type AlertType = "gre-tunnel" | "bgp-peer" | "bgp-prefix" | "gre-client" | "server" | "rtt" | "loss" | "traffic"
|
||||
type AlertSeverity = "critical" | "warning" | "info"
|
||||
type AlertCooldown = "1м" | "5м" | "15м" | "1ч" | "4ч" | "24ч"
|
||||
|
||||
interface AlertRule {
|
||||
id: string
|
||||
name: string
|
||||
type: AlertType
|
||||
target: string // display label
|
||||
condition: string // display label
|
||||
severity: AlertSeverity
|
||||
enabled: boolean
|
||||
cooldown: AlertCooldown
|
||||
lastFired: string | null // relative time string
|
||||
chatId: string // override; empty = use global
|
||||
}
|
||||
|
||||
interface HistoryEntry {
|
||||
id: string
|
||||
ruleName: string
|
||||
severity: AlertSeverity
|
||||
message: string
|
||||
time: string
|
||||
sent: boolean
|
||||
}
|
||||
|
||||
interface TelegramConfig {
|
||||
token: string
|
||||
chatId: string
|
||||
connected: boolean
|
||||
}
|
||||
|
||||
// ─── static config ────────────────────────────────────────────────────────────
|
||||
|
||||
const TYPE_META: Record<AlertType, {
|
||||
Icon: React.FC<{ className?: string }>
|
||||
label: string
|
||||
iconClass: string
|
||||
bg: string
|
||||
}> = {
|
||||
"gre-tunnel": { Icon: CableIcon, label: "GRE-туннель", iconClass: "text-violet-500", bg: "bg-violet-500/10" },
|
||||
"bgp-peer": { Icon: NetworkIcon, label: "BGP-сосед", iconClass: "text-blue-500", bg: "bg-blue-500/10" },
|
||||
"bgp-prefix": { Icon: RouteIcon, label: "BGP-префикс", iconClass: "text-sky-500", bg: "bg-sky-500/10" },
|
||||
"gre-client": { Icon: UserIcon, label: "GRE-клиент", iconClass: "text-purple-500", bg: "bg-purple-500/10" },
|
||||
"server": { Icon: ServerIcon, label: "Сервер", iconClass: "text-slate-500", bg: "bg-slate-500/10" },
|
||||
"rtt": { Icon: TimerIcon, label: "Задержка (RTT)", iconClass: "text-amber-500", bg: "bg-amber-500/10" },
|
||||
"loss": { Icon: WifiOffIcon, label: "Потери пакетов", iconClass: "text-orange-500", bg: "bg-orange-500/10" },
|
||||
"traffic": { Icon: TrendingDownIcon, label: "Низкий трафик", iconClass: "text-rose-500", bg: "bg-rose-500/10" },
|
||||
}
|
||||
|
||||
const SEVERITY_META: Record<AlertSeverity, { label: string; dot: string; badge: string; chip: string }> = {
|
||||
critical: {
|
||||
label: "Критическое",
|
||||
dot: "bg-red-500",
|
||||
badge: "bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-400",
|
||||
chip: "border-red-400 bg-red-500/10 text-red-600 dark:text-red-400",
|
||||
},
|
||||
warning: {
|
||||
label: "Предупреждение",
|
||||
dot: "bg-amber-400",
|
||||
badge: "bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-400",
|
||||
chip: "border-amber-400 bg-amber-500/10 text-amber-600 dark:text-amber-400",
|
||||
},
|
||||
info: {
|
||||
label: "Информационное",
|
||||
dot: "bg-blue-500",
|
||||
badge: "bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-400",
|
||||
chip: "border-blue-400 bg-blue-500/10 text-blue-600 dark:text-blue-400",
|
||||
},
|
||||
}
|
||||
|
||||
const TYPE_TARGETS: Record<AlertType, string[]> = {
|
||||
"gre-tunnel": ["gre-ivanov-01 / core-01", "gre-ivanov-01 / lab-01", "gre-ivanov-02 / core-01", "gre-ivanov-03 / lab-01", "gre-petrov-01 / core-01", "gre-petrov-01 / lab-01", "gre-kozlov-01 / core-01"],
|
||||
"bgp-peer": ["mt-msk-core-01", "mt-spb-edge-01", "mt-fra-edge-01", "mt-ams-edge-01", "mt-sgp-edge-01", "mt-ams-test-01", "mt-msk-lab-01"],
|
||||
"bgp-prefix": ["любой префикс", "185.13.0.0/22", "77.88.8.0/24", "8.8.8.0/24", "1.1.1.0/24"],
|
||||
"gre-client": ["gre-ivanov-01", "gre-ivanov-02", "gre-ivanov-03", "gre-petrov-01", "gre-kozlov-01"],
|
||||
"server": ["mt-msk-core-01", "mt-spb-edge-01", "mt-fra-edge-01", "mt-ams-edge-01", "mt-sgp-edge-01", "mt-ams-test-01", "mt-msk-lab-01"],
|
||||
"rtt": ["mt-msk-core-01 → 8.8.8.8", "mt-spb-edge-01 → 8.8.8.8", "mt-fra-edge-01 → 8.8.8.8", "mt-ams-edge-01 → 1.1.1.1", "mt-sgp-edge-01 → 1.1.1.1"],
|
||||
"loss": ["mt-msk-core-01 → 8.8.8.8", "mt-spb-edge-01 → 8.8.8.8", "mt-fra-edge-01 → 8.8.8.8", "mt-ams-edge-01 → 1.1.1.1", "mt-sgp-edge-01 → 1.1.1.1"],
|
||||
"traffic": ["mt-msk-core-01", "mt-spb-edge-01", "mt-fra-edge-01", "mt-ams-edge-01", "mt-sgp-edge-01", "mt-ams-test-01", "mt-msk-lab-01"],
|
||||
}
|
||||
|
||||
const TYPE_CONDITIONS: Record<AlertType, string[]> = {
|
||||
"gre-tunnel": ["перешёл в offline", "восстановился"],
|
||||
"bgp-peer": ["разорвал сессию", "восстановил сессию"],
|
||||
"bgp-prefix": ["был отозван", "был получен"],
|
||||
"gre-client": ["отключился", "подключился"],
|
||||
"server": ["перешёл в offline", "перешёл в degraded", "восстановился"],
|
||||
"rtt": ["> порога", "< порога"],
|
||||
"loss": ["> порога"],
|
||||
"traffic": ["RX < порога", "TX < порога"],
|
||||
}
|
||||
|
||||
const THRESHOLD_UNIT: Partial<Record<AlertType, string>> = {
|
||||
rtt: "мс",
|
||||
loss: "%",
|
||||
traffic: "Мбит/с",
|
||||
}
|
||||
|
||||
const COOLDOWNS: AlertCooldown[] = ["1м", "5м", "15м", "1ч", "4ч", "24ч"]
|
||||
|
||||
// ─── mock data ────────────────────────────────────────────────────────────────
|
||||
|
||||
const INIT_RULES: AlertRule[] = [
|
||||
{ id: "r1", name: "GRE-туннель Иванов offline", type: "gre-tunnel", target: "gre-ivanov-01 / core-01", condition: "перешёл в offline", severity: "critical", enabled: true, cooldown: "5м", lastFired: "3ч назад", chatId: "" },
|
||||
{ id: "r2", name: "BGP-сосед core-01 down", type: "bgp-peer", target: "mt-msk-core-01", condition: "разорвал сессию", severity: "critical", enabled: true, cooldown: "15м", lastFired: "1д назад", chatId: "" },
|
||||
{ id: "r3", name: "Высокая задержка FRA", type: "rtt", target: "mt-fra-edge-01 → 8.8.8.8", condition: "> 120 мс", severity: "warning", enabled: true, cooldown: "15м", lastFired: "45м назад", chatId: "" },
|
||||
{ id: "r4", name: "Потери пакетов AMS", type: "loss", target: "mt-ams-edge-01 → 1.1.1.1", condition: "> 5%", severity: "warning", enabled: true, cooldown: "5м", lastFired: null, chatId: "" },
|
||||
{ id: "r5", name: "Сервер SGP offline", type: "server", target: "mt-sgp-edge-01", condition: "перешёл в offline", severity: "critical", enabled: true, cooldown: "1ч", lastFired: "2д назад", chatId: "" },
|
||||
{ id: "r6", name: "BGP-префикс отозван core-01", type: "bgp-prefix", target: "любой префикс", condition: "был отозван", severity: "info", enabled: true, cooldown: "5м", lastFired: "6ч назад", chatId: "-1009876543210" },
|
||||
{ id: "r7", name: "GRE-клиент Козлов offline", type: "gre-client", target: "gre-kozlov-01", condition: "отключился", severity: "warning", enabled: false, cooldown: "1ч", lastFired: null, chatId: "" },
|
||||
{ id: "r8", name: "Низкий трафик AMS-edge", type: "traffic", target: "mt-ams-edge-01", condition: "RX < 10 Мбит/с", severity: "info", enabled: false, cooldown: "4ч", lastFired: null, chatId: "" },
|
||||
]
|
||||
|
||||
const INIT_HISTORY: HistoryEntry[] = [
|
||||
{ id: "h1", ruleName: "Высокая задержка FRA", severity: "warning", message: "RTT mt-fra-edge-01 → 8.8.8.8: 137 мс (порог 120 мс)", time: "45м назад", sent: true },
|
||||
{ id: "h2", ruleName: "GRE-туннель Иванов offline", severity: "critical", message: "gre-ivanov-01 / core-01 перешёл в offline", time: "3ч назад", sent: true },
|
||||
{ id: "h3", ruleName: "BGP-префикс отозван core-01", severity: "info", message: "Префикс 185.13.0.0/22 отозван на mt-msk-core-01", time: "6ч назад", sent: true },
|
||||
{ id: "h4", ruleName: "BGP-сосед core-01 down", severity: "critical", message: "BGP-сессия с mt-msk-core-01 разорвана", time: "1д назад", sent: true },
|
||||
{ id: "h5", ruleName: "Сервер SGP offline", severity: "critical", message: "mt-sgp-edge-01 недоступен, ping timeout", time: "2д назад", sent: false },
|
||||
]
|
||||
|
||||
const INIT_TG: TelegramConfig = {
|
||||
token: "7412358964:AAFkL9xZqBb2pC8nYrVtHmwKjXeOdSuN1A",
|
||||
chatId: "-1001234567890",
|
||||
connected: true,
|
||||
}
|
||||
|
||||
// ─── small helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<button role="switch" aria-checked={checked} onClick={() => onChange(!checked)}
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors",
|
||||
checked ? "bg-primary" : "bg-muted-foreground/30",
|
||||
)}>
|
||||
<span className={cn(
|
||||
"inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform",
|
||||
checked ? "translate-x-4" : "translate-x-0.5",
|
||||
)} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLabel({ children }: { children: React.ReactNode }) {
|
||||
return <p className="text-sm font-medium mb-1.5 leading-none">{children}</p>
|
||||
}
|
||||
|
||||
function FieldHint({ children }: { children: React.ReactNode }) {
|
||||
return <p className="text-xs text-muted-foreground mt-1.5">{children}</p>
|
||||
}
|
||||
|
||||
function NativeSelect({ value, onChange, children, className }: {
|
||||
value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className={cn(
|
||||
// exact same tokens as the project's Input component
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1",
|
||||
"text-sm text-foreground transition-colors outline-none",
|
||||
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
|
||||
"dark:bg-input/30",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
function SeverityDot({ severity }: { severity: AlertSeverity }) {
|
||||
return <span className={cn("inline-block size-2 rounded-full shrink-0", SEVERITY_META[severity].dot)} />
|
||||
}
|
||||
|
||||
function SeverityBadge({ severity }: { severity: AlertSeverity }) {
|
||||
return (
|
||||
<span className={cn("text-[10px] px-1.5 py-0.5 rounded-full font-medium whitespace-nowrap", SEVERITY_META[severity].badge)}>
|
||||
{severity === "critical" ? "Критич." : severity === "warning" ? "Предупр." : "Инфо"}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── alert rule row ───────────────────────────────────────────────────────────
|
||||
|
||||
function AlertRuleRow({ rule, onToggle, onDelete }: {
|
||||
rule: AlertRule
|
||||
onToggle: (id: string, enabled: boolean) => void
|
||||
onDelete: (id: string) => void
|
||||
}) {
|
||||
const { Icon, iconClass, bg } = TYPE_META[rule.type]
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex items-center gap-3 px-4 py-3 hover:bg-muted/20 transition-colors group",
|
||||
!rule.enabled && "opacity-55",
|
||||
)}>
|
||||
{/* toggle */}
|
||||
<Toggle checked={rule.enabled} onChange={v => onToggle(rule.id, v)} />
|
||||
|
||||
{/* severity dot */}
|
||||
<SeverityDot severity={rule.severity} />
|
||||
|
||||
{/* type icon */}
|
||||
<div className={cn("size-7 rounded-md flex items-center justify-center shrink-0", bg)}>
|
||||
<Icon className={cn("size-3.5", iconClass)} />
|
||||
</div>
|
||||
|
||||
{/* name + target */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{rule.name}</p>
|
||||
<p className="text-[11px] text-muted-foreground truncate">
|
||||
{TYPE_META[rule.type].label} · <span className="font-mono">{rule.target}</span>
|
||||
{" · "}{rule.condition}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* cooldown */}
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground shrink-0">
|
||||
{rule.cooldown}
|
||||
</span>
|
||||
|
||||
{/* chat override badge */}
|
||||
{rule.chatId && (
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded border border-border text-muted-foreground hidden xl:inline shrink-0">
|
||||
#{rule.chatId.slice(-6)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* last fired */}
|
||||
<span className={cn(
|
||||
"text-[11px] shrink-0 w-[80px] text-right",
|
||||
rule.lastFired ? "text-muted-foreground" : "text-muted-foreground/40",
|
||||
)}>
|
||||
{rule.lastFired ?? "—"}
|
||||
</span>
|
||||
|
||||
{/* severity badge */}
|
||||
<div className="hidden lg:block shrink-0">
|
||||
<SeverityBadge severity={rule.severity} />
|
||||
</div>
|
||||
|
||||
{/* actions (visible on hover) */}
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||
<button
|
||||
onClick={() => onDelete(rule.id)}
|
||||
className="size-7 rounded flex items-center justify-center text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors">
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── telegram config card ─────────────────────────────────────────────────────
|
||||
|
||||
function TelegramCard({ cfg, onChange }: {
|
||||
cfg: TelegramConfig
|
||||
onChange: (c: TelegramConfig) => void
|
||||
}) {
|
||||
const [showToken, setShowToken] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [testResult, setTestResult] = useState<"ok" | "fail" | null>(null)
|
||||
|
||||
const handleTest = () => {
|
||||
setTesting(true)
|
||||
setTestResult(null)
|
||||
setTimeout(() => {
|
||||
setTesting(false)
|
||||
setTestResult(cfg.connected ? "ok" : "fail")
|
||||
setTimeout(() => setTestResult(null), 3000)
|
||||
}, 1400)
|
||||
}
|
||||
|
||||
const _maskedToken = cfg.token.replace(/:.+/, ":••••••••••••••••••••••••")
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3 pt-4 px-4">
|
||||
<CardTitle className="text-sm flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-base">✈️</span> Telegram
|
||||
</span>
|
||||
<span className={cn(
|
||||
"flex items-center gap-1.5 text-[11px] font-normal px-2 py-0.5 rounded-full",
|
||||
cfg.connected
|
||||
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}>
|
||||
<span className={cn("size-1.5 rounded-full", cfg.connected ? "bg-emerald-500" : "bg-muted-foreground")} />
|
||||
{cfg.connected ? "Подключён" : "Не настроен"}
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4 space-y-3">
|
||||
|
||||
{/* bot token */}
|
||||
<div>
|
||||
<FieldLabel>Bot Token</FieldLabel>
|
||||
<div className="flex gap-1.5">
|
||||
<Input
|
||||
type={showToken ? "text" : "password"}
|
||||
value={cfg.token}
|
||||
onChange={e => onChange({ ...cfg, token: e.target.value })}
|
||||
className="text-xs font-mono h-8"
|
||||
placeholder="1234567890:AAF..."
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowToken(v => !v)}
|
||||
className="size-8 rounded-md border border-input flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0">
|
||||
{showToken ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(cfg.token)}
|
||||
className="size-8 rounded-md border border-input flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0">
|
||||
<CopyIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* default chat id */}
|
||||
<div>
|
||||
<FieldLabel>Chat ID по умолчанию</FieldLabel>
|
||||
<Input
|
||||
value={cfg.chatId}
|
||||
onChange={e => onChange({ ...cfg, chatId: e.target.value })}
|
||||
className="text-xs font-mono h-8"
|
||||
placeholder="-100xxxxxxxxxx"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
Для групп используйте отрицательный ID. Каждое правило может переопределить.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* test button */}
|
||||
<Button
|
||||
size="sm" variant="outline" className="w-full h-8 text-xs gap-2"
|
||||
onClick={handleTest} disabled={testing}>
|
||||
{testing ? (
|
||||
<RefreshCwIcon className="size-3.5 animate-spin" />
|
||||
) : testResult === "ok" ? (
|
||||
<CheckIcon className="size-3.5 text-emerald-500" />
|
||||
) : testResult === "fail" ? (
|
||||
<XIcon className="size-3.5 text-destructive" />
|
||||
) : (
|
||||
<SendIcon className="size-3.5" />
|
||||
)}
|
||||
{testing ? "Отправка…"
|
||||
: testResult === "ok" ? "Сообщение отправлено"
|
||||
: testResult === "fail" ? "Ошибка отправки"
|
||||
: "Отправить тестовое сообщение"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── history card ─────────────────────────────────────────────────────────────
|
||||
|
||||
function HistoryCard({ entries }: { entries: HistoryEntry[] }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2 pt-4 px-4">
|
||||
<CardTitle className="text-sm">Журнал срабатываний</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4">
|
||||
{entries.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground py-2">Нет срабатываний</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{entries.map(e => (
|
||||
<div key={e.id} className="flex gap-2.5 items-start">
|
||||
<SeverityDot severity={e.severity} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[11px] font-medium leading-tight truncate">{e.ruleName}</p>
|
||||
<p className="text-[10px] text-muted-foreground leading-snug mt-0.5 line-clamp-2">{e.message}</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<p className="text-[10px] text-muted-foreground whitespace-nowrap">{e.time}</p>
|
||||
<span className={cn(
|
||||
"text-[9px] font-medium px-1 py-0.5 rounded mt-0.5 inline-block",
|
||||
e.sent
|
||||
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
|
||||
: "bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-400",
|
||||
)}>
|
||||
{e.sent ? "✓ отправлено" : "✗ ошибка"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── add rule sheet ───────────────────────────────────────────────────────────
|
||||
|
||||
const BLANK_FORM = {
|
||||
name: "",
|
||||
type: "gre-tunnel" as AlertType,
|
||||
target: "",
|
||||
condition: "",
|
||||
threshold: "",
|
||||
severity: "warning" as AlertSeverity,
|
||||
cooldown: "5м" as AlertCooldown,
|
||||
chatId: "",
|
||||
}
|
||||
|
||||
function AddRuleSheet({ open, onClose, onSave }: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSave: (rule: Omit<AlertRule, "id" | "lastFired" | "enabled">) => void
|
||||
}) {
|
||||
const [form, setForm] = useState({ ...BLANK_FORM })
|
||||
|
||||
// reset when open
|
||||
const handleOpen = (o: boolean) => { if (o) setForm({ ...BLANK_FORM }) }
|
||||
|
||||
const targets = TYPE_TARGETS[form.type]
|
||||
const conditions = TYPE_CONDITIONS[form.type]
|
||||
const unit = THRESHOLD_UNIT[form.type]
|
||||
|
||||
const conditionDisplay = unit && form.threshold
|
||||
? `${form.condition.replace("порога", form.threshold + " " + unit)}`
|
||||
: form.condition
|
||||
|
||||
const canSave = form.name.trim() && form.target && form.condition && (!unit || form.threshold)
|
||||
|
||||
const handleSave = () => {
|
||||
if (!canSave) return
|
||||
onSave({
|
||||
name: form.name.trim(),
|
||||
type: form.type,
|
||||
target: form.target,
|
||||
condition: conditionDisplay || form.condition,
|
||||
severity: form.severity,
|
||||
cooldown: form.cooldown,
|
||||
chatId: form.chatId.trim(),
|
||||
})
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={o => { if (!o) onClose(); handleOpen(o) }}>
|
||||
{/* p-0 + gap-0: we control all spacing internally */}
|
||||
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-md">
|
||||
|
||||
{/* ── fixed header ── */}
|
||||
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
|
||||
<SheetTitle className="flex items-center gap-2 text-base">
|
||||
<BellIcon className="size-4" />
|
||||
Новое правило оповещения
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
{/* ── scrollable body ── */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||||
|
||||
{/* name */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<FieldLabel>Название правила</FieldLabel>
|
||||
<Input
|
||||
placeholder="Например: GRE-туннель Иванов offline"
|
||||
value={form.name}
|
||||
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* type */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<FieldLabel>Тип события</FieldLabel>
|
||||
<NativeSelect
|
||||
value={form.type}
|
||||
onChange={v => setForm(f => ({ ...f, type: v as AlertType, target: "", condition: "", threshold: "" }))}
|
||||
>
|
||||
{(Object.entries(TYPE_META) as [AlertType, typeof TYPE_META[AlertType]][]).map(([t, m]) => (
|
||||
<option key={t} value={t}>{m.label}</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
|
||||
{/* target + condition on same row when both are simple selects */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<FieldLabel>Объект</FieldLabel>
|
||||
<NativeSelect value={form.target} onChange={v => setForm(f => ({ ...f, target: v }))}>
|
||||
<option value="" disabled>— выбрать —</option>
|
||||
{targets.map(t => <option key={t} value={t}>{t}</option>)}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<FieldLabel>Условие</FieldLabel>
|
||||
<NativeSelect value={form.condition} onChange={v => setForm(f => ({ ...f, condition: v }))}>
|
||||
<option value="" disabled>— выбрать —</option>
|
||||
{conditions.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* threshold — only for RTT / loss / traffic */}
|
||||
{unit && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<FieldLabel>Пороговое значение</FieldLabel>
|
||||
<div className="flex items-center gap-0">
|
||||
<Input
|
||||
type="number" min="0"
|
||||
value={form.threshold}
|
||||
onChange={e => setForm(f => ({ ...f, threshold: e.target.value }))}
|
||||
className="rounded-r-none"
|
||||
placeholder="0"
|
||||
/>
|
||||
<span className="h-8 px-3 flex items-center rounded-r-lg border border-l-0 border-input
|
||||
bg-muted text-sm text-muted-foreground shrink-0">
|
||||
{unit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* severity — vertical radio cards */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<FieldLabel>Серьёзность</FieldLabel>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{(["critical", "warning", "info"] as AlertSeverity[]).map(s => {
|
||||
const active = form.severity === s
|
||||
return (
|
||||
<button key={s} onClick={() => setForm(f => ({ ...f, severity: s }))}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors w-full",
|
||||
active ? SEVERITY_META[s].chip : "border-input hover:bg-muted/50 text-foreground",
|
||||
)}>
|
||||
<span className={cn("size-2.5 rounded-full shrink-0", SEVERITY_META[s].dot)} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium leading-none">{SEVERITY_META[s].label}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 leading-none">
|
||||
{s === "critical" ? "Немедленное уведомление, звуковой сигнал"
|
||||
: s === "warning" ? "Важное событие, тихое уведомление"
|
||||
: "Информационное, без уведомления"}
|
||||
</p>
|
||||
</div>
|
||||
{active && <CheckIcon className="size-4 ml-auto shrink-0 opacity-70" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* cooldown */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<FieldLabel>Повторять не чаще чем</FieldLabel>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{COOLDOWNS.map(c => (
|
||||
<button key={c} onClick={() => setForm(f => ({ ...f, cooldown: c }))}
|
||||
className={cn(
|
||||
"px-3 py-1.5 rounded-lg border text-sm font-mono transition-colors",
|
||||
form.cooldown === c
|
||||
? "border-primary bg-primary/10 text-primary font-semibold"
|
||||
: "border-input text-muted-foreground hover:bg-muted/50 hover:text-foreground",
|
||||
)}>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* chat id override */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<FieldLabel>Chat ID — переопределить</FieldLabel>
|
||||
<Input
|
||||
value={form.chatId}
|
||||
onChange={e => setForm(f => ({ ...f, chatId: e.target.value }))}
|
||||
className="font-mono text-sm"
|
||||
placeholder="-100xxxxxxxxxx"
|
||||
/>
|
||||
<FieldHint>Пусто → используется глобальный Chat ID из настроек Telegram</FieldHint>
|
||||
</div>
|
||||
|
||||
{/* Telegram message preview */}
|
||||
{form.name && form.target && form.condition && (
|
||||
<div className="rounded-xl border border-border bg-muted/40 overflow-hidden">
|
||||
<div className="px-3 py-2 border-b border-border/60 flex items-center gap-1.5">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Предпросмотр сообщения
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-3 py-2.5 font-mono text-xs leading-relaxed space-y-0.5">
|
||||
<p>
|
||||
{form.severity === "critical" ? "🔴" : form.severity === "warning" ? "🟡" : "🔵"}
|
||||
{" "}<span className="font-semibold">{form.name}</span>
|
||||
</p>
|
||||
<p className="text-muted-foreground">Объект: {form.target}</p>
|
||||
<p className="text-muted-foreground">
|
||||
Событие: {conditionDisplay || form.condition}
|
||||
</p>
|
||||
<p className="text-muted-foreground">Cooldown: {form.cooldown}</p>
|
||||
<p className="text-muted-foreground">
|
||||
Chat: {form.chatId || "(глобальный)"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── fixed footer ── */}
|
||||
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
|
||||
<Button variant="outline" className="flex-1" onClick={onClose}>Отмена</Button>
|
||||
<Button className="flex-1" onClick={handleSave} disabled={!canSave}>
|
||||
Сохранить правило
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type SeverityFilter = AlertSeverity | "all"
|
||||
|
||||
export default function AlertsPage() {
|
||||
const [rules, setRules] = useState<AlertRule[]>(INIT_RULES)
|
||||
const [history] = useState<HistoryEntry[]>(INIT_HISTORY)
|
||||
const [tg, setTg] = useState<TelegramConfig>(INIT_TG)
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [sevFilter, setSevFilter] = useState<SeverityFilter>("all")
|
||||
const [onlyActive, setOnlyActive] = useState(false)
|
||||
const [search, setSearch] = useState("")
|
||||
|
||||
const total = rules.length
|
||||
const active = rules.filter(r => r.enabled).length
|
||||
const critical = rules.filter(r => r.severity === "critical").length
|
||||
const warning = rules.filter(r => r.severity === "warning").length
|
||||
const info = rules.filter(r => r.severity === "info").length
|
||||
|
||||
const filteredRules = useMemo(() => rules.filter(r => {
|
||||
if (sevFilter !== "all" && r.severity !== sevFilter) return false
|
||||
if (onlyActive && !r.enabled) return false
|
||||
if (search && !r.name.toLowerCase().includes(search.toLowerCase()) &&
|
||||
!r.target.toLowerCase().includes(search.toLowerCase())) return false
|
||||
return true
|
||||
}), [rules, sevFilter, onlyActive, search])
|
||||
|
||||
const handleToggle = (id: string, enabled: boolean) =>
|
||||
setRules(rs => rs.map(r => r.id === id ? { ...r, enabled } : r))
|
||||
|
||||
const handleDelete = (id: string) =>
|
||||
setRules(rs => rs.filter(r => r.id !== id))
|
||||
|
||||
const handleAdd = (rule: Omit<AlertRule, "id" | "lastFired" | "enabled">) => {
|
||||
setRules(rs => [...rs, {
|
||||
...rule,
|
||||
id: `r${Date.now()}`,
|
||||
enabled: true,
|
||||
lastFired: null,
|
||||
}])
|
||||
}
|
||||
|
||||
// Summary chip click handler
|
||||
const handleChip = (sev: SeverityFilter) =>
|
||||
setSevFilter(f => f === sev ? "all" : sev)
|
||||
|
||||
const chipActive = (sev: SeverityFilter) => sevFilter === sev
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Система" }, { label: "Оповещения" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm">
|
||||
<RefreshCwIcon className="size-4" />Обновить
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setSheetOpen(true)}>
|
||||
<PlusIcon className="size-4" />Добавить правило
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
{/* ── summary chip bar ── */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
|
||||
{/* stat chips */}
|
||||
{([
|
||||
{ key: "all", label: `Всего: ${total}`, cls: "border-border bg-muted/50 text-foreground" },
|
||||
{ key: "critical", label: `Критических: ${critical}`, cls: SEVERITY_META.critical.chip },
|
||||
{ key: "warning", label: `Предупреждений: ${warning}`, cls: SEVERITY_META.warning.chip },
|
||||
{ key: "info", label: `Информационных: ${info}`, cls: SEVERITY_META.info.chip },
|
||||
] as { key: SeverityFilter; label: string; cls: string }[]).map(({ key, label, cls }) => (
|
||||
<button key={key} onClick={() => handleChip(key)}
|
||||
className={cn(
|
||||
"text-xs px-3 py-1.5 rounded-full border font-medium transition-colors",
|
||||
chipActive(key)
|
||||
? cls
|
||||
: "border-border bg-muted/40 text-muted-foreground hover:bg-muted",
|
||||
)}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="w-px h-4 bg-border mx-1" />
|
||||
|
||||
{/* active count chip */}
|
||||
<button onClick={() => setOnlyActive(v => !v)}
|
||||
className={cn(
|
||||
"text-xs px-3 py-1.5 rounded-full border font-medium transition-colors",
|
||||
onlyActive
|
||||
? "border-emerald-400 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||||
: "border-border bg-muted/40 text-muted-foreground hover:bg-muted",
|
||||
)}>
|
||||
Активных: {active}
|
||||
</button>
|
||||
|
||||
{/* telegram status */}
|
||||
<span className={cn(
|
||||
"ml-auto text-xs px-3 py-1.5 rounded-full border flex items-center gap-1.5 font-medium",
|
||||
tg.connected
|
||||
? "border-emerald-400 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||||
: "border-red-400 bg-red-500/10 text-red-600 dark:text-red-400",
|
||||
)}>
|
||||
<span className={cn("size-1.5 rounded-full", tg.connected ? "bg-emerald-500" : "bg-red-500")} />
|
||||
{tg.connected ? "Telegram: Подключён" : "Telegram: Не настроен"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── main layout ── */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[1fr_300px] gap-5 items-start">
|
||||
|
||||
{/* ── rules card ── */}
|
||||
<Card>
|
||||
<CardHeader className="pb-0 pt-4 px-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<CardTitle className="text-sm">Правила оповещения</CardTitle>
|
||||
<Input
|
||||
placeholder="Поиск…"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="h-7 text-xs max-w-[200px]"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="px-0 pb-0 pt-2">
|
||||
{filteredRules.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground/40">
|
||||
<BellOffIcon className="size-8 mb-2 opacity-40" />
|
||||
<p className="text-sm">Правила не найдены</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/60">
|
||||
{filteredRules.map(r => (
|
||||
<AlertRuleRow
|
||||
key={r.id}
|
||||
rule={r}
|
||||
onToggle={handleToggle}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* footer */}
|
||||
<div className="px-4 py-2.5 border-t flex items-center justify-between text-[11px] text-muted-foreground">
|
||||
<span>
|
||||
{filteredRules.length} из {total} правил
|
||||
{onlyActive && " · только активные"}
|
||||
{sevFilter !== "all" && ` · ${SEVERITY_META[sevFilter].label.toLowerCase()}`}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setSheetOpen(true)}
|
||||
className="flex items-center gap-1 hover:text-foreground transition-colors">
|
||||
<PlusIcon className="size-3" />Добавить правило
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── right sidebar ── */}
|
||||
<div className="flex flex-col gap-5">
|
||||
<TelegramCard cfg={tg} onChange={setTg} />
|
||||
<HistoryCard entries={history} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AddRuleSheet
|
||||
open={sheetOpen}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
onSave={handleAdd}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client"
|
||||
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { DataTable } from "@/components/data-table"
|
||||
import { asns } from "@/lib/data"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon } from "lucide-react"
|
||||
|
||||
export default function AsnsPage() {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Данные" }, { label: "ASN" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm"><UploadIcon className="size-4" />Импорт</Button>
|
||||
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||||
<Button size="sm"><PlusIcon className="size-4" />Добавить ASN</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">ASN</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Автономные системы — источники маршрутов для BGP-фильтров
|
||||
</p>
|
||||
</div>
|
||||
<DataTable
|
||||
data={asns}
|
||||
searchPlaceholder="Поиск по ASN, организации…"
|
||||
searchKeys={["asn", "org", "country"]}
|
||||
columns={[
|
||||
{
|
||||
key: "asn",
|
||||
label: "ASN",
|
||||
render: (d) => <span className="font-mono font-semibold">{d.asn}</span>,
|
||||
},
|
||||
{
|
||||
key: "org",
|
||||
label: "Организация",
|
||||
render: (d) => <span className="font-medium">{d.org}</span>,
|
||||
},
|
||||
{
|
||||
key: "country",
|
||||
label: "Страна",
|
||||
render: (d) => <span className="text-xs border border-border rounded px-2 py-0.5">{d.country}</span>,
|
||||
},
|
||||
{
|
||||
key: "prefixes",
|
||||
label: "Префиксов",
|
||||
render: (d) => <span className="font-mono tabular-nums">{d.prefixes.toLocaleString("ru")}</span>,
|
||||
},
|
||||
{
|
||||
key: "filter",
|
||||
label: "Фильтр",
|
||||
render: (d) => (
|
||||
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
|
||||
<FilterIcon className="size-3 text-muted-foreground" />{d.filter}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "updated",
|
||||
label: "Обновлён",
|
||||
render: (d) => <span className="text-xs text-muted-foreground">{d.updated}</span>,
|
||||
},
|
||||
{
|
||||
key: "enabled",
|
||||
label: "Статус",
|
||||
render: (d) => (
|
||||
<span className={`text-xs font-medium ${d.enabled ? "text-emerald-600" : "text-muted-foreground"}`}>
|
||||
{d.enabled ? "Активен" : "Отключён"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,680 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { StatusBadge } from "@/components/status-badge"
|
||||
import { backups as initialBackups, servers } from "@/lib/data"
|
||||
import type { Backup } from "@/lib/data"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||
SheetDescription, SheetFooter, SheetClose,
|
||||
} from "@/components/ui/sheet"
|
||||
import {
|
||||
RefreshCwIcon, PlusIcon, DownloadIcon, Trash2Icon,
|
||||
HardDriveIcon, ClockIcon, ServerIcon, CheckCircleIcon,
|
||||
FolderIcon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// ─── small UI helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<button type="button" role="switch" aria-checked={checked} onClick={() => onChange(!checked)}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors ${checked ? "bg-primary" : "bg-input"}`}>
|
||||
<span className={`pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${checked ? "translate-x-4" : "translate-x-0"}`} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionTitle({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 pb-1">
|
||||
<span className="text-muted-foreground">{icon}</span>
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">{label}</label>
|
||||
{children}
|
||||
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SegmentedControl<T extends string>({ value, onChange, options }: {
|
||||
value: T; onChange: (v: T) => void; options: { value: T; label: string }[]
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5 w-fit">
|
||||
{options.map((o) => (
|
||||
<button key={o.value} type="button" onClick={() => onChange(o.value)}
|
||||
className={`px-3 py-1 text-sm rounded transition-colors ${value === o.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type PageTab = "history" | "settings"
|
||||
type KindFilter = "all" | "auto" | "manual"
|
||||
type BackupFreq = "daily" | "weekly" | "monthly"
|
||||
type StorageType = "local" | "ftp" | "scp" | "smb"
|
||||
type BackupFormat = "rsc" | "backup"
|
||||
|
||||
const WEEK_DAYS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]
|
||||
|
||||
// ─── defaults ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const defaultSchedule = {
|
||||
enabled: true,
|
||||
frequency: "daily" as BackupFreq,
|
||||
hour: 3,
|
||||
minute: 0,
|
||||
weekDay: 0,
|
||||
monthDay: 1,
|
||||
keepCount: 7,
|
||||
format: "rsc" as BackupFormat,
|
||||
}
|
||||
|
||||
const defaultStorage = {
|
||||
type: "local" as StorageType,
|
||||
localPath: "/var/backup/mikrotik",
|
||||
host: "",
|
||||
port: "",
|
||||
username: "",
|
||||
password: "",
|
||||
remotePath: "/mikrotik-backups",
|
||||
share: "backups",
|
||||
showPassword: false,
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
export default function BackupsPage() {
|
||||
// ── state ──────────────────────────────────────────────────────────────
|
||||
const [tab, setTab] = useState<PageTab>("history")
|
||||
const [backupList, setBackupList] = useState<Backup[]>(initialBackups)
|
||||
const [kindFilter, setKindFilter] = useState<KindFilter>("all")
|
||||
|
||||
// Schedule
|
||||
const [schedule, setSchedule] = useState(defaultSchedule)
|
||||
const setSched = <K extends keyof typeof defaultSchedule>(k: K, v: (typeof defaultSchedule)[K]) =>
|
||||
setSchedule((s) => ({ ...s, [k]: v }))
|
||||
|
||||
// Storage
|
||||
const [storage, setStorage] = useState(defaultStorage)
|
||||
const setStore = <K extends keyof typeof defaultStorage>(k: K, v: (typeof defaultStorage)[K]) =>
|
||||
setStorage((s) => ({ ...s, [k]: v }))
|
||||
|
||||
// Server selection (all enabled by default)
|
||||
const [selectedServers, setSelectedServers] = useState<Set<string>>(
|
||||
new Set(servers.map((s) => s.id))
|
||||
)
|
||||
function toggleServer(id: string) {
|
||||
setSelectedServers((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Settings saved flash
|
||||
const [saved, setSaved] = useState(false)
|
||||
function handleSave() {
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
}
|
||||
|
||||
// Manual backup sheet
|
||||
const [manualOpen, setManualOpen] = useState(false)
|
||||
const [manualServers, setManualServers] = useState<Set<string>>(new Set())
|
||||
const [manualNotes, setManualNotes] = useState("")
|
||||
function toggleManualServer(id: string) {
|
||||
setManualServers((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
function handleManualBackup() {
|
||||
const now = new Date()
|
||||
const ts = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,"0")}-${String(now.getDate()).padStart(2,"0")}_${String(now.getHours()).padStart(2,"0")}-${String(now.getMinutes()).padStart(2,"0")}`
|
||||
const newBackups: Backup[] = Array.from(manualServers).map((sid, i) => {
|
||||
const sv = servers.find((s) => s.id === sid)!
|
||||
return {
|
||||
id: `b${Date.now()}${i}`,
|
||||
server: sv.name,
|
||||
filename: `${sv.name}_${ts}_manual.rsc`,
|
||||
size: `${Math.floor(60 + Math.random()*80)} КБ`,
|
||||
created: "Только что",
|
||||
kind: "manual",
|
||||
notes: manualNotes || "",
|
||||
}
|
||||
})
|
||||
setBackupList((list) => [...newBackups, ...list])
|
||||
setManualOpen(false)
|
||||
setManualServers(new Set())
|
||||
setManualNotes("")
|
||||
setTab("history")
|
||||
}
|
||||
|
||||
// Delete backup
|
||||
function handleDelete(id: string) {
|
||||
setBackupList((list) => list.filter((b) => b.id !== id))
|
||||
}
|
||||
|
||||
// ── derived ────────────────────────────────────────────────────────────
|
||||
const filtered = useMemo(() =>
|
||||
backupList.filter((b) => kindFilter === "all" || b.kind === kindFilter),
|
||||
[backupList, kindFilter]
|
||||
)
|
||||
|
||||
const autoCount = backupList.filter((b) => b.kind === "auto").length
|
||||
const manualCount = backupList.filter((b) => b.kind === "manual").length
|
||||
const serverCount = new Set(backupList.map((b) => b.server)).size
|
||||
|
||||
// Schedule summary string
|
||||
const schedSummary = (() => {
|
||||
if (!schedule.enabled) return "Отключено"
|
||||
const t = `${String(schedule.hour).padStart(2,"0")}:${String(schedule.minute).padStart(2,"0")}`
|
||||
if (schedule.frequency === "daily") return `Каждый день в ${t}`
|
||||
if (schedule.frequency === "weekly") return `Каждую неделю (${WEEK_DAYS[schedule.weekDay]}) в ${t}`
|
||||
return `${schedule.monthDay}-го числа каждого месяца в ${t}`
|
||||
})()
|
||||
|
||||
// Storage path summary
|
||||
const pathSummary = storage.type === "local"
|
||||
? storage.localPath || "/var/backup/mikrotik"
|
||||
: `${storage.type.toUpperCase()}://${storage.host || "host"}${storage.remotePath || "/"}`
|
||||
|
||||
// ── render ─────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "Бэкапы" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => { setManualServers(new Set(servers.map(s=>s.id))); setManualOpen(true) }}>
|
||||
<RefreshCwIcon className="size-4" />Снять со всех
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => { setManualServers(new Set()); setManualNotes(""); setManualOpen(true) }}>
|
||||
<PlusIcon className="size-4" />Новый бэкап
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: "Всего бэкапов", value: backupList.length, icon: <HardDriveIcon className="size-4" /> },
|
||||
{ label: "Авто", value: autoCount, icon: <ClockIcon className="size-4" /> },
|
||||
{ label: "Вручную", value: manualCount, icon: <PlusIcon className="size-4" /> },
|
||||
{ label: "Серверов охвачено",value: serverCount, icon: <ServerIcon className="size-4" /> },
|
||||
].map((s) => (
|
||||
<Card key={s.label}>
|
||||
<CardContent className="px-5 py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{s.label}</p>
|
||||
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
|
||||
</div>
|
||||
<span className="text-muted-foreground/40">{s.icon}</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Info bar */}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground px-1">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<ClockIcon className="size-3" />
|
||||
{schedSummary}
|
||||
</span>
|
||||
<span className="h-3 w-px bg-border" />
|
||||
<span className="flex items-center gap-1.5">
|
||||
<FolderIcon className="size-3" />
|
||||
{pathSummary}
|
||||
</span>
|
||||
<span className="h-3 w-px bg-border" />
|
||||
<span className="flex items-center gap-1.5">
|
||||
<ServerIcon className="size-3" />
|
||||
{selectedServers.size} из {servers.length} серверов
|
||||
</span>
|
||||
<button onClick={() => setTab("settings")}
|
||||
className="ml-auto text-xs text-primary hover:underline">
|
||||
Изменить настройки →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-1 border-b border-border">
|
||||
{([
|
||||
{ id: "history", label: "История бэкапов" },
|
||||
{ id: "settings", label: "Настройки" },
|
||||
] as { id: PageTab; label: string }[]).map((t) => (
|
||||
<button key={t.id} onClick={() => setTab(t.id)}
|
||||
className={`px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors ${tab === t.id ? "border-primary text-foreground" : "border-transparent text-muted-foreground hover:text-foreground"}`}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── История ──────────────────────────────────────────────────── */}
|
||||
{tab === "history" && (
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 px-5 py-3 border-b flex-wrap">
|
||||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
|
||||
{([
|
||||
{ value: "all", label: "Все", count: backupList.length },
|
||||
{ value: "auto", label: "Авто", count: autoCount },
|
||||
{ value: "manual", label: "Вручную", count: manualCount },
|
||||
] as { value: KindFilter; label: string; count: number }[]).map((t) => (
|
||||
<button key={t.value} onClick={() => setKindFilter(t.value)}
|
||||
className={`flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors ${kindFilter === t.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>
|
||||
{t.label}
|
||||
<span className="text-xs tabular-nums opacity-60">{t.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} бэкапов</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-xs text-muted-foreground">
|
||||
<th className="text-left font-medium px-5 py-3">Файл</th>
|
||||
<th className="text-left font-medium px-4 py-3">Сервер</th>
|
||||
<th className="text-left font-medium px-4 py-3">Размер</th>
|
||||
<th className="text-left font-medium px-4 py-3">Тип</th>
|
||||
<th className="text-left font-medium px-4 py-3">Заметки</th>
|
||||
<th className="text-left font-medium px-4 py-3">Создан</th>
|
||||
<th className="w-28 px-3 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{filtered.length === 0 && (
|
||||
<tr><td colSpan={7} className="px-5 py-10 text-center text-sm text-muted-foreground">Нет бэкапов</td></tr>
|
||||
)}
|
||||
{filtered.map((b) => (
|
||||
<tr key={b.id} className="hover:bg-muted/40 transition-colors group">
|
||||
<td className="px-5 py-3 font-mono text-xs font-medium">{b.filename}</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground">{b.server}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">{b.size}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={cn("text-xs px-2 py-0.5 rounded border font-medium",
|
||||
b.kind === "manual"
|
||||
? "bg-blue-500/10 text-blue-400 border-blue-500/20"
|
||||
: "bg-muted text-muted-foreground border-border"
|
||||
)}>
|
||||
{b.kind === "auto" ? "авто" : "вручную"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-muted-foreground max-w-[200px] truncate">{b.notes || "—"}</td>
|
||||
<td className="px-4 py-3 text-xs text-muted-foreground">{b.created}</td>
|
||||
<td className="px-3 py-3">
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button variant="ghost" size="icon" className="size-7" title="Скачать">
|
||||
<DownloadIcon className="size-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="size-7" title="Восстановить">
|
||||
<RefreshCwIcon className="size-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="size-7 text-destructive hover:text-destructive"
|
||||
title="Удалить" onClick={() => handleDelete(b.id)}>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Настройки ────────────────────────────────────────────────── */}
|
||||
{tab === "settings" && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
|
||||
{/* Расписание */}
|
||||
<Card>
|
||||
<CardContent className="px-5 py-5 flex flex-col gap-5">
|
||||
<SectionTitle icon={<ClockIcon className="size-3.5" />}>Расписание</SectionTitle>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Автоматический бэкап</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Создавать бэкапы по расписанию</p>
|
||||
</div>
|
||||
<Toggle checked={schedule.enabled} onChange={(v) => setSched("enabled", v)} />
|
||||
</div>
|
||||
|
||||
<div className={cn("flex flex-col gap-4 transition-opacity", !schedule.enabled && "opacity-40 pointer-events-none")}>
|
||||
<Field label="Частота">
|
||||
<SegmentedControl
|
||||
value={schedule.frequency}
|
||||
onChange={(v) => setSched("frequency", v)}
|
||||
options={[
|
||||
{ value: "daily", label: "Ежедневно" },
|
||||
{ value: "weekly", label: "Еженедельно" },
|
||||
{ value: "monthly", label: "Ежемесячно" },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{schedule.frequency === "weekly" && (
|
||||
<Field label="День недели">
|
||||
<div className="flex gap-1">
|
||||
{WEEK_DAYS.map((d, i) => (
|
||||
<button key={i} type="button" onClick={() => setSched("weekDay", i)}
|
||||
className={cn(
|
||||
"w-9 h-9 rounded text-sm font-medium border transition-colors",
|
||||
schedule.weekDay === i
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "border-border text-muted-foreground hover:text-foreground"
|
||||
)}>
|
||||
{d}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{schedule.frequency === "monthly" && (
|
||||
<Field label="День месяца" hint="1–28">
|
||||
<Input type="number" min={1} max={28} className="font-mono w-24"
|
||||
value={schedule.monthDay}
|
||||
onChange={(e) => setSched("monthDay", Math.min(28, Math.max(1, Number(e.target.value))))} />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label="Время запуска">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Input type="number" min={0} max={23} className="font-mono w-20 text-center"
|
||||
value={String(schedule.hour).padStart(2, "0")}
|
||||
onChange={(e) => setSched("hour", Math.min(23, Math.max(0, Number(e.target.value))))} />
|
||||
</div>
|
||||
<span className="text-muted-foreground font-mono text-lg">:</span>
|
||||
<div className="flex gap-1">
|
||||
{[0, 15, 30, 45].map((m) => (
|
||||
<button key={m} type="button" onClick={() => setSched("minute", m)}
|
||||
className={cn(
|
||||
"px-2.5 py-1.5 rounded text-xs font-mono border transition-colors",
|
||||
schedule.minute === m
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "border-border text-muted-foreground hover:text-foreground"
|
||||
)}>
|
||||
{String(m).padStart(2, "0")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="Хранить бэкапов" hint="На каждый сервер">
|
||||
<Input type="number" min={1} max={90} className="font-mono"
|
||||
value={schedule.keepCount}
|
||||
onChange={(e) => setSched("keepCount", Math.max(1, Number(e.target.value)))} />
|
||||
</Field>
|
||||
<Field label="Формат файла">
|
||||
<SegmentedControl
|
||||
value={schedule.format}
|
||||
onChange={(v) => setSched("format", v)}
|
||||
options={[
|
||||
{ value: "rsc", label: ".rsc" },
|
||||
{ value: "backup", label: ".backup" },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Хранилище */}
|
||||
<Card>
|
||||
<CardContent className="px-5 py-5 flex flex-col gap-5">
|
||||
<SectionTitle icon={<FolderIcon className="size-3.5" />}>Хранилище</SectionTitle>
|
||||
|
||||
<Field label="Тип хранилища">
|
||||
<SegmentedControl
|
||||
value={storage.type}
|
||||
onChange={(v) => setStore("type", v)}
|
||||
options={[
|
||||
{ value: "local", label: "Локально" },
|
||||
{ value: "ftp", label: "FTP" },
|
||||
{ value: "scp", label: "SCP" },
|
||||
{ value: "smb", label: "SMB" },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{storage.type === "local" && (
|
||||
<Field label="Путь сохранения" hint="Директория на сервере приложения">
|
||||
<Input className="font-mono" placeholder="/var/backup/mikrotik"
|
||||
value={storage.localPath}
|
||||
onChange={(e) => setStore("localPath", e.target.value)} />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{storage.type !== "local" && (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="col-span-2">
|
||||
<Field label="Хост">
|
||||
<Input className="font-mono" placeholder="192.168.1.100"
|
||||
value={storage.host} onChange={(e) => setStore("host", e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Порт">
|
||||
<Input className="font-mono"
|
||||
placeholder={storage.type === "ftp" ? "21" : storage.type === "scp" ? "22" : "445"}
|
||||
value={storage.port} onChange={(e) => setStore("port", e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{storage.type === "smb" && (
|
||||
<Field label="Общая папка (Share)">
|
||||
<Input className="font-mono" placeholder="backups"
|
||||
value={storage.share} onChange={(e) => setStore("share", e.target.value)} />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Пользователь">
|
||||
<Input className="font-mono" placeholder="backup-user"
|
||||
value={storage.username} onChange={(e) => setStore("username", e.target.value)} />
|
||||
</Field>
|
||||
<Field label={storage.type === "scp" ? "Пароль / ключ" : "Пароль"}>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={storage.showPassword ? "text" : "password"}
|
||||
className="font-mono pr-8"
|
||||
placeholder="••••••••"
|
||||
value={storage.password}
|
||||
onChange={(e) => setStore("password", e.target.value)}
|
||||
/>
|
||||
<button type="button"
|
||||
onClick={() => setStore("showPassword", !storage.showPassword)}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-xs">
|
||||
{storage.showPassword ? "скрыть" : "показ"}
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="Удалённый путь">
|
||||
<Input className="font-mono" placeholder="/mikrotik-backups"
|
||||
value={storage.remotePath} onChange={(e) => setStore("remotePath", e.target.value)} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3 text-xs text-muted-foreground">
|
||||
<p className="font-medium text-foreground mb-1">Путь сохранения</p>
|
||||
<p className="font-mono text-foreground break-all">{pathSummary}</p>
|
||||
{storage.type !== "local" && (
|
||||
<p className="mt-1">Файлы: <span className="font-mono text-foreground">{pathSummary}/{"<server-name>"}_{"{date}"}.{schedule.format}</span></p>
|
||||
)}
|
||||
{storage.type === "local" && (
|
||||
<p className="mt-1">Файлы: <span className="font-mono text-foreground">{storage.localPath || "/var/backup/mikrotik"}/{"<server-name>"}_{"{date}"}.{schedule.format}</span></p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Серверы */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardContent className="px-5 py-5 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<SectionTitle icon={<ServerIcon className="size-3.5" />}>Серверы для бэкапа</SectionTitle>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button type="button" onClick={() => setSelectedServers(new Set(servers.map(s => s.id)))}
|
||||
className="text-xs text-primary hover:underline">Выбрать все</button>
|
||||
<span className="text-border">·</span>
|
||||
<button type="button" onClick={() => setSelectedServers(new Set())}
|
||||
className="text-xs text-muted-foreground hover:text-foreground hover:underline">Сбросить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-2">
|
||||
{servers.map((s) => {
|
||||
const checked = selectedServers.has(s.id)
|
||||
return (
|
||||
<button key={s.id} type="button" onClick={() => toggleServer(s.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg border p-3 text-left transition-colors",
|
||||
checked
|
||||
? "border-primary/40 bg-primary/5"
|
||||
: "border-border hover:border-border/80 hover:bg-muted/40"
|
||||
)}>
|
||||
<div className={cn(
|
||||
"flex size-4 shrink-0 items-center justify-center rounded border transition-colors",
|
||||
checked ? "bg-primary border-primary" : "border-border"
|
||||
)}>
|
||||
{checked && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4l2.5 2.5L9 1" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{s.name}</p>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground">{s.site}</span>
|
||||
<StatusBadge status={s.status} />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Выбрано {selectedServers.size} из {servers.length} серверов
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Save button */}
|
||||
<div className="lg:col-span-2 flex items-center gap-3">
|
||||
<Button onClick={handleSave} className="gap-2">
|
||||
Сохранить настройки
|
||||
</Button>
|
||||
{saved && (
|
||||
<span className="flex items-center gap-1.5 text-sm text-emerald-500">
|
||||
<CheckCircleIcon className="size-4" />
|
||||
Настройки сохранены
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ══ Sheet: Manual backup ══════════════════════════════════════════════ */}
|
||||
<Sheet open={manualOpen} onOpenChange={setManualOpen}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
|
||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||
<SheetTitle>Новый бэкап</SheetTitle>
|
||||
<SheetDescription>Снять конфигурацию вручную с выбранных серверов</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<p className="text-sm font-medium">Выберите серверы</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" onClick={() => setManualServers(new Set(servers.map(s=>s.id)))}
|
||||
className="text-xs text-primary hover:underline">Все</button>
|
||||
<span className="text-border">·</span>
|
||||
<button type="button" onClick={() => setManualServers(new Set())}
|
||||
className="text-xs text-muted-foreground hover:text-foreground hover:underline">Сбросить</button>
|
||||
</div>
|
||||
</div>
|
||||
{servers.map((s) => {
|
||||
const checked = manualServers.has(s.id)
|
||||
return (
|
||||
<button key={s.id} type="button" onClick={() => toggleManualServer(s.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg border p-3 text-left transition-colors",
|
||||
checked ? "border-primary/40 bg-primary/5" : "border-border hover:bg-muted/40"
|
||||
)}>
|
||||
<div className={cn(
|
||||
"flex size-4 shrink-0 items-center justify-center rounded border transition-colors",
|
||||
checked ? "bg-primary border-primary" : "border-border"
|
||||
)}>
|
||||
{checked && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4l2.5 2.5L9 1" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">{s.name}</p>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-xs font-mono text-muted-foreground">{s.host}</span>
|
||||
<StatusBadge status={s.status} />
|
||||
</div>
|
||||
</div>
|
||||
{s.status === "offline" && (
|
||||
<span className="text-xs text-muted-foreground">недоступен</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">Заметка</label>
|
||||
<Input placeholder="Например: перед обновлением BGP"
|
||||
value={manualNotes} onChange={(e) => setManualNotes(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
|
||||
<Button className="flex-1"
|
||||
disabled={manualServers.size === 0}
|
||||
onClick={handleManualBackup}>
|
||||
Снять бэкап ({manualServers.size})
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,979 @@
|
||||
"use client"
|
||||
|
||||
import { Fragment, useState, useMemo, useEffect } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
RefreshCwIcon, DownloadIcon, SearchIcon,
|
||||
ActivityIcon, BarChart3Icon, ChevronRightIcon, ChevronDownIcon,
|
||||
ArrowDownIcon, ClipboardCopyIcon, ServerIcon,
|
||||
XIcon,
|
||||
} from "lucide-react"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
|
||||
// ─── types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type BgpState = "Established" | "Active" | "Idle" | "Connect" | "OpenSent" | "OpenConfirm"
|
||||
type BgpType = "eBGP" | "iBGP"
|
||||
type BgpAfi = "IPv4 Unicast" | "IPv6 Unicast" | "VPNv4 Unicast"
|
||||
type BgpTab = "sessions" | "routers" | "analytics"
|
||||
type StateFilter = "all" | BgpState
|
||||
type TypeFilter = "all" | BgpType
|
||||
|
||||
interface BgpSession {
|
||||
id: string
|
||||
serverId: string
|
||||
serverLabel: string
|
||||
serverSite: string
|
||||
peerIp: string
|
||||
remoteAs: number
|
||||
localAs: number
|
||||
routerId: string
|
||||
description: string
|
||||
state: BgpState
|
||||
type: BgpType
|
||||
afi: BgpAfi
|
||||
uptime: string | null
|
||||
holdTime: number
|
||||
keepalive: number
|
||||
prefixesRx: number
|
||||
prefixesTx: number
|
||||
prefixesActive: number
|
||||
inputMessages: number
|
||||
outputMessages: number
|
||||
capabilities: string[]
|
||||
lastError: string | null
|
||||
}
|
||||
|
||||
// ─── AS name lookup ───────────────────────────────────────────────────────────
|
||||
|
||||
const AS_NAMES: Record<number, string> = {
|
||||
8359: "МТС / Tele2",
|
||||
13238: "Яндекс",
|
||||
12389: "Ростелеком",
|
||||
24940: "Hetzner",
|
||||
6777: "AMS-IX",
|
||||
1299: "Telia",
|
||||
65001: "iBGP internal",
|
||||
}
|
||||
// ─── mock data ────────────────────────────────────────────────────────────────
|
||||
|
||||
const SESSIONS: BgpSession[] = [
|
||||
// ── srv1 (mt-msk-core-01) ────────────────────────────────────────────
|
||||
{
|
||||
id: "s1", serverId: "srv1", serverLabel: "mt-msk-core-01", serverSite: "MSK",
|
||||
peerIp: "185.1.1.1", remoteAs: 8359, localAs: 65001,
|
||||
routerId: "185.1.1.1", description: "МТС — upstream transit",
|
||||
state: "Established", type: "eBGP", afi: "IPv4 Unicast",
|
||||
uptime: "14д 6ч 22м", holdTime: 90, keepalive: 30,
|
||||
prefixesRx: 280241, prefixesTx: 42, prefixesActive: 277894,
|
||||
inputMessages: 1842204, outputMessages: 14412,
|
||||
capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP", "ADD-PATH"],
|
||||
lastError: null,
|
||||
},
|
||||
{
|
||||
id: "s2", serverId: "srv1", serverLabel: "mt-msk-core-01", serverSite: "MSK",
|
||||
peerIp: "77.88.8.1", remoteAs: 13238, localAs: 65001,
|
||||
routerId: "77.88.44.1", description: "Яндекс — IX peering",
|
||||
state: "Established", type: "eBGP", afi: "IPv4 Unicast",
|
||||
uptime: "12д 3ч 11м", holdTime: 90, keepalive: 30,
|
||||
prefixesRx: 11840, prefixesTx: 42, prefixesActive: 11840,
|
||||
inputMessages: 184220, outputMessages: 14100,
|
||||
capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP"],
|
||||
lastError: null,
|
||||
},
|
||||
{
|
||||
id: "s3", serverId: "srv1", serverLabel: "mt-msk-core-01", serverSite: "MSK",
|
||||
peerIp: "10.200.0.1", remoteAs: 65001, localAs: 65001,
|
||||
routerId: "10.0.0.2", description: "iBGP → SPB-EDGE",
|
||||
state: "Established", type: "iBGP", afi: "IPv4 Unicast",
|
||||
uptime: "8д 14ч 5м", holdTime: 90, keepalive: 30,
|
||||
prefixesRx: 620, prefixesTx: 280283, prefixesActive: 620,
|
||||
inputMessages: 48210, outputMessages: 512800,
|
||||
capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP", "Route-Target Constraint"],
|
||||
lastError: null,
|
||||
},
|
||||
{
|
||||
id: "s4", serverId: "srv1", serverLabel: "mt-msk-core-01", serverSite: "MSK",
|
||||
peerIp: "10.200.1.1", remoteAs: 65001, localAs: 65001,
|
||||
routerId: "10.0.0.3", description: "iBGP → FRA-EDGE",
|
||||
state: "Established", type: "iBGP", afi: "IPv4 Unicast",
|
||||
uptime: "12д 2ч 18м", holdTime: 90, keepalive: 30,
|
||||
prefixesRx: 512, prefixesTx: 280283, prefixesActive: 512,
|
||||
inputMessages: 41022, outputMessages: 488200,
|
||||
capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP"],
|
||||
lastError: null,
|
||||
},
|
||||
{
|
||||
id: "s5", serverId: "srv1", serverLabel: "mt-msk-core-01", serverSite: "MSK",
|
||||
peerIp: "10.200.2.1", remoteAs: 65001, localAs: 65001,
|
||||
routerId: "10.0.0.4", description: "iBGP → AMS-EDGE",
|
||||
state: "Active", type: "iBGP", afi: "IPv4 Unicast",
|
||||
uptime: null, holdTime: 90, keepalive: 30,
|
||||
prefixesRx: 0, prefixesTx: 0, prefixesActive: 0,
|
||||
inputMessages: 0, outputMessages: 0,
|
||||
capabilities: [],
|
||||
lastError: "Hold timer expired",
|
||||
},
|
||||
|
||||
// ── srv2 (mt-spb-edge-01) ────────────────────────────────────────────
|
||||
{
|
||||
id: "s6", serverId: "srv2", serverLabel: "mt-spb-edge-01", serverSite: "SPB",
|
||||
peerIp: "195.54.55.1", remoteAs: 12389, localAs: 65001,
|
||||
routerId: "195.54.55.1", description: "Ростелеком — upstream",
|
||||
state: "Established", type: "eBGP", afi: "IPv4 Unicast",
|
||||
uptime: "9д 1ч 44м", holdTime: 90, keepalive: 30,
|
||||
prefixesRx: 192440, prefixesTx: 42, prefixesActive: 191002,
|
||||
inputMessages: 982440, outputMessages: 12200,
|
||||
capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP"],
|
||||
lastError: null,
|
||||
},
|
||||
{
|
||||
id: "s7", serverId: "srv2", serverLabel: "mt-spb-edge-01", serverSite: "SPB",
|
||||
peerIp: "10.200.0.1", remoteAs: 65001, localAs: 65001,
|
||||
routerId: "10.0.0.1", description: "iBGP → MSK-CORE",
|
||||
state: "Established", type: "iBGP", afi: "IPv4 Unicast",
|
||||
uptime: "8д 14ч 3м", holdTime: 90, keepalive: 30,
|
||||
prefixesRx: 280283, prefixesTx: 620, prefixesActive: 277000,
|
||||
inputMessages: 512800, outputMessages: 48210,
|
||||
capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP", "Route-Target Constraint"],
|
||||
lastError: null,
|
||||
},
|
||||
|
||||
// ── srv3 (mt-fra-edge-01) ────────────────────────────────────────────
|
||||
{
|
||||
id: "s8", serverId: "srv3", serverLabel: "mt-fra-edge-01", serverSite: "FRA",
|
||||
peerIp: "91.108.4.1", remoteAs: 24940, localAs: 65001,
|
||||
routerId: "91.108.4.1", description: "Hetzner — upstream",
|
||||
state: "Established", type: "eBGP", afi: "IPv4 Unicast",
|
||||
uptime: "21д 7ч 12м", holdTime: 90, keepalive: 30,
|
||||
prefixesRx: 218820, prefixesTx: 42, prefixesActive: 215400,
|
||||
inputMessages: 1184200, outputMessages: 11800,
|
||||
capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP", "Graceful Restart"],
|
||||
lastError: null,
|
||||
},
|
||||
{
|
||||
id: "s9", serverId: "srv3", serverLabel: "mt-fra-edge-01", serverSite: "FRA",
|
||||
peerIp: "193.188.128.1", remoteAs: 6777, localAs: 65001,
|
||||
routerId: "193.188.128.1", description: "AMS-IX — peering (idle)",
|
||||
state: "Idle", type: "eBGP", afi: "IPv4 Unicast",
|
||||
uptime: null, holdTime: 90, keepalive: 30,
|
||||
prefixesRx: 0, prefixesTx: 0, prefixesActive: 0,
|
||||
inputMessages: 821, outputMessages: 412,
|
||||
capabilities: [],
|
||||
lastError: "Administratively down",
|
||||
},
|
||||
{
|
||||
id: "s10", serverId: "srv3", serverLabel: "mt-fra-edge-01", serverSite: "FRA",
|
||||
peerIp: "10.200.1.1", remoteAs: 65001, localAs: 65001,
|
||||
routerId: "10.0.0.1", description: "iBGP → MSK-CORE",
|
||||
state: "Established", type: "iBGP", afi: "IPv4 Unicast",
|
||||
uptime: "12д 2ч 14м", holdTime: 90, keepalive: 30,
|
||||
prefixesRx: 280283, prefixesTx: 512, prefixesActive: 278100,
|
||||
inputMessages: 488200, outputMessages: 41022,
|
||||
capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP"],
|
||||
lastError: null,
|
||||
},
|
||||
|
||||
// ── srv6 (mt-ams-test-01) ────────────────────────────────────────────
|
||||
{
|
||||
id: "s11", serverId: "srv6", serverLabel: "mt-ams-test-01", serverSite: "AMS",
|
||||
peerIp: "80.249.208.1", remoteAs: 6777, localAs: 65001,
|
||||
routerId: "0.0.0.0", description: "AMS-IX — negotiating",
|
||||
state: "OpenSent", type: "eBGP", afi: "IPv4 Unicast",
|
||||
uptime: null, holdTime: 90, keepalive: 30,
|
||||
prefixesRx: 0, prefixesTx: 0, prefixesActive: 0,
|
||||
inputMessages: 2, outputMessages: 1,
|
||||
capabilities: [],
|
||||
lastError: null,
|
||||
},
|
||||
{
|
||||
id: "s12", serverId: "srv6", serverLabel: "mt-ams-test-01", serverSite: "AMS",
|
||||
peerIp: "10.200.5.1", remoteAs: 65001, localAs: 65001,
|
||||
routerId: "10.0.0.1", description: "iBGP → MSK-CORE",
|
||||
state: "Established", type: "iBGP", afi: "IPv4 Unicast",
|
||||
uptime: "5д 9ч 17м", holdTime: 90, keepalive: 30,
|
||||
prefixesRx: 280283, prefixesTx: 14, prefixesActive: 276000,
|
||||
inputMessages: 184200, outputMessages: 4100,
|
||||
capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP"],
|
||||
lastError: null,
|
||||
},
|
||||
]
|
||||
|
||||
// ─── visual helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
const STATE_STYLE: Record<BgpState, { bg: string; text: string; dot: string; label: string }> = {
|
||||
Established: { bg: "bg-emerald-500/10", text: "text-emerald-600 dark:text-emerald-400", dot: "bg-emerald-500", label: "Established" },
|
||||
Active: { bg: "bg-amber-500/10", text: "text-amber-600 dark:text-amber-400", dot: "bg-amber-500", label: "Active" },
|
||||
Idle: { bg: "bg-slate-500/10", text: "text-slate-500 dark:text-slate-400", dot: "bg-slate-500", label: "Idle" },
|
||||
Connect: { bg: "bg-blue-500/10", text: "text-blue-600 dark:text-blue-400", dot: "bg-blue-500", label: "Connect" },
|
||||
OpenSent: { bg: "bg-violet-500/10", text: "text-violet-600 dark:text-violet-400", dot: "bg-violet-500", label: "OpenSent" },
|
||||
OpenConfirm: { bg: "bg-violet-500/10", text: "text-violet-600 dark:text-violet-400", dot: "bg-violet-500", label: "OpenConfirm" },
|
||||
}
|
||||
const TYPE_STYLE: Record<BgpType, { bg: string; text: string }> = {
|
||||
eBGP: { bg: "bg-blue-500/10", text: "text-blue-600 dark:text-blue-400" },
|
||||
iBGP: { bg: "bg-purple-500/10", text: "text-purple-600 dark:text-purple-400" },
|
||||
}
|
||||
|
||||
function StateBadge({ state }: { state: BgpState }) {
|
||||
const s = STATE_STYLE[state]
|
||||
return (
|
||||
<span className={cn("inline-flex items-center gap-1.5 rounded border px-2 py-0.5 text-[11px] font-semibold",
|
||||
s.bg, s.text, "border-current/20")}>
|
||||
<span className={cn("size-1.5 rounded-full shrink-0", s.dot)} />
|
||||
{s.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function TypeBadge({ type }: { type: BgpType }) {
|
||||
const s = TYPE_STYLE[type]
|
||||
return (
|
||||
<span className={cn("inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-semibold",
|
||||
s.bg, s.text, "border-current/20")}>
|
||||
{type}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function CapChip({ cap }: { cap: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium border bg-muted/60 text-muted-foreground border-border/60">
|
||||
{cap}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function fmtNum(n: number) {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`
|
||||
return String(n)
|
||||
}
|
||||
|
||||
function PrefixBar({ rx, tx, active }: { rx: number; tx: number; active: number }) {
|
||||
const max = Math.max(rx, 1)
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 text-[10px] font-mono">
|
||||
{[
|
||||
{ label: "Получено", val: rx, color: "bg-emerald-500", w: rx / max },
|
||||
{ label: "Активных", val: active, color: "bg-sky-500", w: active / max },
|
||||
{ label: "Отправлено", val: tx, color: "bg-blue-400", w: Math.min(tx / max, 1) },
|
||||
].map(r => (
|
||||
<div key={r.label} className="flex items-center gap-2">
|
||||
<span className="w-20 text-muted-foreground shrink-0">{r.label}</span>
|
||||
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div className={cn("h-full rounded-full", r.color)}
|
||||
style={{ width: `${Math.max(r.w * 100, r.val > 0 ? 2 : 0)}%` }} />
|
||||
</div>
|
||||
<span className="w-14 text-right tabular-nums">{fmtNum(r.val)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── RSC snippet ──────────────────────────────────────────────────────────────
|
||||
|
||||
function rscSnippet(s: BgpSession) {
|
||||
return `/routing bgp connection\nadd name=peer-as${s.remoteAs} remote.address=${s.peerIp}/32 \\\n remote.as=${s.remoteAs} local.role=${s.type === "eBGP" ? "ebgp" : "ibgp"} \\\n output.filter-chain=export-filter input.filter=import-filter \\\n routing-table=main`
|
||||
}
|
||||
|
||||
// ─── session expanded row ─────────────────────────────────────────────────────
|
||||
|
||||
function SessionDetail({ s }: { s: BgpSession }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
function copy() {
|
||||
navigator.clipboard.writeText(rscSnippet(s)).then(() => {
|
||||
setCopied(true); setTimeout(() => setCopied(false), 1800)
|
||||
})
|
||||
}
|
||||
return (
|
||||
<div className="px-4 pb-4 pt-2 bg-muted/20 border-t border-border/60">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
|
||||
{[
|
||||
{ label: "Router ID", value: s.routerId },
|
||||
{ label: "Hold / KA", value: `${s.holdTime}s / ${s.keepalive}s` },
|
||||
{ label: "AFI/SAFI", value: s.afi },
|
||||
{ label: "Сообщения ↓/↑", value: `${fmtNum(s.inputMessages)} / ${fmtNum(s.outputMessages)}` },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label}>
|
||||
<p className="text-[10px] text-muted-foreground mb-0.5">{label}</p>
|
||||
<p className="text-xs font-mono font-medium">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* prefix bars */}
|
||||
{s.state === "Established" && (
|
||||
<div className="mb-4">
|
||||
<p className="text-[10px] text-muted-foreground mb-2 uppercase tracking-wider font-semibold">Префиксы</p>
|
||||
<PrefixBar rx={s.prefixesRx} tx={s.prefixesTx} active={s.prefixesActive} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* capabilities */}
|
||||
{s.capabilities.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<p className="text-[10px] text-muted-foreground mb-1.5 uppercase tracking-wider font-semibold">Capabilities</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{s.capabilities.map(c => <CapChip key={c} cap={c} />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* last error */}
|
||||
{s.lastError && (
|
||||
<div className="mb-4 flex items-center gap-2 rounded-md border border-red-500/20 bg-red-500/5 px-3 py-2">
|
||||
<span className="size-1.5 rounded-full bg-red-500 shrink-0" />
|
||||
<p className="text-xs font-mono text-red-500">{s.lastError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* rsc export */}
|
||||
<div className="mt-2">
|
||||
<p className="text-[10px] text-muted-foreground mb-1.5 uppercase tracking-wider font-semibold">RouterOS Export</p>
|
||||
<div className="rounded-md bg-[#0a0f1a] border border-white/8 px-3 py-2.5 flex items-start justify-between gap-3">
|
||||
<pre className="text-[10px] font-mono text-[#94a3b8] leading-relaxed whitespace-pre-wrap flex-1 min-w-0">
|
||||
{rscSnippet(s)}
|
||||
</pre>
|
||||
<button onClick={copy}
|
||||
className={cn(
|
||||
"shrink-0 flex items-center gap-1 text-[10px] px-2 py-1 rounded border transition-colors",
|
||||
copied
|
||||
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-400"
|
||||
: "border-white/10 text-white/40 hover:text-white/70 hover:border-white/20",
|
||||
)}>
|
||||
<ClipboardCopyIcon className="size-3" />
|
||||
{copied ? "Скопировано" : "Копировать"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── backend mapping ──────────────────────────────────────────────────────────
|
||||
|
||||
interface BackendBgpSession {
|
||||
id: string; serverId: number; serverName: string; serverSite: string; serverCountry: string
|
||||
name: string; peerIp: string; remoteAs: number; localAs: number
|
||||
localId: string; remoteId: string; state: string; type: "eBGP" | "iBGP"
|
||||
uptime: string | null; holdTime: number; keepalive: number
|
||||
prefixesRx: number; prefixesTx: number
|
||||
inputMessages: number; outputMessages: number
|
||||
capabilities: string[]; lastError: string | null
|
||||
}
|
||||
|
||||
function backendToFrontend(b: BackendBgpSession): BgpSession {
|
||||
return {
|
||||
id: `${b.serverId}-${b.id}`,
|
||||
serverId: String(b.serverId),
|
||||
serverLabel: b.serverName,
|
||||
serverSite: b.serverSite,
|
||||
peerIp: b.peerIp,
|
||||
remoteAs: b.remoteAs,
|
||||
localAs: b.localAs,
|
||||
routerId: b.remoteId || b.localId,
|
||||
description: b.name,
|
||||
state: (b.state as BgpState) || "Idle",
|
||||
type: b.type,
|
||||
afi: "IPv4 Unicast",
|
||||
uptime: b.uptime,
|
||||
holdTime: b.holdTime,
|
||||
keepalive: b.keepalive,
|
||||
prefixesRx: b.prefixesRx,
|
||||
prefixesTx: b.prefixesTx,
|
||||
prefixesActive: b.prefixesRx,
|
||||
inputMessages: b.inputMessages,
|
||||
outputMessages: b.outputMessages,
|
||||
capabilities: b.capabilities,
|
||||
lastError: b.lastError,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── sessions tab ─────────────────────────────────────────────────────────────
|
||||
|
||||
const STATE_FILTERS: Array<{ value: StateFilter; label: string }> = [
|
||||
{ value: "all", label: "Все" },
|
||||
{ value: "Established", label: "Established" },
|
||||
{ value: "Active", label: "Active" },
|
||||
{ value: "Idle", label: "Idle" },
|
||||
{ value: "OpenSent", label: "OpenSent" },
|
||||
]
|
||||
|
||||
function SessionsTab({ sessions }: { sessions: BgpSession[] }) {
|
||||
const [search, setSearch] = useState("")
|
||||
const [stateFilter, setStateFilter] = useState<StateFilter>("all")
|
||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
|
||||
const q = search.toLowerCase()
|
||||
const filtered = useMemo(() => sessions.filter(s => {
|
||||
if (stateFilter !== "all" && s.state !== stateFilter) return false
|
||||
if (typeFilter !== "all" && s.type !== typeFilter) return false
|
||||
if (q && !s.peerIp.includes(q) && !s.description.toLowerCase().includes(q)
|
||||
&& !s.serverLabel.includes(q) && !String(s.remoteAs).includes(q)
|
||||
&& !(AS_NAMES[s.remoteAs] ?? "").toLowerCase().includes(q)) return false
|
||||
return true
|
||||
}), [sessions, q, stateFilter, typeFilter])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
{/* filter bar */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* search */}
|
||||
<div className="relative">
|
||||
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
|
||||
<input
|
||||
value={search} onChange={e => setSearch(e.target.value)}
|
||||
placeholder="IP, AS, описание…"
|
||||
className="h-8 pl-8 pr-3 w-52 text-xs bg-muted/50 border border-border rounded-md outline-none focus:border-primary transition-colors"
|
||||
/>
|
||||
{search && (
|
||||
<button onClick={() => setSearch("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
|
||||
<XIcon className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* state filter */}
|
||||
<div className="flex items-center gap-0.5 p-0.5 rounded-md border border-border bg-muted/40">
|
||||
{STATE_FILTERS.map(f => (
|
||||
<button key={f.value} onClick={() => setStateFilter(f.value)}
|
||||
className={cn(
|
||||
"px-2.5 py-1 text-[11px] rounded transition-colors whitespace-nowrap",
|
||||
stateFilter === f.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
|
||||
)}>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* type filter */}
|
||||
<div className="flex items-center gap-0.5 p-0.5 rounded-md border border-border bg-muted/40">
|
||||
{(["all", "eBGP", "iBGP"] as const).map(t => (
|
||||
<button key={t} onClick={() => setTypeFilter(t)}
|
||||
className={cn(
|
||||
"px-2.5 py-1 text-[11px] rounded transition-colors",
|
||||
typeFilter === t ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
|
||||
)}>
|
||||
{t === "all" ? "Все типы" : t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{filtered.length} из {sessions.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* table */}
|
||||
<Card className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/40">
|
||||
<th className="w-8" />
|
||||
{["Роутер", "Peer IP", "Remote AS", "Описание", "Тип", "Состояние", "Uptime", "Prefixes ↓", "Prefixes ↑"].map(h => (
|
||||
<th key={h} className="text-left px-3 py-2.5 font-medium text-muted-foreground whitespace-nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{filtered.map(s => {
|
||||
const isOpen = expandedId === s.id
|
||||
return (
|
||||
<Fragment key={s.id}>
|
||||
<tr
|
||||
onClick={() => setExpandedId(isOpen ? null : s.id)}
|
||||
className={cn(
|
||||
"cursor-pointer transition-colors",
|
||||
isOpen ? "bg-muted/30" : "hover:bg-muted/20",
|
||||
)}>
|
||||
<td className="pl-3 py-2.5">
|
||||
{isOpen
|
||||
? <ChevronDownIcon className="size-3.5 text-muted-foreground" />
|
||||
: <ChevronRightIcon className="size-3.5 text-muted-foreground" />}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono whitespace-nowrap">{s.serverLabel}</td>
|
||||
<td className="px-3 py-2.5 font-mono">{s.peerIp}</td>
|
||||
<td className="px-3 py-2.5 font-mono">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>AS{s.remoteAs}</span>
|
||||
{AS_NAMES[s.remoteAs] && (
|
||||
<span className="text-muted-foreground text-[10px]">{AS_NAMES[s.remoteAs]}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-muted-foreground max-w-[180px] truncate">{s.description}</td>
|
||||
<td className="px-3 py-2.5"><TypeBadge type={s.type} /></td>
|
||||
<td className="px-3 py-2.5"><StateBadge state={s.state} /></td>
|
||||
<td className="px-3 py-2.5 font-mono tabular-nums text-muted-foreground">
|
||||
{s.uptime ?? "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono tabular-nums text-right">
|
||||
{s.prefixesRx > 0
|
||||
? <span className="text-emerald-600 dark:text-emerald-400">{fmtNum(s.prefixesRx)}</span>
|
||||
: <span className="text-muted-foreground">—</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono tabular-nums text-right">
|
||||
{s.prefixesTx > 0
|
||||
? <span className="text-blue-500">{fmtNum(s.prefixesTx)}</span>
|
||||
: <span className="text-muted-foreground">—</span>}
|
||||
</td>
|
||||
</tr>
|
||||
{isOpen && (
|
||||
<tr>
|
||||
<td colSpan={10} className="p-0">
|
||||
<SessionDetail s={s} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={10} className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
Нет сессий по заданным фильтрам
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── routers tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
|
||||
const byRouter = useMemo(() => {
|
||||
const map: Record<string, { label: string; site: string; sessions: BgpSession[] }> = {}
|
||||
sessions.forEach(s => {
|
||||
if (!map[s.serverId]) map[s.serverId] = { label: s.serverLabel, site: s.serverSite, sessions: [] }
|
||||
map[s.serverId].sessions.push(s)
|
||||
})
|
||||
return Object.entries(map).map(([id, v]) => ({ id, ...v }))
|
||||
}, [sessions])
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
{byRouter.map(router => {
|
||||
const estCnt = router.sessions.filter(s => s.state === "Established").length
|
||||
const downCnt = router.sessions.length - estCnt
|
||||
const totalRx = router.sessions.reduce((a, s) => a + s.prefixesRx, 0)
|
||||
|
||||
return (
|
||||
<Card key={router.id} className="overflow-hidden gap-0 py-0">
|
||||
{/* header */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b">
|
||||
<ServerIcon className="size-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium leading-none">{router.label}</p>
|
||||
<p className="text-[11px] font-mono text-muted-foreground mt-0.5">{router.site} · AS65001</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{estCnt > 0 && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-emerald-500/10 text-emerald-500 border border-emerald-500/25 font-medium">
|
||||
✓ {estCnt}
|
||||
</span>
|
||||
)}
|
||||
{downCnt > 0 && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-500/10 text-amber-500 border border-amber-500/25 font-medium">
|
||||
⚠ {downCnt}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* session rows */}
|
||||
<div className="divide-y divide-border/60">
|
||||
{router.sessions.map(s => {
|
||||
const ss = STATE_STYLE[s.state]
|
||||
return (
|
||||
<div key={s.id} className="flex items-center gap-3 px-4 py-2.5 hover:bg-muted/30 transition-colors">
|
||||
<span className={cn("size-2 rounded-full shrink-0", ss.dot)} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-mono">{s.peerIp}</span>
|
||||
<TypeBadge type={s.type} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-[10px] text-muted-foreground">AS{s.remoteAs}</span>
|
||||
{AS_NAMES[s.remoteAs] && (
|
||||
<span className="text-[10px] text-muted-foreground/60">· {AS_NAMES[s.remoteAs]}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
{s.prefixesRx > 0 && (
|
||||
<p className="text-[10px] font-mono text-emerald-500 tabular-nums">
|
||||
↓ {fmtNum(s.prefixesRx)}
|
||||
</p>
|
||||
)}
|
||||
<p className={cn("text-[10px] font-semibold", ss.text)}>{s.state}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* footer summary */}
|
||||
{totalRx > 0 && (
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-t bg-muted/20">
|
||||
<ArrowDownIcon className="size-3 text-emerald-500" />
|
||||
<span className="text-[11px] font-mono text-muted-foreground">
|
||||
Всего получено: <span className="text-emerald-600 dark:text-emerald-400 font-semibold">{fmtNum(totalRx)}</span> префиксов
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── analytics tab ────────────────────────────────────────────────────────────
|
||||
|
||||
function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
|
||||
// top peers by prefixes received (eBGP only)
|
||||
const topPeers = useMemo(() =>
|
||||
[...sessions]
|
||||
.filter(s => s.prefixesRx > 0)
|
||||
.sort((a, b) => b.prefixesRx - a.prefixesRx)
|
||||
.slice(0, 8),
|
||||
[sessions]
|
||||
)
|
||||
const maxRx = topPeers[0]?.prefixesRx ?? 1
|
||||
|
||||
// state distribution
|
||||
const stateCounts = useMemo(() => {
|
||||
const map: Record<string, number> = {}
|
||||
sessions.forEach(s => { map[s.state] = (map[s.state] ?? 0) + 1 })
|
||||
return Object.entries(map).sort((a, b) => b[1] - a[1]) as [BgpState, number][]
|
||||
}, [sessions])
|
||||
|
||||
// total prefix stats
|
||||
const totalRx = sessions.reduce((a, s) => a + s.prefixesRx, 0)
|
||||
const totalActive = sessions.reduce((a, s) => a + s.prefixesActive, 0)
|
||||
const ebgpSessions = sessions.filter(s => s.type === "eBGP").length
|
||||
const ibgpSessions = sessions.filter(s => s.type === "iBGP").length
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* summary row */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: "Всего префиксов", value: fmtNum(totalRx), color: "text-emerald-600 dark:text-emerald-400" },
|
||||
{ label: "Активных маршрутов", value: fmtNum(totalActive), color: "text-sky-600 dark:text-sky-400" },
|
||||
{ label: "eBGP сессий", value: ebgpSessions, color: "" },
|
||||
{ label: "iBGP сессий", value: ibgpSessions, color: "" },
|
||||
].map(s => (
|
||||
<Card key={s.label}>
|
||||
<CardContent className="pt-4 pb-3 px-4">
|
||||
<p className="text-xs text-muted-foreground">{s.label}</p>
|
||||
<p className={cn("text-2xl font-semibold tabular-nums mt-0.5", s.color)}>{s.value}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[1fr_320px] gap-5">
|
||||
{/* prefixes by peer — horizontal bar chart */}
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-4 px-5">
|
||||
<p className="text-sm font-semibold mb-4">Топ-8 пиров по полученным префиксам</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
{topPeers.map((s, i) => {
|
||||
const pct = (s.prefixesRx / maxRx) * 100
|
||||
const ss = STATE_STYLE[s.state]
|
||||
return (
|
||||
<div key={s.id} className="flex items-center gap-3">
|
||||
<span className="text-[11px] font-mono text-muted-foreground w-4 tabular-nums text-right">
|
||||
{i + 1}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1 gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-xs font-mono truncate">{s.peerIp}</span>
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">AS{s.remoteAs}</span>
|
||||
<TypeBadge type={s.type} />
|
||||
</div>
|
||||
<span className={cn("text-[10px] font-semibold shrink-0 tabular-nums font-mono", "text-emerald-600 dark:text-emerald-400")}>
|
||||
{fmtNum(s.prefixesRx)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all", ss.dot)}
|
||||
style={{ width: `${pct}%`, opacity: s.state === "Established" ? 0.8 : 0.3 }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">{s.serverLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{topPeers.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">Нет данных о префиксах</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* right column */}
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* state distribution */}
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-4 px-5">
|
||||
<p className="text-sm font-semibold mb-3">Распределение состояний</p>
|
||||
{stateCounts.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">Нет данных</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{stateCounts.map(([state, count]) => {
|
||||
const ss = STATE_STYLE[state]
|
||||
const pct = sessions.length > 0 ? (count / sessions.length) * 100 : 0
|
||||
return (
|
||||
<div key={state} className="flex items-center gap-2">
|
||||
<span className={cn("size-2 rounded-full shrink-0", ss.dot)} />
|
||||
<span className="text-xs w-24">{state}</span>
|
||||
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
|
||||
<div className={cn("h-full rounded-full", ss.dot)} style={{ width: `${pct}%`, opacity: 0.75 }} />
|
||||
</div>
|
||||
<span className="text-xs font-mono tabular-nums w-6 text-right">{count}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* eBGP vs iBGP */}
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-4 px-5">
|
||||
<p className="text-sm font-semibold mb-3">eBGP vs iBGP</p>
|
||||
{sessions.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">Нет данных</p>
|
||||
) : (
|
||||
<>
|
||||
{[
|
||||
{ type: "eBGP" as BgpType, label: "Внешние (eBGP)", count: ebgpSessions },
|
||||
{ type: "iBGP" as BgpType, label: "Внутренние (iBGP)", count: ibgpSessions },
|
||||
].map(({ type, count }) => {
|
||||
const pct = sessions.length > 0 ? (count / sessions.length) * 100 : 0
|
||||
return (
|
||||
<div key={type} className="flex items-center gap-2 mb-2 last:mb-0">
|
||||
<TypeBadge type={type} />
|
||||
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
|
||||
<div className={cn("h-full rounded-full", type === "eBGP" ? "bg-blue-500" : "bg-purple-500")}
|
||||
style={{ width: `${pct}%`, opacity: 0.75 }} />
|
||||
</div>
|
||||
<span className="text-xs font-mono w-4 text-right">{count}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* active AS list */}
|
||||
<div className="mt-4 pt-3 border-t border-border/60">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-2 font-semibold">Автономные системы</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
{Object.entries(
|
||||
sessions.filter(s => s.type === "eBGP").reduce<Record<number, number>>((acc, s) => {
|
||||
acc[s.remoteAs] = (acc[s.remoteAs] ?? 0) + 1; return acc
|
||||
}, {})
|
||||
).sort((a, b) => b[1] - a[1]).map(([as, cnt]) => (
|
||||
<div key={as} className="flex items-center justify-between text-[11px]">
|
||||
<span className="font-mono text-muted-foreground">AS{as}</span>
|
||||
<span className="text-muted-foreground/60">{AS_NAMES[Number(as)] ?? ""}</span>
|
||||
<span className="font-mono tabular-nums">{cnt}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const TABS: Array<{ id: BgpTab; label: string; icon: React.ReactNode }> = [
|
||||
{ id: "sessions", label: "Сессии", icon: <ActivityIcon className="size-3.5" /> },
|
||||
{ id: "routers", label: "По роутерам", icon: <ServerIcon className="size-3.5" /> },
|
||||
{ id: "analytics", label: "Аналитика", icon: <BarChart3Icon className="size-3.5" /> },
|
||||
]
|
||||
|
||||
export default function BgpPage() {
|
||||
const [activeTab, setActiveTab] = useState<BgpTab>("sessions")
|
||||
|
||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
||||
const isLive = mode === "live" && backendStatus === true
|
||||
|
||||
const [liveSessions, setLiveSessions] = useState<BgpSession[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [fetchedAt, setFetchedAt] = useState<Date | null>(null)
|
||||
const [liveError, setLiveError] = useState<string | null>(null)
|
||||
const [fetchTick, setFetchTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) { setLiveSessions([]); return }
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setLiveError(null)
|
||||
fetch(`${backendUrl}/api/bgp/sessions`)
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||
return r.json() as Promise<BackendBgpSession[]>
|
||||
})
|
||||
.then(data => {
|
||||
if (cancelled) return
|
||||
setLiveSessions(data.map(backendToFrontend))
|
||||
setFetchedAt(new Date())
|
||||
setLoading(false)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return
|
||||
setLiveError(err instanceof Error ? err.message : String(err))
|
||||
setLoading(false)
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [isLive, backendUrl, fetchTick])
|
||||
|
||||
// Use live or mock data for all tabs and KPI
|
||||
const sessions = isLive ? liveSessions : SESSIONS
|
||||
|
||||
const established = sessions.filter(s => s.state === "Established").length
|
||||
const notEstab = sessions.length - established
|
||||
const totalRx = sessions.reduce((a, s) => a + s.prefixesRx, 0)
|
||||
const serverCount = useMemo(
|
||||
() => new Set(liveSessions.map(s => s.serverId)).size,
|
||||
[liveSessions],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "BGP" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setFetchTick(t => t + 1)}>
|
||||
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* tab bar */}
|
||||
<div className="border-b bg-background shrink-0">
|
||||
<div className="flex items-center px-6">
|
||||
{TABS.map(t => (
|
||||
<button key={t.id} onClick={() => setActiveTab(t.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
|
||||
activeTab === t.id
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
|
||||
)}>
|
||||
{t.icon}{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
{/* data source banner */}
|
||||
{isLive && loading && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<RefreshCwIcon className="size-3.5 animate-spin" />
|
||||
Загрузка BGP-сессий…
|
||||
</div>
|
||||
)}
|
||||
{isLive && !loading && fetchedAt && !liveError && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2.5 py-0.5 text-[11px] font-medium text-emerald-600 dark:text-emerald-400">
|
||||
<span className="size-1.5 rounded-full bg-emerald-500" />
|
||||
Живые данные · {serverCount} серверов · обновлено {fetchedAt.toLocaleTimeString("ru")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setFetchTick(t => t + 1)}
|
||||
className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground transition-colors">
|
||||
<RefreshCwIcon className="size-3" />
|
||||
Обновить
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isLive && liveError && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-amber-500/30 bg-amber-500/8 px-3 py-2">
|
||||
<span className="size-1.5 rounded-full bg-amber-500 shrink-0" />
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">Ошибка загрузки: {liveError}</p>
|
||||
</div>
|
||||
)}
|
||||
{isLive && !loading && liveSessions.length === 0 && !liveError && fetchedAt && (
|
||||
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
BGP не настроен ни на одном сервере
|
||||
</div>
|
||||
)}
|
||||
{mode === "mock" && (
|
||||
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||
Моковые данные
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* KPI strip */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: "Сессий всего", value: sessions.length, color: "" },
|
||||
{ label: "Established", value: established, color: "text-emerald-600 dark:text-emerald-400" },
|
||||
{ label: "Не установлено", value: notEstab, color: notEstab > 0 ? "text-amber-500" : "text-muted-foreground" },
|
||||
{ label: "Получено префиксов", value: fmtNum(totalRx),color: "" },
|
||||
].map(s => (
|
||||
<Card key={s.label}>
|
||||
<CardContent className="pt-4 pb-3 px-4">
|
||||
<p className="text-xs text-muted-foreground">{s.label}</p>
|
||||
<p className={cn("text-2xl font-semibold tabular-nums mt-0.5", s.color)}>{s.value}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* alert: not-established sessions */}
|
||||
{notEstab > 0 && (
|
||||
<div className="flex items-center gap-2.5 rounded-lg border border-amber-500/30 bg-amber-500/8 px-4 py-2.5">
|
||||
<span className="size-2 rounded-full bg-amber-500 shrink-0" />
|
||||
<p className="text-sm text-amber-600 dark:text-amber-400">
|
||||
<span className="font-semibold">{notEstab} сессии</span> не в состоянии Established —
|
||||
проверьте {sessions.filter(s => s.state !== "Established").map(s => s.peerIp).join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* tab content */}
|
||||
{activeTab === "sessions" && <SessionsTab sessions={sessions} />}
|
||||
{activeTab === "routers" && <RoutersTab sessions={sessions} />}
|
||||
{activeTab === "analytics" && <AnalyticsTab sessions={sessions} />}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { routerCertificates, servers } from "@/lib/data"
|
||||
import type { RouterCertificate, CertStatus } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
SearchIcon, ShieldCheckIcon, ShieldAlertIcon, ShieldOffIcon,
|
||||
BadgeCheckIcon, AlertTriangleIcon, AlertCircleIcon,
|
||||
CalendarIcon, KeyRoundIcon, ServerIcon, PlusIcon,
|
||||
ChevronDownIcon, ChevronRightIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const STATUS_CONFIG: Record<CertStatus, {
|
||||
label: string; icon: React.ReactNode; badge: string; row: string
|
||||
}> = {
|
||||
valid: {
|
||||
label: "Действителен",
|
||||
icon: <BadgeCheckIcon className="size-4 text-emerald-500" />,
|
||||
badge: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
|
||||
row: "",
|
||||
},
|
||||
expired: {
|
||||
label: "Истёк",
|
||||
icon: <ShieldOffIcon className="size-4 text-red-500" />,
|
||||
badge: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
|
||||
row: "bg-red-500/5",
|
||||
},
|
||||
revoked: {
|
||||
label: "Отозван",
|
||||
icon: <ShieldAlertIcon className="size-4 text-amber-500" />,
|
||||
badge: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
|
||||
row: "bg-amber-500/5",
|
||||
},
|
||||
}
|
||||
|
||||
function daysLeftColor(days: number): string {
|
||||
if (days < 0) return "text-red-500"
|
||||
if (days <= 7) return "text-red-500"
|
||||
if (days <= 30) return "text-amber-500"
|
||||
return "text-emerald-600 dark:text-emerald-400"
|
||||
}
|
||||
|
||||
function daysLeftBar(days: number, total = 365): number {
|
||||
if (days <= 0) return 0
|
||||
return Math.min(100, Math.round((days / total) * 100))
|
||||
}
|
||||
|
||||
function serverForCert(cert: RouterCertificate) {
|
||||
return servers.find((s) => s.id === cert.serverId)
|
||||
}
|
||||
|
||||
// ─── Certificate row ──────────────────────────────────────────────────────────
|
||||
|
||||
function CertRow({
|
||||
cert,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
cert: RouterCertificate
|
||||
expanded: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const srv = serverForCert(cert)
|
||||
const cfg = STATUS_CONFIG[cert.status]
|
||||
const pct = daysLeftBar(cert.daysLeft)
|
||||
|
||||
return (
|
||||
<div className={cn("border-b last:border-b-0", cfg.row)}>
|
||||
<div
|
||||
className="grid grid-cols-[20px_1fr_1fr_1fr_160px_auto_auto] gap-3 px-4 py-3 items-center hover:bg-muted/30 transition-colors cursor-pointer"
|
||||
onClick={onToggle}
|
||||
>
|
||||
{/* expand */}
|
||||
<button className="text-muted-foreground" onClick={(e) => { e.stopPropagation(); onToggle() }}>
|
||||
{expanded
|
||||
? <ChevronDownIcon className="size-3.5" />
|
||||
: <ChevronRightIcon className="size-3.5" />}
|
||||
</button>
|
||||
|
||||
{/* name */}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{cfg.icon}
|
||||
<span className="font-medium text-sm truncate">{cert.name}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground font-mono mt-0.5 truncate">{cert.commonName}</p>
|
||||
</div>
|
||||
|
||||
{/* server */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground min-w-0">
|
||||
{srv ? <><Flag code={srv.country} size={12} /><span className="font-mono truncate">{srv.name}</span></> : <ServerIcon className="size-3.5" />}
|
||||
</div>
|
||||
|
||||
{/* issued by */}
|
||||
<p className="text-xs text-muted-foreground truncate">{cert.issuedBy}</p>
|
||||
|
||||
{/* days left */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs mb-1">
|
||||
<span className={cn("font-mono font-medium", daysLeftColor(cert.daysLeft))}>
|
||||
{cert.daysLeft < 0 ? `Истёк ${-cert.daysLeft}д назад` : `${cert.daysLeft}д осталось`}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[10px]">{cert.validUntil}</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all",
|
||||
cert.daysLeft < 0 ? "bg-red-500" :
|
||||
cert.daysLeft <= 7 ? "bg-red-500" :
|
||||
cert.daysLeft <= 30 ? "bg-amber-500" :
|
||||
"bg-emerald-500"
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* usage badges */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{cert.usage.map((u) => (
|
||||
<span key={u} className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground border">
|
||||
{u}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* status */}
|
||||
<span className={cn("text-[11px] font-mono px-2 py-0.5 rounded border whitespace-nowrap", cfg.badge)}>
|
||||
{cfg.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* expanded detail */}
|
||||
{expanded && (
|
||||
<div className="px-10 pb-4 grid grid-cols-2 sm:grid-cols-4 gap-4 text-xs border-t border-border/50 pt-3">
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1">Key size</p>
|
||||
<p className="font-mono font-medium">{cert.keySize} bit</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1">Действителен с</p>
|
||||
<p className="font-mono">{cert.validFrom}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1">SAN / Alt Names</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{cert.sans.length > 0
|
||||
? cert.sans.map((s) => <span key={s} className="font-mono bg-muted px-1.5 py-0.5 rounded">{s}</span>)
|
||||
: <span className="text-muted-foreground">—</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1">Trusted</p>
|
||||
<p className={cert.trusted ? "text-emerald-600 dark:text-emerald-400" : "text-red-500"}>
|
||||
{cert.trusted ? "Да (доверенный)" : "Нет (не доверенный)"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
export default function CertificatesPage() {
|
||||
const [search, setSearch] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState<CertStatus | "all">("all")
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
const expiring = useMemo(
|
||||
() => routerCertificates.filter((c) => c.status === "valid" && c.daysLeft >= 0 && c.daysLeft <= 30),
|
||||
[],
|
||||
)
|
||||
const expired = useMemo(() => routerCertificates.filter((c) => c.status === "expired"), [])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return routerCertificates.filter((c) => {
|
||||
if (statusFilter !== "all" && c.status !== statusFilter) return false
|
||||
if (!search) return true
|
||||
const q = search.toLowerCase()
|
||||
return (
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.commonName.toLowerCase().includes(q) ||
|
||||
c.issuedBy.toLowerCase().includes(q) ||
|
||||
c.sans.some((s) => s.includes(q))
|
||||
)
|
||||
})
|
||||
}, [search, statusFilter])
|
||||
|
||||
function toggleExpand(id: string) {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "Сертификаты" }]}
|
||||
actions={
|
||||
<Button size="sm">
|
||||
<PlusIcon className="size-4" />Выпустить сертификат
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
{/* Alerts */}
|
||||
{(expiring.length > 0 || expired.length > 0) && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{expired.length > 0 && (
|
||||
<div className="flex items-start gap-3 rounded-lg bg-red-500/5 border border-red-500/20 px-4 py-3 text-sm">
|
||||
<AlertCircleIcon className="size-5 text-red-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-red-600 dark:text-red-400">
|
||||
{expired.length} {expired.length === 1 ? "истёкший сертификат" : "истёкших сертификата"}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs mt-0.5">
|
||||
{expired.map((c) => c.name).join(", ")} — требуют обновления
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{expiring.length > 0 && (
|
||||
<div className="flex items-start gap-3 rounded-lg bg-amber-500/5 border border-amber-500/20 px-4 py-3 text-sm">
|
||||
<AlertTriangleIcon className="size-5 text-amber-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-amber-600 dark:text-amber-400">
|
||||
{expiring.length} {expiring.length === 1 ? "сертификат истекает" : "сертификата истекают"} в течение 30 дней
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs mt-0.5">
|
||||
{expiring.map((c) => `${c.name} (${c.daysLeft}д)`).join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* KPI */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: "Всего", value: routerCertificates.length, icon: <ShieldCheckIcon className="size-4 text-muted-foreground" /> },
|
||||
{ label: "Действующих", value: routerCertificates.filter((c) => c.status === "valid").length, icon: <BadgeCheckIcon className="size-4 text-emerald-500" /> },
|
||||
{ label: "Истекают", value: expiring.length, icon: <AlertTriangleIcon className="size-4 text-amber-500" /> },
|
||||
{ label: "Истёкших", value: expired.length, icon: <AlertCircleIcon className="size-4 text-red-500" /> },
|
||||
].map((s) => (
|
||||
<Card key={s.label}>
|
||||
<CardContent className="px-5 py-4 flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{s.label}</p>
|
||||
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
|
||||
</div>
|
||||
<div className="mt-0.5">{s.icon}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b flex-wrap">
|
||||
{/* search */}
|
||||
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[240px]">
|
||||
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<input
|
||||
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
||||
placeholder="Поиск по имени, CN, эмитенту…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* status filter */}
|
||||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
|
||||
{(["all", "valid", "expired", "revoked"] as const).map((s) => (
|
||||
<button key={s}
|
||||
onClick={() => setStatusFilter(s)}
|
||||
className={cn(
|
||||
"px-3 py-1 text-xs rounded whitespace-nowrap transition-colors",
|
||||
statusFilter === s
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}>
|
||||
{s === "all" ? "Все" : s === "valid" ? "Действующие" : s === "expired" ? "Истёкшие" : "Отозванные"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} сертификатов</span>
|
||||
</div>
|
||||
|
||||
{/* table header */}
|
||||
<div className="grid grid-cols-[20px_1fr_1fr_1fr_160px_auto_auto] gap-3 px-4 py-2 border-b text-[10px] font-semibold uppercase tracking-widest text-muted-foreground bg-muted/20">
|
||||
<span />
|
||||
<span>Имя / CN</span>
|
||||
<span>Сервер</span>
|
||||
<span>Выпущен</span>
|
||||
<div className="flex items-center gap-1"><CalendarIcon className="size-3" />Срок</div>
|
||||
<div className="flex items-center gap-1"><KeyRoundIcon className="size-3" />Использование</div>
|
||||
<span>Статус</span>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
|
||||
<ShieldCheckIcon className="size-10 mb-3 opacity-20" />
|
||||
<p className="text-sm font-medium">Сертификаты не найдены</p>
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((cert) => (
|
||||
<CertRow
|
||||
key={cert.id}
|
||||
cert={cert}
|
||||
expanded={expandedIds.has(cert.id)}
|
||||
onToggle={() => toggleExpand(cert.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* RouterOS reference */}
|
||||
<Card>
|
||||
<CardContent className="px-5 py-4">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-3">
|
||||
RouterOS 7 · /certificate — команды управления
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
||||
{[
|
||||
{
|
||||
title: "Создать CA",
|
||||
lines: [
|
||||
"/certificate add \\",
|
||||
" name=my-ca \\",
|
||||
" common-name=MyCA \\",
|
||||
" key-size=4096 \\",
|
||||
" days-valid=3650 \\",
|
||||
" key-usage=key-cert-sign,crl-sign",
|
||||
"/certificate sign my-ca",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Выпустить сертификат",
|
||||
lines: [
|
||||
"/certificate add \\",
|
||||
" name=router-cert \\",
|
||||
" common-name=router.example.com \\",
|
||||
" subject-alt-name=\\",
|
||||
" IP:10.0.0.1 \\",
|
||||
" key-size=2048 days-valid=365",
|
||||
"/certificate sign router-cert \\",
|
||||
" ca=my-ca",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Статус и экспорт",
|
||||
lines: [
|
||||
"# Список:",
|
||||
"/certificate print",
|
||||
"",
|
||||
"# Экспорт (PKCS12):",
|
||||
"/certificate export-certificate \\",
|
||||
" router-cert \\",
|
||||
" export-passphrase=secret",
|
||||
"",
|
||||
"# Импорт:",
|
||||
"/certificate import \\",
|
||||
" file-name=cert.crt",
|
||||
],
|
||||
},
|
||||
].map((b) => (
|
||||
<div key={b.title}>
|
||||
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">{b.title}</p>
|
||||
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto whitespace-pre">
|
||||
{b.lines.join("\n")}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useMemo } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import {
|
||||
Card, CardContent, CardHeader, CardTitle, CardDescription,
|
||||
} from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
PlusIcon, SearchIcon, TagIcon, ServerIcon, FilterIcon,
|
||||
ChevronRightIcon, CopyIcon, CheckIcon, TrashIcon, PencilIcon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { filters } from "@/lib/data"
|
||||
|
||||
// ─── types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type CommType = "standard" | "no-export" | "no-advertise" | "local-as" | "custom"
|
||||
|
||||
interface Community {
|
||||
id: string
|
||||
value: string // e.g. "65001:100"
|
||||
name: string
|
||||
description: string
|
||||
type: CommType
|
||||
filterIds: string[] // which filters use this community
|
||||
serverCount: number
|
||||
prefixCount: number
|
||||
action: "permit" | "deny" | "local-pref" | "metric"
|
||||
actionValue?: number // e.g. local-pref value
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
// ─── mock data ────────────────────────────────────────────────────────────────
|
||||
|
||||
const COMMUNITIES: Community[] = [
|
||||
{
|
||||
id: "c1", value: "65001:100", name: "youtube-bypass",
|
||||
description: "Пометка для трафика YouTube — маршрутизация через SPB/FRA exit nodes",
|
||||
type: "standard", filterIds: ["f1"], serverCount: 4, prefixCount: 842,
|
||||
action: "local-pref", actionValue: 200, enabled: true,
|
||||
},
|
||||
{
|
||||
id: "c2", value: "65001:200", name: "streaming-eu",
|
||||
description: "EU-стриминг (Netflix, Twitch) — выход через FRA/AMS",
|
||||
type: "standard", filterIds: ["f1", "f2"], serverCount: 3, prefixCount: 614,
|
||||
action: "local-pref", actionValue: 180, enabled: true,
|
||||
},
|
||||
{
|
||||
id: "c3", value: "65001:300", name: "cdn-bypass",
|
||||
description: "Обход CDN-провайдеров через прямые пиринговые IX-точки",
|
||||
type: "standard", filterIds: ["f3"], serverCount: 5, prefixCount: 4218,
|
||||
action: "local-pref", actionValue: 210, enabled: true,
|
||||
},
|
||||
{
|
||||
id: "c4", value: "65002:100", name: "cdn-secondary",
|
||||
description: "Резервный CDN-путь при деградации основного",
|
||||
type: "standard", filterIds: ["f3"], serverCount: 5, prefixCount: 1842,
|
||||
action: "metric", actionValue: 50, enabled: true,
|
||||
},
|
||||
{
|
||||
id: "c5", value: "65001:400", name: "office-direct",
|
||||
description: "Office 365 / Teams — прямой выход без туннелирования",
|
||||
type: "standard", filterIds: ["f5"], serverCount: 6, prefixCount: 882,
|
||||
action: "permit", enabled: true,
|
||||
},
|
||||
{
|
||||
id: "c6", value: "65001:500", name: "gaming-ll",
|
||||
description: "Low-latency gaming — приоритет по минимальному RTT (Discord, Steam)",
|
||||
type: "standard", filterIds: ["f6"], serverCount: 4, prefixCount: 1212,
|
||||
action: "local-pref", actionValue: 250, enabled: true,
|
||||
},
|
||||
{
|
||||
id: "c7", value: "65002:200", name: "gaming-ll-backup",
|
||||
description: "Резервный путь для gaming-low-latency",
|
||||
type: "standard", filterIds: ["f6"], serverCount: 4, prefixCount: 412,
|
||||
action: "metric", actionValue: 80, enabled: true,
|
||||
},
|
||||
{
|
||||
id: "c8", value: "65007:999", name: "school-block",
|
||||
description: "Блокировка соцсетей для школьного сегмента LAN",
|
||||
type: "standard", filterIds: ["f4"], serverCount: 2, prefixCount: 412,
|
||||
action: "deny", enabled: false,
|
||||
},
|
||||
{
|
||||
id: "c9", value: "no-export", name: "no-export",
|
||||
description: "Не объявлять маршрут за пределы AS (RFC 1997)",
|
||||
type: "no-export", filterIds: [], serverCount: 1, prefixCount: 0,
|
||||
action: "permit", enabled: true,
|
||||
},
|
||||
{
|
||||
id: "c10", value: "no-advertise", name: "no-advertise",
|
||||
description: "Не передавать маршрут ни одному BGP-пиру (RFC 1997)",
|
||||
type: "no-advertise", filterIds: [], serverCount: 1, prefixCount: 0,
|
||||
action: "permit", enabled: true,
|
||||
},
|
||||
]
|
||||
|
||||
const TYPE_LABELS: Record<CommType, string> = {
|
||||
"standard": "Стандартный",
|
||||
"no-export": "No-export",
|
||||
"no-advertise":"No-advertise",
|
||||
"local-as": "Local-AS",
|
||||
"custom": "Кастомный",
|
||||
}
|
||||
|
||||
const ACTION_LABELS: Record<Community["action"], string> = {
|
||||
"permit": "Permit",
|
||||
"deny": "Deny",
|
||||
"local-pref": "Local-pref",
|
||||
"metric": "MED/Metric",
|
||||
}
|
||||
|
||||
const ACTION_COLOR: Record<Community["action"], string> = {
|
||||
"permit": "text-emerald-500",
|
||||
"deny": "text-red-500",
|
||||
"local-pref": "text-blue-500",
|
||||
"metric": "text-amber-500",
|
||||
}
|
||||
|
||||
// ─── page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function CommunitiesPage() {
|
||||
const [search, setSearch] = useState("")
|
||||
const [typeFilter, setTypeFilter] = useState<CommType | "all">("all")
|
||||
const [copied, setCopied] = useState<string | null>(null)
|
||||
const [selected, setSelected] = useState<Community | null>(null)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.toLowerCase()
|
||||
return COMMUNITIES.filter(c => {
|
||||
const matchQ = !q || c.value.toLowerCase().includes(q) || c.name.toLowerCase().includes(q) || c.description.toLowerCase().includes(q)
|
||||
const matchT = typeFilter === "all" || c.type === typeFilter
|
||||
return matchQ && matchT
|
||||
})
|
||||
}, [search, typeFilter])
|
||||
|
||||
const handleCopy = (value: string) => {
|
||||
navigator.clipboard.writeText(value).catch(() => {})
|
||||
setCopied(value)
|
||||
setTimeout(() => setCopied(null), 1500)
|
||||
}
|
||||
|
||||
const filterName = (id: string) => filters.find(f => f.id === id)?.name ?? id
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Данные" }, { label: "Communities" }]}
|
||||
actions={
|
||||
<Button size="sm">
|
||||
<PlusIcon className="size-4" />Добавить
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
{/* ── summary ── */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: "Всего communities", value: String(COMMUNITIES.length) },
|
||||
{ label: "Активных", value: String(COMMUNITIES.filter(c => c.enabled).length) },
|
||||
{ label: "Стандартных", value: String(COMMUNITIES.filter(c => c.type === "standard").length) },
|
||||
{ label: "Использует фильтры",value: String(new Set(COMMUNITIES.flatMap(c => c.filterIds)).size) },
|
||||
].map(({ label, value }) => (
|
||||
<Card key={label}>
|
||||
<CardContent className="pt-4 pb-3 px-4">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="text-2xl font-semibold tabular-nums">{value}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[1fr_320px] gap-5">
|
||||
{/* ── main table ── */}
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* toolbar */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<div className="relative flex-1 min-w-[180px]">
|
||||
<SearchIcon className="absolute left-2.5 top-2 size-4 text-muted-foreground" />
|
||||
<Input className="pl-8 h-8 text-sm" placeholder="Поиск community…" value={search} onChange={e => setSearch(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{(["all", "standard", "no-export", "no-advertise", "custom"] as const).map(t => (
|
||||
<Button key={t} size="sm" variant={typeFilter === t ? "default" : "outline"}
|
||||
className="h-8 text-xs px-2.5" onClick={() => setTypeFilter(t)}>
|
||||
{t === "all" ? "Все" : TYPE_LABELS[t] ?? t}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* list */}
|
||||
<Card className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-xs text-muted-foreground">
|
||||
<th className="text-left font-medium px-4 py-2.5">Community</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Имя / описание</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Тип</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Действие</th>
|
||||
<th className="text-right font-medium px-4 py-2.5">Маршрутов</th>
|
||||
<th className="text-right font-medium px-4 py-2.5">Серверов</th>
|
||||
<th className="px-4 py-2.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(c => (
|
||||
<tr
|
||||
key={c.id}
|
||||
onClick={() => setSelected(c)}
|
||||
className={cn(
|
||||
"border-b last:border-0 cursor-pointer hover:bg-muted/40 transition-colors",
|
||||
selected?.id === c.id && "bg-primary/5",
|
||||
!c.enabled && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<TagIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="font-mono text-xs font-medium bg-muted px-1.5 py-0.5 rounded">
|
||||
{c.value}
|
||||
</span>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); handleCopy(c.value) }}
|
||||
className="text-muted-foreground/40 hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
{copied === c.value
|
||||
? <CheckIcon className="size-3" />
|
||||
: <CopyIcon className="size-3" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<p className="font-medium text-xs">{c.name}</p>
|
||||
<p className="text-xs text-muted-foreground line-clamp-1">{c.description}</p>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className="text-xs text-muted-foreground">{TYPE_LABELS[c.type]}</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className={cn("text-xs font-medium", ACTION_COLOR[c.action])}>
|
||||
{ACTION_LABELS[c.action]}{c.actionValue !== undefined ? ` ${c.actionValue}` : ""}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono text-xs">
|
||||
{c.prefixCount > 0 ? c.prefixCount.toLocaleString() : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<ServerIcon className="size-3 text-muted-foreground" />
|
||||
<span className="font-mono text-xs">{c.serverCount}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<ChevronRightIcon className="size-4 text-muted-foreground/40" />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{filtered.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-2 text-muted-foreground">
|
||||
<TagIcon className="size-8 opacity-30" />
|
||||
<p className="text-sm">Ничего не найдено</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* ── detail panel ── */}
|
||||
{selected ? (
|
||||
<Card className="h-fit sticky top-0">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={cn(
|
||||
"inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium",
|
||||
selected.enabled
|
||||
? "bg-emerald-500/10 text-emerald-600"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{selected.enabled ? "Активен" : "Выключен"}
|
||||
</span>
|
||||
</div>
|
||||
<CardTitle className="text-sm font-mono">{selected.value}</CardTitle>
|
||||
<CardDescription className="text-xs mt-0.5">{selected.name}</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<Button size="sm" variant="ghost" className="size-7 p-0"><PencilIcon className="size-3.5" /></Button>
|
||||
<Button size="sm" variant="ghost" className="size-7 p-0 text-destructive hover:text-destructive"><TrashIcon className="size-3.5" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-sm">
|
||||
<p className="text-xs text-muted-foreground">{selected.description}</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
["Тип", TYPE_LABELS[selected.type]],
|
||||
["Действие", `${ACTION_LABELS[selected.action]}${selected.actionValue !== undefined ? ` ${selected.actionValue}` : ""}`],
|
||||
["Маршрутов", selected.prefixCount > 0 ? selected.prefixCount.toLocaleString() : "—"],
|
||||
["Серверов", String(selected.serverCount)],
|
||||
].map(([k, v]) => (
|
||||
<div key={k} className="flex justify-between gap-2">
|
||||
<span className="text-xs text-muted-foreground">{k}</span>
|
||||
<span className="text-xs font-medium">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selected.filterIds.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-2 flex items-center gap-1.5">
|
||||
<FilterIcon className="size-3" />Использующие фильтры
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selected.filterIds.map(fid => (
|
||||
<span key={fid} className="text-[11px] bg-muted px-2 py-0.5 rounded font-mono">
|
||||
{filterName(fid)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-2 border-t flex flex-col gap-2">
|
||||
<Button size="sm" variant="outline" className="w-full justify-start gap-2" onClick={() => handleCopy(selected.value)}>
|
||||
{copied === selected.value ? <CheckIcon className="size-3.5" /> : <CopyIcon className="size-3.5" />}
|
||||
Скопировать значение
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="h-fit">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 gap-3 text-center">
|
||||
<TagIcon className="size-8 text-muted-foreground/30" />
|
||||
<p className="text-xs text-muted-foreground">Выберите community<br />для просмотра деталей</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { routerContainers, servers } from "@/lib/data"
|
||||
import type { RouterContainer } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
||||
DropdownMenuItem, DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
BoxIcon, PlayIcon, StopCircleIcon, SearchIcon,
|
||||
MoreHorizontalIcon, Trash2Icon, PencilIcon, PowerIcon,
|
||||
CodeXmlIcon, CopyIcon, CheckIcon, ActivityIcon, ServerIcon,
|
||||
TerminalIcon, AlertCircleIcon,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||
SheetDescription, SheetFooter, SheetClose,
|
||||
} from "@/components/ui/sheet"
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function serverFor(id: string) {
|
||||
return servers.find((s) => s.id === id)
|
||||
}
|
||||
|
||||
function statusConfig(status: RouterContainer["status"]) {
|
||||
return {
|
||||
running: {
|
||||
dot: "bg-emerald-500 animate-pulse",
|
||||
badge: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
|
||||
label: "Running",
|
||||
},
|
||||
stopped: {
|
||||
dot: "bg-muted-foreground",
|
||||
badge: "bg-muted/50 text-muted-foreground border-border",
|
||||
label: "Stopped",
|
||||
},
|
||||
error: {
|
||||
dot: "bg-red-500 animate-pulse",
|
||||
badge: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
|
||||
label: "Error",
|
||||
},
|
||||
}[status]
|
||||
}
|
||||
|
||||
// ─── RSC generator ────────────────────────────────────────────────────────────
|
||||
|
||||
function generateContainerRsc(c: RouterContainer): string {
|
||||
const srv = serverFor(c.serverId)
|
||||
const lines: string[] = []
|
||||
lines.push(`# RouterOS Container — ${c.name}`)
|
||||
if (srv) lines.push(`# Сервер: ${srv.name} (${srv.host})`)
|
||||
lines.push(`# Образ: ${c.image}:${c.tag}`)
|
||||
lines.push(`# RouterOS 7.4+ · /container`)
|
||||
lines.push(``)
|
||||
|
||||
// interface
|
||||
for (const iface of c.interfaces) {
|
||||
lines.push(`/interface/veth/add name=${iface} address=172.17.0.2/24 gateway=172.17.0.1`)
|
||||
}
|
||||
lines.push(``)
|
||||
|
||||
// envs
|
||||
if (c.envs.length > 0) {
|
||||
lines.push(`/container/envs/add name=${c.name}-envs \\`)
|
||||
for (const { key, value } of c.envs) {
|
||||
lines.push(` ${key}="${value}" \\`)
|
||||
}
|
||||
lines.push(``)
|
||||
}
|
||||
|
||||
// mounts
|
||||
for (const m of c.mounts) {
|
||||
lines.push(`/container/mounts/add name=${c.name}-mount-${m.dst.replace(/\//g, "-").slice(1)} \\`)
|
||||
if (m.src) lines.push(` src=${m.src} \\`)
|
||||
lines.push(` dst=${m.dst}`)
|
||||
lines.push(``)
|
||||
}
|
||||
|
||||
// container
|
||||
lines.push(`/container/add \\`)
|
||||
lines.push(` remote-image=${c.image}:${c.tag} \\`)
|
||||
lines.push(` interface=${c.interfaces[0] ?? "veth-container"} \\`)
|
||||
if (c.envs.length > 0) lines.push(` envlist=${c.name}-envs \\`)
|
||||
if (c.mounts.length > 0) lines.push(` mounts=${c.mounts.map((m) => `${c.name}-mount-${m.dst.replace(/\//g, "-").slice(1)}`).join(",")} \\`)
|
||||
if (c.cmd) lines.push(` cmd="${c.cmd}" \\`)
|
||||
if (c.startOnBoot) lines.push(` start-on-boot=yes \\`)
|
||||
if (c.comment) lines.push(` comment="${c.comment}" \\`)
|
||||
lines.push(` logging=yes`)
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
// ─── Export Sheet ─────────────────────────────────────────────────────────────
|
||||
|
||||
function ExportSheet({ open, container, onClose }: {
|
||||
open: boolean; container: RouterContainer | null; onClose: () => void
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const code = useMemo(() => container ? generateContainerRsc(container) : "", [container])
|
||||
|
||||
function handleCopy() {
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
setCopied(true); setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl">
|
||||
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<SheetTitle>Экспорт Container</SheetTitle>
|
||||
<SheetDescription>RouterOS 7.4+ · /container · /interface/veth</SheetDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}>
|
||||
{copied ? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</> : <><CopyIcon className="size-3.5" />Копировать</>}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all">
|
||||
{code.split("\n").map((line, i) => {
|
||||
const isComment = line.startsWith("#")
|
||||
const isCmd = /^\//.test(line.trimStart())
|
||||
const isParam = /^\s+[a-z]/.test(line)
|
||||
return (
|
||||
<span key={i} className={
|
||||
isComment ? "text-muted-foreground"
|
||||
: isCmd ? "text-sky-400"
|
||||
: isParam ? "text-violet-300"
|
||||
: "text-foreground"
|
||||
}>
|
||||
{line}{"\n"}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</pre>
|
||||
</div>
|
||||
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
|
||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
|
||||
<Button className="flex-1" onClick={handleCopy}>
|
||||
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
|
||||
{copied ? "Скопировано" : "Копировать .rsc"}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Container card ───────────────────────────────────────────────────────────
|
||||
|
||||
function ContainerCard({
|
||||
container,
|
||||
onExport,
|
||||
}: {
|
||||
container: RouterContainer
|
||||
onExport: () => void
|
||||
}) {
|
||||
const srv = serverFor(container.serverId)
|
||||
const cfg = statusConfig(container.status)
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<div className="px-4 py-3 border-b flex items-center justify-between gap-2 bg-muted/10">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className={cn("size-2 rounded-full shrink-0", cfg.dot)} />
|
||||
<span className="font-mono font-semibold text-sm truncate">{container.name}</span>
|
||||
<span className={cn("text-[10px] font-mono px-1.5 py-0.5 rounded border shrink-0", cfg.badge)}>
|
||||
{cfg.label}
|
||||
</span>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={
|
||||
<Button variant="ghost" size="icon" className="size-7 shrink-0">
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
} />
|
||||
<DropdownMenuContent side="bottom" align="end">
|
||||
{container.status === "running" ? (
|
||||
<DropdownMenuItem><StopCircleIcon className="size-4 text-amber-500" />Остановить</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem><PlayIcon className="size-4 text-emerald-500" />Запустить</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem><TerminalIcon className="size-4" />Логи</DropdownMenuItem>
|
||||
<DropdownMenuItem><PencilIcon className="size-4" />Редактировать</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onExport}><CodeXmlIcon className="size-4" />Экспорт .rsc</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem><PowerIcon className="size-4" />Перезапустить</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive"><Trash2Icon className="size-4" />Удалить</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<CardContent className="px-4 py-3 flex flex-col gap-3">
|
||||
{/* image */}
|
||||
<div className="flex items-center gap-2">
|
||||
<BoxIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="font-mono text-xs text-foreground/80">
|
||||
{container.image}:<span className="text-muted-foreground">{container.tag}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* server */}
|
||||
{srv && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<ServerIcon className="size-3.5 shrink-0" />
|
||||
<Flag code={srv.country} size={12} />
|
||||
<span className="font-mono">{srv.name}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* uptime + stats */}
|
||||
{container.status === "running" && (
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground border-t pt-2.5">
|
||||
{container.uptime && (
|
||||
<div className="flex items-center gap-1">
|
||||
<ActivityIcon className="size-3 text-emerald-500" />
|
||||
<span>{container.uptime}</span>
|
||||
</div>
|
||||
)}
|
||||
{container.cpu !== undefined && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">CPU </span>
|
||||
<span className={cn("font-mono font-medium",
|
||||
container.cpu > 50 ? "text-amber-500" : "text-foreground")}>
|
||||
{container.cpu}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{container.memMb !== undefined && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">RAM </span>
|
||||
<span className="font-mono font-medium">{container.memMb} МБ</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* interfaces */}
|
||||
{container.interfaces.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{container.interfaces.map((i) => (
|
||||
<span key={i} className="text-[10px] font-mono px-1.5 py-0.5 bg-muted border rounded text-muted-foreground">
|
||||
{i}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* mounts */}
|
||||
{container.mounts.length > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{container.mounts.map((m, idx) => (
|
||||
<div key={idx} className="text-[11px] font-mono text-muted-foreground flex items-center gap-1">
|
||||
<span className="text-muted-foreground/40">→</span>
|
||||
<span>{m.dst}</span>
|
||||
{m.src && <><span className="text-muted-foreground/40">←</span><span>{m.src}</span></>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{container.comment && (
|
||||
<p className="text-xs text-muted-foreground border-t pt-2">{container.comment}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
export default function ContainersPage() {
|
||||
const [search, setSearch] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState<RouterContainer["status"] | "all">("all")
|
||||
const [exportContainer, setExportContainer] = useState<RouterContainer | null>(null)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return routerContainers.filter((c) => {
|
||||
if (statusFilter !== "all" && c.status !== statusFilter) return false
|
||||
if (!search) return true
|
||||
const q = search.toLowerCase()
|
||||
return (
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.image.toLowerCase().includes(q) ||
|
||||
(serverFor(c.serverId)?.name.toLowerCase().includes(q) ?? false)
|
||||
)
|
||||
})
|
||||
}, [search, statusFilter])
|
||||
|
||||
const running = routerContainers.filter((c) => c.status === "running").length
|
||||
const stopped = routerContainers.filter((c) => c.status === "stopped").length
|
||||
const errors = routerContainers.filter((c) => c.status === "error").length
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "Контейнеры" }]}
|
||||
actions={
|
||||
<Button size="sm">
|
||||
<BoxIcon className="size-4" />Новый контейнер
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
{/* KPI */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: "Всего", value: routerContainers.length, icon: <BoxIcon className="size-4 text-muted-foreground" /> },
|
||||
{ label: "Running", value: running, icon: <PlayIcon className="size-4 text-emerald-500" /> },
|
||||
{ label: "Stopped", value: stopped, icon: <StopCircleIcon className="size-4 text-muted-foreground" /> },
|
||||
{ label: "Ошибок", value: errors, icon: <AlertCircleIcon className="size-4 text-red-500" /> },
|
||||
].map((s) => (
|
||||
<Card key={s.label}>
|
||||
<CardContent className="px-5 py-4 flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{s.label}</p>
|
||||
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
|
||||
</div>
|
||||
<div className="mt-0.5">{s.icon}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Info banner */}
|
||||
<div className="flex items-start gap-3 rounded-lg bg-violet-500/5 border border-violet-500/20 px-4 py-3 text-sm">
|
||||
<BoxIcon className="size-5 text-violet-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-violet-600 dark:text-violet-400">Контейнеры доступны с RouterOS 7.4+</p>
|
||||
<p className="text-muted-foreground text-xs mt-0.5">
|
||||
Поддерживаются Docker-совместимые образы. Требуется установка пакета <code className="font-mono bg-muted px-1 rounded">container</code>.
|
||||
Интерфейсы veth создаются автоматически.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[240px]">
|
||||
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<input
|
||||
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
||||
placeholder="Поиск по имени, образу, серверу…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
|
||||
{(["all", "running", "stopped", "error"] as const).map((s) => (
|
||||
<button key={s}
|
||||
onClick={() => setStatusFilter(s)}
|
||||
className={cn(
|
||||
"px-3 py-1 text-xs rounded whitespace-nowrap transition-colors",
|
||||
statusFilter === s
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}>
|
||||
{s === "all" ? "Все" : s === "running" ? "Running" : s === "stopped" ? "Stopped" : "Error"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} контейнеров</span>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
{filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
|
||||
<BoxIcon className="size-10 mb-3 opacity-20" />
|
||||
<p className="text-sm font-medium">Контейнеры не найдены</p>
|
||||
<p className="text-xs mt-1">Добавьте первый контейнер или измените фильтр</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{filtered.map((c) => (
|
||||
<ContainerCard
|
||||
key={c.id}
|
||||
container={c}
|
||||
onExport={() => setExportContainer(c)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* RouterOS reference */}
|
||||
<Card>
|
||||
<CardContent className="px-5 py-4">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-3">
|
||||
RouterOS 7.4+ · /container — быстрые команды
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
||||
{[
|
||||
{
|
||||
title: "Установка пакета",
|
||||
lines: [
|
||||
"# Скачать пакет container:",
|
||||
"# mikrotik.com → Software",
|
||||
"",
|
||||
"/system/package/install",
|
||||
" container",
|
||||
"",
|
||||
"# Перезагрузить:",
|
||||
"/system/reboot",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Создать контейнер",
|
||||
lines: [
|
||||
"# veth интерфейс:",
|
||||
"/interface/veth/add \\",
|
||||
" name=veth-nginx \\",
|
||||
" address=172.17.0.2/24 \\",
|
||||
" gateway=172.17.0.1",
|
||||
"",
|
||||
"# Контейнер:",
|
||||
"/container/add \\",
|
||||
" remote-image=nginx:alpine \\",
|
||||
" interface=veth-nginx",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Управление",
|
||||
lines: [
|
||||
"# Список:",
|
||||
"/container/print",
|
||||
"",
|
||||
"# Запуск:",
|
||||
"/container/start 0",
|
||||
"",
|
||||
"# Остановка:",
|
||||
"/container/stop 0",
|
||||
"",
|
||||
"# Логи:",
|
||||
"/container/shell 0",
|
||||
],
|
||||
},
|
||||
].map((b) => (
|
||||
<div key={b.title}>
|
||||
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">{b.title}</p>
|
||||
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto whitespace-pre">
|
||||
{b.lines.join("\n")}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ExportSheet
|
||||
open={!!exportContainer}
|
||||
container={exportContainer}
|
||||
onClose={() => setExportContainer(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { StatusDot } from "@/components/status-dot"
|
||||
import { StatusBadge } from "@/components/status-badge"
|
||||
import { Sparkline } from "@/components/sparkline"
|
||||
import { LatencyChart } from "@/components/dashboard/latency-chart"
|
||||
import { BandwidthChart } from "@/components/dashboard/bandwidth-chart"
|
||||
import { servers, pingProbes, systemEvents, dashLatency, traffic } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { AlertCircleIcon, AlertTriangleIcon, InfoIcon, FilterIcon, DownloadIcon } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function StatCard({
|
||||
label, value, unit, delta, deltaDir, spark, sparkColor,
|
||||
}: {
|
||||
label: string; value: string; unit?: string; delta?: string
|
||||
deltaDir?: "up" | "down"; spark?: number[]; sparkColor?: string
|
||||
}) {
|
||||
return (
|
||||
<Card className="relative overflow-hidden">
|
||||
<CardContent className="pt-5 pb-4 px-5">
|
||||
<p className="text-sm font-medium text-muted-foreground">{label}</p>
|
||||
<div className="flex items-baseline gap-1.5 mt-1">
|
||||
<span className="text-3xl font-semibold tracking-tight tabular-nums">{value}</span>
|
||||
{unit && <span className="text-sm text-muted-foreground">{unit}</span>}
|
||||
</div>
|
||||
{delta && (
|
||||
<p className={`text-xs mt-1 flex items-center gap-1 ${deltaDir === "up" ? "text-emerald-600 dark:text-emerald-400" : deltaDir === "down" ? "text-red-500 dark:text-red-400" : "text-muted-foreground"}`}>
|
||||
{delta}
|
||||
</p>
|
||||
)}
|
||||
{spark && spark.length > 1 && (
|
||||
<div className="absolute right-4 bottom-4 opacity-60">
|
||||
<Sparkline data={spark} width={80} height={32} color={sparkColor ?? "currentColor"} filled />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Обзор" }, { label: "Дашборд" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="p-6 flex flex-col gap-6">
|
||||
|
||||
{/* KPI row */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label="Серверы онлайн" value="6" unit="/ 7"
|
||||
delta="+1 со вчерашнего" deltaDir="up"
|
||||
spark={[5,5,6,6,5,6,6,6,7,6,6,6,6,6,6,6,6]}
|
||||
sparkColor="var(--chart-line-1)"
|
||||
/>
|
||||
<StatCard
|
||||
label="Активные фильтры" value="5" unit="/ 6"
|
||||
delta="2.4к доменов синхронизировано" deltaDir="up"
|
||||
spark={[3,4,4,5,4,5,5,5,5,5,5,5,5,5,5]}
|
||||
sparkColor="var(--chart-line-2)"
|
||||
/>
|
||||
<StatCard
|
||||
label="BGP-префиксы" value="8 432"
|
||||
delta="+412 в последнем обновлении" deltaDir="up"
|
||||
spark={[7800,7900,8000,8100,8050,8120,8200,8240,8300,8350,8380,8400,8420,8430,8432]}
|
||||
sparkColor="var(--chart-line-4)"
|
||||
/>
|
||||
<StatCard
|
||||
label="Активные алерты" value="4"
|
||||
delta="2 критических · 2 предупреждения" deltaDir="down"
|
||||
spark={[1,2,2,3,3,4,5,4,4,4,3,3,4,4,4]}
|
||||
sparkColor="var(--chart-5)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Latency chart + Events */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">Задержка до серверов</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">Последние 60 минут · ping от монитора → серверы MikroTik</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 px-3">
|
||||
<LatencyChart series={dashLatency} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">Последние события</CardTitle>
|
||||
<Button variant="ghost" size="sm" className="text-xs h-7">Все →</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Система и BGP-активность</p>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 px-0">
|
||||
<div className="divide-y divide-border">
|
||||
{systemEvents.map((e) => (
|
||||
<div key={e.id} className="grid grid-cols-[20px_1fr_auto] gap-3 px-5 py-3 items-start">
|
||||
<div className="mt-0.5">
|
||||
{e.sev === "destructive" && <AlertCircleIcon className="size-4 text-red-500" />}
|
||||
{e.sev === "warning" && <AlertTriangleIcon className="size-4 text-amber-500" />}
|
||||
{e.sev === "info" && <InfoIcon className="size-4 text-blue-500" />}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[13px] font-medium leading-tight">{e.title}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">{e.message}</p>
|
||||
</div>
|
||||
<span className="text-[11px] font-mono text-muted-foreground">{e.when}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Bandwidth + Server status */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[3fr_2fr] gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">Пропускная способность</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">Суммарный RX / TX по всем серверам</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span className="flex items-center gap-1.5"><span className="w-3 h-0.5 bg-foreground/80 rounded inline-block" />RX 318 Мбит/с</span>
|
||||
<span className="flex items-center gap-1.5"><span className="w-3 h-0.5 bg-blue-500 rounded inline-block" />TX 244 Мбит/с</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 px-3">
|
||||
<BandwidthChart rx={traffic.rx} tx={traffic.tx} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">Состояние серверов</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">7 узлов MikroTik</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs">Управление →</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 px-0">
|
||||
<div className="divide-y divide-border">
|
||||
{servers.slice(0, 5).map((s) => (
|
||||
<div key={s.id} className="grid grid-cols-[10px_1fr_auto_auto] gap-3 px-5 py-2.5 items-center">
|
||||
<StatusDot status={s.status} pulse />
|
||||
<div>
|
||||
<p className="text-[13px] font-medium leading-tight">{s.name}</p>
|
||||
<p className="text-[11px] font-mono text-muted-foreground flex items-center gap-1">
|
||||
<Flag code={s.country} className="not-mono" />{s.host} · {s.site} · {s.asn}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`text-xs font-mono ${s.latency == null ? "text-red-500" : s.latency > 60 ? "text-amber-500" : "text-muted-foreground"}`}>
|
||||
{s.latency == null ? "недоступен" : `${s.latency}мс`}
|
||||
</span>
|
||||
<StatusBadge status={s.status} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Ping probes table */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">Активные пробы</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">8 ping-зондов · опрос раз в секунду</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs">Открыть монитор →</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 px-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-xs text-muted-foreground">
|
||||
<th className="text-left font-medium px-5 py-2.5">Проба</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Цель</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Фильтр</th>
|
||||
<th className="text-right font-medium px-4 py-2.5">RTT</th>
|
||||
<th className="text-right font-medium px-4 py-2.5">Потери</th>
|
||||
<th className="text-left font-medium px-4 py-2.5 w-36">60с</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Статус</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{pingProbes.map((p) => {
|
||||
const sparkColor = p.status === "down" ? "hsl(0 84% 60%)" : p.status === "warn" ? "hsl(32 94% 44%)" : "hsl(142 76% 36%)"
|
||||
return (
|
||||
<tr key={p.id} className="hover:bg-muted/40 transition-colors">
|
||||
<td className="px-5 py-2.5 font-medium">{p.name}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground">{p.target}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className="inline-flex items-center gap-1 text-xs border border-border rounded px-2 py-0.5">
|
||||
<FilterIcon className="size-3 text-muted-foreground" />{p.filter}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-right">{p.rtt == null ? "—" : `${p.rtt} мс`}</td>
|
||||
<td className={`px-4 py-2.5 font-mono text-right ${p.loss > 5 ? "text-red-500" : p.loss > 0 ? "text-amber-500" : "text-muted-foreground"}`}>
|
||||
{p.loss}%
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<Sparkline data={p.series} width={120} height={24} color={sparkColor} />
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<StatusBadge status={p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"} />
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client"
|
||||
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { DataTable } from "@/components/data-table"
|
||||
import { domains } from "@/lib/data"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon } from "lucide-react"
|
||||
|
||||
export default function DomainsPage() {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Данные" }, { label: "Домены" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm"><UploadIcon className="size-4" />Импорт</Button>
|
||||
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||||
<Button size="sm"><PlusIcon className="size-4" />Добавить домен</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Домены</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Отслеживаемые домены для BGP-фильтрации — синхронизируются в address-list MikroTik
|
||||
</p>
|
||||
</div>
|
||||
<DataTable
|
||||
data={domains}
|
||||
searchPlaceholder="Поиск по домену…"
|
||||
searchKeys={["domain", "asn", "filter"]}
|
||||
columns={[
|
||||
{
|
||||
key: "domain",
|
||||
label: "Домен",
|
||||
render: (d) => <span className="font-medium">{d.domain}</span>,
|
||||
},
|
||||
{
|
||||
key: "resolvedIp",
|
||||
label: "Resolved IP",
|
||||
render: (d) => <span className="font-mono text-xs text-muted-foreground">{d.resolvedIp}</span>,
|
||||
},
|
||||
{
|
||||
key: "asn",
|
||||
label: "ASN",
|
||||
render: (d) => <span className="font-mono text-xs">{d.asn}</span>,
|
||||
},
|
||||
{
|
||||
key: "purpose",
|
||||
label: "Назначение",
|
||||
render: (d) => (
|
||||
<span className="text-xs border border-border rounded px-2 py-0.5">{d.purpose}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "filter",
|
||||
label: "Фильтр",
|
||||
render: (d) => (
|
||||
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
|
||||
<FilterIcon className="size-3 text-muted-foreground" />{d.filter}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "updated",
|
||||
label: "Обновлён",
|
||||
render: (d) => <span className="text-xs text-muted-foreground">{d.updated}</span>,
|
||||
},
|
||||
{
|
||||
key: "enabled",
|
||||
label: "Статус",
|
||||
render: (d) => (
|
||||
<span className={`text-xs font-medium ${d.enabled ? "text-emerald-600" : "text-muted-foreground"}`}>
|
||||
{d.enabled ? "Активен" : "Отключён"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,864 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { greTunnels, grePools, servers } from "@/lib/data"
|
||||
import type { GreTunnel, GreStatus, IpsecEncAlg, IpsecAuthAlg, IpsecDhGroup, IkeVersion } from "@/lib/data"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||
SheetDescription, SheetFooter, SheetClose,
|
||||
} from "@/components/ui/sheet"
|
||||
import {
|
||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
||||
DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, DropdownMenuGroup,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Flag } from "@/components/flag"
|
||||
import {
|
||||
PlusIcon, SearchIcon, RefreshCwIcon, MoreHorizontalIcon,
|
||||
LockIcon, LockOpenIcon, ShieldCheckIcon, NetworkIcon,
|
||||
EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon,
|
||||
CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon, CopyIcon, CheckIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
// ─── label maps ─────────────────────────────────────────────────────────────
|
||||
|
||||
const ENC_LABELS: Record<IpsecEncAlg, string> = { "aes-128": "AES-128", "aes-192": "AES-192", "aes-256": "AES-256" }
|
||||
const AUTH_LABELS: Record<IpsecAuthAlg, string> = { sha1: "SHA-1", sha256: "SHA-256", sha512: "SHA-512" }
|
||||
const DH_LABELS: Record<IpsecDhGroup, string> = {
|
||||
modp1024: "DH-2 (1024)", modp2048: "DH-14 (2048)", modp4096: "DH-16 (4096)",
|
||||
ecp256: "ECP-256", ecp384: "ECP-384", ecp521: "ECP-521",
|
||||
}
|
||||
const IKE_LABELS: Record<IkeVersion, string> = { ikev1: "IKEv1", ikev2: "IKEv2" }
|
||||
|
||||
const ENC_ROS: Record<IpsecEncAlg, string> = { "aes-128": "aes-128-cbc", "aes-192": "aes-192-cbc", "aes-256": "aes-256-cbc" }
|
||||
const AUTH_ROS: Record<IpsecAuthAlg, string> = { sha1: "sha1", sha256: "sha256", sha512: "sha512" }
|
||||
|
||||
const STATUS_MAP: Record<GreStatus, { label: string; dot: string }> = {
|
||||
up: { label: "Up", dot: "bg-emerald-500" },
|
||||
degraded: { label: "Degraded", dot: "bg-amber-500" },
|
||||
down: { label: "Down", dot: "bg-red-500" },
|
||||
}
|
||||
|
||||
const serverById = Object.fromEntries(servers.map((s) => [s.id, s]))
|
||||
const poolById = Object.fromEntries(grePools.map((p) => [p.id, p]))
|
||||
|
||||
// ─── RouterOS code generator ─────────────────────────────────────────────────
|
||||
|
||||
function generateRosCommands(t: GreTunnel): string {
|
||||
const lines: string[] = []
|
||||
const srv = serverById[t.serverId]
|
||||
|
||||
lines.push(`# Сервер: ${srv?.name ?? t.serverId} (${srv?.host ?? ""})`)
|
||||
lines.push(`# Сгенерировано RouterLists · ${new Date().toLocaleDateString("ru")}`)
|
||||
lines.push("")
|
||||
|
||||
// GRE interface
|
||||
lines.push("# ── GRE-интерфейс ────────────────────────────────────────────")
|
||||
const greParts = [
|
||||
`/interface gre add`,
|
||||
` name=${t.name}`,
|
||||
` remote-address=${t.remoteAddress}`,
|
||||
t.localAddress !== "0.0.0.0" ? ` local-address=${t.localAddress}` : null,
|
||||
t.ipsec ? ` ipsec-secret="${t.ipsec.secret}"` : null,
|
||||
` mtu=${t.mtu}`,
|
||||
t.keepaliveInterval > 0 ? ` keepalive=${t.keepaliveInterval}s,${t.keepaliveRetries}` : ` keepalive=0`,
|
||||
` dscp=${t.dscp}`,
|
||||
` clamp-tcp-mss=${t.clampTcpMss ? "yes" : "no"}`,
|
||||
` allow-fast-path=${t.allowFastPath ? "yes" : "no"}`,
|
||||
t.comment ? ` comment="${t.comment}"` : null,
|
||||
!t.enabled ? ` disabled=yes` : null,
|
||||
].filter(Boolean) as string[]
|
||||
lines.push(greParts.join(" \\\n"))
|
||||
|
||||
// Inner IP
|
||||
lines.push("")
|
||||
lines.push("# ── Внутренний IP ────────────────────────────────────────────")
|
||||
lines.push(`/ip address add \\`)
|
||||
lines.push(` address=${t.localInnerIp} \\`)
|
||||
lines.push(` interface=${t.name}`)
|
||||
|
||||
// IPsec manual equivalent
|
||||
if (t.ipsec) {
|
||||
const ikeMode = t.ipsec.ikeVersion === "ikev2" ? "ike2" : "ike1"
|
||||
const pfsGroup = t.ipsec.pfs ? t.ipsec.dhGroup : "none"
|
||||
|
||||
lines.push("")
|
||||
lines.push("# ── IPsec (авто через ipsec-secret; ручной эквивалент) ───────")
|
||||
lines.push("")
|
||||
lines.push(`/ip ipsec peer add \\`)
|
||||
lines.push(` name=${t.name} \\`)
|
||||
lines.push(` address=${t.remoteAddress} \\`)
|
||||
lines.push(` exchange-mode=${ikeMode} \\`)
|
||||
lines.push(` auth-method=pre-shared-key \\`)
|
||||
lines.push(` secret="${t.ipsec.secret}"`)
|
||||
lines.push("")
|
||||
lines.push(`/ip ipsec proposal add \\`)
|
||||
lines.push(` name=${t.name} \\`)
|
||||
lines.push(` enc-algorithms=${ENC_ROS[t.ipsec.encAlg]} \\`)
|
||||
lines.push(` auth-algorithms=${AUTH_ROS[t.ipsec.authAlg]} \\`)
|
||||
lines.push(` pfs-group=${pfsGroup} \\`)
|
||||
lines.push(` lifetime=${t.ipsec.lifetime}`)
|
||||
lines.push("")
|
||||
lines.push(`/ip ipsec policy add \\`)
|
||||
lines.push(` src-address=${t.localAddress !== "0.0.0.0" ? t.localAddress + "/32" : "0.0.0.0/0"} \\`)
|
||||
lines.push(` dst-address=${t.remoteAddress}/32 \\`)
|
||||
lines.push(` proposal=${t.name} \\`)
|
||||
lines.push(` tunnel=yes`)
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
// ─── small ui helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function TunnelStatus({ status }: { status: GreStatus }) {
|
||||
const s = STATUS_MAP[status]
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-sm">
|
||||
<span className={`size-1.5 rounded-full ${s.dot}`} />
|
||||
{s.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function IpsecBadge({ secured }: { secured: boolean }) {
|
||||
return secured ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium border rounded px-2 py-0.5 bg-emerald-500/10 text-emerald-400 border-emerald-500/20">
|
||||
<LockIcon className="size-3" /> IPsec
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium border rounded px-2 py-0.5 bg-muted text-muted-foreground border-border">
|
||||
<LockOpenIcon className="size-3" /> Открытый
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, hint, required, children }: {
|
||||
label: string; hint?: string; required?: boolean; children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">
|
||||
{label}{required && <span className="text-destructive ml-0.5">*</span>}
|
||||
</label>
|
||||
{children}
|
||||
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors ${checked ? "bg-primary" : "bg-input"}`}
|
||||
>
|
||||
<span className={`pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${checked ? "translate-x-4" : "translate-x-0"}`} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SegmentedControl<T extends string>({ value, onChange, options }: {
|
||||
value: T; onChange: (v: T) => void; options: { value: T; label: string }[]
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5 w-fit">
|
||||
{options.map((o) => (
|
||||
<button key={o.value} type="button" onClick={() => onChange(o.value)}
|
||||
className={`px-3 py-1 text-sm rounded transition-colors ${value === o.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── default form states ─────────────────────────────────────────────────────
|
||||
|
||||
const defaultTunnelForm = {
|
||||
name: "", serverId: "", localAddress: "", remoteAddress: "",
|
||||
poolId: "", localInnerIp: "", remoteInnerIp: "", comment: "", enabled: true,
|
||||
ipsecEnabled: false, ipsecSecret: "", ipsecShowSecret: false,
|
||||
ipsecIkeVersion: "ikev2" as IkeVersion,
|
||||
ipsecEncAlg: "aes-256" as IpsecEncAlg,
|
||||
ipsecAuthAlg: "sha256" as IpsecAuthAlg,
|
||||
ipsecDhGroup: "modp2048" as IpsecDhGroup,
|
||||
ipsecLifetime: "1d 00:00:00", ipsecPfs: true,
|
||||
mtu: 1476, keepaliveInterval: 10, keepaliveRetries: 10,
|
||||
dscp: "inherit", clampTcpMss: true, allowFastPath: true,
|
||||
showAdvanced: false,
|
||||
}
|
||||
|
||||
const defaultPoolForm = { name: "", cidr: "", comment: "" }
|
||||
|
||||
type TabFilter = "all" | "up" | "ipsec" | "plain"
|
||||
type PageTab = "tunnels" | "pools"
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
export default function GrePage() {
|
||||
const [pageTab, setPageTab] = useState<PageTab>("tunnels")
|
||||
const [tabFilter, setTabFilter] = useState<TabFilter>("all")
|
||||
const [search, setSearch] = useState("")
|
||||
|
||||
const [tunnelOpen, setTunnelOpen] = useState(false)
|
||||
const [poolOpen, setPoolOpen] = useState(false)
|
||||
const [codePreviewTunnel, setCodePreviewTunnel] = useState<GreTunnel | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const [tForm, setTForm] = useState(defaultTunnelForm)
|
||||
const [pForm, setPForm] = useState(defaultPoolForm)
|
||||
|
||||
const setT = <K extends keyof typeof defaultTunnelForm>(k: K, v: (typeof defaultTunnelForm)[K]) =>
|
||||
setTForm((f) => ({ ...f, [k]: v }))
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return greTunnels.filter((t) => {
|
||||
if (tabFilter === "up" && t.status !== "up") return false
|
||||
if (tabFilter === "ipsec" && !t.ipsec) return false
|
||||
if (tabFilter === "plain" && t.ipsec) return false
|
||||
if (!search) return true
|
||||
const q = search.toLowerCase()
|
||||
return (
|
||||
t.name.toLowerCase().includes(q) ||
|
||||
t.remoteAddress.includes(q) ||
|
||||
t.localInnerIp.includes(q) ||
|
||||
serverById[t.serverId]?.name.toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
}, [tabFilter, search])
|
||||
|
||||
const upCount = greTunnels.filter((t) => t.status === "up").length
|
||||
const ipsecCount = greTunnels.filter((t) => t.ipsec).length
|
||||
|
||||
const tunnelTabs: { value: TabFilter; label: string; count: number }[] = [
|
||||
{ value: "all", label: "Все", count: greTunnels.length },
|
||||
{ value: "up", label: "Активные", count: upCount },
|
||||
{ value: "ipsec", label: "С IPsec", count: ipsecCount },
|
||||
{ value: "plain", label: "Без IPsec", count: greTunnels.length - ipsecCount },
|
||||
]
|
||||
|
||||
function handleCopy(code: string) {
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "GRE-туннели" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm"><RefreshCwIcon className="size-4" />Обновить статус</Button>
|
||||
<Button size="sm" onClick={() => { setTForm(defaultTunnelForm); setTunnelOpen(true) }}>
|
||||
<PlusIcon className="size-4" />Добавить туннель
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
{/* Legacy banner */}
|
||||
<div className="flex items-start gap-3 rounded-lg bg-amber-500/5 border border-amber-500/20 px-4 py-3 text-sm">
|
||||
<ShieldCheckIcon className="size-5 text-amber-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-amber-600 dark:text-amber-400">
|
||||
В RouterOS 7.x рекомендуется использовать WireGuard вместо GRE+IPsec
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs mt-0.5">
|
||||
WireGuard проще в настройке, обеспечивает лучшую производительность и современную криптографию (ChaCha20-Poly1305).
|
||||
GRE-туннели остаются поддерживаемыми, но WireGuard — предпочтительный выбор для новых развёртываний.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: "Всего туннелей", value: greTunnels.length, icon: <NetworkIcon className="size-4 text-muted-foreground" /> },
|
||||
{ label: "Активно", value: upCount, icon: <ShieldCheckIcon className="size-4 text-emerald-500" /> },
|
||||
{ label: "Защищены IPsec", value: ipsecCount, icon: <LockIcon className="size-4 text-violet-400" /> },
|
||||
{ label: "IP-пулов", value: grePools.length, icon: <NetworkIcon className="size-4 text-sky-400" /> },
|
||||
].map((s) => (
|
||||
<Card key={s.label}>
|
||||
<CardContent className="px-5 py-4 flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{s.label}</p>
|
||||
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
|
||||
</div>
|
||||
<div className="mt-0.5">{s.icon}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Page tabs */}
|
||||
<div className="flex items-center gap-1 border-b">
|
||||
{(["tunnels", "pools"] as PageTab[]).map((tab) => (
|
||||
<button key={tab} onClick={() => setPageTab(tab)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors -mb-px ${pageTab === tab ? "border-foreground text-foreground" : "border-transparent text-muted-foreground hover:text-foreground"}`}>
|
||||
{tab === "tunnels" ? "Туннели" : "IP-пулы"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Tunnels ── */}
|
||||
{pageTab === "tunnels" && (
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 px-5 py-3 border-b flex-wrap">
|
||||
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
|
||||
{tunnelTabs.map((t) => (
|
||||
<button key={t.value} onClick={() => setTabFilter(t.value)}
|
||||
className={`flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors ${tabFilter === t.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>
|
||||
{t.label}
|
||||
<span className="text-xs tabular-nums opacity-60">{t.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[220px]">
|
||||
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<input className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
||||
placeholder="Поиск по имени, IP…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} туннелей</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-xs text-muted-foreground">
|
||||
<th className="text-left font-medium px-5 py-3">Интерфейс / Сервер</th>
|
||||
<th className="text-left font-medium px-4 py-3">Эндпоинты</th>
|
||||
<th className="text-left font-medium px-4 py-3">Внутренний IP</th>
|
||||
<th className="text-left font-medium px-4 py-3">Пул</th>
|
||||
<th className="text-left font-medium px-4 py-3">IPsec</th>
|
||||
<th className="text-left font-medium px-4 py-3">Шифрование</th>
|
||||
<th className="text-center font-medium px-4 py-3">MTU</th>
|
||||
<th className="text-left font-medium px-4 py-3">Keepalive</th>
|
||||
<th className="text-left font-medium px-4 py-3">Статус</th>
|
||||
<th className="w-20 px-3 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{filtered.map((t) => {
|
||||
const srv = serverById[t.serverId]
|
||||
const pool = poolById[t.poolId]
|
||||
return (
|
||||
<tr key={t.id} className="hover:bg-muted/40 transition-colors">
|
||||
<td className="px-5 py-3">
|
||||
<p className="font-medium font-mono text-[13px]">{t.name}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 flex items-center gap-1">
|
||||
{srv && <Flag code={srv.country} />}
|
||||
{srv?.name ?? t.serverId}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-mono text-xs">
|
||||
{t.localAddress === "0.0.0.0" ? <span className="text-muted-foreground">авто</span> : t.localAddress}
|
||||
</p>
|
||||
<p className="font-mono text-xs text-muted-foreground">→ {t.remoteAddress}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-mono text-xs">{t.localInnerIp}</p>
|
||||
<p className="font-mono text-xs text-muted-foreground">{t.remoteInnerIp}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs text-muted-foreground font-mono">{pool?.name ?? "—"}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3"><IpsecBadge secured={!!t.ipsec} /></td>
|
||||
<td className="px-4 py-3">
|
||||
{t.ipsec ? (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-mono">{ENC_LABELS[t.ipsec.encAlg]} / {AUTH_LABELS[t.ipsec.authAlg]}</span>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{DH_LABELS[t.ipsec.dhGroup].split(" ")[0]} · {IKE_LABELS[t.ipsec.ikeVersion]}{t.ipsec.pfs && " · PFS"}
|
||||
</span>
|
||||
</div>
|
||||
) : <span className="text-xs text-muted-foreground">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center font-mono text-xs">{t.mtu}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">
|
||||
{t.keepaliveInterval === 0 ? "откл." : `${t.keepaliveInterval}с / ${t.keepaliveRetries}`}
|
||||
</td>
|
||||
<td className="px-4 py-3"><TunnelStatus status={t.status} /></td>
|
||||
|
||||
{/* actions */}
|
||||
<td className="px-3 py-3">
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
{/* Code preview button */}
|
||||
<Button
|
||||
variant="ghost" size="icon" className="size-7"
|
||||
title="Предпросмотр кода RouterOS"
|
||||
onClick={() => setCodePreviewTunnel(t)}
|
||||
>
|
||||
<CodeXmlIcon className="size-3.5" />
|
||||
</Button>
|
||||
|
||||
{/* Actions dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={
|
||||
<Button variant="ghost" size="icon" className="size-7">
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
} />
|
||||
<DropdownMenuContent side="bottom" align="end">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>{t.name}</DropdownMenuLabel>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setCodePreviewTunnel(t)}>
|
||||
<CodeXmlIcon className="size-4" /> Просмотр кода
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<PencilIcon className="size-4" /> Редактировать
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<PowerIcon className="size-4" />
|
||||
{t.enabled ? "Выключить" : "Включить"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">
|
||||
<Trash2Icon className="size-4" /> Удалить туннель
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── IP Pools ── */}
|
||||
{pageTab === "pools" && (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b">
|
||||
<span className="text-sm text-muted-foreground">{grePools.length} пула</span>
|
||||
<Button size="sm" variant="outline" onClick={() => { setPForm(defaultPoolForm); setPoolOpen(true) }}>
|
||||
<PlusIcon className="size-4" />Добавить пул
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-xs text-muted-foreground">
|
||||
<th className="text-left font-medium px-5 py-3">Имя пула</th>
|
||||
<th className="text-left font-medium px-4 py-3">Диапазон CIDR</th>
|
||||
<th className="text-right font-medium px-4 py-3">Назначено /30</th>
|
||||
<th className="text-right font-medium px-4 py-3">Доступно /30</th>
|
||||
<th className="text-left font-medium px-4 py-3">Использование</th>
|
||||
<th className="text-left font-medium px-4 py-3">Назначение</th>
|
||||
<th className="w-10 px-3 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{grePools.map((pool) => {
|
||||
const pct = Math.round((pool.allocated / pool.total) * 100)
|
||||
return (
|
||||
<tr key={pool.id} className="hover:bg-muted/40 transition-colors">
|
||||
<td className="px-5 py-3 font-mono text-[13px] font-medium">{pool.name}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">{pool.cidr}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{pool.allocated}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-muted-foreground">{pool.total - pool.allocated}</td>
|
||||
<td className="px-4 py-3 min-w-[140px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div className={`h-full rounded-full ${pct > 80 ? "bg-amber-500" : "bg-emerald-500"}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground tabular-nums w-8 text-right">{pct}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground text-xs">{pool.comment}</td>
|
||||
<td className="px-3 py-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={
|
||||
<Button variant="ghost" size="icon" className="size-7">
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
} />
|
||||
<DropdownMenuContent side="bottom" align="end">
|
||||
<DropdownMenuItem><PencilIcon className="size-4" /> Редактировать</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive"><Trash2Icon className="size-4" /> Удалить пул</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="border-t px-5 py-4">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-3">Назначения по пулам</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
{grePools.map((pool) => {
|
||||
const poolTunnels = greTunnels.filter((t) => t.poolId === pool.id)
|
||||
return (
|
||||
<div key={pool.id}>
|
||||
<p className="text-xs font-mono font-medium mb-1.5">{pool.name}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{poolTunnels.map((t) => (
|
||||
<div key={t.id} className="flex items-center gap-2 border border-border rounded-md px-3 py-1.5 bg-muted/30 text-xs">
|
||||
<span className={`size-1.5 rounded-full ${STATUS_MAP[t.status].dot}`} />
|
||||
<span className="font-mono font-medium">{t.name}</span>
|
||||
<span className="text-muted-foreground">{t.localInnerIp} ↔ {t.remoteInnerIp}</span>
|
||||
{t.ipsec && <LockIcon className="size-3 text-emerald-400" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* RouterOS reference */}
|
||||
<Card>
|
||||
<CardContent className="px-5 py-4">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-3">RouterOS 7.20+ — параметры GRE-интерфейса</p>
|
||||
<div className="grid grid-cols-2 gap-x-8 gap-y-1 text-xs font-mono">
|
||||
{[
|
||||
["/interface gre add", ""],
|
||||
[" name=", "имя интерфейса"],
|
||||
[" remote-address=", "IP удалённого конца (обязателен)"],
|
||||
[" local-address=", "0.0.0.0 = авто"],
|
||||
[" ipsec-secret=", "PSK → auto peer+policy+proposal"],
|
||||
[" mtu=", "по умолчанию 1476"],
|
||||
[" keepalive=", "10s,10 (интервал,попытки)"],
|
||||
[" dscp=", "inherit | 0-63"],
|
||||
[" clamp-tcp-mss=", "yes | no"],
|
||||
[" allow-fast-path=", "yes | no"],
|
||||
["/ip address add", ""],
|
||||
[" address=x.x.x.x/30", "внутренний IP туннеля"],
|
||||
[" interface=<name>", ""],
|
||||
].map(([cmd, desc], i) => (
|
||||
<div key={i} className="flex gap-2 py-0.5">
|
||||
<span className="text-foreground/70 shrink-0">{cmd}</span>
|
||||
{desc && <span className="text-muted-foreground"># {desc}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ══ Sheet: Code Preview ════════════════════════════════════════════════ */}
|
||||
<Sheet open={!!codePreviewTunnel} onOpenChange={(open) => { if (!open) setCodePreviewTunnel(null) }}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-2xl flex flex-col gap-0 p-0">
|
||||
{codePreviewTunnel && (() => {
|
||||
const code = generateRosCommands(codePreviewTunnel)
|
||||
return (
|
||||
<>
|
||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<SheetTitle className="font-mono">{codePreviewTunnel.name}</SheetTitle>
|
||||
<SheetDescription>Команды RouterOS 7.20+ для создания туннеля</SheetDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
className="shrink-0 gap-1.5"
|
||||
onClick={() => handleCopy(code)}
|
||||
>
|
||||
{copied
|
||||
? <><CheckIcon className="size-3.5 text-emerald-500" /> Скопировано</>
|
||||
: <><CopyIcon className="size-3.5" /> Копировать</>}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* meta strip */}
|
||||
<div className="flex flex-wrap gap-3 px-6 py-3 border-b bg-muted/30 text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className={`size-1.5 rounded-full ${STATUS_MAP[codePreviewTunnel.status].dot}`} />
|
||||
{STATUS_MAP[codePreviewTunnel.status].label}
|
||||
</span>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<span>{serverById[codePreviewTunnel.serverId]?.name}</span>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<span className="font-mono">{codePreviewTunnel.localAddress === "0.0.0.0" ? "авто" : codePreviewTunnel.localAddress} → {codePreviewTunnel.remoteAddress}</span>
|
||||
{codePreviewTunnel.ipsec && (
|
||||
<>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<span className="flex items-center gap-1 text-emerald-400"><LockIcon className="size-3" /> IPsec {IKE_LABELS[codePreviewTunnel.ipsec.ikeVersion]}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* code block */}
|
||||
<pre className="px-6 py-5 text-xs font-mono leading-relaxed text-foreground/90 whitespace-pre overflow-x-auto select-all">
|
||||
{code.split("\n").map((line, i) => {
|
||||
const isComment = line.startsWith("#")
|
||||
const isSection = isComment && line.includes("──")
|
||||
const isKey = /^\s+[a-z]/.test(line)
|
||||
return (
|
||||
<span key={i} className={
|
||||
isSection ? "text-muted-foreground/60"
|
||||
: isComment ? "text-muted-foreground"
|
||||
: isKey ? "text-sky-400/90"
|
||||
: "text-foreground"
|
||||
}>
|
||||
{line}
|
||||
{"\n"}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
|
||||
<Button className="flex-1 gap-1.5" onClick={() => handleCopy(code)}>
|
||||
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
|
||||
{copied ? "Скопировано" : "Копировать команды"}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* ══ Sheet: Add Tunnel ══════════════════════════════════════════════════ */}
|
||||
<Sheet open={tunnelOpen} onOpenChange={setTunnelOpen}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
|
||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||
<SheetTitle>Новый GRE-туннель</SheetTitle>
|
||||
<SheetDescription>RouterOS 7.20+ · /interface gre add</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionTitle>Основные</SectionTitle>
|
||||
<Field label="Имя интерфейса" required hint="Только латиница, цифры и дефис, например gre-msk-spb">
|
||||
<Input className="font-mono" placeholder="gre-msk-spb" value={tForm.name} onChange={(e) => setT("name", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Сервер (MikroTik)" required>
|
||||
<select value={tForm.serverId} onChange={(e) => setT("serverId", e.target.value)}
|
||||
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
<option value="" disabled>Выбрать сервер…</option>
|
||||
{servers.map((s) => <option key={s.id} value={s.id}>{s.name} ({s.site})</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Комментарий">
|
||||
<Input placeholder="Описание туннеля" value={tForm.comment} onChange={(e) => setT("comment", e.target.value)} />
|
||||
</Field>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Включён</span>
|
||||
<Toggle checked={tForm.enabled} onChange={(v) => setT("enabled", v)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionTitle>Эндпоинты</SectionTitle>
|
||||
<Field label="Локальный адрес" hint="Оставьте пустым или 0.0.0.0 для автоопределения">
|
||||
<Input className="font-mono" placeholder="0.0.0.0" value={tForm.localAddress} onChange={(e) => setT("localAddress", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Удалённый адрес" required hint="Внешний IP удалённого MikroTik">
|
||||
<Input className="font-mono" placeholder="203.0.113.1" value={tForm.remoteAddress} onChange={(e) => setT("remoteAddress", e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionTitle>Внутренний IP</SectionTitle>
|
||||
<Field label="IP-пул" required hint="Из какого пула выделяется /30-блок">
|
||||
<select value={tForm.poolId} onChange={(e) => setT("poolId", e.target.value)}
|
||||
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
<option value="" disabled>Выбрать пул…</option>
|
||||
{grePools.map((p) => <option key={p.id} value={p.id}>{p.name} ({p.cidr}) — свободно {p.total - p.allocated} блоков</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Локальный IP" required hint="/ip address на этом конце">
|
||||
<Input className="font-mono" placeholder="10.200.0.1/30" value={tForm.localInnerIp} onChange={(e) => setT("localInnerIp", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Удалённый IP" required hint="/ip address на другом конце">
|
||||
<Input className="font-mono" placeholder="10.200.0.2/30" value={tForm.remoteInnerIp} onChange={(e) => setT("remoteInnerIp", e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionTitle>IPsec</SectionTitle>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Включить IPsec</p>
|
||||
<p className="text-xs text-muted-foreground">RouterOS автоматически создаст peer, policy и proposal</p>
|
||||
</div>
|
||||
<Toggle checked={tForm.ipsecEnabled} onChange={(v) => setT("ipsecEnabled", v)} />
|
||||
</div>
|
||||
|
||||
{tForm.ipsecEnabled && (
|
||||
<div className="flex flex-col gap-4 pl-4 border-l-2 border-emerald-500/30">
|
||||
<Field label="Пароль (PSK)" required hint="ipsec-secret — pre-shared key для автоматического IKE">
|
||||
<div className="relative">
|
||||
<Input type={tForm.ipsecShowSecret ? "text" : "password"} className="font-mono pr-9"
|
||||
placeholder="Минимум 8 символов" value={tForm.ipsecSecret} onChange={(e) => setT("ipsecSecret", e.target.value)} />
|
||||
<button type="button" onClick={() => setT("ipsecShowSecret", !tForm.ipsecShowSecret)}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
|
||||
{tForm.ipsecShowSecret ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="IKE-версия">
|
||||
<SegmentedControl value={tForm.ipsecIkeVersion} onChange={(v) => setT("ipsecIkeVersion", v)}
|
||||
options={[{ value: "ikev1", label: "IKEv1" }, { value: "ikev2", label: "IKEv2 (рек.)" }]} />
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Шифрование">
|
||||
<select value={tForm.ipsecEncAlg} onChange={(e) => setT("ipsecEncAlg", e.target.value as IpsecEncAlg)}
|
||||
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
{(Object.entries(ENC_LABELS) as [IpsecEncAlg, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Хеш-алгоритм">
|
||||
<select value={tForm.ipsecAuthAlg} onChange={(e) => setT("ipsecAuthAlg", e.target.value as IpsecAuthAlg)}
|
||||
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
{(Object.entries(AUTH_LABELS) as [IpsecAuthAlg, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="DH-группа" hint="Группа Диффи-Хеллмана для обмена ключами">
|
||||
<select value={tForm.ipsecDhGroup} onChange={(e) => setT("ipsecDhGroup", e.target.value as IpsecDhGroup)}
|
||||
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
{(Object.entries(DH_LABELS) as [IpsecDhGroup, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Срок жизни SA" hint="Формат: 1d 00:00:00">
|
||||
<Input className="font-mono" value={tForm.ipsecLifetime} onChange={(e) => setT("ipsecLifetime", e.target.value)} />
|
||||
</Field>
|
||||
<div className="flex items-center justify-between pt-6">
|
||||
<span className="text-sm font-medium">PFS</span>
|
||||
<Toggle checked={tForm.ipsecPfs} onChange={(v) => setT("ipsecPfs", v)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<button type="button" onClick={() => setT("showAdvanced", !tForm.showAdvanced)}
|
||||
className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors">
|
||||
{tForm.showAdvanced ? <ChevronDownIcon className="size-3.5" /> : <ChevronRightIcon className="size-3.5" />}
|
||||
Дополнительно
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
</button>
|
||||
{tForm.showAdvanced && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Field label="MTU" hint="По умолч. 1476">
|
||||
<Input type="number" className="font-mono" value={tForm.mtu} onChange={(e) => setT("mtu", Number(e.target.value))} />
|
||||
</Field>
|
||||
<Field label="Keepalive, с" hint="0 = откл.">
|
||||
<Input type="number" className="font-mono" value={tForm.keepaliveInterval} onChange={(e) => setT("keepaliveInterval", Number(e.target.value))} />
|
||||
</Field>
|
||||
<Field label="Попытки">
|
||||
<Input type="number" className="font-mono" value={tForm.keepaliveRetries} onChange={(e) => setT("keepaliveRetries", Number(e.target.value))} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="DSCP">
|
||||
<select value={tForm.dscp} onChange={(e) => setT("dscp", e.target.value)}
|
||||
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||
<option value="inherit">inherit</option>
|
||||
{Array.from({ length: 64 }, (_, i) => <option key={i} value={String(i)}>{i}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
{[
|
||||
{ key: "clampTcpMss" as const, label: "Clamp TCP MSS", desc: "Ограничить MSS до MTU туннеля" },
|
||||
{ key: "allowFastPath" as const, label: "Allow Fast Path", desc: "Аппаратное ускорение трафика" },
|
||||
].map(({ key, label, desc }) => (
|
||||
<div key={key} className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
<p className="text-xs text-muted-foreground">{desc}</p>
|
||||
</div>
|
||||
<Toggle checked={tForm[key] as boolean} onChange={(v) => setT(key, v)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
|
||||
<Button className="flex-1" onClick={() => setTunnelOpen(false)}>Создать туннель</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* ══ Sheet: Add Pool ════════════════════════════════════════════════════ */}
|
||||
<Sheet open={poolOpen} onOpenChange={setPoolOpen}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
|
||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||
<SheetTitle>Новый IP-пул</SheetTitle>
|
||||
<SheetDescription>Пул адресов для назначения внутренних IP GRE-туннелям</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionTitle>Параметры пула</SectionTitle>
|
||||
<Field label="Имя пула" required hint="Например pool-gre-office или pool-gre-dc2">
|
||||
<Input className="font-mono" placeholder="pool-gre-core" value={pForm.name}
|
||||
onChange={(e) => setPForm((f) => ({ ...f, name: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label="Диапазон CIDR" required hint="Блок, из которого будут нарезаться /30 на каждый туннель">
|
||||
<Input className="font-mono" placeholder="10.200.0.0/24" value={pForm.cidr}
|
||||
onChange={(e) => setPForm((f) => ({ ...f, cidr: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label="Назначение / Комментарий">
|
||||
<Input placeholder="Ядровые межузловые туннели" value={pForm.comment}
|
||||
onChange={(e) => setPForm((f) => ({ ...f, comment: e.target.value }))} />
|
||||
</Field>
|
||||
{pForm.cidr && /\/\d+$/.test(pForm.cidr) && (() => {
|
||||
const prefix = parseInt(pForm.cidr.split("/")[1] ?? "0")
|
||||
const blocks = prefix <= 30 ? Math.pow(2, 30 - prefix) : 0
|
||||
return blocks > 0 ? (
|
||||
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3 text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
Доступно <span className="font-semibold text-foreground font-mono">{blocks}</span> блоков /30
|
||||
{" "}= до <span className="font-semibold text-foreground font-mono">{blocks}</span> туннелей
|
||||
</p>
|
||||
</div>
|
||||
) : null
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
|
||||
<Button className="flex-1" onClick={() => setPoolOpen(false)}>Создать пул</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client"
|
||||
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { DataTable } from "@/components/data-table"
|
||||
import { ipRanges } from "@/lib/data"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon } from "lucide-react"
|
||||
|
||||
export default function IpRangesPage() {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Данные" }, { label: "IP-диапазоны" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm"><UploadIcon className="size-4" />Импорт</Button>
|
||||
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||||
<Button size="sm"><PlusIcon className="size-4" />Добавить диапазон</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">IP-диапазоны</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
CIDR-блоки, отправляемые в address-list MikroTik по всем настроенным фильтрам
|
||||
</p>
|
||||
</div>
|
||||
<DataTable
|
||||
data={ipRanges}
|
||||
searchPlaceholder="Поиск по CIDR, ASN…"
|
||||
searchKeys={["cidr", "asn", "country", "filter"]}
|
||||
columns={[
|
||||
{
|
||||
key: "cidr",
|
||||
label: "CIDR",
|
||||
render: (d) => <span className="font-mono font-medium">{d.cidr}</span>,
|
||||
},
|
||||
{
|
||||
key: "asn",
|
||||
label: "ASN",
|
||||
render: (d) => <span className="font-mono text-xs text-muted-foreground">{d.asn}</span>,
|
||||
},
|
||||
{
|
||||
key: "country",
|
||||
label: "Страна",
|
||||
render: (d) => <span className="text-xs border border-border rounded px-2 py-0.5">{d.country}</span>,
|
||||
},
|
||||
{
|
||||
key: "purpose",
|
||||
label: "Назначение",
|
||||
render: (d) => <span className="text-xs border border-border rounded px-2 py-0.5">{d.purpose}</span>,
|
||||
},
|
||||
{
|
||||
key: "filter",
|
||||
label: "Фильтр",
|
||||
render: (d) => (
|
||||
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
|
||||
<FilterIcon className="size-3 text-muted-foreground" />{d.filter}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "updated",
|
||||
label: "Обновлён",
|
||||
render: (d) => <span className="text-xs text-muted-foreground">{d.updated}</span>,
|
||||
},
|
||||
{
|
||||
key: "enabled",
|
||||
label: "Статус",
|
||||
render: (d) => (
|
||||
<span className={`text-xs font-medium ${d.enabled ? "text-emerald-600" : "text-muted-foreground"}`}>
|
||||
{d.enabled ? "Активен" : "Отключён"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { AppSidebar } from "@/components/app-sidebar"
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
||||
import { CommandPalette } from "@/components/command-palette"
|
||||
import { DataSourceProvider } from "@/lib/data-source"
|
||||
|
||||
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<DataSourceProvider>
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset className="h-svh overflow-hidden">{children}</SidebarInset>
|
||||
<CommandPalette />
|
||||
</SidebarProvider>
|
||||
</DataSourceProvider>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,802 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState, useMemo, useCallback } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { servers, greTunnels } from "@/lib/data"
|
||||
import {
|
||||
PlayIcon, SquareIcon, CopyIcon, Trash2Icon, PlusIcon,
|
||||
ActivityIcon, RouteIcon, SearchIcon, NetworkIcon, RulerIcon,
|
||||
ZapIcon, ClockIcon, CheckIcon, TerminalIcon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// ─── types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type DiagTool = "ping" | "traceroute" | "bandwidth" | "dns" | "route" | "mtu"
|
||||
type RunStatus = "running" | "done" | "error"
|
||||
type TraceProto = "icmp" | "udp" | "tcp"
|
||||
type DnsType = "A" | "AAAA" | "MX" | "NS" | "TXT" | "CNAME" | "PTR"
|
||||
|
||||
interface OutputLine { text: string; kind: "normal" | "ok" | "err" | "dim" | "header" | "cmd" }
|
||||
|
||||
interface DiagTest {
|
||||
id: string
|
||||
tool: DiagTool
|
||||
status: RunStatus
|
||||
srcServerId: string
|
||||
srcServerName: string
|
||||
target: string
|
||||
command: string
|
||||
startedAt: number
|
||||
lines: OutputLine[]
|
||||
totalLines: number // final line count — reveals progressively
|
||||
}
|
||||
|
||||
type SchedType = "ping" | "bandwidth" | "both"
|
||||
|
||||
interface SchedRule {
|
||||
id: string; srcId: string; tunnelId: string; type: SchedType
|
||||
intervalMin: number; enabled: boolean
|
||||
lastRun: string | null; nextRunMin: number | null
|
||||
}
|
||||
|
||||
// ─── tool metadata ────────────────────────────────────────────────────────────
|
||||
|
||||
const TOOL_META: Record<DiagTool, {
|
||||
label: string
|
||||
ros: string // RouterOS tool path
|
||||
Icon: React.FC<{ className?: string }>
|
||||
color: string
|
||||
description: string
|
||||
}> = {
|
||||
ping: { label: "Ping", ros: "/tool ping", Icon: ActivityIcon, color: "text-sky-500", description: "Проверка связи и RTT" },
|
||||
traceroute: { label: "Traceroute", ros: "/tool traceroute", Icon: RouteIcon, color: "text-violet-500", description: "Трассировка маршрута" },
|
||||
bandwidth: { label: "BW-тест", ros: "/tool bandwidth-test", Icon: ZapIcon, color: "text-emerald-500", description: "Пропускная способность" },
|
||||
dns: { label: "DNS", ros: "/resolve", Icon: SearchIcon, color: "text-amber-500", description: "Разрешение DNS-имён" },
|
||||
route: { label: "Маршрут", ros: "/ip route lookup", Icon: NetworkIcon, color: "text-blue-500", description: "Поиск активного маршрута" },
|
||||
mtu: { label: "MTU-тест", ros: "/tool ping", Icon: RulerIcon, color: "text-orange-500", description: "Определение MTU пути" },
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function jitter(base: number, pct = 0.15) {
|
||||
return Math.max(1, Math.round(base + (Math.random() - 0.5) * base * pct * 2))
|
||||
}
|
||||
|
||||
function seededRng(seed: string) {
|
||||
let h = 0
|
||||
for (let i = 0; i < seed.length; i++) h = (Math.imul(31, h) + seed.charCodeAt(i)) | 0
|
||||
h = Math.abs(h)
|
||||
return (n = 233280) => { h = (h * 9301 + 49297) % n; return h / n }
|
||||
}
|
||||
|
||||
function baseRtt(srcId: string, _target: string): number {
|
||||
const map: Record<string, number> = { srv1: 13, srv2: 11, srv3: 5, srv4: 8, srv5: 80, srv6: 8, srv7: 14 }
|
||||
return map[srcId] ?? 20
|
||||
}
|
||||
|
||||
function randomIp(rng: () => number, base = "10.") {
|
||||
return `${base}${Math.floor(rng() * 255)}.${Math.floor(rng() * 255)}.${Math.floor(rng() * 255)}`
|
||||
}
|
||||
|
||||
// ─── output generators ────────────────────────────────────────────────────────
|
||||
|
||||
function genPingOutput(target: string, srcId: string, count: number, size: number, _ttl: number): OutputLine[] {
|
||||
const rng = seededRng(target + srcId)
|
||||
const base = baseRtt(srcId, target)
|
||||
const rtts: (number | null)[] = Array.from({ length: count }, () =>
|
||||
rng() < 0.04 ? null : jitter(base, 0.2),
|
||||
)
|
||||
const lines: OutputLine[] = [
|
||||
{ text: ` SEQ HOST SIZE TTL TIME STATUS`, kind: "header" },
|
||||
]
|
||||
rtts.forEach((rtt, i) => {
|
||||
lines.push({
|
||||
text: ` ${String(i).padStart(3)} ${target.padEnd(40)} ${String(size).padStart(4)} ${String(rtt ? 118 : 0).padStart(3)} ${rtt ? `${rtt}ms`.padStart(6) : " "} ${rtt ? "" : "timeout"}`,
|
||||
kind: rtt ? "normal" : "err",
|
||||
})
|
||||
})
|
||||
const recv = rtts.filter(r => r !== null) as number[]
|
||||
const loss = Math.round((count - recv.length) / count * 100)
|
||||
lines.push({ text: "", kind: "dim" })
|
||||
lines.push({
|
||||
text: ` sent=${count} received=${recv.length} packet-loss=${loss}% min-rtt=${Math.min(...recv)}ms avg-rtt=${Math.round(recv.reduce((a, b) => a + b, 0) / recv.length)}ms max-rtt=${Math.max(...recv)}ms`,
|
||||
kind: loss === 0 ? "ok" : "err",
|
||||
})
|
||||
return lines
|
||||
}
|
||||
|
||||
function genTraceOutput(target: string, srcId: string, proto: TraceProto, maxHops: number): OutputLine[] {
|
||||
const rng = seededRng(target + srcId + proto)
|
||||
const base = baseRtt(srcId, target)
|
||||
const hopCount = Math.min(maxHops, 4 + Math.floor(rng() * 3))
|
||||
const lines: OutputLine[] = [
|
||||
{ text: ` # ADDRESS LOSS SENT LAST AVG BEST WORST`, kind: "header" },
|
||||
]
|
||||
for (let i = 1; i <= hopCount; i++) {
|
||||
const addr = i === 1 ? "10.200.0.2" : i === hopCount ? target : randomIp(rng, "95.213.")
|
||||
const frac = i / hopCount
|
||||
const rtt1 = Math.round(base * frac * jitter(1, 0.1))
|
||||
const rtt2 = Math.round(base * frac * jitter(1, 0.08))
|
||||
const rtt3 = Math.round(base * frac * jitter(1, 0.12))
|
||||
const avg = Math.round((rtt1 + rtt2 + rtt3) / 3)
|
||||
lines.push({
|
||||
text: ` ${String(i).padStart(2)} ${addr.padEnd(40)} 0% 3 ${`${rtt1}ms`.padStart(6)} ${`${avg}ms`.padStart(6)} ${`${Math.min(rtt1,rtt2,rtt3)}ms`.padStart(6)} ${`${Math.max(rtt1,rtt2,rtt3)}ms`.padStart(6)}`,
|
||||
kind: i === hopCount ? "ok" : "normal",
|
||||
})
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
function genDnsOutput(target: string, srcId: string, type: DnsType): OutputLine[] {
|
||||
const rng = seededRng(target + srcId + type)
|
||||
const ttl = 300 - Math.floor(rng() * 200)
|
||||
const time = 5 + Math.floor(rng() * 20)
|
||||
const lines: OutputLine[] = [
|
||||
{ text: `;; QUESTION SECTION:`, kind: "dim" },
|
||||
{ text: `;; ${target}. IN ${type}`, kind: "dim" },
|
||||
{ text: "", kind: "dim" },
|
||||
{ text: `;; ANSWER SECTION:`, kind: "header" },
|
||||
]
|
||||
const count = type === "A" ? 2 + Math.floor(rng() * 2) : 1
|
||||
for (let i = 0; i < count; i++) {
|
||||
let value = ""
|
||||
if (type === "A") value = `142.250.${74 + Math.floor(rng() * 10)}.${Math.floor(rng() * 200)}`
|
||||
if (type === "AAAA") value = `2001:db8::${Math.floor(rng() * 0xffff).toString(16)}`
|
||||
if (type === "MX") value = `10 mail.${target}.`
|
||||
if (type === "NS") value = `ns${i + 1}.${target}.`
|
||||
if (type === "TXT") value = `"v=spf1 include:${target} ~all"`
|
||||
if (type === "CNAME") value = `${target}.cdn.example.com.`
|
||||
if (type === "PTR") value = `${target}.in-addr.arpa.`
|
||||
lines.push({ text: `${target.padEnd(24)} ${String(ttl).padStart(5)} IN ${type.padEnd(5)} ${value}`, kind: "ok" })
|
||||
}
|
||||
lines.push({ text: "", kind: "dim" })
|
||||
lines.push({ text: `;; Query time: ${time} msec`, kind: "dim" })
|
||||
lines.push({ text: `;; SERVER: 1.1.1.1`, kind: "dim" })
|
||||
return lines
|
||||
}
|
||||
|
||||
function genRouteOutput(target: string, srcId: string): OutputLine[] {
|
||||
const rng = seededRng(target + srcId)
|
||||
const gw = `10.200.0.${2 + Math.floor(rng() * 14)}`
|
||||
const iface = ["gre-msk-fra", "gre-msk-ams", "gre-msk-spb"][Math.floor(rng() * 3)]
|
||||
const prefix = target.split(".").slice(0, 3).join(".") + ".0/24"
|
||||
const asn = [15169, 32934, 20940][Math.floor(rng() * 3)]
|
||||
const comm = `65001:${100 + Math.floor(rng() * 4) * 100}`
|
||||
return [
|
||||
{ text: `Flags: X - disabled, A - active, B - blackhole, U - unreachable`, kind: "dim" },
|
||||
{ text: "", kind: "dim" },
|
||||
{ text: ` # DST-ADDRESS PREF-SRC GATEWAY DISTANCE SCOPE TARGET-SCOPE`, kind: "header" },
|
||||
{ text: ` 0 A ${prefix.padEnd(18)} ${gw.padEnd(15)} 200 30 10`, kind: "ok" },
|
||||
{ text: ` routing-table: main`, kind: "dim" },
|
||||
{ text: ` bgp-as-path: ${asn}`, kind: "normal" },
|
||||
{ text: ` bgp-communities: ${comm} ${asn}:5003`, kind: "normal" },
|
||||
{ text: ` bgp-local-pref: 100`, kind: "dim" },
|
||||
{ text: ` bgp-med: 0`, kind: "dim" },
|
||||
{ text: ` bgp-origin: igp`, kind: "dim" },
|
||||
{ text: ` bgp-nexthop: ${gw}`, kind: "dim" },
|
||||
{ text: ` bgp-ext-communities: (nothing)`, kind: "dim" },
|
||||
{ text: ` interface: ${iface}`, kind: "normal" },
|
||||
{ text: ` gateway: ${gw}`, kind: "normal" },
|
||||
{ text: "", kind: "dim" },
|
||||
{ text: `1 route found`, kind: "ok" },
|
||||
]
|
||||
}
|
||||
|
||||
function genMtuOutput(target: string, srcId: string): OutputLine[] {
|
||||
const base = baseRtt(srcId, target)
|
||||
const mtu = [1476, 1472, 1468, 1400][Math.floor(Math.random() * 2)] // common GRE MTUs
|
||||
const sizes = [1500, 1492, mtu + 8, mtu + 4, mtu, mtu - 4]
|
||||
const lines: OutputLine[] = [
|
||||
{ text: `MTU path discovery → ${target} (do-not-fragment ping)`, kind: "header" },
|
||||
{ text: "", kind: "dim" },
|
||||
{ text: ` SIZE RESULT RTT`, kind: "dim" },
|
||||
]
|
||||
sizes.forEach(sz => {
|
||||
const pass = sz <= mtu
|
||||
const rtt = pass ? jitter(base, 0.1) : null
|
||||
lines.push({
|
||||
text: ` ${String(sz).padStart(4)} ${pass ? "✓ PASS" : "✗ FAIL (frag needed)"} ${pass ? `${rtt}ms` : "—"}`,
|
||||
kind: pass ? "ok" : "err",
|
||||
})
|
||||
})
|
||||
lines.push({ text: "", kind: "dim" })
|
||||
lines.push({ text: `MTU discovered: ${mtu} bytes`, kind: "ok" })
|
||||
const overhead = 1500 - mtu
|
||||
lines.push({ text: `Overhead: ${overhead} bytes (GRE ${overhead >= 24 ? "+IPsec" : "no IPsec"})`, kind: "dim" })
|
||||
return lines
|
||||
}
|
||||
|
||||
function genBwOutput(target: string, srcId: string, proto: string, duration: number): OutputLine[] {
|
||||
const base = [410, 580, 220, 680][Math.floor(Math.random() * 4)]
|
||||
seededRng(target + srcId)
|
||||
const lines: OutputLine[] = [
|
||||
{ text: ` status: running`, kind: "dim" },
|
||||
{ text: ` direction: both`, kind: "dim" },
|
||||
{ text: ` protocol: ${proto.toUpperCase()}`, kind: "dim" },
|
||||
{ text: ` duration: ${duration}s`, kind: "dim" },
|
||||
{ text: "", kind: "dim" },
|
||||
]
|
||||
for (let t = 2; t <= duration; t += 2) {
|
||||
const tx = jitter(base * 0.98, 0.08)
|
||||
const rx = jitter(base * 0.95, 0.09)
|
||||
lines.push({
|
||||
text: ` [${String(t).padStart(2)}s] tx-current: ${tx}Mbps rx-current: ${rx}Mbps`,
|
||||
kind: "normal",
|
||||
})
|
||||
}
|
||||
const txAvg = jitter(base * 0.97, 0.04)
|
||||
const rxAvg = jitter(base * 0.94, 0.04)
|
||||
lines.push({ text: "", kind: "dim" })
|
||||
lines.push({ text: ` status: done`, kind: "ok" })
|
||||
lines.push({ text: ` tx-total-average: ${txAvg}Mbps`, kind: "ok" })
|
||||
lines.push({ text: ` rx-total-average: ${rxAvg}Mbps`, kind: "ok" })
|
||||
return lines
|
||||
}
|
||||
|
||||
// ─── command builder ──────────────────────────────────────────────────────────
|
||||
|
||||
function buildCommand(
|
||||
tool: DiagTool, src: string, target: string,
|
||||
opts: { pingCount?: number; pingSize?: number; pingTtl?: number; traceProto?: TraceProto; traceMaxHops?: number; dnsType?: DnsType; mtuStart?: number; bwProto?: string; bwDuration?: number; bwTarget?: string },
|
||||
): string {
|
||||
const host = servers.find(s => s.id === src)?.host ?? "?"
|
||||
switch (tool) {
|
||||
case "ping":
|
||||
return `/tool ping address=${target} count=${opts.pingCount ?? 5} size=${opts.pingSize ?? 64} ttl=${opts.pingTtl ?? 64} src-address=${host}`
|
||||
case "traceroute":
|
||||
return `/tool traceroute address=${target} max-hops=${opts.traceMaxHops ?? 30} protocol=${opts.traceProto ?? "icmp"} src-address=${host}`
|
||||
case "bandwidth":
|
||||
return `/tool bandwidth-test address=${opts.bwTarget ?? target} duration=${opts.bwDuration ?? 10}s protocol=${opts.bwProto ?? "tcp"} direction=both`
|
||||
case "dns":
|
||||
return `/resolve ${target} type=${opts.dnsType ?? "A"} server=1.1.1.1`
|
||||
case "route":
|
||||
return `/ip route lookup ip=${target}`
|
||||
case "mtu":
|
||||
return `/tool ping address=${target} do-not-fragment count=1 size=${opts.mtuStart ?? 1500} src-address=${host}`
|
||||
}
|
||||
}
|
||||
|
||||
// ─── small UI components ──────────────────────────────────────────────────────
|
||||
|
||||
function NativeSelect({ value, onChange, children, className }: {
|
||||
value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string
|
||||
}) {
|
||||
return (
|
||||
<select value={value} onChange={e => onChange(e.target.value)}
|
||||
className={cn(
|
||||
"h-8 min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-sm",
|
||||
"text-foreground transition-colors outline-none",
|
||||
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 dark:bg-input/30",
|
||||
className,
|
||||
)}>
|
||||
{children}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<button onClick={() => onChange(!checked)}
|
||||
className={cn("relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors",
|
||||
checked ? "bg-primary" : "bg-muted-foreground/30")}>
|
||||
<span className={cn("inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform",
|
||||
checked ? "translate-x-4" : "translate-x-0.5")} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function OptionLabel({ children }: { children: React.ReactNode }) {
|
||||
return <p className="text-[11px] font-medium text-muted-foreground mb-1">{children}</p>
|
||||
}
|
||||
|
||||
function SegBtn<T extends string | number>({ value, current, onClick, children }: {
|
||||
value: T; current: T; onClick: (v: T) => void; children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button onClick={() => onClick(value)}
|
||||
className={cn(
|
||||
"px-2.5 py-1 text-xs font-mono border-r last:border-r-0 border-input transition-colors",
|
||||
value === current ? "bg-muted text-foreground font-semibold" : "text-muted-foreground hover:bg-muted/50",
|
||||
)}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── terminal output ──────────────────────────────────────────────────────────
|
||||
|
||||
function TerminalOutput({ test }: { test: DiagTest }) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const visible = test.lines.slice(0, test.totalLines)
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight
|
||||
}, [visible.length])
|
||||
|
||||
return (
|
||||
<div ref={ref}
|
||||
className="h-72 overflow-y-auto rounded-lg bg-zinc-950 dark:bg-zinc-900 border border-zinc-800 px-4 py-3 font-mono text-xs leading-relaxed">
|
||||
{/* command line */}
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-zinc-500">[{test.srcServerName}]</span>
|
||||
<span className="text-emerald-400">$</span>
|
||||
<span className="text-zinc-300">{test.command}</span>
|
||||
{test.status === "running" && (
|
||||
<span className="inline-block size-1.5 rounded-full bg-emerald-400 animate-pulse ml-1" />
|
||||
)}
|
||||
</div>
|
||||
{/* output */}
|
||||
{visible.map((line, i) => (
|
||||
<div key={i} className={cn(
|
||||
"whitespace-pre leading-5",
|
||||
line.kind === "ok" && "text-emerald-400",
|
||||
line.kind === "err" && "text-red-400",
|
||||
line.kind === "dim" && "text-zinc-500",
|
||||
line.kind === "header" && "text-zinc-400 font-semibold",
|
||||
line.kind === "cmd" && "text-amber-400",
|
||||
line.kind === "normal" && "text-zinc-300",
|
||||
)}>
|
||||
{line.text || " "}
|
||||
</div>
|
||||
))}
|
||||
{test.status === "running" && visible.length < test.lines.length && (
|
||||
<div className="text-zinc-600 animate-pulse">…</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── schedule tab ─────────────────────────────────────────────────────────────
|
||||
|
||||
const INIT_RULES: SchedRule[] = [
|
||||
{ id: "r1", srcId: "srv1", tunnelId: "gre1", type: "both", intervalMin: 15, enabled: true, lastRun: "3 мин назад", nextRunMin: 12 },
|
||||
{ id: "r2", srcId: "srv1", tunnelId: "gre2", type: "ping", intervalMin: 5, enabled: true, lastRun: "1 мин назад", nextRunMin: 4 },
|
||||
{ id: "r3", srcId: "srv7", tunnelId: "gre5", type: "bandwidth", intervalMin: 60, enabled: false, lastRun: "2ч назад", nextRunMin: null },
|
||||
]
|
||||
|
||||
function ScheduleTab({ rules, setRules }: {
|
||||
rules: SchedRule[]; setRules: React.Dispatch<React.SetStateAction<SchedRule[]>>
|
||||
}) {
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [addSrc, setAddSrc] = useState("srv1")
|
||||
const [addTun, setAddTun] = useState("gre1")
|
||||
const [addType, setAddType] = useState<SchedType>("ping")
|
||||
const [addMin, setAddMin] = useState(10)
|
||||
const addTunnels = useMemo(() => greTunnels.filter(t => t.serverId === addSrc), [addSrc])
|
||||
|
||||
const typeLabel: Record<SchedType, string> = { ping: "Ping", bandwidth: "BW-тест", both: "Ping + BW" }
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Card className="overflow-hidden">
|
||||
{rules.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 gap-2 text-muted-foreground">
|
||||
<ClockIcon className="size-7 opacity-20" />
|
||||
<p className="text-sm">Нет правил расписания</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-[40px_1fr_140px_80px_100px_1fr_auto] gap-2 items-center px-4 py-2 bg-muted/30 border-b text-[10px] font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
<span />
|
||||
<span>Туннель</span>
|
||||
<span>Сервер</span>
|
||||
<span>Тип</span>
|
||||
<span>Интервал</span>
|
||||
<span>Последний / следующий</span>
|
||||
<span />
|
||||
</div>
|
||||
<div className="divide-y divide-border/60">
|
||||
{rules.map(rule => {
|
||||
const src = servers.find(s => s.id === rule.srcId)
|
||||
const tun = greTunnels.find(t => t.id === rule.tunnelId)
|
||||
return (
|
||||
<div key={rule.id} className={cn(
|
||||
"grid grid-cols-[40px_1fr_140px_80px_100px_1fr_auto] gap-2 items-center px-4 py-2.5 hover:bg-muted/20 transition-colors",
|
||||
!rule.enabled && "opacity-50",
|
||||
)}>
|
||||
<Toggle checked={rule.enabled}
|
||||
onChange={v => setRules(p => p.map(r => r.id === rule.id ? { ...r, enabled: v } : r))} />
|
||||
<code className="font-mono text-xs truncate">{tun?.name ?? rule.tunnelId}</code>
|
||||
<span className="text-xs text-muted-foreground truncate">{src?.name ?? rule.srcId}</span>
|
||||
<span className={cn("text-[10px] px-1.5 py-0.5 rounded border font-medium w-fit",
|
||||
rule.type === "ping" ? "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20"
|
||||
: rule.type === "bandwidth" ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
: "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20",
|
||||
)}>{typeLabel[rule.type]}</span>
|
||||
<span className="text-xs text-muted-foreground">каждые {rule.intervalMin} мин</span>
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-2 min-w-0">
|
||||
{rule.lastRun && <span className="truncate">{rule.lastRun}</span>}
|
||||
{rule.nextRunMin != null && rule.enabled && (
|
||||
<span className="text-sky-600 dark:text-sky-400 shrink-0">· через {rule.nextRunMin} мин</span>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => setRules(p => p.filter(r => r.id !== rule.id))}
|
||||
className="size-6 flex items-center justify-center rounded text-muted-foreground/40 hover:text-red-500 hover:bg-red-500/10 transition-colors">
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
{showAdd ? (
|
||||
<Card className="overflow-hidden">
|
||||
<div className="px-4 py-3 border-b flex items-center gap-2 text-sm font-medium">
|
||||
<PlusIcon className="size-4 text-muted-foreground" />Новое правило
|
||||
</div>
|
||||
<div className="px-4 py-4 flex flex-wrap items-end gap-3">
|
||||
<div><OptionLabel>Сервер</OptionLabel>
|
||||
<NativeSelect value={addSrc} onChange={setAddSrc} className="min-w-[160px]">
|
||||
{servers.filter(s => s.enabled).map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
<div><OptionLabel>GRE-туннель</OptionLabel>
|
||||
<NativeSelect value={addTun} onChange={setAddTun} className="min-w-[150px]">
|
||||
{addTunnels.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
<div><OptionLabel>Тип</OptionLabel>
|
||||
<NativeSelect value={addType} onChange={v => setAddType(v as SchedType)}>
|
||||
<option value="ping">Ping</option>
|
||||
<option value="bandwidth">BW-тест</option>
|
||||
<option value="both">Ping + BW</option>
|
||||
</NativeSelect>
|
||||
</div>
|
||||
<div><OptionLabel>Интервал (мин)</OptionLabel>
|
||||
<Input type="number" min={1} max={1440} value={addMin}
|
||||
onChange={e => setAddMin(Math.max(1, parseInt(e.target.value) || 1))}
|
||||
className="w-20 font-mono text-sm" />
|
||||
</div>
|
||||
<Button size="sm" disabled={!addTunnels.length}
|
||||
onClick={() => {
|
||||
setRules(p => [...p, { id: `r${Date.now()}`, srcId: addSrc, tunnelId: addTun, type: addType, intervalMin: addMin, enabled: true, lastRun: null, nextRunMin: addMin }])
|
||||
setShowAdd(false)
|
||||
}}>
|
||||
<CheckIcon className="size-4" />Добавить
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowAdd(false)}>Отмена</Button>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" className="w-fit" onClick={() => setShowAdd(true)}>
|
||||
<PlusIcon className="size-4" />Добавить правило
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ProbesPage() {
|
||||
// ── tool config ──
|
||||
const [tool, setTool] = useState<DiagTool>("ping")
|
||||
const [srcId, setSrcId] = useState(servers[0]?.id ?? "srv1")
|
||||
const [target, setTarget] = useState("8.8.8.8")
|
||||
const [pingCount, setPingCount] = useState(5)
|
||||
const [pingSize, setPingSize] = useState(64)
|
||||
const [pingTtl] = useState(64)
|
||||
const [traceProto, setTraceProto] = useState<TraceProto>("icmp")
|
||||
const [traceHops] = useState(30)
|
||||
const [dnsType, setDnsType] = useState<DnsType>("A")
|
||||
const [bwTunId, setBwTunId] = useState(greTunnels[0]?.id ?? "gre1")
|
||||
const [bwProto, setBwProto] = useState<"tcp" | "udp">("tcp")
|
||||
const [bwDuration, setBwDuration] = useState(10)
|
||||
|
||||
// ── run state ──
|
||||
const [tests, setTests] = useState<DiagTest[]>([])
|
||||
const [tab, setTab] = useState<"history" | "schedule">("history")
|
||||
const [rules, setRules] = useState<SchedRule[]>(INIT_RULES)
|
||||
const nextId = useRef(1)
|
||||
const tickRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const bwTunnels = useMemo(() => greTunnels.filter(t => t.serverId === srcId), [srcId])
|
||||
const srcServer = useMemo(() => servers.find(s => s.id === srcId), [srcId])
|
||||
|
||||
// current command preview
|
||||
const cmdPreview = useMemo(() => buildCommand(tool, srcId, target, {
|
||||
pingCount, pingSize, pingTtl, traceProto, traceMaxHops: traceHops, dnsType,
|
||||
bwTarget: greTunnels.find(t => t.id === bwTunId)?.remoteAddress, bwProto, bwDuration,
|
||||
}), [tool, srcId, target, pingCount, pingSize, pingTtl, traceProto, traceHops, dnsType, bwTunId, bwProto, bwDuration])
|
||||
|
||||
// ── progressive reveal tick ──
|
||||
useEffect(() => {
|
||||
tickRef.current = setInterval(() => {
|
||||
setTests(prev => {
|
||||
const hasRunning = prev.some(t => t.status === "running")
|
||||
if (!hasRunning) return prev
|
||||
return prev.map(t => {
|
||||
if (t.status !== "running") return t
|
||||
const speed = t.tool === "dns" || t.tool === "route" ? t.lines.length : 1
|
||||
const nextTotal = Math.min(t.totalLines + speed, t.lines.length)
|
||||
const done = nextTotal >= t.lines.length
|
||||
return { ...t, totalLines: nextTotal, status: done ? "done" : "running" }
|
||||
})
|
||||
})
|
||||
}, 400)
|
||||
return () => { if (tickRef.current) clearInterval(tickRef.current) }
|
||||
}, [])
|
||||
|
||||
// ── run test ──
|
||||
const runTest = useCallback(() => {
|
||||
const srv = servers.find(s => s.id === srcId)
|
||||
if (!srv) return
|
||||
const id = String(nextId.current++)
|
||||
let lines: OutputLine[] = []
|
||||
const bwTun = greTunnels.find(t => t.id === bwTunId)
|
||||
|
||||
switch (tool) {
|
||||
case "ping": lines = genPingOutput(target, srcId, pingCount, pingSize, pingTtl); break
|
||||
case "traceroute":lines = genTraceOutput(target, srcId, traceProto, traceHops); break
|
||||
case "dns": lines = genDnsOutput(target, srcId, dnsType); break
|
||||
case "route": lines = genRouteOutput(target, srcId); break
|
||||
case "mtu": lines = genMtuOutput(target, srcId); break
|
||||
case "bandwidth": lines = genBwOutput(bwTun?.remoteAddress ?? target, srcId, bwProto, bwDuration); break
|
||||
}
|
||||
|
||||
const test: DiagTest = {
|
||||
id, tool, status: "running",
|
||||
srcServerId: srcId, srcServerName: srv.name,
|
||||
target: tool === "bandwidth" ? (bwTun?.name ?? target) : target,
|
||||
command: cmdPreview,
|
||||
startedAt: Date.now(),
|
||||
lines,
|
||||
totalLines: 0,
|
||||
}
|
||||
setTests(p => [test, ...p.slice(0, 9)]) // keep last 10
|
||||
setTab("history")
|
||||
}, [tool, srcId, target, pingCount, pingSize, pingTtl, traceProto, traceHops, dnsType, bwTunId, bwProto, bwDuration, cmdPreview])
|
||||
|
||||
const stopTest = (id: string) => setTests(p => p.map(t => t.id === id ? { ...t, status: "done", totalLines: t.lines.length } : t))
|
||||
const clearTest = (id: string) => setTests(p => p.filter(t => t.id !== id))
|
||||
|
||||
const running = tests.filter(t => t.status === "running")
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Инструменты" }, { label: "Диагностика" }]}
|
||||
actions={
|
||||
running.length > 0
|
||||
? <Button variant="outline" size="sm" onClick={() => setTests(p => p.map(t => ({ ...t, status: "done" as const, totalLines: t.lines.length })))}>
|
||||
<SquareIcon className="size-4" />Остановить все
|
||||
</Button>
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
{/* ── tool selector + config ── */}
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-4 px-4 flex flex-col gap-4">
|
||||
|
||||
{/* tool chips */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{(Object.entries(TOOL_META) as [DiagTool, typeof TOOL_META[DiagTool]][]).map(([t, m]) => {
|
||||
const active = tool === t
|
||||
return (
|
||||
<button key={t} onClick={() => setTool(t)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-xs font-medium transition-colors",
|
||||
active
|
||||
? "border-foreground bg-foreground text-background"
|
||||
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
|
||||
)}>
|
||||
<m.Icon className={cn("size-3.5", active ? "" : m.color)} />
|
||||
{m.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* main config row */}
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
|
||||
{/* source server */}
|
||||
<div>
|
||||
<OptionLabel>Источник</OptionLabel>
|
||||
<NativeSelect value={srcId} onChange={setSrcId} className="min-w-[175px]">
|
||||
{servers.filter(s => s.enabled).map(s => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
|
||||
{/* target — all tools except bandwidth */}
|
||||
{tool !== "bandwidth" && (
|
||||
<div className="flex-1 min-w-[140px]">
|
||||
<OptionLabel>
|
||||
{tool === "dns" ? "Домен" : tool === "route" ? "Destination IP" : "Цель (IP или домен)"}
|
||||
</OptionLabel>
|
||||
<Input value={target} onChange={e => setTarget(e.target.value)}
|
||||
placeholder={tool === "dns" ? "google.com" : tool === "route" ? "8.8.8.8" : "8.8.8.8"}
|
||||
className="font-mono text-sm"
|
||||
onKeyDown={e => e.key === "Enter" && runTest()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* bandwidth: select GRE tunnel */}
|
||||
{tool === "bandwidth" && (
|
||||
<div>
|
||||
<OptionLabel>GRE-туннель (цель)</OptionLabel>
|
||||
<NativeSelect value={bwTunId} onChange={setBwTunId} className="min-w-[180px]">
|
||||
{bwTunnels.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* inline quick options */}
|
||||
{tool === "ping" && (
|
||||
<>
|
||||
<div>
|
||||
<OptionLabel>Кол-во</OptionLabel>
|
||||
<div className="flex rounded-lg border border-input overflow-hidden h-8">
|
||||
{[5, 10, 25, 100].map(n => <SegBtn key={n} value={n} current={pingCount} onClick={setPingCount}>{n}</SegBtn>)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<OptionLabel>Размер (байт)</OptionLabel>
|
||||
<div className="flex rounded-lg border border-input overflow-hidden h-8">
|
||||
{[64, 128, 512, 1472].map(n => <SegBtn key={n} value={n} current={pingSize} onClick={setPingSize}>{n}</SegBtn>)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tool === "traceroute" && (
|
||||
<div>
|
||||
<OptionLabel>Протокол</OptionLabel>
|
||||
<div className="flex rounded-lg border border-input overflow-hidden h-8">
|
||||
{(["icmp", "udp", "tcp"] as TraceProto[]).map(p => <SegBtn key={p} value={p} current={traceProto} onClick={setTraceProto}>{p.toUpperCase()}</SegBtn>)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tool === "dns" && (
|
||||
<div>
|
||||
<OptionLabel>Тип записи</OptionLabel>
|
||||
<div className="flex rounded-lg border border-input overflow-hidden h-8">
|
||||
{(["A", "AAAA", "MX", "NS", "TXT", "PTR"] as DnsType[]).map(t => <SegBtn key={t} value={t} current={dnsType} onClick={setDnsType}>{t}</SegBtn>)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tool === "bandwidth" && (
|
||||
<>
|
||||
<div>
|
||||
<OptionLabel>Протокол</OptionLabel>
|
||||
<div className="flex rounded-lg border border-input overflow-hidden h-8">
|
||||
{(["tcp", "udp"] as const).map(p => <SegBtn key={p} value={p} current={bwProto} onClick={setBwProto}>{p.toUpperCase()}</SegBtn>)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<OptionLabel>Длительность</OptionLabel>
|
||||
<div className="flex rounded-lg border border-input overflow-hidden h-8">
|
||||
{[5, 10, 30].map(n => <SegBtn key={n} value={n} current={bwDuration} onClick={setBwDuration}>{n}с</SegBtn>)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button onClick={runTest} className="h-8 shrink-0 gap-1.5 self-end">
|
||||
<PlayIcon className="size-3.5" />Запустить
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* RouterOS command preview */}
|
||||
<div className="flex items-center gap-2 rounded-lg bg-zinc-950 dark:bg-zinc-900 px-3 py-2 border border-zinc-800">
|
||||
<TerminalIcon className="size-3 text-zinc-500 shrink-0" />
|
||||
<span className="text-zinc-500 text-xs font-mono shrink-0">[{srcServer?.name}]</span>
|
||||
<span className="text-emerald-400 text-xs font-mono shrink-0">$</span>
|
||||
<span className="text-zinc-300 text-xs font-mono truncate">{cmdPreview}</span>
|
||||
<button onClick={() => navigator.clipboard.writeText(cmdPreview)}
|
||||
className="shrink-0 text-zinc-600 hover:text-zinc-300 transition-colors ml-auto">
|
||||
<CopyIcon className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── tabs ── */}
|
||||
<div>
|
||||
<div className="flex items-center gap-0 border-b">
|
||||
{([
|
||||
["history", "История тестов"],
|
||||
["schedule", "Расписание"],
|
||||
] as const).map(([t, label]) => (
|
||||
<button key={t} onClick={() => setTab(t)}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium -mb-px border-b-2 transition-colors",
|
||||
tab === t
|
||||
? "border-foreground text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground",
|
||||
)}>
|
||||
{label}
|
||||
{t === "history" && tests.length > 0 && (
|
||||
<span className="ml-1.5 text-[10px] font-mono px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground">
|
||||
{tests.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
{/* history */}
|
||||
{tab === "history" && (
|
||||
tests.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground gap-2">
|
||||
<TerminalIcon className="size-8 opacity-20" />
|
||||
<p className="text-sm">Запустите тест — результаты появятся здесь</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{tests.map(test => {
|
||||
const { Icon, color, label } = TOOL_META[test.tool]
|
||||
return (
|
||||
<Card key={test.id} className="overflow-hidden">
|
||||
{/* header */}
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-2.5 border-b bg-muted/20">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Icon className={cn("size-4 shrink-0", color)} />
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
<span className="text-xs font-mono text-muted-foreground truncate">{test.target}</span>
|
||||
<span className="text-[10px] text-muted-foreground">← {test.srcServerName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{test.status === "running" && (
|
||||
<>
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<span className="size-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||
запущен
|
||||
</span>
|
||||
<Button size="sm" variant="outline" className="h-6 px-2 text-xs"
|
||||
onClick={() => stopTest(test.id)}>
|
||||
<SquareIcon className="size-3" />Стоп
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{test.status === "done" && (
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{((test.lines.length * 0.4)).toFixed(1)}с
|
||||
</span>
|
||||
)}
|
||||
<button onClick={() => navigator.clipboard.writeText(test.lines.map(l => l.text).join("\n"))}
|
||||
className="size-6 flex items-center justify-center rounded text-muted-foreground/40 hover:text-foreground hover:bg-muted transition-colors">
|
||||
<CopyIcon className="size-3.5" />
|
||||
</button>
|
||||
<button onClick={() => clearTest(test.id)}
|
||||
className="size-6 flex items-center justify-center rounded text-muted-foreground/40 hover:text-red-500 hover:bg-red-500/10 transition-colors">
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* terminal output */}
|
||||
<div className="p-3">
|
||||
<TerminalOutput test={test} />
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* schedule */}
|
||||
{tab === "schedule" && <ScheduleTab rules={rules} setRules={setRules} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,879 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||
import { StatusDot } from "@/components/status-dot"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { servers as mockServers, type Server } from "@/lib/data"
|
||||
import { PlusIcon, SaveIcon, TrashIcon, SearchIcon, XIcon, PencilIcon, ChevronDownIcon, ChevronRightIcon, AlertCircleIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
interface BackendServer {
|
||||
id: number
|
||||
name: string
|
||||
host: string
|
||||
type: "jump-host" | "exit-node" | "home-router"
|
||||
site: string
|
||||
country: string
|
||||
asn: string
|
||||
enabled: boolean
|
||||
status: "online" | "offline" | null
|
||||
latency: number | null
|
||||
}
|
||||
|
||||
interface GatewayOption {
|
||||
id: string
|
||||
name: string
|
||||
ip: string
|
||||
status: "up" | "down"
|
||||
}
|
||||
|
||||
const INFER_COUNTRIES = [
|
||||
{ code: "RU", keys: ["MSK", "SPB", "RTK", "MTS", "VPSVILLE", "IHOR"] },
|
||||
{ code: "SE", keys: ["SWE", "STO"] },
|
||||
{ code: "FI", keys: ["HEL", "FIN"] },
|
||||
{ code: "DE", keys: ["FRA", "GER", "DE"] },
|
||||
{ code: "NL", keys: ["AMS", "NLD", "NL"] },
|
||||
{ code: "SG", keys: ["SGP", "SIN", "SG"] },
|
||||
{ code: "TR", keys: ["TUR", "TR"] },
|
||||
{ code: "US", keys: ["USA", "US", "NYC", "LAX"] },
|
||||
]
|
||||
|
||||
function inferCountry(name: string): string | null {
|
||||
const upper = name.toUpperCase()
|
||||
for (const c of INFER_COUNTRIES) {
|
||||
if (c.keys.some(k => upper.includes(k))) return c.code
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const COUNTRY_OPTIONS = [
|
||||
{ code: "RU", label: "Россия" }, { code: "DE", label: "Германия" },
|
||||
{ code: "NL", label: "Нидерланды" }, { code: "SG", label: "Сингапур" },
|
||||
{ code: "FI", label: "Финляндия" }, { code: "SE", label: "Швеция" },
|
||||
{ code: "FR", label: "Франция" }, { code: "GB", label: "Великобритания" },
|
||||
{ code: "PL", label: "Польша" }, { code: "US", label: "США" },
|
||||
{ code: "UA", label: "Украина" }, { code: "TR", label: "Турция" },
|
||||
{ code: "JP", label: "Япония" }, { code: "HK", label: "Гонконг" },
|
||||
{ code: "KZ", label: "Казахстан" }, { code: "BY", label: "Беларусь" },
|
||||
{ code: "LT", label: "Литва" }, { code: "LV", label: "Латвия" },
|
||||
{ code: "EE", label: "Эстония" }, { code: "CZ", label: "Чехия" },
|
||||
{ code: "AT", label: "Австрия" }, { code: "CH", label: "Швейцария" },
|
||||
{ code: "NO", label: "Норвегия" },
|
||||
]
|
||||
|
||||
interface RecursiveRouteRow {
|
||||
id: string
|
||||
dstAddress: string
|
||||
gateway: string
|
||||
distance: number
|
||||
scope: number | null
|
||||
targetScope: number | null
|
||||
routingTable: string
|
||||
checkGateway: string
|
||||
comment: string
|
||||
disabled: boolean
|
||||
country: string
|
||||
}
|
||||
|
||||
interface RouteGroup {
|
||||
key: string
|
||||
dstAddress: string
|
||||
routingTable: string
|
||||
comment: string
|
||||
endpoints: RecursiveRouteRow[]
|
||||
}
|
||||
|
||||
function makeApiFetch(backendUrl: string) {
|
||||
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {}
|
||||
const res = await fetch(backendUrl.replace(/\/$/, "") + path, {
|
||||
...init,
|
||||
headers: { ...headers, ...(init?.headers ?? {}) },
|
||||
})
|
||||
if (!res.ok) {
|
||||
let message = res.statusText
|
||||
try {
|
||||
const err = await res.json() as { error?: string }
|
||||
message = err.error ?? message
|
||||
} catch {
|
||||
const text = await res.text().catch(() => "")
|
||||
if (text) message = text
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
}
|
||||
|
||||
function mapBackendServer(s: BackendServer): Server {
|
||||
return {
|
||||
id: String(s.id),
|
||||
name: s.name || s.host,
|
||||
host: s.host,
|
||||
model: "—",
|
||||
os: "—",
|
||||
site: s.site,
|
||||
country: s.country || "UN",
|
||||
asn: s.asn,
|
||||
type: s.type,
|
||||
enabled: s.enabled,
|
||||
status: (s.status ?? "offline") as Server["status"],
|
||||
latency: s.latency != null ? Math.round(s.latency) : null,
|
||||
sessions: 0,
|
||||
}
|
||||
}
|
||||
|
||||
interface RouteForm {
|
||||
dstAddress: string
|
||||
routingTable: string
|
||||
comment: string
|
||||
endpoints: RouteEndpointForm[]
|
||||
}
|
||||
|
||||
interface RouteEndpointForm {
|
||||
id: string
|
||||
gateway: string
|
||||
distance: number
|
||||
scope: number | null
|
||||
targetScope: number | null
|
||||
checkGateway: string
|
||||
country: string
|
||||
}
|
||||
|
||||
const newEndpoint = (): RouteEndpointForm => ({
|
||||
id: `ep-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
gateway: "",
|
||||
distance: 1,
|
||||
scope: null,
|
||||
targetScope: null,
|
||||
checkGateway: "ping",
|
||||
country: "",
|
||||
})
|
||||
|
||||
const emptyForm = (): RouteForm => ({
|
||||
dstAddress: "",
|
||||
routingTable: "main",
|
||||
comment: "",
|
||||
endpoints: [newEndpoint()],
|
||||
})
|
||||
|
||||
function Field({ label, hint, required, children }: {
|
||||
label: string
|
||||
hint?: string
|
||||
required?: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">
|
||||
{label}{required && <span className="text-destructive ml-0.5">*</span>}
|
||||
</label>
|
||||
{children}
|
||||
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-0.5">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RouteGroupRows({
|
||||
group, expanded, onToggle, onEdit, onDelete,
|
||||
}: {
|
||||
group: RouteGroup
|
||||
expanded: boolean
|
||||
onToggle: () => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const [confirmDel, setConfirmDel] = useState(false)
|
||||
const bestDistance = Math.min(...group.endpoints.map(ep => ep.distance))
|
||||
const sorted = [...group.endpoints].sort((a, b) => a.distance - b.distance)
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className={cn(
|
||||
"hover:bg-muted/40 transition-colors cursor-pointer group",
|
||||
expanded && "bg-muted/30",
|
||||
)}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex items-start gap-2">
|
||||
{expanded
|
||||
? <ChevronDownIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground" />
|
||||
: <ChevronRightIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/40" />}
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium truncate">{group.dstAddress}</p>
|
||||
<p className="text-xs font-mono text-muted-foreground">{group.comment || "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{sorted.map((ep, idx) => {
|
||||
const code = ep.country || inferCountry(ep.gateway)
|
||||
return (
|
||||
<div key={ep.id} className="flex items-center gap-1.5 text-[11px] font-mono">
|
||||
<span className={cn(
|
||||
"size-1.5 rounded-full shrink-0",
|
||||
idx === 0 ? "bg-emerald-500" : "bg-sky-500",
|
||||
)} />
|
||||
{code ? <Flag code={code} size={14} className="shrink-0" /> : <span className="text-[10px] text-muted-foreground w-3.5 text-center shrink-0">?</span>}
|
||||
<span className="font-semibold text-sky-600 dark:text-sky-400 truncate min-w-0">{ep.gateway}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs tabular-nums">{group.endpoints.length}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">d{bestDistance}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">{group.routingTable || "main"}</td>
|
||||
<td className="px-3 py-3" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button size="sm" variant="ghost" className="size-7 p-0 text-muted-foreground hover:text-foreground" onClick={onEdit}><PencilIcon className="size-3.5" /></Button>
|
||||
<Button size="sm" variant="ghost" className={cn("size-7 p-0 transition-colors", confirmDel ? "text-destructive bg-destructive/10 hover:bg-destructive/20" : "text-muted-foreground hover:text-destructive")} onClick={() => { if (!confirmDel) setConfirmDel(true); else onDelete() }} onBlur={() => setConfirmDel(false)}>
|
||||
{confirmDel ? <AlertCircleIcon className="size-3.5" /> : <TrashIcon className="size-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{expanded && (
|
||||
<tr className="bg-muted/20">
|
||||
<td colSpan={6} className="px-8 py-5 border-b border-border/50">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-2 text-xs">
|
||||
<span className="text-muted-foreground">Route: <span className="font-mono text-foreground">{group.dstAddress}</span></span>
|
||||
<span className="text-muted-foreground">Table: <span className="font-mono text-foreground">{group.routingTable || "main"}</span></span>
|
||||
{group.comment && <span className="text-muted-foreground italic">{group.comment}</span>}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2.5">
|
||||
{sorted.map((ep, idx) => (
|
||||
<div key={ep.id} className="rounded-lg border border-border bg-background px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{(ep.country || inferCountry(ep.gateway)) && (
|
||||
<Flag code={ep.country || inferCountry(ep.gateway) || ""} size={16} />
|
||||
)}
|
||||
<span className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wide">Endpoint {idx + 1}</span>
|
||||
</div>
|
||||
<span className="text-[11px] font-mono">distance: {ep.distance}</span>
|
||||
</div>
|
||||
<p className="mt-1.5 font-mono text-sm break-all leading-tight">{ep.gateway}</p>
|
||||
<div className="mt-1.5 text-[11px] text-muted-foreground flex items-center gap-3">
|
||||
<span>scope: {ep.scope ?? "—"}</span>
|
||||
<span>t.scope: {ep.targetScope ?? "—"}</span>
|
||||
<span>check: {ep.checkGateway || "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function EndpointCountryField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
const [query, setQuery] = useState("")
|
||||
const q = query.trim().toUpperCase()
|
||||
const visible = q.length === 0
|
||||
? COUNTRY_OPTIONS
|
||||
: COUNTRY_OPTIONS.filter(c => c.code.startsWith(q) || c.label.toLowerCase().includes(query.trim().toLowerCase()))
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium">Страна</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input placeholder="Поиск или код (RU, DE…)" value={query}
|
||||
onChange={e => {
|
||||
const v = e.target.value.toUpperCase().slice(0, 3)
|
||||
setQuery(v)
|
||||
if (v.length === 2) {
|
||||
const match = COUNTRY_OPTIONS.find(c => c.code === v)
|
||||
if (match) onChange(match.code)
|
||||
else onChange(v)
|
||||
}
|
||||
}}
|
||||
className="font-mono pr-10 h-8 text-sm" />
|
||||
{value && (
|
||||
<span className="absolute right-2.5 top-1/2 -translate-y-1/2">
|
||||
<Flag code={value} size={20} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{value && (
|
||||
<span className="text-sm font-mono text-muted-foreground shrink-0">
|
||||
{COUNTRY_OPTIONS.find(c => c.code === value)?.label ?? value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-5 gap-1.5 max-h-40 overflow-y-auto pr-0.5">
|
||||
{visible.map(c => (
|
||||
<button key={c.code} type="button"
|
||||
onClick={() => { onChange(c.code); setQuery("") }}
|
||||
title={`${c.code} · ${c.label}`}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1 px-1 py-2 rounded-lg border text-[10px] transition-all",
|
||||
value === c.code
|
||||
? "border-primary bg-primary/5 ring-1 ring-primary/30 font-semibold text-primary"
|
||||
: "border-border hover:border-muted-foreground/40 hover:bg-muted/40 text-muted-foreground",
|
||||
)}>
|
||||
<Flag code={c.code} size={24} />
|
||||
<span className="font-mono leading-none">{c.code}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RouteSheet({
|
||||
open, mode, initial, onSave, onClose, gateways,
|
||||
}: {
|
||||
open: boolean
|
||||
mode: "create" | "edit"
|
||||
initial: RouteForm
|
||||
onSave: (v: RouteForm) => void
|
||||
onClose: () => void
|
||||
gateways: GatewayOption[]
|
||||
}) {
|
||||
const [form, setForm] = useState<RouteForm>(initial)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { setForm(initial); setError(null) }, [initial, open])
|
||||
const set = <K extends keyof RouteForm>(k: K, v: RouteForm[K]) => setForm(f => ({ ...f, [k]: v }))
|
||||
const setEp = <K extends keyof RouteEndpointForm>(id: string, k: K, v: RouteEndpointForm[K]) =>
|
||||
setForm(f => ({ ...f, endpoints: f.endpoints.map(ep => ep.id === id ? { ...ep, [k]: v } : ep) }))
|
||||
|
||||
function handleSave() {
|
||||
if (!form.dstAddress.trim()) { setError("Dst Address обязателен"); return }
|
||||
if (form.endpoints.length === 0) { setError("Добавь хотя бы одну конечную точку"); return }
|
||||
if (form.endpoints.some(ep => !ep.gateway.trim())) { setError("У каждой конечной точки должен быть Gateway"); return }
|
||||
setError(null)
|
||||
onSave(form)
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={v => { if (!v) onClose() }}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0" showCloseButton={false}>
|
||||
<SheetHeader className="px-5 pt-5 pb-4 border-b shrink-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<SheetTitle className="text-base">{mode === "create" ? "Новый маршрут" : "Редактировать маршрут"}</SheetTitle>
|
||||
<SheetDescription className="text-xs mt-0.5">Рекурсивный статический маршрут</SheetDescription>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon-sm" onClick={onClose} className="shrink-0 mt-0.5"><XIcon className="size-4" /></Button>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden px-6 py-5 flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionTitle>Основные</SectionTitle>
|
||||
<Field label="Dst Address" required hint="Например 8.8.8.8/32 или 1.1.1.0/24">
|
||||
<Input className="font-mono h-9" placeholder="8.8.8.8/32" value={form.dstAddress} onChange={(e) => set("dstAddress", e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionTitle>Конечные точки</SectionTitle>
|
||||
<div className="flex flex-col gap-3">
|
||||
{form.endpoints.map((ep, idx) => (
|
||||
<div key={ep.id} className="rounded-lg border border-border bg-muted/20 p-3 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Endpoint {idx + 1}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm(f => ({ ...f, endpoints: f.endpoints.filter(x => x.id !== ep.id) }))}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors"
|
||||
disabled={form.endpoints.length <= 1}
|
||||
>
|
||||
<TrashIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<EndpointCountryField value={ep.country} onChange={(v) => setEp(ep.id, "country", v)} />
|
||||
|
||||
<Field label="Gateway" required hint="Можно выбрать карточкой ниже или ввести вручную в формате ip%gateway">
|
||||
<Input className="font-mono h-9" placeholder="1.2.3.4%GW-NAME" value={ep.gateway} onChange={(e) => setEp(ep.id, "gateway", e.target.value)} />
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="Distance (приоритет)">
|
||||
<Input type="number" className="h-9" value={ep.distance} onChange={(e) => setEp(ep.id, "distance", Number(e.target.value) || 1)} />
|
||||
</Field>
|
||||
<Field label="Check Gateway">
|
||||
<Input className="h-9 font-mono" placeholder="ping" value={ep.checkGateway} onChange={(e) => setEp(ep.id, "checkGateway", e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="Scope">
|
||||
<Input type="number" className="h-9" value={ep.scope ?? ""} onChange={(e) => setEp(ep.id, "scope", e.target.value ? Number(e.target.value) : null)} />
|
||||
</Field>
|
||||
<Field label="T.Scope">
|
||||
<Input type="number" className="h-9" value={ep.targetScope ?? ""} onChange={(e) => setEp(ep.id, "targetScope", e.target.value ? Number(e.target.value) : null)} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5 max-h-[180px] overflow-y-auto overflow-x-hidden pr-1">
|
||||
{gateways.map((g) => {
|
||||
const value = `${g.ip}%${g.name}`
|
||||
const selected = ep.gateway === value
|
||||
const country = inferCountry(g.name)
|
||||
return (
|
||||
<button
|
||||
key={`${ep.id}-${g.id}`}
|
||||
type="button"
|
||||
onClick={() => setEp(ep.id, "gateway", value)}
|
||||
className={cn(
|
||||
"text-left rounded-lg border px-3 py-2.5 transition-all",
|
||||
selected
|
||||
? "border-primary bg-primary/5 ring-1 ring-primary/30"
|
||||
: "border-border hover:border-muted-foreground/40 hover:bg-muted/40",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("size-1.5 rounded-full shrink-0", g.status === "up" ? "bg-emerald-500" : "bg-red-500")} />
|
||||
{country && <Flag code={country} className="shrink-0" />}
|
||||
<span className="font-mono text-xs font-semibold flex-1 truncate">{g.name}</span>
|
||||
{selected && <CheckIcon className="size-3.5 text-primary shrink-0" />}
|
||||
</div>
|
||||
<div className="mt-1.5 grid grid-cols-[auto_auto_1fr] items-start gap-2 text-[11px] text-muted-foreground font-mono">
|
||||
<span className="text-muted-foreground/50 break-all">{g.ip}</span>
|
||||
<span className="text-muted-foreground/30">→</span>
|
||||
<span className={cn("min-w-0 break-all whitespace-normal leading-tight", selected ? "text-primary font-medium" : "")}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="outline" size="sm" className="w-fit gap-1.5" onClick={() => setForm(f => ({ ...f, endpoints: [...f.endpoints, newEndpoint()] }))}>
|
||||
<PlusIcon className="size-3.5" />Добавить endpoint
|
||||
</Button>
|
||||
{gateways.length === 0 && (
|
||||
<div className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
|
||||
Нет доступных шлюзов на выбранном роутере.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionTitle>Параметры</SectionTitle>
|
||||
<Field label="Routing Table"><Input className="h-9 font-mono" value={form.routingTable} onChange={(e) => set("routingTable", e.target.value)} /></Field>
|
||||
<Field label="Комментарий">
|
||||
<Input className="h-9" value={form.comment} onChange={(e) => set("comment", e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
{error && <div className="flex items-center gap-2 text-sm text-destructive bg-destructive/10 border border-destructive/20 px-3 py-2 rounded-md"><AlertCircleIcon className="size-4 shrink-0" />{error}</div>}
|
||||
</div>
|
||||
<SheetFooter className="px-6 py-4 border-t shrink-0 gap-2">
|
||||
<Button variant="outline" onClick={onClose} className="flex-1">Отмена</Button>
|
||||
<Button onClick={handleSave} className="flex-1"><CheckIcon className="size-4" />{mode === "create" ? "Добавить" : "Сохранить"}</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" }) {
|
||||
return (
|
||||
<span className={cn(
|
||||
"inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold border shrink-0",
|
||||
type === "home-router" ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20" :
|
||||
type === "jump-host"
|
||||
? "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20"
|
||||
: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
|
||||
)}>
|
||||
{type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RecursiveRoutesPage() {
|
||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
||||
const isLive = mode === "live" && backendStatus === true
|
||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||
|
||||
const [servers, setServers] = useState<Server[]>(mockServers)
|
||||
const [selectedServerId, setSelectedServerId] = useState<string>(mockServers[0]?.id ?? "")
|
||||
const [rows, setRows] = useState<RecursiveRouteRow[]>([])
|
||||
const [busy, setBusy] = useState<"load" | "save" | "from" | "to" | null>(null)
|
||||
const [search, setSearch] = useState("")
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [sheetMode, setSheetMode] = useState<"create" | "edit">("create")
|
||||
const [sheetInitial, setSheetInitial] = useState<RouteForm>(emptyForm())
|
||||
const [editingGroupKey, setEditingGroupKey] = useState<string | null>(null)
|
||||
const [gatewayOptions, setGatewayOptions] = useState<GatewayOption[]>([])
|
||||
const [expandedGroupKey, setExpandedGroupKey] = useState<string | null>(null)
|
||||
const [opError, setOpError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setServers(mockServers)
|
||||
setSelectedServerId(mockServers[0]?.id ?? "")
|
||||
setRows([])
|
||||
return
|
||||
}
|
||||
apiFetch<BackendServer[]>("/api/servers")
|
||||
.then((data) => {
|
||||
const mapped = data.map(mapBackendServer)
|
||||
setServers(mapped)
|
||||
setSelectedServerId(prev => (mapped.some(s => s.id === prev) ? prev : (mapped[0]?.id ?? "")))
|
||||
})
|
||||
.catch(() => {
|
||||
setServers([])
|
||||
setSelectedServerId("")
|
||||
})
|
||||
}, [isLive, apiFetch])
|
||||
|
||||
const loadRoutes = useCallback(async () => {
|
||||
if (!isLive || !selectedServerId) return
|
||||
setOpError(null)
|
||||
setBusy("load")
|
||||
try {
|
||||
const res = await apiFetch<{ routes: RecursiveRouteRow[] }>(`/api/recursive-routes?serverId=${selectedServerId}`)
|
||||
setRows(res.routes)
|
||||
} catch (e) {
|
||||
setRows([])
|
||||
setOpError(e instanceof Error ? e.message : "Не удалось загрузить маршруты")
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}, [isLive, selectedServerId, apiFetch])
|
||||
|
||||
const loadGateways = useCallback(async () => {
|
||||
if (!isLive || !selectedServerId) return
|
||||
try {
|
||||
const res = await apiFetch<{ gateways: GatewayOption[] }>(`/api/recursive-routes/gateways?serverId=${selectedServerId}`)
|
||||
setGatewayOptions(res.gateways)
|
||||
} catch {
|
||||
setGatewayOptions([])
|
||||
}
|
||||
}, [isLive, selectedServerId, apiFetch])
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void loadRoutes()
|
||||
}, [loadRoutes])
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void loadGateways()
|
||||
}, [loadGateways])
|
||||
|
||||
const saveToDb = useCallback(async () => {
|
||||
if (!isLive || !selectedServerId) return
|
||||
setOpError(null)
|
||||
setBusy("save")
|
||||
try {
|
||||
await apiFetch<{ ok: boolean }>("/api/recursive-routes", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ serverId: selectedServerId, routes: rows }),
|
||||
})
|
||||
await loadRoutes()
|
||||
} catch (e) {
|
||||
setOpError(e instanceof Error ? e.message : "Не удалось сохранить маршруты в БД")
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}, [isLive, selectedServerId, rows, apiFetch, loadRoutes])
|
||||
|
||||
const syncFromRouter = useCallback(async () => {
|
||||
if (!isLive || !selectedServerId) return
|
||||
setOpError(null)
|
||||
setBusy("from")
|
||||
try {
|
||||
await apiFetch<{ ok: boolean }>("/api/recursive-routes/sync/from-router", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ serverId: selectedServerId }),
|
||||
})
|
||||
await loadRoutes()
|
||||
} catch (e) {
|
||||
setOpError(e instanceof Error ? e.message : "Не удалось синхронизировать маршруты с роутера")
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}, [isLive, selectedServerId, apiFetch, loadRoutes])
|
||||
|
||||
const syncToRouter = useCallback(async () => {
|
||||
if (!isLive || !selectedServerId) return
|
||||
setOpError(null)
|
||||
setBusy("to")
|
||||
try {
|
||||
await apiFetch<{ ok: boolean }>("/api/recursive-routes/sync/to-router", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ serverId: selectedServerId }),
|
||||
})
|
||||
} catch (e) {
|
||||
setOpError(e instanceof Error ? e.message : "Не удалось применить маршруты на роутер")
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}, [isLive, selectedServerId, apiFetch])
|
||||
|
||||
function groupKeyOf(row: RecursiveRouteRow): string {
|
||||
return row.dstAddress.trim().toLowerCase()
|
||||
}
|
||||
function openCreate() {
|
||||
setSheetMode("create")
|
||||
setEditingGroupKey(null)
|
||||
setSheetInitial(emptyForm())
|
||||
setSheetOpen(true)
|
||||
}
|
||||
function openEdit(group: RouteGroup) {
|
||||
setSheetMode("edit")
|
||||
setEditingGroupKey(group.key)
|
||||
setSheetInitial({
|
||||
dstAddress: group.dstAddress,
|
||||
routingTable: group.routingTable,
|
||||
comment: group.comment,
|
||||
endpoints: group.endpoints.map(ep => ({
|
||||
id: ep.id,
|
||||
gateway: ep.gateway,
|
||||
distance: ep.distance,
|
||||
scope: ep.scope,
|
||||
targetScope: ep.targetScope,
|
||||
checkGateway: ep.checkGateway,
|
||||
country: ep.country || "",
|
||||
})),
|
||||
})
|
||||
setSheetOpen(true)
|
||||
}
|
||||
function handleSaveSheet(v: RouteForm) {
|
||||
const toRow = (ep: RouteEndpointForm, id: string): RecursiveRouteRow => ({
|
||||
id,
|
||||
dstAddress: v.dstAddress,
|
||||
gateway: ep.gateway,
|
||||
distance: ep.distance,
|
||||
scope: ep.scope,
|
||||
targetScope: ep.targetScope,
|
||||
routingTable: v.routingTable,
|
||||
checkGateway: ep.checkGateway,
|
||||
comment: v.comment,
|
||||
disabled: false,
|
||||
country: ep.country || inferCountry(ep.gateway) || "",
|
||||
})
|
||||
if (sheetMode === "create") {
|
||||
const base = `new-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
|
||||
const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`))
|
||||
setRows(prev => [...prev, ...expanded])
|
||||
} else if (editingGroupKey) {
|
||||
setRows(prev => {
|
||||
const kept = prev.filter(r => groupKeyOf(r) !== editingGroupKey)
|
||||
const base = `edit-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
|
||||
const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`))
|
||||
return [...kept, ...expanded]
|
||||
})
|
||||
}
|
||||
setSheetOpen(false)
|
||||
}
|
||||
|
||||
const currentServer = servers.find(s => s.id === selectedServerId)
|
||||
const filteredRows = useMemo(() => {
|
||||
const q = search.trim().toLowerCase()
|
||||
if (!q) return rows
|
||||
return rows.filter(r =>
|
||||
r.dstAddress.toLowerCase().includes(q) ||
|
||||
r.gateway.toLowerCase().includes(q) ||
|
||||
r.routingTable.toLowerCase().includes(q) ||
|
||||
r.comment.toLowerCase().includes(q),
|
||||
)
|
||||
}, [rows, search])
|
||||
const groupedRoutes = useMemo(() => {
|
||||
const map = new Map<string, RouteGroup>()
|
||||
for (const r of filteredRows) {
|
||||
const key = groupKeyOf(r)
|
||||
const ex = map.get(key)
|
||||
if (ex) {
|
||||
ex.endpoints.push(r)
|
||||
if (!ex.comment && r.comment) ex.comment = r.comment
|
||||
}
|
||||
else map.set(key, {
|
||||
key,
|
||||
dstAddress: r.dstAddress,
|
||||
routingTable: r.routingTable,
|
||||
comment: r.comment,
|
||||
endpoints: [r],
|
||||
})
|
||||
}
|
||||
return [...map.values()]
|
||||
}, [filteredRows])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "Рекурсивные маршруты" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={syncFromRouter} disabled={!isLive || busy !== null}>
|
||||
{busy === "from" ? "Синхронизация..." : "Router => DB"}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={syncToRouter} disabled={!isLive || busy !== null}>
|
||||
{busy === "to" ? "Применение..." : "DB => Router"}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={saveToDb} disabled={!isLive || busy !== null}>
|
||||
<SaveIcon className="size-4" />Сохранить в БД
|
||||
</Button>
|
||||
<Button size="sm" onClick={openCreate} disabled={!isLive || busy !== null}>
|
||||
<PlusIcon className="size-4" />Добавить
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="border-b bg-muted/20 px-6 py-3 flex items-center gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-muted-foreground">Всего маршрутов</span>
|
||||
<span className="font-semibold tabular-nums">{rows.length}</span>
|
||||
</div>
|
||||
<div className="w-px h-4 bg-border mx-1 shrink-0" />
|
||||
{servers.map((s) => {
|
||||
const count = s.id === selectedServerId ? rows.length : 0
|
||||
const active = selectedServerId === s.id
|
||||
return (
|
||||
<button key={s.id}
|
||||
onClick={() => setSelectedServerId(s.id)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all",
|
||||
active
|
||||
? "bg-foreground text-background border-foreground"
|
||||
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
|
||||
!s.enabled && !active && "opacity-40",
|
||||
)}>
|
||||
<StatusDot status={s.status} />
|
||||
<Flag code={s.country} size={12} />
|
||||
<span className="font-mono">{s.name}</span>
|
||||
<TypeChip type={s.type} />
|
||||
<span className={cn(
|
||||
"tabular-nums font-semibold",
|
||||
active ? "" : count > 0 ? "text-foreground" : "opacity-40",
|
||||
)}>{count}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-3 flex items-center gap-3 border-b flex-wrap shrink-0">
|
||||
<div className="relative min-w-[200px] max-w-xs flex-1">
|
||||
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
|
||||
<Input className="pl-8 h-8 text-sm" placeholder="Dst, gateway, table, comment…"
|
||||
value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
{search && (
|
||||
<button onClick={() => setSearch("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground ml-auto">
|
||||
{filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`}
|
||||
</p>
|
||||
{opError && (
|
||||
<div className="w-full text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
|
||||
{opError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{!isLive ? (
|
||||
<Card className="p-6 text-sm text-muted-foreground">
|
||||
Раздел работает в режиме "Живые данные". Переключи источник данных в настройках.
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="overflow-hidden py-0 gap-0">
|
||||
{currentServer && (
|
||||
<div className="flex items-center gap-2.5 px-4 py-3 border-b bg-muted/10">
|
||||
<StatusDot status={currentServer.status} pulse={currentServer.status === "online"} />
|
||||
<Flag code={currentServer.country} size={16} />
|
||||
<span className="font-mono text-sm font-semibold">{currentServer.name}</span>
|
||||
<TypeChip type={currentServer.type} />
|
||||
<code className="text-[11px] font-mono text-muted-foreground">{currentServer.host}</code>
|
||||
<span className="text-xs text-muted-foreground">{currentServer.asn}</span>
|
||||
{currentServer.latency !== null && (
|
||||
<span className={cn(
|
||||
"text-xs font-mono",
|
||||
currentServer.latency > 60 ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground",
|
||||
)}>{currentServer.latency}мс</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span>{rows.length} маршрутов</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-xs text-muted-foreground">
|
||||
<th className="text-left font-medium px-5 py-3">Route / Comment</th>
|
||||
<th className="text-left font-medium px-4 py-3">Gateways</th>
|
||||
<th className="text-left font-medium px-4 py-3">EP</th>
|
||||
<th className="text-left font-medium px-4 py-3">Priority</th>
|
||||
<th className="text-left font-medium px-4 py-3">Table</th>
|
||||
<th className="w-10 px-3 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{groupedRoutes.map((g) => (
|
||||
<RouteGroupRows
|
||||
key={g.key}
|
||||
group={g}
|
||||
expanded={expandedGroupKey === g.key}
|
||||
onToggle={() => setExpandedGroupKey(prev => prev === g.key ? null : g.key)}
|
||||
onEdit={() => openEdit(g)}
|
||||
onDelete={() => setRows(prev => prev.filter(r => groupKeyOf(r) !== g.key))}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{groupedRoutes.length === 0 && (
|
||||
<div className="p-8 text-center text-sm text-muted-foreground">
|
||||
Нет маршрутов в БД для этого сервера. Нажми "Router => DB" для загрузки.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={openCreate}
|
||||
className="w-full flex items-center gap-2 px-4 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t">
|
||||
<PlusIcon className="size-3.5" />
|
||||
Добавить маршрут
|
||||
</button>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<RouteSheet
|
||||
open={sheetOpen}
|
||||
mode={sheetMode}
|
||||
initial={sheetInitial}
|
||||
onSave={handleSaveSheet}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
gateways={gatewayOptions}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,624 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { servers as mockServers } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import {
|
||||
TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// ─── types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Unified server representation for the terminal sidebar */
|
||||
interface TermServer {
|
||||
uid: string // unique key (mock id or String(backend id))
|
||||
backendId: number | null // null in mock mode
|
||||
name: string
|
||||
host: string
|
||||
country: string
|
||||
status: "online" | "offline" | "degraded" | null
|
||||
enabled: boolean
|
||||
rosVersion: string | null
|
||||
identityName: string | null
|
||||
}
|
||||
|
||||
interface TermLine {
|
||||
id: number
|
||||
kind: "output" | "prompt" | "error" | "info"
|
||||
text: string
|
||||
}
|
||||
|
||||
// ─── mock CLI responses ───────────────────────────────────────────────────────
|
||||
|
||||
type CmdFn = (args: string[], serverName: string) => string
|
||||
|
||||
const MOCK_COMMANDS: Record<string, CmdFn> = {
|
||||
"ip address print": () =>
|
||||
"Flags: X - disabled, I - invalid, D - dynamic\n" +
|
||||
" # ADDRESS NETWORK INTERFACE\n" +
|
||||
" 0 10.0.0.1/24 10.0.0.0 ether1-wan\n" +
|
||||
" 1 192.168.88.1/24 192.168.88.0 bridge-lan\n" +
|
||||
" 2 10.200.0.1/30 10.200.0.0 gre-msk-spb\n" +
|
||||
" 3 10.200.0.5/30 10.200.0.4 gre-msk-fra\n" +
|
||||
" 4 D 169.254.0.0/16 169.254.0.0 lo0",
|
||||
|
||||
"ip route print": () =>
|
||||
"Flags: A - active, D - dynamic, C - connect, S - static, b - bgp, o - ospf\n" +
|
||||
" # DST-ADDRESS PREF-SRC GATEWAY DIST\n" +
|
||||
" 0 ADS 0.0.0.0/0 10.0.0.254 1\n" +
|
||||
" 1 Ab 1.1.1.0/24 10.0.1.1 20\n" +
|
||||
" 2 Ab 8.8.8.0/24 10.0.1.1 20\n" +
|
||||
" 3 ADC 10.0.0.0/24 10.0.0.1 0\n" +
|
||||
" 4 ADC 10.200.0.0/30 10.200.0.1 0",
|
||||
|
||||
"interface print": () =>
|
||||
"Flags: X - disabled, D - dynamic, R - running\n" +
|
||||
" # NAME TYPE MTU\n" +
|
||||
" 0 R ether1-wan ether 1500\n" +
|
||||
" 1 R ether2-lan ether 1500\n" +
|
||||
" 2 R bridge-lan bridge 1500\n" +
|
||||
" 3 R gre-msk-spb gre 1476\n" +
|
||||
" 4 R gre-msk-fra gre 1476\n" +
|
||||
" 5 gre-msk-ams gre 1476",
|
||||
|
||||
"system resource print": () =>
|
||||
" uptime: 12d 4h 22m 18s\n" +
|
||||
" version: 7.14.3 (stable)\n" +
|
||||
" free-memory: 512.0 MiB\n" +
|
||||
" total-memory: 2048.0 MiB\n" +
|
||||
" cpu: Intel(R) Xeon(R)\n" +
|
||||
" cpu-count: 4\n" +
|
||||
" cpu-frequency: 3200 MHz\n" +
|
||||
" cpu-load: 12%\n" +
|
||||
" free-hdd-space: 8192.0 MiB\n" +
|
||||
" total-hdd-space: 16384.0 MiB\n" +
|
||||
" architecture-name: x86\n" +
|
||||
" board-name: CHR\n" +
|
||||
" platform: MikroTik",
|
||||
|
||||
"system identity print": (_, n) => ` name: ${n}`,
|
||||
|
||||
"ip firewall filter print": () =>
|
||||
"Flags: X - disabled, I - invalid, D - dynamic\n\n" +
|
||||
" 0 \n ;;; Accept established connections\n" +
|
||||
" chain=input action=accept connection-state=established,related\n\n" +
|
||||
" 1 \n ;;; YouTube bypass traffic\n" +
|
||||
" chain=forward action=accept src-address-list=youtube-bypass protocol=tcp dst-port=443\n" +
|
||||
" packets=188421 bytes=14283698176\n\n" +
|
||||
" 2 \n ;;; Block SSH from WAN\n" +
|
||||
" chain=input action=drop in-interface=ether1-wan protocol=tcp dst-port=22\n" +
|
||||
" packets=882412 bytes=54618112",
|
||||
|
||||
"routing bgp session print": () =>
|
||||
"Flags: E - established\n" +
|
||||
" # NAME REMOTE-AS REMOTE-ADDRESS STATE UPTIME\n" +
|
||||
" 0 E peer-spb-01 65002 10.0.1.1 established 12d 4h 22m\n" +
|
||||
" 1 E peer-fra-01 65003 10.0.2.1 established 9d 12h 11m\n" +
|
||||
" 2 peer-ams-01 65004 10.0.3.1 active —",
|
||||
|
||||
"routing ospf neighbor print": () =>
|
||||
"Flags: V - virtual\n" +
|
||||
" # ROUTER-ID STATE CHANGES ADJACENCY INTERFACE\n" +
|
||||
" 0 10.0.0.7 Full 8 12d 4h 18m gre-msk-spb\n" +
|
||||
" 1 10.0.1.1 Full 3 9d 11h 42m gre-msk-fra",
|
||||
|
||||
"routing bfd session print": () =>
|
||||
" # LOCAL-ADDRESS REMOTE-ADDRESS STATE UPTIME\n" +
|
||||
" 0 10.200.0.1%gre-msk-spb 10.200.0.2%gre-msk-spb up 12d 4h\n" +
|
||||
" 1 10.200.1.1%gre-msk-fra 10.200.1.2%gre-msk-fra up 9d 11h",
|
||||
|
||||
"log print": () =>
|
||||
"may/01 08:14:22 ospf,debug,packet GRE-MSK-FRA: hello received, RouterID: 10.0.2.1\n" +
|
||||
"may/01 08:14:21 bgp,debug peer-spb-01 sending UPDATE\n" +
|
||||
"may/01 08:14:18 system,info user admin logged from 10.10.0.5\n" +
|
||||
"may/01 08:12:44 firewall,info forward: in:ether2-lan out:gre-msk-fra proto TCP\n" +
|
||||
"may/01 08:11:03 script,info backup-script: backup saved to /backup/mt.rsc\n" +
|
||||
"may/01 08:09:17 system,warning cpu load is 85% on cpu0",
|
||||
|
||||
"ping": (args) => {
|
||||
const host = args[0] || "8.8.8.8"
|
||||
const count = parseInt(args.find(a => a.startsWith("count="))?.split("=")[1] || "4")
|
||||
const rtt = Math.round(10 + Math.random() * 50)
|
||||
const lines = [`PING ${host}`]
|
||||
for (let i = 0; i < Math.min(count, 5); i++) {
|
||||
const t = rtt + Math.round((Math.random() - 0.5) * 8)
|
||||
lines.push(` seq=${i} ttl=56 time=${t}ms`)
|
||||
}
|
||||
lines.push(` sent=${count} received=${count} packet-loss=0%`)
|
||||
return lines.join("\n")
|
||||
},
|
||||
}
|
||||
|
||||
function mockResolve(raw: string, serverName: string): string {
|
||||
const trimmed = raw.trim()
|
||||
if (!trimmed) return ""
|
||||
|
||||
if (trimmed === "?" || trimmed === "help")
|
||||
return "ip address print | ip route print | interface print\n" +
|
||||
"system resource print | ip firewall filter print\n" +
|
||||
"routing bgp session print | routing ospf neighbor print\n" +
|
||||
"routing bfd session print | log print | ping <host>"
|
||||
|
||||
if (trimmed === "clear" || trimmed === "/clear") return "__CLEAR__"
|
||||
if (trimmed === "quit" || trimmed === "exit") return "Connection closed."
|
||||
|
||||
const key = Object.keys(MOCK_COMMANDS).find(k =>
|
||||
trimmed === k || trimmed.toLowerCase().startsWith(k + " ")
|
||||
)
|
||||
if (key) {
|
||||
const rest = trimmed.slice(key.length).trim().split(/\s+/)
|
||||
return MOCK_COMMANDS[key](rest, serverName)
|
||||
}
|
||||
|
||||
return `bad command name ${trimmed.split(" ")[0]} (line 1 column 1)`
|
||||
}
|
||||
|
||||
// ─── MOTD builders ────────────────────────────────────────────────────────────
|
||||
|
||||
function mockMotd(server: TermServer): string {
|
||||
return [
|
||||
"",
|
||||
" MMM MMM KKK TTTTTTTTTTT KKK",
|
||||
" MMM MMMM MMM III KKK KKK RRR OOOOOO TTT TTT II KKK KKK",
|
||||
" MMM MMM III KKK KKK RRR RRROOOOO TTT TTT II KKK KKK",
|
||||
"",
|
||||
` MikroTik RouterOS ${server.rosVersion ?? "7.14.3"} (c) 1999-${new Date().getFullYear()} https://www.mikrotik.com/`,
|
||||
"",
|
||||
"[?] Gives the list of available commands",
|
||||
"[Tab] Completes the command/word",
|
||||
"[/] Move up to base level",
|
||||
"[..] Move up one level",
|
||||
"",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
function liveMotd(server: TermServer): string {
|
||||
return [
|
||||
"",
|
||||
` MikroTik RouterOS ${server.rosVersion ?? "7.x"} (c) 1999-${new Date().getFullYear()} https://www.mikrotik.com/`,
|
||||
"",
|
||||
` Connected to ${server.name} (${server.host})`,
|
||||
"",
|
||||
"[?] Gives the list of available commands — type help or ?",
|
||||
"[Tab] Completes the command/word",
|
||||
"[/] Move up to base level",
|
||||
"",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
// ─── terminal component ───────────────────────────────────────────────────────
|
||||
|
||||
function Terminal({
|
||||
server,
|
||||
isLive,
|
||||
backendUrl,
|
||||
}: {
|
||||
server: TermServer
|
||||
isLive: boolean
|
||||
backendUrl: string
|
||||
}) {
|
||||
const [lines, setLines] = useState<TermLine[]>([])
|
||||
const [input, setInput] = useState("")
|
||||
const [history, setHistory] = useState<string[]>([])
|
||||
const [histIdx, setHistIdx] = useState(-1)
|
||||
const [idCtr, setIdCtr] = useState(0)
|
||||
const [executing, setExecuting] = useState(false)
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const nextId = useCallback((): number => {
|
||||
let id = 0
|
||||
setIdCtr(prev => { id = prev + 1; return prev + 1 })
|
||||
return id
|
||||
}, [])
|
||||
|
||||
const addLines = useCallback((texts: string[], kind: TermLine["kind"] = "output") => {
|
||||
setLines(prev => [
|
||||
...prev,
|
||||
...texts.map(text => ({ id: 0, kind, text })),
|
||||
].map((l, i, arr) => ({ ...l, id: arr.length - texts.length + i }))
|
||||
// Note: IDs don't need to be perfect unique here since we append
|
||||
)
|
||||
setIdCtr(prev => prev + texts.length)
|
||||
}, [])
|
||||
|
||||
// Initialize MOTD on mount / server change
|
||||
useEffect(() => {
|
||||
const motd = isLive ? liveMotd(server) : mockMotd(server)
|
||||
const init = motd.split("\n").map((text, i) => ({ id: i, kind: "output" as const, text }))
|
||||
setLines(init)
|
||||
setIdCtr(init.length)
|
||||
setInput("")
|
||||
setHistory([])
|
||||
setHistIdx(-1)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [server.uid, isLive])
|
||||
|
||||
// Auto-scroll
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" })
|
||||
}, [lines])
|
||||
|
||||
const prompt = `[admin@${server.identityName ?? server.name}] > `
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
if (executing) return
|
||||
const cmd = input.trim()
|
||||
|
||||
setLines(prev => [...prev, { id: nextId(), kind: "prompt", text: prompt + input }])
|
||||
setInput("")
|
||||
setHistIdx(-1)
|
||||
|
||||
if (!cmd) return
|
||||
|
||||
setHistory(h => [cmd, ...h.filter(x => x !== cmd)].slice(0, 100))
|
||||
|
||||
// local always-available commands
|
||||
const lower = cmd.toLowerCase()
|
||||
if (lower === "clear" || lower === "/clear") { setLines([]); return }
|
||||
if (lower === "quit" || lower === "exit") {
|
||||
setLines(prev => [...prev, { id: nextId(), kind: "info", text: "Connection closed." }])
|
||||
return
|
||||
}
|
||||
|
||||
if (isLive && server.backendId !== null) {
|
||||
setExecuting(true)
|
||||
try {
|
||||
const res = await fetch(`${backendUrl}/api/servers/${server.backendId}/exec`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ command: cmd }),
|
||||
})
|
||||
const data = await res.json() as { output?: string; error?: string }
|
||||
const text = data.output ?? data.error ?? "(empty response)"
|
||||
const kind: TermLine["kind"] = text.startsWith("error:") ? "error" : "output"
|
||||
text.split("\n").forEach(line =>
|
||||
setLines(prev => [...prev, { id: nextId(), kind, text: line }])
|
||||
)
|
||||
} catch (err) {
|
||||
setLines(prev => [...prev, { id: nextId(), kind: "error", text: `network error: ${String(err)}` }])
|
||||
} finally {
|
||||
setExecuting(false)
|
||||
}
|
||||
} else {
|
||||
// mock mode
|
||||
const result = mockResolve(cmd, server.name)
|
||||
if (result === "__CLEAR__") { setLines([]); return }
|
||||
if (result) {
|
||||
result.split("\n").forEach(line =>
|
||||
setLines(prev => [...prev, { id: nextId(), kind: "output", text: line }])
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [executing, input, nextId, prompt, isLive, server, backendUrl])
|
||||
|
||||
const handleKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault(); void submit()
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
setHistIdx(i => {
|
||||
const next = Math.min(i + 1, history.length - 1)
|
||||
if (history[next] !== undefined) setInput(history[next])
|
||||
return next
|
||||
})
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
setHistIdx(i => {
|
||||
const next = i - 1
|
||||
if (next < 0) { setInput(""); return -1 }
|
||||
if (history[next] !== undefined) setInput(history[next])
|
||||
return next
|
||||
})
|
||||
} else if (e.key === "l" && e.ctrlKey) {
|
||||
e.preventDefault(); setLines([])
|
||||
}
|
||||
}
|
||||
|
||||
const lineColor = (kind: TermLine["kind"]) => {
|
||||
if (kind === "error") return "text-red-400"
|
||||
if (kind === "prompt") return "text-emerald-400"
|
||||
if (kind === "info") return "text-sky-400"
|
||||
return "text-[#c9d1d9]"
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col bg-[#0d1117] rounded-lg border border-[#30363d] overflow-hidden font-mono text-xs h-full"
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
{/* title bar */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-[#30363d] bg-[#161b22] shrink-0">
|
||||
<CircleIcon className="size-3 text-red-500 fill-red-500" />
|
||||
<CircleIcon className="size-3 text-amber-400 fill-amber-400" />
|
||||
<CircleIcon className="size-3 text-emerald-500 fill-emerald-500" />
|
||||
<span className="mx-auto text-[#8b949e] text-[11px]">
|
||||
{server.name} — {isLive ? "RouterOS REST" : "SSH Terminal (mock)"}
|
||||
</span>
|
||||
{isLive && (
|
||||
<span className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium border border-emerald-500/30 bg-emerald-500/10 text-emerald-400">
|
||||
<span className="size-1 rounded-full bg-emerald-400" />LIVE
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); setLines([]) }}
|
||||
className="text-[#8b949e] hover:text-white transition-colors ml-1"
|
||||
title="Очистить"
|
||||
>
|
||||
<TrashIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* output */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto p-3 space-y-0.5 min-h-0">
|
||||
{lines.map((line, i) => (
|
||||
<div key={`${line.id}-${i}`} className={cn("whitespace-pre-wrap break-all leading-5", lineColor(line.kind))}>
|
||||
{line.text}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* executing indicator */}
|
||||
{executing && (
|
||||
<div className="flex items-center gap-2 text-amber-400 opacity-80">
|
||||
<Loader2Icon className="size-3 animate-spin" />
|
||||
<span>executing…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* input line */}
|
||||
{!executing && (
|
||||
<div className="flex items-center gap-0">
|
||||
<span className="text-emerald-400 select-none shrink-0">{prompt}</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={handleKey}
|
||||
className="terminal-input-active flex-1 bg-transparent outline-none text-[#c9d1d9] caret-emerald-400"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── quick commands ───────────────────────────────────────────────────────────
|
||||
|
||||
const QUICK_CMDS = [
|
||||
{ cmd: "system resource print", label: "system resource print" },
|
||||
{ cmd: "interface print", label: "interface print" },
|
||||
{ cmd: "ip address print", label: "ip address print" },
|
||||
{ cmd: "ip route print", label: "ip route print" },
|
||||
{ cmd: "ip firewall filter print", label: "ip firewall filter print" },
|
||||
{ cmd: "routing bgp session print", label: "routing bgp session print" },
|
||||
{ cmd: "routing ospf neighbor print", label: "routing ospf neighbor print" },
|
||||
{ cmd: "routing ospf area print", label: "routing ospf area print" },
|
||||
{ cmd: "routing bfd session print", label: "routing bfd session print" },
|
||||
{ cmd: "log print", label: "log print" },
|
||||
]
|
||||
|
||||
// ─── page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Convert mock server list to TermServer[] */
|
||||
function mockServersToTermServers(): TermServer[] {
|
||||
return mockServers.map(s => ({
|
||||
uid: s.id,
|
||||
backendId: null,
|
||||
name: s.name,
|
||||
host: s.host,
|
||||
country: s.country,
|
||||
status: s.status as TermServer["status"],
|
||||
enabled: s.enabled,
|
||||
rosVersion: s.os ?? null,
|
||||
identityName: s.name,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Backend ServerRead shape (only fields we need) */
|
||||
interface BackendServer {
|
||||
id: number
|
||||
name: string
|
||||
host: string
|
||||
country: string
|
||||
status: "online" | "offline" | null
|
||||
enabled: boolean
|
||||
os: string | null
|
||||
identityName: string | null
|
||||
}
|
||||
|
||||
export default function TerminalPage() {
|
||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
||||
const isLive = mode === "live" && backendStatus === true
|
||||
|
||||
// Server list state
|
||||
const [liveServers, setLiveServers] = useState<TermServer[]>([])
|
||||
const [serversLoading, setServersLoading] = useState(false)
|
||||
const [refreshKey, setRefreshKey] = useState(0) // force terminal remount on reconnect
|
||||
|
||||
// Load servers from backend when in live mode
|
||||
useEffect(() => {
|
||||
if (!isLive) { setLiveServers([]); return }
|
||||
let cancelled = false
|
||||
setServersLoading(true)
|
||||
fetch(`${backendUrl}/api/servers`)
|
||||
.then(r => r.json() as Promise<BackendServer[]>)
|
||||
.then(data => {
|
||||
if (cancelled) return
|
||||
setLiveServers(data.map(s => ({
|
||||
uid: String(s.id),
|
||||
backendId: s.id,
|
||||
name: s.name || s.host,
|
||||
host: s.host,
|
||||
country: s.country || "",
|
||||
status: s.status,
|
||||
enabled: s.enabled,
|
||||
rosVersion: s.os,
|
||||
identityName: s.identityName,
|
||||
})))
|
||||
setServersLoading(false)
|
||||
})
|
||||
.catch(() => { if (!cancelled) setServersLoading(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [isLive, backendUrl, refreshKey])
|
||||
|
||||
const termServers: TermServer[] = isLive ? liveServers : mockServersToTermServers()
|
||||
|
||||
// Select first available server by default
|
||||
const [selectedUid, setSelectedUid] = useState<string>("")
|
||||
|
||||
const defaultUid = useMemo(() => {
|
||||
const first = termServers.find(s => s.status !== "offline" && s.enabled)
|
||||
?? termServers[0]
|
||||
return first?.uid ?? ""
|
||||
}, [termServers])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedUid && defaultUid) setSelectedUid(defaultUid)
|
||||
}, [defaultUid, selectedUid])
|
||||
|
||||
// Reset selection when switching modes
|
||||
useEffect(() => { setSelectedUid("") }, [isLive])
|
||||
|
||||
const selected = termServers.find(s => s.uid === selectedUid) ?? termServers[0]
|
||||
|
||||
const termKey = `${selectedUid}-${refreshKey}-${isLive ? "live" : "mock"}`
|
||||
|
||||
function injectCommand(cmd: string) {
|
||||
const el = document.querySelector<HTMLInputElement>(".terminal-input-active")
|
||||
if (!el) return
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set
|
||||
nativeSetter?.call(el, cmd)
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }))
|
||||
el.focus()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Инструменты" }, { label: "Терминал" }]}
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={() => {
|
||||
setRefreshKey(k => k + 1)
|
||||
if (isLive) setSelectedUid("")
|
||||
}}>
|
||||
<RefreshCwIcon className="size-4" />Переподключить
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-hidden p-6">
|
||||
<div className="grid grid-cols-[220px_1fr] gap-5 h-full">
|
||||
|
||||
{/* ── sidebar ── */}
|
||||
<div className="flex flex-col gap-4 overflow-y-auto min-h-0">
|
||||
|
||||
{/* server picker */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Узел</p>
|
||||
{isLive && serversLoading && (
|
||||
<Loader2Icon className="size-3 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
{isLive && !serversLoading && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-medium rounded border border-emerald-500/30 bg-emerald-500/10 text-emerald-400 px-1.5 py-0.5">
|
||||
<span className="size-1 rounded-full bg-emerald-400" />LIVE
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLive && !serversLoading && liveServers.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground px-2.5">
|
||||
Нет доступных серверов
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
{termServers.map(s => {
|
||||
const isOffline = s.status === "offline"
|
||||
const isSelected = s.uid === selectedUid
|
||||
return (
|
||||
<button
|
||||
key={s.uid}
|
||||
disabled={isOffline || !s.enabled}
|
||||
onClick={() => { setSelectedUid(s.uid); setRefreshKey(k => k + 1) }}
|
||||
className={cn(
|
||||
"w-full text-left rounded-md px-2.5 py-2 text-xs transition-colors",
|
||||
"flex items-center gap-2",
|
||||
isSelected
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted",
|
||||
(isOffline || !s.enabled) && "opacity-40 cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
"inline-block size-1.5 rounded-full shrink-0",
|
||||
s.status === "online" ? "bg-emerald-500" :
|
||||
s.status === "degraded" ? "bg-amber-400" :
|
||||
s.status === null ? "bg-sky-400" : "bg-red-500",
|
||||
)} />
|
||||
{s.country && <Flag code={s.country} />}
|
||||
<span className="truncate font-mono">{s.name}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* quick commands */}
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
Быстрые команды
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{QUICK_CMDS.map(({ cmd, label }) => (
|
||||
<button
|
||||
key={cmd}
|
||||
className="w-full text-left rounded-md px-2.5 py-1.5 text-[11px] font-mono text-muted-foreground hover:bg-muted hover:text-foreground transition-colors truncate block"
|
||||
onClick={() => injectCommand(cmd)}
|
||||
title={cmd}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* hints */}
|
||||
<div className="mt-auto text-[10px] text-muted-foreground/50 space-y-0.5 px-0.5">
|
||||
<p>↑↓ — история команд</p>
|
||||
<p>Ctrl+L — очистить экран</p>
|
||||
{isLive
|
||||
? <p className="text-sky-400/60">Команды выполняются на роутере</p>
|
||||
: <p>Режим: mock-данные</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── terminal ── */}
|
||||
{selected ? (
|
||||
<Terminal
|
||||
key={termKey}
|
||||
server={selected}
|
||||
isLive={isLive}
|
||||
backendUrl={backendUrl}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center bg-[#0d1117] rounded-lg border border-[#30363d] text-[#8b949e] text-sm font-mono">
|
||||
{serversLoading ? "Загрузка серверов…" : "Выберите сервер"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,395 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { vxlanTunnels, servers } from "@/lib/data"
|
||||
import type { VxlanTunnel } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
||||
DropdownMenuItem, DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
SearchIcon, NetworkIcon, PlusIcon, MoreHorizontalIcon,
|
||||
Trash2Icon, PencilIcon, PowerIcon, CopyIcon, CheckIcon,
|
||||
CodeXmlIcon, LayersIcon,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||
SheetDescription, SheetFooter, SheetClose,
|
||||
} from "@/components/ui/sheet"
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function serverFor(id: string) {
|
||||
return servers.find((s) => s.id === id)
|
||||
}
|
||||
|
||||
// ─── RSC generator ───────────────────────────────────────────────────────────
|
||||
|
||||
function generateVxlanRsc(t: VxlanTunnel): string {
|
||||
const srv = serverFor(t.serverId)
|
||||
const lines: string[] = []
|
||||
lines.push(`# VXLAN — ${t.name} · VNI ${t.vni}`)
|
||||
if (srv) lines.push(`# Сервер: ${srv.name} (${srv.host})`)
|
||||
lines.push(`# RouterOS 7.x · /interface/vxlan`)
|
||||
lines.push(``)
|
||||
lines.push(`/interface/vxlan/add \\`)
|
||||
lines.push(` name=${t.name} \\`)
|
||||
lines.push(` vni=${t.vni} \\`)
|
||||
lines.push(` port=${t.dstPort} \\`)
|
||||
lines.push(` vtep-mac-address=auto \\`)
|
||||
lines.push(` arp-proxy=${t.arpProxy ? "yes" : "no"} \\`)
|
||||
lines.push(` mac-learning=${t.macLearning ? "yes" : "no"} \\`)
|
||||
lines.push(` l2mtu=${t.l2mtu} \\`)
|
||||
if (t.comment) lines.push(` comment="${t.comment}" \\`)
|
||||
if (!t.enabled) lines.push(` disabled=yes \\`)
|
||||
lines.push(``)
|
||||
|
||||
// FDB entries for remote VTEPs
|
||||
for (const vtep of t.remoteVteps) {
|
||||
lines.push(`/interface/vxlan/vteps/add \\`)
|
||||
lines.push(` interface=${t.name} \\`)
|
||||
lines.push(` remote-ip=${vtep}`)
|
||||
lines.push(``)
|
||||
}
|
||||
|
||||
// Bridge
|
||||
lines.push(`# Добавить в bridge:`)
|
||||
lines.push(`/interface/bridge/port/add \\`)
|
||||
lines.push(` bridge=bridge-overlay \\`)
|
||||
lines.push(` interface=${t.name}`)
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
// ─── Export Sheet ─────────────────────────────────────────────────────────────
|
||||
|
||||
function ExportSheet({ open, tunnel, onClose }: {
|
||||
open: boolean; tunnel: VxlanTunnel | null; onClose: () => void
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const code = useMemo(() => tunnel ? generateVxlanRsc(tunnel) : "", [tunnel])
|
||||
|
||||
function handleCopy() {
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
setCopied(true); setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl">
|
||||
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<SheetTitle>Экспорт VXLAN</SheetTitle>
|
||||
<SheetDescription>RouterOS 7.x · /interface/vxlan + vteps</SheetDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}>
|
||||
{copied ? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</> : <><CopyIcon className="size-3.5" />Копировать</>}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all">
|
||||
{code.split("\n").map((line, i) => {
|
||||
const isComment = line.startsWith("#")
|
||||
const isCmd = /^\//.test(line.trimStart())
|
||||
const isParam = /^\s+[a-z]/.test(line)
|
||||
return (
|
||||
<span key={i} className={
|
||||
isComment ? "text-muted-foreground"
|
||||
: isCmd ? "text-sky-400"
|
||||
: isParam ? "text-violet-300"
|
||||
: "text-foreground"
|
||||
}>
|
||||
{line}{"\n"}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</pre>
|
||||
</div>
|
||||
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
|
||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
|
||||
<Button className="flex-1" onClick={handleCopy}>
|
||||
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
|
||||
{copied ? "Скопировано" : "Копировать .rsc"}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Tunnel row ───────────────────────────────────────────────────────────────
|
||||
|
||||
function TunnelRow({
|
||||
tunnel,
|
||||
onExport,
|
||||
}: {
|
||||
tunnel: VxlanTunnel
|
||||
onExport: () => void
|
||||
}) {
|
||||
const srv = serverFor(tunnel.serverId)
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"grid grid-cols-[10px_1fr_1fr_auto_auto_auto_auto_auto_auto_auto_auto] gap-3 px-4 py-3 items-center border-b last:border-b-0 hover:bg-muted/30 transition-colors",
|
||||
!tunnel.enabled && "opacity-50",
|
||||
)}>
|
||||
{/* status dot */}
|
||||
<span className={cn(
|
||||
"size-2 rounded-full shrink-0",
|
||||
tunnel.status === "up" ? "bg-emerald-500" : "bg-red-500",
|
||||
)} />
|
||||
|
||||
{/* name */}
|
||||
<div className="min-w-0">
|
||||
<p className="font-mono font-medium text-sm truncate">{tunnel.name}</p>
|
||||
<p className="text-[11px] text-muted-foreground font-mono">VTEP: {tunnel.vtepIp}</p>
|
||||
</div>
|
||||
|
||||
{/* server */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground min-w-0">
|
||||
{srv && <><Flag code={srv.country} size={12} /><span className="font-mono truncate">{srv.name}</span></>}
|
||||
</div>
|
||||
|
||||
{/* VNI */}
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-muted-foreground">VNI</p>
|
||||
<p className="font-mono text-sm">{tunnel.vni}</p>
|
||||
</div>
|
||||
|
||||
{/* Port */}
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-muted-foreground">Port</p>
|
||||
<p className="font-mono text-sm">{tunnel.dstPort}</p>
|
||||
</div>
|
||||
|
||||
{/* Remote VTEPs */}
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-muted-foreground">Remote VTEP</p>
|
||||
<p className="font-mono text-sm">{tunnel.remoteVteps.length}</p>
|
||||
</div>
|
||||
|
||||
{/* ARP Proxy */}
|
||||
<span className={cn("text-[10px] font-mono px-1.5 py-0.5 rounded",
|
||||
tunnel.arpProxy ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400" : "bg-muted text-muted-foreground")}>
|
||||
ARP {tunnel.arpProxy ? "✓" : "✗"}
|
||||
</span>
|
||||
|
||||
{/* MAC learning */}
|
||||
<span className={cn("text-[10px] font-mono px-1.5 py-0.5 rounded",
|
||||
tunnel.macLearning ? "bg-sky-500/10 text-sky-600 dark:text-sky-400" : "bg-muted text-muted-foreground")}>
|
||||
MAC {tunnel.macLearning ? "✓" : "✗"}
|
||||
</span>
|
||||
|
||||
{/* Status badge */}
|
||||
<span className={cn(
|
||||
"text-[11px] font-mono px-2 py-0.5 rounded border whitespace-nowrap",
|
||||
tunnel.status === "up"
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
: "bg-red-500/10 text-red-500 border-red-500/20",
|
||||
)}>
|
||||
{tunnel.status === "up" ? "UP" : "DOWN"}
|
||||
</span>
|
||||
|
||||
{/* menu */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={
|
||||
<Button variant="ghost" size="icon" className="size-7">
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
} />
|
||||
<DropdownMenuContent side="bottom" align="end">
|
||||
<DropdownMenuItem onClick={onExport}><CodeXmlIcon className="size-4" />Экспорт .rsc</DropdownMenuItem>
|
||||
<DropdownMenuItem><PencilIcon className="size-4" />Редактировать</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem><PowerIcon className="size-4" />{tunnel.enabled ? "Отключить" : "Включить"}</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive"><Trash2Icon className="size-4" />Удалить</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
export default function VxlanPage() {
|
||||
const [search, setSearch] = useState("")
|
||||
const [exportTunnel, setExportTunnel] = useState<VxlanTunnel | null>(null)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return vxlanTunnels
|
||||
const q = search.toLowerCase()
|
||||
return vxlanTunnels.filter((t) =>
|
||||
t.name.includes(q) ||
|
||||
String(t.vni).includes(q) ||
|
||||
t.vtepIp.includes(q) ||
|
||||
(serverFor(t.serverId)?.name.toLowerCase().includes(q) ?? false)
|
||||
)
|
||||
}, [search])
|
||||
|
||||
const upCount = vxlanTunnels.filter((t) => t.status === "up").length
|
||||
const vnis = new Set(vxlanTunnels.map((t) => t.vni)).size
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "VXLAN" }]}
|
||||
actions={
|
||||
<Button size="sm">
|
||||
<PlusIcon className="size-4" />Новый VXLAN
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
{/* KPI */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: "Туннелей", value: vxlanTunnels.length, icon: <NetworkIcon className="size-4 text-muted-foreground" /> },
|
||||
{ label: "Активных", value: upCount, icon: <LayersIcon className="size-4 text-emerald-500" /> },
|
||||
{ label: "Уникальных VNI", value: vnis, icon: <LayersIcon className="size-4 text-sky-400" /> },
|
||||
{ label: "Серверов", value: new Set(vxlanTunnels.map((t) => t.serverId)).size, icon: <NetworkIcon className="size-4 text-violet-400" /> },
|
||||
].map((s) => (
|
||||
<Card key={s.label}>
|
||||
<CardContent className="px-5 py-4 flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{s.label}</p>
|
||||
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
|
||||
</div>
|
||||
<div className="mt-0.5">{s.icon}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Info banner */}
|
||||
<div className="flex items-start gap-3 rounded-lg bg-sky-500/5 border border-sky-500/20 px-4 py-3 text-sm">
|
||||
<NetworkIcon className="size-5 text-sky-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-sky-600 dark:text-sky-400">VXLAN — L2-over-L3 оверлей для RouterOS 7.x</p>
|
||||
<p className="text-muted-foreground text-xs mt-0.5">
|
||||
Доступен с RouterOS 7.1+. VNI (Virtual Network Identifier) — уникальный идентификатор сегмента (0–16777215).
|
||||
Рекомендуется использовать совместно с WireGuard или GRE туннелями для шифрования.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b">
|
||||
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[240px]">
|
||||
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<input
|
||||
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
||||
placeholder="Поиск по имени, VNI, серверу…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} туннелей</span>
|
||||
</div>
|
||||
|
||||
{/* header */}
|
||||
<div className="grid grid-cols-[10px_1fr_1fr_auto_auto_auto_auto_auto_auto_auto_auto] gap-3 px-4 py-2 border-b text-[10px] font-semibold uppercase tracking-widest text-muted-foreground bg-muted/20">
|
||||
<span />
|
||||
<span>Имя / VTEP IP</span>
|
||||
<span>Сервер</span>
|
||||
<span>VNI</span>
|
||||
<span>Port</span>
|
||||
<span>Remote</span>
|
||||
<span />
|
||||
<span />
|
||||
<span>Статус</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
|
||||
<NetworkIcon className="size-10 mb-3 opacity-20" />
|
||||
<p className="text-sm font-medium">VXLAN туннели не найдены</p>
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((t) => (
|
||||
<TunnelRow key={t.id} tunnel={t} onExport={() => setExportTunnel(t)} />
|
||||
))
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Reference */}
|
||||
<Card>
|
||||
<CardContent className="px-5 py-4">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-3">
|
||||
RouterOS 7 · /interface/vxlan — быстрые команды
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
||||
{[
|
||||
{
|
||||
title: "Создать VXLAN",
|
||||
lines: [
|
||||
"/interface/vxlan/add \\",
|
||||
" name=vxlan-10 \\",
|
||||
" vni=10010 \\",
|
||||
" port=8472 \\",
|
||||
" arp-proxy=yes \\",
|
||||
" mac-learning=yes",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Добавить VTEP",
|
||||
lines: [
|
||||
"/interface/vxlan/vteps/add \\",
|
||||
" interface=vxlan-10 \\",
|
||||
" remote-ip=10.0.1.1",
|
||||
"",
|
||||
"/interface/vxlan/vteps/add \\",
|
||||
" interface=vxlan-10 \\",
|
||||
" remote-ip=10.0.2.1",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Bridge + IP",
|
||||
lines: [
|
||||
"/interface/bridge/add \\",
|
||||
" name=br-overlay",
|
||||
"",
|
||||
"/interface/bridge/port/add \\",
|
||||
" bridge=br-overlay \\",
|
||||
" interface=vxlan-10",
|
||||
"",
|
||||
"/ip/address/add \\",
|
||||
" address=10.100.0.1/24 \\",
|
||||
" interface=br-overlay",
|
||||
],
|
||||
},
|
||||
].map((b) => (
|
||||
<div key={b.title}>
|
||||
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">{b.title}</p>
|
||||
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto whitespace-pre">
|
||||
{b.lines.join("\n")}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ExportSheet
|
||||
open={!!exportTunnel}
|
||||
tunnel={exportTunnel}
|
||||
onClose={() => setExportTunnel(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { servers } from "@/lib/data"
|
||||
import type { WireGuardInterface, WireGuardPeer } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||
SheetDescription, SheetFooter, SheetClose,
|
||||
} from "@/components/ui/sheet"
|
||||
import {
|
||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
||||
DropdownMenuItem, DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
ShieldCheckIcon, PlusIcon, SearchIcon, KeyRoundIcon,
|
||||
ChevronDownIcon, ChevronRightIcon, MoreHorizontalIcon,
|
||||
PencilIcon, Trash2Icon, PowerIcon, CopyIcon, CheckIcon,
|
||||
CodeXmlIcon, UsersIcon, ActivityIcon, ArrowDownIcon, ArrowUpIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
// ─── collect all WireGuard interfaces from all servers ────────────────────────
|
||||
|
||||
interface WgIfaceWithServer extends WireGuardInterface {
|
||||
serverId: string
|
||||
serverName: string
|
||||
serverCountry: string
|
||||
}
|
||||
|
||||
function collectInterfaces(): WgIfaceWithServer[] {
|
||||
const result: WgIfaceWithServer[] = []
|
||||
for (const srv of servers) {
|
||||
for (const wg of srv.wireGuardIfaces ?? []) {
|
||||
result.push({
|
||||
...wg,
|
||||
serverId: srv.id,
|
||||
serverName: srv.name,
|
||||
serverCountry: srv.country,
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmtBytes(n: number | undefined): string {
|
||||
if (!n) return "—"
|
||||
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)} ГБ`
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} МБ`
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)} КБ`
|
||||
return `${n} Б`
|
||||
}
|
||||
|
||||
function truncKey(key: string): string {
|
||||
if (key.length <= 20) return key
|
||||
return `${key.slice(0, 8)}…${key.slice(-8)}`
|
||||
}
|
||||
|
||||
// ─── RSC generator ────────────────────────────────────────────────────────────
|
||||
|
||||
function generateWgRsc(iface: WgIfaceWithServer): string {
|
||||
const lines: string[] = []
|
||||
lines.push(`# WireGuard — ${iface.name} · ${iface.serverName}`)
|
||||
lines.push(`# RouterOS 7.x`)
|
||||
lines.push(``)
|
||||
lines.push(`/interface wireguard add \\`)
|
||||
lines.push(` name=${iface.name} \\`)
|
||||
lines.push(` listen-port=${iface.listenPort} \\`)
|
||||
lines.push(` mtu=${iface.mtu} \\`)
|
||||
if (iface.comment) lines.push(` comment="${iface.comment}" \\`)
|
||||
if (!iface.enabled) lines.push(` disabled=yes \\`)
|
||||
lines.push(``)
|
||||
for (const p of iface.peers) {
|
||||
lines.push(`/interface wireguard peers add \\`)
|
||||
lines.push(` interface=${iface.name} \\`)
|
||||
lines.push(` public-key="${p.publicKey}" \\`)
|
||||
lines.push(` allowed-address=${p.allowedIps.join(",")} \\`)
|
||||
if (p.endpoint) lines.push(` endpoint-address=${p.endpoint.split(":")[0]} \\`)
|
||||
if (p.endpoint) lines.push(` endpoint-port=${p.endpoint.split(":")[1] ?? "13231"} \\`)
|
||||
if (p.persistent) lines.push(` persistent-keepalive=25 \\`)
|
||||
if (p.comment) lines.push(` comment="${p.comment}" \\`)
|
||||
lines.push(``)
|
||||
}
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
// ─── Peer row ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function PeerRow({ peer }: { peer: WireGuardPeer }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-3 px-4 py-2.5 items-center text-xs border-t border-border/50 bg-muted/20">
|
||||
{/* public key */}
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<KeyRoundIcon className="size-3 text-muted-foreground shrink-0" />
|
||||
<span className="font-mono text-muted-foreground truncate" title={peer.publicKey}>
|
||||
{truncKey(peer.publicKey)}
|
||||
</span>
|
||||
</div>
|
||||
{/* allowed IPs */}
|
||||
<div className="font-mono text-muted-foreground truncate">
|
||||
{peer.allowedIps.join(", ")}
|
||||
</div>
|
||||
{/* handshake */}
|
||||
<span className={cn(
|
||||
"font-mono text-[11px] whitespace-nowrap",
|
||||
peer.latestHandshake ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground",
|
||||
)}>
|
||||
{peer.latestHandshake ?? "нет рукопожатия"}
|
||||
</span>
|
||||
{/* rx / tx */}
|
||||
<div className="flex items-center gap-2 text-muted-foreground whitespace-nowrap">
|
||||
<span className="flex items-center gap-0.5">
|
||||
<ArrowDownIcon className="size-3 text-emerald-500" />{fmtBytes(peer.transferRx)}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<ArrowUpIcon className="size-3 text-blue-400" />{fmtBytes(peer.transferTx)}
|
||||
</span>
|
||||
</div>
|
||||
{/* endpoint */}
|
||||
<span className="font-mono text-muted-foreground/60 text-[11px]">{peer.endpoint ?? "—"}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Interface card ───────────────────────────────────────────────────────────
|
||||
|
||||
function IfaceRow({
|
||||
iface,
|
||||
expanded,
|
||||
onToggleExpand,
|
||||
onExport,
|
||||
}: {
|
||||
iface: WgIfaceWithServer
|
||||
expanded: boolean
|
||||
onToggleExpand: () => void
|
||||
onExport: () => void
|
||||
}) {
|
||||
const onlinePeers = iface.peers.filter((p) => !!p.latestHandshake).length
|
||||
|
||||
return (
|
||||
<div className={cn("border-b last:border-b-0", !iface.enabled && "opacity-50")}>
|
||||
<div
|
||||
className="grid grid-cols-[20px_1fr_auto_auto_auto_auto_auto_auto] gap-3 px-4 py-3 items-center hover:bg-muted/30 transition-colors cursor-pointer"
|
||||
onClick={onToggleExpand}
|
||||
>
|
||||
{/* expand */}
|
||||
<button className="text-muted-foreground" onClick={(e) => { e.stopPropagation(); onToggleExpand() }}>
|
||||
{expanded
|
||||
? <ChevronDownIcon className="size-3.5" />
|
||||
: <ChevronRightIcon className="size-3.5" />}
|
||||
</button>
|
||||
|
||||
{/* name + server */}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"size-2 rounded-full shrink-0",
|
||||
iface.status === "up" ? "bg-emerald-500 animate-pulse" : "bg-red-500",
|
||||
)} />
|
||||
<span className="font-mono font-semibold text-sm">{iface.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground font-mono">
|
||||
<Flag code={iface.serverCountry} size={12} />
|
||||
{iface.serverName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* port */}
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-muted-foreground">Порт</p>
|
||||
<p className="font-mono text-sm">{iface.listenPort}</p>
|
||||
</div>
|
||||
|
||||
{/* MTU */}
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-muted-foreground">MTU</p>
|
||||
<p className="font-mono text-sm">{iface.mtu}</p>
|
||||
</div>
|
||||
|
||||
{/* peers */}
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-muted-foreground">Пиров</p>
|
||||
<p className="font-mono text-sm">
|
||||
<span className="text-emerald-600 dark:text-emerald-400">{onlinePeers}</span>
|
||||
<span className="text-muted-foreground">/{iface.peers.length}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* status badge */}
|
||||
<span className={cn(
|
||||
"text-[11px] font-mono px-2 py-0.5 rounded border",
|
||||
iface.status === "up"
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
: "bg-red-500/10 text-red-500 border-red-500/20",
|
||||
)}>
|
||||
{iface.status === "up" ? "UP" : "DOWN"}
|
||||
</span>
|
||||
|
||||
{/* menu */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={
|
||||
<Button variant="ghost" size="icon" className="size-7" onClick={(e) => e.stopPropagation()}>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
} />
|
||||
<DropdownMenuContent side="bottom" align="end">
|
||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onExport() }}>
|
||||
<CodeXmlIcon className="size-4" />Экспорт .rsc
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem><PencilIcon className="size-4" />Редактировать</DropdownMenuItem>
|
||||
<DropdownMenuItem><PlusIcon className="size-4" />Добавить пира</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem><PowerIcon className="size-4" />{iface.enabled ? "Отключить" : "Включить"}</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive"><Trash2Icon className="size-4" />Удалить</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* expanded peers */}
|
||||
{expanded && iface.peers.length > 0 && (
|
||||
<div>
|
||||
<div className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-3 px-4 py-1.5 bg-muted/10 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground border-t border-border/50">
|
||||
<span>Public Key</span>
|
||||
<span>Allowed IPs</span>
|
||||
<span>Последнее рукопожатие</span>
|
||||
<span>RX / TX</span>
|
||||
<span>Endpoint</span>
|
||||
</div>
|
||||
{iface.peers.map((p) => <PeerRow key={p.publicKey} peer={p} />)}
|
||||
</div>
|
||||
)}
|
||||
{expanded && iface.peers.length === 0 && (
|
||||
<div className="px-4 py-4 text-xs text-muted-foreground text-center border-t border-border/50">
|
||||
Нет пиров
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Export Sheet ─────────────────────────────────────────────────────────────
|
||||
|
||||
function ExportSheet({ open, iface, onClose }: {
|
||||
open: boolean; iface: WgIfaceWithServer | null; onClose: () => void
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const code = useMemo(() => iface ? generateWgRsc(iface) : "", [iface])
|
||||
|
||||
function handleCopy() {
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
setCopied(true); setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl">
|
||||
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<SheetTitle>Экспорт WireGuard</SheetTitle>
|
||||
<SheetDescription>RouterOS 7.x · /interface wireguard + peers</SheetDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}>
|
||||
{copied
|
||||
? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</>
|
||||
: <><CopyIcon className="size-3.5" />Копировать</>}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all">
|
||||
{code.split("\n").map((line, i) => {
|
||||
const isComment = line.startsWith("#")
|
||||
const isCmd = line.trimStart().startsWith("/interface")
|
||||
const isParam = /^\s+[a-z]/.test(line)
|
||||
return (
|
||||
<span key={i} className={
|
||||
isComment ? "text-muted-foreground"
|
||||
: isCmd ? "text-sky-400"
|
||||
: isParam ? "text-violet-300"
|
||||
: "text-foreground"
|
||||
}>
|
||||
{line}{"\n"}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</pre>
|
||||
</div>
|
||||
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
|
||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
|
||||
<Button className="flex-1" onClick={handleCopy}>
|
||||
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
|
||||
{copied ? "Скопировано" : "Копировать .rsc"}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
export default function WireGuardPage() {
|
||||
const allIfaces = useMemo(() => collectInterfaces(), [])
|
||||
|
||||
const [search, setSearch] = useState("")
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
|
||||
const [exportIface, setExportIface] = useState<WgIfaceWithServer | null>(null)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return allIfaces
|
||||
const q = search.toLowerCase()
|
||||
return allIfaces.filter((i) =>
|
||||
i.name.includes(q) ||
|
||||
i.serverName.toLowerCase().includes(q) ||
|
||||
i.peers.some((p) => p.allowedIps.some((a) => a.includes(q)) || (p.endpoint ?? "").includes(q))
|
||||
)
|
||||
}, [allIfaces, search])
|
||||
|
||||
const totalPeers = allIfaces.reduce((s, i) => s + i.peers.length, 0)
|
||||
const onlinePeers = allIfaces.reduce((s, i) => s + i.peers.filter((p) => !!p.latestHandshake).length, 0)
|
||||
const upIfaces = allIfaces.filter((i) => i.status === "up").length
|
||||
|
||||
function toggleExpand(id: string) {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "WireGuard" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button size="sm">
|
||||
<PlusIcon className="size-4" />Новый интерфейс
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
{/* KPI */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: "Интерфейсов", value: allIfaces.length, icon: <ShieldCheckIcon className="size-4 text-muted-foreground" /> },
|
||||
{ label: "Активных (UP)", value: upIfaces, icon: <ActivityIcon className="size-4 text-emerald-500" /> },
|
||||
{ label: "Всего пиров", value: totalPeers, icon: <UsersIcon className="size-4 text-sky-400" /> },
|
||||
{ label: "Пиров онлайн", value: `${onlinePeers}/${totalPeers}`, icon: <KeyRoundIcon className="size-4 text-violet-400" /> },
|
||||
].map((s) => (
|
||||
<Card key={s.label}>
|
||||
<CardContent className="px-5 py-4 flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{s.label}</p>
|
||||
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
|
||||
</div>
|
||||
<div className="mt-0.5">{s.icon}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Info banner */}
|
||||
<div className="flex items-start gap-3 rounded-lg bg-sky-500/5 border border-sky-500/20 px-4 py-3 text-sm">
|
||||
<ShieldCheckIcon className="size-5 text-sky-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-sky-600 dark:text-sky-400">WireGuard — рекомендуемый туннельный протокол в RouterOS 7.x</p>
|
||||
<p className="text-muted-foreground text-xs mt-0.5">
|
||||
Доступен с RouterOS 7.1+. Более высокая производительность и безопасность по сравнению с GRE+IPsec.
|
||||
Ключи генерируются командой <code className="font-mono bg-muted px-1 rounded">/interface/wireguard/print</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search + table */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b">
|
||||
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[260px]">
|
||||
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<input
|
||||
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
|
||||
placeholder="Поиск по имени, серверу, IP…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} интерфейсов</span>
|
||||
</div>
|
||||
|
||||
{/* table header */}
|
||||
<div className="grid grid-cols-[20px_1fr_auto_auto_auto_auto_auto_auto] gap-3 px-4 py-2 border-b text-[10px] font-semibold uppercase tracking-widest text-muted-foreground bg-muted/20">
|
||||
<span />
|
||||
<span>Интерфейс / Сервер</span>
|
||||
<span>Порт</span>
|
||||
<span>MTU</span>
|
||||
<span>Пиры</span>
|
||||
<span>Статус</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
|
||||
<ShieldCheckIcon className="size-10 mb-3 opacity-20" />
|
||||
<p className="text-sm font-medium">Нет WireGuard интерфейсов</p>
|
||||
<p className="text-xs mt-1">Добавьте первый интерфейс или проверьте поиск</p>
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((iface) => (
|
||||
<IfaceRow
|
||||
key={iface.id}
|
||||
iface={iface}
|
||||
expanded={expandedIds.has(iface.id)}
|
||||
onToggleExpand={() => toggleExpand(iface.id)}
|
||||
onExport={() => setExportIface(iface)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* RouterOS reference */}
|
||||
<Card>
|
||||
<CardContent className="px-5 py-4">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-3">
|
||||
RouterOS 7 · /interface wireguard — быстрые команды
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
||||
{[
|
||||
{
|
||||
title: "Создать интерфейс",
|
||||
lines: [
|
||||
"/interface wireguard add \\",
|
||||
" name=wg0 \\",
|
||||
" listen-port=13231 \\",
|
||||
" mtu=1420",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Добавить пира",
|
||||
lines: [
|
||||
"/interface wireguard peers add \\",
|
||||
" interface=wg0 \\",
|
||||
' public-key="<ключ>" \\',
|
||||
" allowed-address=10.0.0.2/32 \\",
|
||||
" endpoint-address=1.2.3.4 \\",
|
||||
" persistent-keepalive=25",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Назначить IP",
|
||||
lines: [
|
||||
"/ip address add \\",
|
||||
" address=10.210.0.1/30 \\",
|
||||
" interface=wg0",
|
||||
"",
|
||||
"# Статус:",
|
||||
"/interface wireguard print",
|
||||
],
|
||||
},
|
||||
].map((b) => (
|
||||
<div key={b.title}>
|
||||
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">{b.title}</p>
|
||||
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto">
|
||||
{b.lines.join("\n")}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ExportSheet
|
||||
open={!!exportIface}
|
||||
iface={exportIface}
|
||||
onClose={() => setExportIface(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-mono);
|
||||
--font-heading: var(--font-sans);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
/* ─── Light theme ─────────────────────────────────────────────────────────── */
|
||||
|
||||
:root {
|
||||
/* Base — subtle blue-gray tint so cards (white) float above the page */
|
||||
--background: oklch(0.973 0.004 247.858);
|
||||
--foreground: oklch(0.141 0.006 264.0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.006 264.0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.006 264.0);
|
||||
|
||||
/* Primary — dark charcoal for buttons / key UI */
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
|
||||
/* Secondary / muted — very light blue-gray */
|
||||
--secondary: oklch(0.958 0.005 247.858);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.958 0.005 247.858);
|
||||
--muted-foreground: oklch(0.520 0.010 264.0);
|
||||
|
||||
/* Accent — soft blue highlight */
|
||||
--accent: oklch(0.942 0.016 264.376);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
|
||||
/* Borders + inputs */
|
||||
--border: oklch(0.904 0.006 264.0);
|
||||
--input: oklch(0.904 0.006 264.0);
|
||||
|
||||
/* Focus ring — blue, matching the brand accent */
|
||||
--ring: oklch(0.488 0.243 264.376);
|
||||
|
||||
/* ── Colorful chart palette (light) ── */
|
||||
--chart-1: oklch(0.546 0.215 264.376); /* blue */
|
||||
--chart-2: oklch(0.527 0.154 150.069); /* emerald */
|
||||
--chart-3: oklch(0.625 0.162 41.500); /* orange */
|
||||
--chart-4: oklch(0.591 0.224 296.400); /* violet */
|
||||
--chart-5: oklch(0.577 0.245 27.325); /* red */
|
||||
|
||||
/* ── Semantic line colors for charts (light) ── */
|
||||
--chart-line-1: oklch(0.527 0.154 150.069); /* green — MSK / RX */
|
||||
--chart-line-2: oklch(0.546 0.215 264.376); /* blue — SPB / TX */
|
||||
--chart-line-3: oklch(0.625 0.162 41.500); /* orange — FRA */
|
||||
--chart-line-4: oklch(0.591 0.224 296.400); /* violet — AMS */
|
||||
--chart-rx: oklch(0.527 0.154 150.069);
|
||||
--chart-tx: oklch(0.546 0.215 264.376);
|
||||
|
||||
--radius: 0.625rem;
|
||||
|
||||
/* ── Sidebar — intentionally dark even in light mode ── */
|
||||
--sidebar: oklch(0.152 0.010 264.376);
|
||||
--sidebar-foreground: oklch(0.920 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.240 0.012 264.376);
|
||||
--sidebar-accent-foreground: oklch(0.950 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 8%);
|
||||
--sidebar-ring: oklch(0.488 0.243 264.376);
|
||||
|
||||
/* ── Status ── */
|
||||
--status-online: oklch(0.527 0.154 150.069);
|
||||
--status-offline: oklch(0.577 0.245 27.325);
|
||||
--status-degraded: oklch(0.666 0.179 58.318);
|
||||
}
|
||||
|
||||
/* ─── Dark theme ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.dark {
|
||||
/* Base — blue-tinted dark for modern network terminal aesthetic */
|
||||
--background: oklch(0.133 0.011 264.376);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.191 0.011 264.376);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.191 0.011 264.376);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
|
||||
/* Primary — bright white for dark backgrounds */
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
|
||||
/* Secondary / muted — dark blue-gray */
|
||||
--secondary: oklch(0.252 0.010 264.376);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.252 0.010 264.376);
|
||||
--muted-foreground: oklch(0.648 0.008 264.376);
|
||||
|
||||
/* Accent — slightly lighter blue-tinted dark */
|
||||
--accent: oklch(0.285 0.022 264.376);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.488 0.243 264.376);
|
||||
|
||||
/* ── Colorful chart palette (dark — brighter for visibility) ── */
|
||||
--chart-1: oklch(0.637 0.222 264.376); /* bright blue */
|
||||
--chart-2: oklch(0.696 0.170 151.328); /* bright emerald */
|
||||
--chart-3: oklch(0.713 0.176 47.600); /* bright orange */
|
||||
--chart-4: oklch(0.714 0.173 303.900); /* bright violet */
|
||||
--chart-5: oklch(0.704 0.191 22.216); /* bright red */
|
||||
|
||||
/* ── Semantic line colors (dark) ── */
|
||||
--chart-line-1: oklch(0.696 0.170 151.328); /* green */
|
||||
--chart-line-2: oklch(0.637 0.222 264.376); /* blue */
|
||||
--chart-line-3: oklch(0.713 0.176 47.600); /* orange */
|
||||
--chart-line-4: oklch(0.714 0.173 303.900); /* violet */
|
||||
--chart-rx: oklch(0.696 0.170 151.328);
|
||||
--chart-tx: oklch(0.637 0.222 264.376);
|
||||
|
||||
/* ── Sidebar — slightly lighter than bg in dark mode ── */
|
||||
--sidebar: oklch(0.191 0.011 264.376);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.262 0.014 264.376);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.488 0.243 264.376);
|
||||
|
||||
/* Status stays the same */
|
||||
--status-online: oklch(0.527 0.154 150.069);
|
||||
--status-offline: oklch(0.577 0.245 27.325);
|
||||
--status-degraded: oklch(0.666 0.179 58.318);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import Script from "next/script";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "RouterLists",
|
||||
description: "MikroTik network management platform",
|
||||
};
|
||||
|
||||
// Runs before React hydration — prevents flash of wrong theme (FOUC).
|
||||
const ANTI_FOUC = `
|
||||
try {
|
||||
var t = localStorage.getItem('rl-theme') || 'dark';
|
||||
var d = t === 'system'
|
||||
? window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
: t;
|
||||
document.documentElement.classList.toggle('dark', d === 'dark');
|
||||
document.documentElement.classList.toggle('light', d === 'light');
|
||||
} catch(e) {}
|
||||
`.trim()
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="ru"
|
||||
suppressHydrationWarning
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<head>
|
||||
<Script id="anti-fouc" strategy="beforeInteractive">
|
||||
{ANTI_FOUC}
|
||||
</Script>
|
||||
</head>
|
||||
<body className="min-h-full flex flex-col">
|
||||
<ThemeProvider defaultTheme="dark" disableTransitionOnChange>
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Home() {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
.env
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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
|
||||
);
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1777572014210,
|
||||
"tag": "0000_living_xorn",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+3008
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 })
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
Ничего не найдено по “{query}”
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Vendored
+6
@@ -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.
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+9833
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user