commit f3f831653f48c24ae02d15cf10b3e929d4d63e14 Author: Denozordec Date: Sat May 2 01:17:08 2026 +0700 Init commit diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..29709be --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1 @@ +{"version":"0.0.1","configurations":[{"name":"MikrotikManager","runtimeExecutable":"npm","runtimeArgs":["run","dev"],"port":3000,"autoPort":true}]} diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..3b6d903 --- /dev/null +++ b/.claude/settings.local.json @@ -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\\\\|Promiseconsole.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\\)\\)\")" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/app/(main)/alerts/page.tsx b/app/(main)/alerts/page.tsx new file mode 100644 index 0000000..5f9c0ef --- /dev/null +++ b/app/(main)/alerts/page.tsx @@ -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 + 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 = { + 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 = { + "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 = { + "gre-tunnel": ["перешёл в offline", "восстановился"], + "bgp-peer": ["разорвал сессию", "восстановил сессию"], + "bgp-prefix": ["был отозван", "был получен"], + "gre-client": ["отключился", "подключился"], + "server": ["перешёл в offline", "перешёл в degraded", "восстановился"], + "rtt": ["> порога", "< порога"], + "loss": ["> порога"], + "traffic": ["RX < порога", "TX < порога"], +} + +const THRESHOLD_UNIT: Partial> = { + 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 ( + + ) +} + +function FieldLabel({ children }: { children: React.ReactNode }) { + return

{children}

+} + +function FieldHint({ children }: { children: React.ReactNode }) { + return

{children}

+} + +function NativeSelect({ value, onChange, children, className }: { + value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string +}) { + return ( + + ) +} + +function SeverityDot({ severity }: { severity: AlertSeverity }) { + return +} + +function SeverityBadge({ severity }: { severity: AlertSeverity }) { + return ( + + {severity === "critical" ? "Критич." : severity === "warning" ? "Предупр." : "Инфо"} + + ) +} + +// ─── 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 ( +
+ {/* toggle */} + onToggle(rule.id, v)} /> + + {/* severity dot */} + + + {/* type icon */} +
+ +
+ + {/* name + target */} +
+

{rule.name}

+

+ {TYPE_META[rule.type].label} · {rule.target} + {" · "}{rule.condition} +

+
+ + {/* cooldown */} + + {rule.cooldown} + + + {/* chat override badge */} + {rule.chatId && ( + + #{rule.chatId.slice(-6)} + + )} + + {/* last fired */} + + {rule.lastFired ?? "—"} + + + {/* severity badge */} +
+ +
+ + {/* actions (visible on hover) */} +
+ +
+
+ ) +} + +// ─── 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 ( + + + + + ✈️ Telegram + + + + {cfg.connected ? "Подключён" : "Не настроен"} + + + + + + {/* bot token */} +
+ Bot Token +
+ onChange({ ...cfg, token: e.target.value })} + className="text-xs font-mono h-8" + placeholder="1234567890:AAF..." + /> + + +
+
+ + {/* default chat id */} +
+ Chat ID по умолчанию + onChange({ ...cfg, chatId: e.target.value })} + className="text-xs font-mono h-8" + placeholder="-100xxxxxxxxxx" + /> +

+ Для групп используйте отрицательный ID. Каждое правило может переопределить. +

+
+ + {/* test button */} + +
+
+ ) +} + +// ─── history card ───────────────────────────────────────────────────────────── + +function HistoryCard({ entries }: { entries: HistoryEntry[] }) { + return ( + + + Журнал срабатываний + + + {entries.length === 0 ? ( +

Нет срабатываний

+ ) : ( +
+ {entries.map(e => ( +
+ +
+

{e.ruleName}

+

{e.message}

+
+
+

{e.time}

+ + {e.sent ? "✓ отправлено" : "✗ ошибка"} + +
+
+ ))} +
+ )} +
+
+ ) +} + +// ─── 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) => 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 ( + { if (!o) onClose(); handleOpen(o) }}> + {/* p-0 + gap-0: we control all spacing internally */} + + + {/* ── fixed header ── */} + + + + Новое правило оповещения + + + + {/* ── scrollable body ── */} +
+ + {/* name */} +
+ Название правила + setForm(f => ({ ...f, name: e.target.value }))} + /> +
+ + {/* type */} +
+ Тип события + setForm(f => ({ ...f, type: v as AlertType, target: "", condition: "", threshold: "" }))} + > + {(Object.entries(TYPE_META) as [AlertType, typeof TYPE_META[AlertType]][]).map(([t, m]) => ( + + ))} + +
+ + {/* target + condition on same row when both are simple selects */} +
+
+ Объект + setForm(f => ({ ...f, target: v }))}> + + {targets.map(t => )} + +
+
+ Условие + setForm(f => ({ ...f, condition: v }))}> + + {conditions.map(c => )} + +
+
+ + {/* threshold — only for RTT / loss / traffic */} + {unit && ( +
+ Пороговое значение +
+ setForm(f => ({ ...f, threshold: e.target.value }))} + className="rounded-r-none" + placeholder="0" + /> + + {unit} + +
+
+ )} + + {/* severity — vertical radio cards */} +
+ Серьёзность +
+ {(["critical", "warning", "info"] as AlertSeverity[]).map(s => { + const active = form.severity === s + return ( + + ) + })} +
+
+ + {/* cooldown */} +
+ Повторять не чаще чем +
+ {COOLDOWNS.map(c => ( + + ))} +
+
+ + {/* chat id override */} +
+ Chat ID — переопределить + setForm(f => ({ ...f, chatId: e.target.value }))} + className="font-mono text-sm" + placeholder="-100xxxxxxxxxx" + /> + Пусто → используется глобальный Chat ID из настроек Telegram +
+ + {/* Telegram message preview */} + {form.name && form.target && form.condition && ( +
+
+ + Предпросмотр сообщения + +
+
+

+ {form.severity === "critical" ? "🔴" : form.severity === "warning" ? "🟡" : "🔵"} + {" "}{form.name} +

+

Объект: {form.target}

+

+ Событие: {conditionDisplay || form.condition} +

+

Cooldown: {form.cooldown}

+

+ Chat: {form.chatId || "(глобальный)"} +

+
+
+ )} +
+ + {/* ── fixed footer ── */} + + + + +
+
+ ) +} + +// ─── page ───────────────────────────────────────────────────────────────────── + +type SeverityFilter = AlertSeverity | "all" + +export default function AlertsPage() { + const [rules, setRules] = useState(INIT_RULES) + const [history] = useState(INIT_HISTORY) + const [tg, setTg] = useState(INIT_TG) + const [sheetOpen, setSheetOpen] = useState(false) + const [sevFilter, setSevFilter] = useState("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) => { + 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 ( +
+ + + + + } + /> + +
+
+ + {/* ── summary chip bar ── */} +
+ + {/* 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 }) => ( + + ))} + +
+ + {/* active count chip */} + + + {/* telegram status */} + + + {tg.connected ? "Telegram: Подключён" : "Telegram: Не настроен"} + +
+ + {/* ── main layout ── */} +
+ + {/* ── rules card ── */} + + +
+ Правила оповещения + setSearch(e.target.value)} + className="h-7 text-xs max-w-[200px]" + /> +
+
+ + + {filteredRules.length === 0 ? ( +
+ +

Правила не найдены

+
+ ) : ( +
+ {filteredRules.map(r => ( + + ))} +
+ )} + + {/* footer */} +
+ + {filteredRules.length} из {total} правил + {onlyActive && " · только активные"} + {sevFilter !== "all" && ` · ${SEVERITY_META[sevFilter].label.toLowerCase()}`} + + +
+
+
+ + {/* ── right sidebar ── */} +
+ + +
+
+
+
+ + setSheetOpen(false)} + onSave={handleAdd} + /> +
+ ) +} diff --git a/app/(main)/asns/page.tsx b/app/(main)/asns/page.tsx new file mode 100644 index 0000000..8f08bd7 --- /dev/null +++ b/app/(main)/asns/page.tsx @@ -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 ( +
+ + + + + + } + /> +
+
+
+

ASN

+

+ Автономные системы — источники маршрутов для BGP-фильтров +

+
+ {d.asn}, + }, + { + key: "org", + label: "Организация", + render: (d) => {d.org}, + }, + { + key: "country", + label: "Страна", + render: (d) => {d.country}, + }, + { + key: "prefixes", + label: "Префиксов", + render: (d) => {d.prefixes.toLocaleString("ru")}, + }, + { + key: "filter", + label: "Фильтр", + render: (d) => ( + + {d.filter} + + ), + }, + { + key: "updated", + label: "Обновлён", + render: (d) => {d.updated}, + }, + { + key: "enabled", + label: "Статус", + render: (d) => ( + + {d.enabled ? "Активен" : "Отключён"} + + ), + }, + ]} + /> +
+
+
+ ) +} diff --git a/app/(main)/backups/page.tsx b/app/(main)/backups/page.tsx new file mode 100644 index 0000000..3a38a7c --- /dev/null +++ b/app/(main)/backups/page.tsx @@ -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 ( + + ) +} + +function SectionTitle({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) { + return ( +
+ {icon} + {children} +
+
+ ) +} + +function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { + return ( +
+ + {children} + {hint &&

{hint}

} +
+ ) +} + +function SegmentedControl({ value, onChange, options }: { + value: T; onChange: (v: T) => void; options: { value: T; label: string }[] +}) { + return ( +
+ {options.map((o) => ( + + ))} +
+ ) +} + +// ─── 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("history") + const [backupList, setBackupList] = useState(initialBackups) + const [kindFilter, setKindFilter] = useState("all") + + // Schedule + const [schedule, setSchedule] = useState(defaultSchedule) + const setSched = (k: K, v: (typeof defaultSchedule)[K]) => + setSchedule((s) => ({ ...s, [k]: v })) + + // Storage + const [storage, setStorage] = useState(defaultStorage) + const setStore = (k: K, v: (typeof defaultStorage)[K]) => + setStorage((s) => ({ ...s, [k]: v })) + + // Server selection (all enabled by default) + const [selectedServers, setSelectedServers] = useState>( + 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>(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 ( +
+ + + + + } + /> + +
+
+ + {/* Stats */} +
+ {[ + { label: "Всего бэкапов", value: backupList.length, icon: }, + { label: "Авто", value: autoCount, icon: }, + { label: "Вручную", value: manualCount, icon: }, + { label: "Серверов охвачено",value: serverCount, icon: }, + ].map((s) => ( + + +
+

{s.label}

+

{s.value}

+
+ {s.icon} +
+
+ ))} +
+ + {/* Info bar */} +
+ + + {schedSummary} + + + + + {pathSummary} + + + + + {selectedServers.size} из {servers.length} серверов + + +
+ + {/* Tabs */} +
+ {([ + { id: "history", label: "История бэкапов" }, + { id: "settings", label: "Настройки" }, + ] as { id: PageTab; label: string }[]).map((t) => ( + + ))} +
+ + {/* ── История ──────────────────────────────────────────────────── */} + {tab === "history" && ( + +
+
+ {([ + { 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) => ( + + ))} +
+ {filtered.length} бэкапов +
+ +
+ + + + + + + + + + + + + {filtered.length === 0 && ( + + )} + {filtered.map((b) => ( + + + + + + + + + + ))} + +
ФайлСерверРазмерТипЗаметкиСоздан +
Нет бэкапов
{b.filename}{b.server}{b.size} + + {b.kind === "auto" ? "авто" : "вручную"} + + {b.notes || "—"}{b.created} +
+ + + +
+
+
+
+ )} + + {/* ── Настройки ────────────────────────────────────────────────── */} + {tab === "settings" && ( +
+ + {/* Расписание */} + + + }>Расписание + +
+
+

Автоматический бэкап

+

Создавать бэкапы по расписанию

+
+ setSched("enabled", v)} /> +
+ +
+ + setSched("frequency", v)} + options={[ + { value: "daily", label: "Ежедневно" }, + { value: "weekly", label: "Еженедельно" }, + { value: "monthly", label: "Ежемесячно" }, + ]} + /> + + + {schedule.frequency === "weekly" && ( + +
+ {WEEK_DAYS.map((d, i) => ( + + ))} +
+
+ )} + + {schedule.frequency === "monthly" && ( + + setSched("monthDay", Math.min(28, Math.max(1, Number(e.target.value))))} /> + + )} + + +
+
+ setSched("hour", Math.min(23, Math.max(0, Number(e.target.value))))} /> +
+ : +
+ {[0, 15, 30, 45].map((m) => ( + + ))} +
+
+
+ +
+ + setSched("keepCount", Math.max(1, Number(e.target.value)))} /> + + + setSched("format", v)} + options={[ + { value: "rsc", label: ".rsc" }, + { value: "backup", label: ".backup" }, + ]} + /> + +
+
+
+
+ + {/* Хранилище */} + + + }>Хранилище + + + setStore("type", v)} + options={[ + { value: "local", label: "Локально" }, + { value: "ftp", label: "FTP" }, + { value: "scp", label: "SCP" }, + { value: "smb", label: "SMB" }, + ]} + /> + + + {storage.type === "local" && ( + + setStore("localPath", e.target.value)} /> + + )} + + {storage.type !== "local" && ( + <> +
+
+ + setStore("host", e.target.value)} /> + +
+ + setStore("port", e.target.value)} /> + +
+ + {storage.type === "smb" && ( + + setStore("share", e.target.value)} /> + + )} + +
+ + setStore("username", e.target.value)} /> + + +
+ setStore("password", e.target.value)} + /> + +
+
+
+ + + setStore("remotePath", e.target.value)} /> + + + )} + + {/* Preview */} +
+

Путь сохранения

+

{pathSummary}

+ {storage.type !== "local" && ( +

Файлы: {pathSummary}/{""}_{"{date}"}.{schedule.format}

+ )} + {storage.type === "local" && ( +

Файлы: {storage.localPath || "/var/backup/mikrotik"}/{""}_{"{date}"}.{schedule.format}

+ )} +
+
+
+ + {/* Серверы */} + + +
+ }>Серверы для бэкапа +
+ + · + +
+
+ +
+ {servers.map((s) => { + const checked = selectedServers.has(s.id) + return ( + + ) + })} +
+ +

+ Выбрано {selectedServers.size} из {servers.length} серверов +

+
+
+ + {/* Save button */} +
+ + {saved && ( + + + Настройки сохранены + + )} +
+
+ )} + +
+
+ + {/* ══ Sheet: Manual backup ══════════════════════════════════════════════ */} + + + + Новый бэкап + Снять конфигурацию вручную с выбранных серверов + + +
+
+
+

Выберите серверы

+
+ + · + +
+
+ {servers.map((s) => { + const checked = manualServers.has(s.id) + return ( + + ) + })} +
+ +
+ + setManualNotes(e.target.value)} /> +
+
+ + + }>Отмена + + +
+
+
+ ) +} diff --git a/app/(main)/bgp/page.tsx b/app/(main)/bgp/page.tsx new file mode 100644 index 0000000..7a2b22f --- /dev/null +++ b/app/(main)/bgp/page.tsx @@ -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 = { + 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 = { + 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 = { + 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 ( + + + {s.label} + + ) +} + +function TypeBadge({ type }: { type: BgpType }) { + const s = TYPE_STYLE[type] + return ( + + {type} + + ) +} + +function CapChip({ cap }: { cap: string }) { + return ( + + {cap} + + ) +} + +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 ( +
+ {[ + { 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 => ( +
+ {r.label} +
+
0 ? 2 : 0)}%` }} /> +
+ {fmtNum(r.val)} +
+ ))} +
+ ) +} + +// ─── 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 ( +
+
+ {[ + { 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 }) => ( +
+

{label}

+

{value}

+
+ ))} +
+ + {/* prefix bars */} + {s.state === "Established" && ( +
+

Префиксы

+ +
+ )} + + {/* capabilities */} + {s.capabilities.length > 0 && ( +
+

Capabilities

+
+ {s.capabilities.map(c => )} +
+
+ )} + + {/* last error */} + {s.lastError && ( +
+ +

{s.lastError}

+
+ )} + + {/* rsc export */} +
+

RouterOS Export

+
+
+            {rscSnippet(s)}
+          
+ +
+
+
+ ) +} + +// ─── 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("all") + const [typeFilter, setTypeFilter] = useState("all") + const [expandedId, setExpandedId] = useState(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 ( +
+ + {/* filter bar */} +
+ {/* search */} +
+ + 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 && ( + + )} +
+ + {/* state filter */} +
+ {STATE_FILTERS.map(f => ( + + ))} +
+ + {/* type filter */} +
+ {(["all", "eBGP", "iBGP"] as const).map(t => ( + + ))} +
+ + + {filtered.length} из {sessions.length} + +
+ + {/* table */} + +
+ + + + + ))} + + + + {filtered.map(s => { + const isOpen = expandedId === s.id + return ( + + setExpandedId(isOpen ? null : s.id)} + className={cn( + "cursor-pointer transition-colors", + isOpen ? "bg-muted/30" : "hover:bg-muted/20", + )}> + + + + + + + + + + + + {isOpen && ( + + + + )} + + ) + })} + + {filtered.length === 0 && ( + + + + )} + +
+ {["Роутер", "Peer IP", "Remote AS", "Описание", "Тип", "Состояние", "Uptime", "Prefixes ↓", "Prefixes ↑"].map(h => ( + {h}
+ {isOpen + ? + : } + {s.serverLabel}{s.peerIp} +
+ AS{s.remoteAs} + {AS_NAMES[s.remoteAs] && ( + {AS_NAMES[s.remoteAs]} + )} +
+
{s.description} + {s.uptime ?? "—"} + + {s.prefixesRx > 0 + ? {fmtNum(s.prefixesRx)} + : } + + {s.prefixesTx > 0 + ? {fmtNum(s.prefixesTx)} + : } +
+ +
+ Нет сессий по заданным фильтрам +
+
+
+
+ ) +} + +// ─── routers tab ────────────────────────────────────────────────────────────── + +function RoutersTab({ sessions }: { sessions: BgpSession[] }) { + const byRouter = useMemo(() => { + const map: Record = {} + 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 ( +
+ {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 ( + + {/* header */} +
+ +
+

{router.label}

+

{router.site} · AS65001

+
+
+ {estCnt > 0 && ( + + ✓ {estCnt} + + )} + {downCnt > 0 && ( + + ⚠ {downCnt} + + )} +
+
+ + {/* session rows */} +
+ {router.sessions.map(s => { + const ss = STATE_STYLE[s.state] + return ( +
+ +
+
+ {s.peerIp} + +
+
+ AS{s.remoteAs} + {AS_NAMES[s.remoteAs] && ( + · {AS_NAMES[s.remoteAs]} + )} +
+
+
+ {s.prefixesRx > 0 && ( +

+ ↓ {fmtNum(s.prefixesRx)} +

+ )} +

{s.state}

+
+
+ ) + })} +
+ + {/* footer summary */} + {totalRx > 0 && ( +
+ + + Всего получено: {fmtNum(totalRx)} префиксов + +
+ )} +
+ ) + })} +
+ ) +} + +// ─── 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 = {} + 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 ( +
+ {/* summary row */} +
+ {[ + { 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 => ( + + +

{s.label}

+

{s.value}

+
+
+ ))} +
+ +
+ {/* prefixes by peer — horizontal bar chart */} + + +

Топ-8 пиров по полученным префиксам

+
+ {topPeers.map((s, i) => { + const pct = (s.prefixesRx / maxRx) * 100 + const ss = STATE_STYLE[s.state] + return ( +
+ + {i + 1} + +
+
+
+ {s.peerIp} + AS{s.remoteAs} + +
+ + {fmtNum(s.prefixesRx)} + +
+
+
+
+

{s.serverLabel}

+
+
+ ) + })} + {topPeers.length === 0 && ( +

Нет данных о префиксах

+ )} +
+ + + + {/* right column */} +
+ {/* state distribution */} + + +

Распределение состояний

+ {stateCounts.length === 0 ? ( +

Нет данных

+ ) : ( +
+ {stateCounts.map(([state, count]) => { + const ss = STATE_STYLE[state] + const pct = sessions.length > 0 ? (count / sessions.length) * 100 : 0 + return ( +
+ + {state} +
+
+
+ {count} +
+ ) + })} +
+ )} + + + + {/* eBGP vs iBGP */} + + +

eBGP vs iBGP

+ {sessions.length === 0 ? ( +

Нет данных

+ ) : ( + <> + {[ + { 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 ( +
+ +
+
+
+ {count} +
+ ) + })} + + {/* active AS list */} +
+

Автономные системы

+
+ {Object.entries( + sessions.filter(s => s.type === "eBGP").reduce>((acc, s) => { + acc[s.remoteAs] = (acc[s.remoteAs] ?? 0) + 1; return acc + }, {}) + ).sort((a, b) => b[1] - a[1]).map(([as, cnt]) => ( +
+ AS{as} + {AS_NAMES[Number(as)] ?? ""} + {cnt} +
+ ))} +
+
+ + )} + + +
+
+
+ ) +} + +// ─── page ───────────────────────────────────────────────────────────────────── + +const TABS: Array<{ id: BgpTab; label: string; icon: React.ReactNode }> = [ + { id: "sessions", label: "Сессии", icon: }, + { id: "routers", label: "По роутерам", icon: }, + { id: "analytics", label: "Аналитика", icon: }, +] + +export default function BgpPage() { + const [activeTab, setActiveTab] = useState("sessions") + + const { mode, backendUrl, backendStatus } = useDataSource() + const isLive = mode === "live" && backendStatus === true + + const [liveSessions, setLiveSessions] = useState([]) + const [loading, setLoading] = useState(false) + const [fetchedAt, setFetchedAt] = useState(null) + const [liveError, setLiveError] = useState(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 + }) + .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 ( +
+ + + + + } + /> + + {/* tab bar */} +
+
+ {TABS.map(t => ( + + ))} +
+
+ +
+
+ + {/* data source banner */} + {isLive && loading && ( +
+ + Загрузка BGP-сессий… +
+ )} + {isLive && !loading && fetchedAt && !liveError && ( +
+ + + Живые данные · {serverCount} серверов · обновлено {fetchedAt.toLocaleTimeString("ru")} + + +
+ )} + {isLive && liveError && ( +
+ +

Ошибка загрузки: {liveError}

+
+ )} + {isLive && !loading && liveSessions.length === 0 && !liveError && fetchedAt && ( +
+ BGP не настроен ни на одном сервере +
+ )} + {mode === "mock" && ( + + Моковые данные + + )} + + {/* KPI strip */} +
+ {[ + { 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 => ( + + +

{s.label}

+

{s.value}

+
+
+ ))} +
+ + {/* alert: not-established sessions */} + {notEstab > 0 && ( +
+ +

+ {notEstab} сессии не в состоянии Established — + проверьте {sessions.filter(s => s.state !== "Established").map(s => s.peerIp).join(", ")} +

+
+ )} + + {/* tab content */} + {activeTab === "sessions" && } + {activeTab === "routers" && } + {activeTab === "analytics" && } + +
+
+
+ ) +} diff --git a/app/(main)/certificates/page.tsx b/app/(main)/certificates/page.tsx new file mode 100644 index 0000000..d1d6878 --- /dev/null +++ b/app/(main)/certificates/page.tsx @@ -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 = { + valid: { + label: "Действителен", + icon: , + badge: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20", + row: "", + }, + expired: { + label: "Истёк", + icon: , + badge: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20", + row: "bg-red-500/5", + }, + revoked: { + label: "Отозван", + icon: , + 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 ( +
+
+ {/* expand */} + + + {/* name */} +
+
+ {cfg.icon} + {cert.name} +
+

{cert.commonName}

+
+ + {/* server */} +
+ {srv ? <>{srv.name} : } +
+ + {/* issued by */} +

{cert.issuedBy}

+ + {/* days left */} +
+
+ + {cert.daysLeft < 0 ? `Истёк ${-cert.daysLeft}д назад` : `${cert.daysLeft}д осталось`} + + {cert.validUntil} +
+
+
+
+
+ + {/* usage badges */} +
+ {cert.usage.map((u) => ( + + {u} + + ))} +
+ + {/* status */} + + {cfg.label} + +
+ + {/* expanded detail */} + {expanded && ( +
+
+

Key size

+

{cert.keySize} bit

+
+
+

Действителен с

+

{cert.validFrom}

+
+
+

SAN / Alt Names

+
+ {cert.sans.length > 0 + ? cert.sans.map((s) => {s}) + : } +
+
+
+

Trusted

+

+ {cert.trusted ? "Да (доверенный)" : "Нет (не доверенный)"} +

+
+
+ )} +
+ ) +} + +// ════════════════════════════════════════════════════════════════════════════ +export default function CertificatesPage() { + const [search, setSearch] = useState("") + const [statusFilter, setStatusFilter] = useState("all") + const [expandedIds, setExpandedIds] = useState>(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 ( +
+ + Выпустить сертификат + + } + /> + +
+
+ + {/* Alerts */} + {(expiring.length > 0 || expired.length > 0) && ( +
+ {expired.length > 0 && ( +
+ +
+

+ {expired.length} {expired.length === 1 ? "истёкший сертификат" : "истёкших сертификата"} +

+

+ {expired.map((c) => c.name).join(", ")} — требуют обновления +

+
+
+ )} + {expiring.length > 0 && ( +
+ +
+

+ {expiring.length} {expiring.length === 1 ? "сертификат истекает" : "сертификата истекают"} в течение 30 дней +

+

+ {expiring.map((c) => `${c.name} (${c.daysLeft}д)`).join(", ")} +

+
+
+ )} +
+ )} + + {/* KPI */} +
+ {[ + { label: "Всего", value: routerCertificates.length, icon: }, + { label: "Действующих", value: routerCertificates.filter((c) => c.status === "valid").length, icon: }, + { label: "Истекают", value: expiring.length, icon: }, + { label: "Истёкших", value: expired.length, icon: }, + ].map((s) => ( + + +
+

{s.label}

+

{s.value}

+
+
{s.icon}
+
+
+ ))} +
+ + {/* Table */} + +
+ {/* search */} +
+ + setSearch(e.target.value)} + /> +
+ + {/* status filter */} +
+ {(["all", "valid", "expired", "revoked"] as const).map((s) => ( + + ))} +
+ + {filtered.length} сертификатов +
+ + {/* table header */} +
+ + Имя / CN + Сервер + Выпущен +
Срок
+
Использование
+ Статус +
+ + {filtered.length === 0 ? ( +
+ +

Сертификаты не найдены

+
+ ) : ( + filtered.map((cert) => ( + toggleExpand(cert.id)} + /> + )) + )} +
+ + {/* RouterOS reference */} + + +

+ RouterOS 7 · /certificate — команды управления +

+
+ {[ + { + 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) => ( +
+

{b.title}

+
+                      {b.lines.join("\n")}
+                    
+
+ ))} +
+
+
+ +
+
+
+ ) +} diff --git a/app/(main)/communities/page.tsx b/app/(main)/communities/page.tsx new file mode 100644 index 0000000..b76e253 --- /dev/null +++ b/app/(main)/communities/page.tsx @@ -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 = { + "standard": "Стандартный", + "no-export": "No-export", + "no-advertise":"No-advertise", + "local-as": "Local-AS", + "custom": "Кастомный", +} + +const ACTION_LABELS: Record = { + "permit": "Permit", + "deny": "Deny", + "local-pref": "Local-pref", + "metric": "MED/Metric", +} + +const ACTION_COLOR: Record = { + "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("all") + const [copied, setCopied] = useState(null) + const [selected, setSelected] = useState(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 ( +
+ + Добавить + + } + /> + +
+
+ + {/* ── summary ── */} +
+ {[ + { 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 }) => ( + + +

{label}

+

{value}

+
+
+ ))} +
+ +
+ {/* ── main table ── */} +
+ {/* toolbar */} +
+
+ + setSearch(e.target.value)} /> +
+
+ {(["all", "standard", "no-export", "no-advertise", "custom"] as const).map(t => ( + + ))} +
+
+ + {/* list */} + +
+ + + + + + + + + + + + + {filtered.map(c => ( + 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", + )} + > + + + + + + + + + ))} + +
CommunityИмя / описаниеТипДействиеМаршрутовСерверов +
+
+ + + {c.value} + + +
+
+

{c.name}

+

{c.description}

+
+ {TYPE_LABELS[c.type]} + + + {ACTION_LABELS[c.action]}{c.actionValue !== undefined ? ` ${c.actionValue}` : ""} + + + {c.prefixCount > 0 ? c.prefixCount.toLocaleString() : "—"} + +
+ + {c.serverCount} +
+
+ +
+ {filtered.length === 0 && ( +
+ +

Ничего не найдено

+
+ )} +
+
+
+ + {/* ── detail panel ── */} + {selected ? ( + + +
+
+
+ + {selected.enabled ? "Активен" : "Выключен"} + +
+ {selected.value} + {selected.name} +
+
+ + +
+
+
+ +

{selected.description}

+ +
+ {[ + ["Тип", 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]) => ( +
+ {k} + {v} +
+ ))} +
+ + {selected.filterIds.length > 0 && ( +
+

+ Использующие фильтры +

+
+ {selected.filterIds.map(fid => ( + + {filterName(fid)} + + ))} +
+
+ )} + +
+ +
+
+
+ ) : ( + + + +

Выберите community
для просмотра деталей

+
+
+ )} +
+
+
+
+ ) +} diff --git a/app/(main)/containers/page.tsx b/app/(main)/containers/page.tsx new file mode 100644 index 0000000..86c7f61 --- /dev/null +++ b/app/(main)/containers/page.tsx @@ -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 ( + { if (!v) onClose() }}> + + +
+
+ Экспорт Container + RouterOS 7.4+ · /container · /interface/veth +
+ +
+
+
+
+            {code.split("\n").map((line, i) => {
+              const isComment = line.startsWith("#")
+              const isCmd = /^\//.test(line.trimStart())
+              const isParam = /^\s+[a-z]/.test(line)
+              return (
+                
+                  {line}{"\n"}
+                
+              )
+            })}
+          
+
+ + }>Закрыть + + +
+
+ ) +} + +// ─── Container card ─────────────────────────────────────────────────────────── + +function ContainerCard({ + container, + onExport, +}: { + container: RouterContainer + onExport: () => void +}) { + const srv = serverFor(container.serverId) + const cfg = statusConfig(container.status) + + return ( + +
+
+ + {container.name} + + {cfg.label} + +
+ + + + + } /> + + {container.status === "running" ? ( + Остановить + ) : ( + Запустить + )} + Логи + Редактировать + Экспорт .rsc + + Перезапустить + + Удалить + + +
+ + + {/* image */} +
+ + + {container.image}:{container.tag} + +
+ + {/* server */} + {srv && ( +
+ + + {srv.name} +
+ )} + + {/* uptime + stats */} + {container.status === "running" && ( +
+ {container.uptime && ( +
+ + {container.uptime} +
+ )} + {container.cpu !== undefined && ( +
+ CPU + 50 ? "text-amber-500" : "text-foreground")}> + {container.cpu}% + +
+ )} + {container.memMb !== undefined && ( +
+ RAM + {container.memMb} МБ +
+ )} +
+ )} + + {/* interfaces */} + {container.interfaces.length > 0 && ( +
+ {container.interfaces.map((i) => ( + + {i} + + ))} +
+ )} + + {/* mounts */} + {container.mounts.length > 0 && ( +
+ {container.mounts.map((m, idx) => ( +
+ + {m.dst} + {m.src && <>{m.src}} +
+ ))} +
+ )} + + {container.comment && ( +

{container.comment}

+ )} +
+
+ ) +} + +// ════════════════════════════════════════════════════════════════════════════ +export default function ContainersPage() { + const [search, setSearch] = useState("") + const [statusFilter, setStatusFilter] = useState("all") + const [exportContainer, setExportContainer] = useState(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 ( +
+ + Новый контейнер + + } + /> + +
+
+ + {/* KPI */} +
+ {[ + { label: "Всего", value: routerContainers.length, icon: }, + { label: "Running", value: running, icon: }, + { label: "Stopped", value: stopped, icon: }, + { label: "Ошибок", value: errors, icon: }, + ].map((s) => ( + + +
+

{s.label}

+

{s.value}

+
+
{s.icon}
+
+
+ ))} +
+ + {/* Info banner */} +
+ +
+

Контейнеры доступны с RouterOS 7.4+

+

+ Поддерживаются Docker-совместимые образы. Требуется установка пакета container. + Интерфейсы veth создаются автоматически. +

+
+
+ + {/* Toolbar */} +
+
+ + setSearch(e.target.value)} + /> +
+ +
+ {(["all", "running", "stopped", "error"] as const).map((s) => ( + + ))} +
+ + {filtered.length} контейнеров +
+ + {/* Grid */} + {filtered.length === 0 ? ( +
+ +

Контейнеры не найдены

+

Добавьте первый контейнер или измените фильтр

+
+ ) : ( +
+ {filtered.map((c) => ( + setExportContainer(c)} + /> + ))} +
+ )} + + {/* RouterOS reference */} + + +

+ RouterOS 7.4+ · /container — быстрые команды +

+
+ {[ + { + 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) => ( +
+

{b.title}

+
+                      {b.lines.join("\n")}
+                    
+
+ ))} +
+
+
+ +
+
+ + setExportContainer(null)} + /> +
+ ) +} diff --git a/app/(main)/dashboard/page.tsx b/app/(main)/dashboard/page.tsx new file mode 100644 index 0000000..fa012d9 --- /dev/null +++ b/app/(main)/dashboard/page.tsx @@ -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 ( + + +

{label}

+
+ {value} + {unit && {unit}} +
+ {delta && ( +

+ {delta} +

+ )} + {spark && spark.length > 1 && ( +
+ +
+ )} +
+
+ ) +} + +export default function DashboardPage() { + return ( +
+ + + + } + /> + +
+
+ + {/* KPI row */} +
+ + + + +
+ + {/* Latency chart + Events */} +
+ + +
+
+ Задержка до серверов +

Последние 60 минут · ping от монитора → серверы MikroTik

+
+
+
+ + + +
+ + + +
+ Последние события + +
+

Система и BGP-активность

+
+ +
+ {systemEvents.map((e) => ( +
+
+ {e.sev === "destructive" && } + {e.sev === "warning" && } + {e.sev === "info" && } +
+
+

{e.title}

+

{e.message}

+
+ {e.when} +
+ ))} +
+
+
+
+ + {/* Bandwidth + Server status */} +
+ + +
+
+ Пропускная способность +

Суммарный RX / TX по всем серверам

+
+
+ RX 318 Мбит/с + TX 244 Мбит/с +
+
+
+ + + +
+ + + +
+
+ Состояние серверов +

7 узлов MikroTik

+
+ +
+
+ +
+ {servers.slice(0, 5).map((s) => ( +
+ +
+

{s.name}

+

+ {s.host} · {s.site} · {s.asn} +

+
+ 60 ? "text-amber-500" : "text-muted-foreground"}`}> + {s.latency == null ? "недоступен" : `${s.latency}мс`} + + +
+ ))} +
+
+
+
+ + {/* Ping probes table */} + + +
+
+ Активные пробы +

8 ping-зондов · опрос раз в секунду

+
+ +
+
+ +
+ + + + + + + + + + + + + + {pingProbes.map((p) => { + const sparkColor = p.status === "down" ? "hsl(0 84% 60%)" : p.status === "warn" ? "hsl(32 94% 44%)" : "hsl(142 76% 36%)" + return ( + + + + + + + + + + ) + })} + +
ПробаЦельФильтрRTTПотери60сСтатус
{p.name}{p.target} + + {p.filter} + + {p.rtt == null ? "—" : `${p.rtt} мс`} 5 ? "text-red-500" : p.loss > 0 ? "text-amber-500" : "text-muted-foreground"}`}> + {p.loss}% + + + + +
+
+
+
+ +
+
+
+ ) +} diff --git a/app/(main)/domains/page.tsx b/app/(main)/domains/page.tsx new file mode 100644 index 0000000..6fdafa0 --- /dev/null +++ b/app/(main)/domains/page.tsx @@ -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 ( +
+ + + + + + } + /> +
+
+
+

Домены

+

+ Отслеживаемые домены для BGP-фильтрации — синхронизируются в address-list MikroTik +

+
+ {d.domain}, + }, + { + key: "resolvedIp", + label: "Resolved IP", + render: (d) => {d.resolvedIp}, + }, + { + key: "asn", + label: "ASN", + render: (d) => {d.asn}, + }, + { + key: "purpose", + label: "Назначение", + render: (d) => ( + {d.purpose} + ), + }, + { + key: "filter", + label: "Фильтр", + render: (d) => ( + + {d.filter} + + ), + }, + { + key: "updated", + label: "Обновлён", + render: (d) => {d.updated}, + }, + { + key: "enabled", + label: "Статус", + render: (d) => ( + + {d.enabled ? "Активен" : "Отключён"} + + ), + }, + ]} + /> +
+
+
+ ) +} diff --git a/app/(main)/filters/page.tsx b/app/(main)/filters/page.tsx new file mode 100644 index 0000000..56c81af --- /dev/null +++ b/app/(main)/filters/page.tsx @@ -0,0 +1,1551 @@ +"use client" + +import { useMemo, useState, useCallback, useEffect } from "react" +import { PageHeader } from "@/components/page-header" +import { StatusDot } from "@/components/status-dot" +import { Flag } from "@/components/flag" +import { Card } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Separator } from "@/components/ui/separator" +import { cn } from "@/lib/utils" +import { + servers, greTunnels, serverFilterRulesets, filters, + type FilterRule, type ServerFilterRuleset, type Server, type GreTunnel, +} from "@/lib/data" +import { useDataSource } from "@/lib/data-source" +import { + PlusIcon, SearchIcon, XIcon, CheckIcon, AlertCircleIcon, + PencilIcon, TrashIcon, ChevronUpIcon, ChevronDownIcon, + StarIcon, ArrowUpDownIcon, ArrowUpIcon, ArrowDownIcon, + FileCodeIcon, CopyIcon, NetworkIcon, TagIcon, + ArrowRightIcon, AlertTriangleIcon, +} from "lucide-react" +import { + Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter, +} from "@/components/ui/sheet" + +// ── helpers ──────────────────────────────────────────────────────────────────── + +function newId() { return `r${Date.now()}-${Math.random().toString(36).slice(2, 6)}` } +function innerIpToGateway(ip: string) { return ip.split("/")[0] } + +const COMMUNITY_NAMES: Record = { + "65001:100": "youtube-bypass", + "65001:200": "streaming-eu", + "65001:300": "cdn-bypass", + "65002:100": "cdn-secondary", + "65001:400": "office-direct", + "65001:500": "gaming-ll", + "65002:200": "gaming-ll-backup", + "65007:999": "school-block", + // blackhole communities + "65535:666": "BLACKHOLE (RFC 7999)", + "65001:666": "blackhole-local", + "65002:666": "blackhole-peer", + "no-export": "no-export", + "no-advertise": "no-advertise", +} + +// Communities that imply blackhole action by default +const BLACKHOLE_COMMUNITIES = new Set(["65535:666", "65001:666", "65002:666"]) +const COMMUNITY_SUGGESTIONS = Object.keys(COMMUNITY_NAMES) + +// ── RouterOS config generator (combined, all servers) ───────────────────────── +// +// Единый скрипт для всех MikroTik (RouterOS 7). +// Роутер определяет себя через [/system identity get name]. +// Все community → gateway маппинги — ОДИН filter rule на сервер +// с ветвлением if/else if внутри параметра rule="...". + +function generateCombinedRouterOSConfig( + rulesets: ServerFilterRuleset[], + serversList: Server[], + tunnelsList: GreTunnel[], +): string { + const now = new Date().toLocaleString("ru-RU", { dateStyle: "short", timeStyle: "short" }) + const withRules = rulesets.filter(r => r.rules.length > 0) + const totalRules = rulesets.reduce((s, r) => s + r.rules.length, 0) + const bhCount = rulesets.reduce((s, r) => s + r.rules.filter(x => x.action === "blackhole").length, 0) + + const lines: string[] = [ + `# RouterLists — конфиг фильтрации`, + `# Дата : ${now}`, + `# Серверов : ${withRules.length}`, + `# Communities : ${totalRules - bhCount} route + ${bhCount} blackhole`, + ``, + ] + + if (withRules.length === 0) { + lines.push(`# Нет правил фильтрации.`) + } else { + withRules.forEach((rs, si) => { + const server = serversList.find(s => s.id === rs.serverId) + if (!server) return + + lines.push(`# ${server.name} · ${server.type === "jump-host" ? "JH" : "EN"} · ${server.site} · ${server.host}`) + + const branches = rs.rules.map((rule, i) => { + const isBlackhole = rule.action === "blackhole" + const tunnel = !isBlackhole ? tunnelsList.find(t => t.id === rule.gatewayTunnelId) : undefined + const iface = tunnel ? tunnel.name : "unknown" + const label = rule.communityName ?? rule.community + const desc = rule.description ? `${label} — ${rule.description}` : label + const kw = i === 0 ? "if" : "} else if" + if (isBlackhole) { + return [ + ` ${kw} (bgp-communities.has(\\"${rule.community}\\")) {`, + ` # ${desc}`, + ` set type blackhole;`, + ` accept;`, + ].join("\n") + } + return [ + ` ${kw} (bgp-communities.has(\\"${rule.community}\\")) {`, + ` # ${desc}`, + ` set gateway ${rule.gateway};`, + ` set out-interface ${iface};`, + ` accept;`, + ].join("\n") + }).join("\n") + + lines.push(`/routing filter rule add \\`) + lines.push(` chain=bgp-in \\`) + lines.push(` comment="RouterLists: ${server.name}" \\`) + lines.push(` rule="`) + lines.push(branches) + lines.push(` }"`) + + if (si < withRules.length - 1) lines.push(``) + }) + } + + return lines.join("\n") +} + +// ── shared components ────────────────────────────────────────────────────────── + +function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" }) { + return ( + + {type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"} + + ) +} + +// ── GRE gateway selector ─────────────────────────────────────────────────────── + +function GatewayCards({ + serverId, selected, onSelect, tunnelsList, serversList, +}: { + serverId: string + selected: string + onSelect: (gw: string, tunnelId: string) => void + tunnelsList: GreTunnel[] + serversList: Server[] +}) { + const tunnels = tunnelsList.filter(t => t.serverId === serverId) + + if (tunnels.length === 0) { + return ( +
+ Нет GRE-туннелей на этом сервере. +
+ Добавьте туннели на странице «GRE-туннели» +
+ ) + } + + return ( +
+ {tunnels.map(t => { + const gw = innerIpToGateway(t.remoteInnerIp) + const isSelected = selected === gw + const remoteSrv = serversList.find(s => s.host === t.remoteAddress) + return ( + + ) + })} +
+ ) +} + +// ── community autocomplete ───────────────────────────────────────────────────── + +function CommunityInput({ value, onChange }: { value: string; onChange: (v: string) => void }) { + const [open, setOpen] = useState(false) + const filtered = COMMUNITY_SUGGESTIONS.filter(c => + c.toLowerCase().includes(value.toLowerCase()) || + (COMMUNITY_NAMES[c] ?? "").toLowerCase().includes(value.toLowerCase()) + ) + return ( +
+ { onChange(e.target.value); setOpen(true) }} + onFocus={() => setOpen(true)} onBlur={() => setTimeout(() => setOpen(false), 150)} + placeholder="65001:100" className="font-mono h-9" /> + {open && filtered.length > 0 && ( +
+ {filtered.map(c => ( + + ))} +
+ )} +
+ ) +} + +// ── filter rule row ──────────────────────────────────────────────────────────── + +function FilterRow({ + rule, index, isLast, onEdit, onDelete, onMoveUp, onMoveDown, tunnelsList, serversList, +}: { + rule: FilterRule; index: number; isLast: boolean + onEdit: () => void; onDelete: () => void; onMoveUp: () => void; onMoveDown: () => void + tunnelsList: GreTunnel[] + serversList: Server[] +}) { + const [confirmDel, setConfirmDel] = useState(false) + const isBlackhole = rule.action === "blackhole" + const tunnel = !isBlackhole ? tunnelsList.find(t => t.id === rule.gatewayTunnelId) : undefined + const remoteSrv = tunnel ? serversList.find(s => s.host === tunnel.remoteAddress) : undefined + const communityName = COMMUNITY_NAMES[rule.community] ?? rule.communityName + + return ( +
+ {/* priority */} +
+ {isLast + ? + : {index + 1} + } +
+ + {/* reorder */} +
+ + +
+ + {/* community */} +
+
+ + {rule.community} + + {isBlackhole && ( + + ⊘ blackhole + + )} +
+ {communityName && ( +

{communityName}

+ )} +
+ + {/* gateway / tunnel — or blackhole target */} +
+ {isBlackhole ? ( +
+ + type=blackhole +
+ ) : ( + <> +
+ + {remoteSrv && } + {rule.gateway} +
+ {tunnel && ( +

{tunnel.name}

+ )} + + )} +
+ + {/* description */} +

{rule.description || "—"}

+ + {/* actions */} +
+ + +
+
+ ) +} + +// ── rule sheet ───────────────────────────────────────────────────────────────── + +interface RuleForm { + action: "route" | "blackhole" + community: string + gateway: string + gatewayTunnelId: string + description: string +} +const emptyForm = (): RuleForm => ({ + action: "route", community: "", gateway: "", gatewayTunnelId: "", description: "", +}) + +function RuleSheet({ + open, mode, initial, serverId, onSave, onClose, serversList, tunnelsList, +}: { + open: boolean; mode: "create" | "edit" + initial: RuleForm; serverId: string + onSave: (f: RuleForm) => void; onClose: () => void + serversList: Server[] + tunnelsList: GreTunnel[] +}) { + const [form, setForm] = useState(initial) + const [error, setError] = useState(null) + const [touched, setTouched] = useState(false) + + const set = (k: K, v: RuleForm[K]) => { + setForm(f => ({ ...f, [k]: v })) + if (touched) validate_({ ...form, [k]: v }) + } + + // When community changes, auto-detect blackhole communities + const handleCommunityChange = (v: string) => { + const next: RuleForm = { + ...form, + community: v, + action: BLACKHOLE_COMMUNITIES.has(v) ? "blackhole" : form.action, + } + setForm(next) + if (touched) validate_(next) + } + + const validate_ = (f: RuleForm) => { + if (!f.community.trim()) { setError("BGP Community обязательно"); return false } + if (f.action === "route" && !f.gateway.trim()) { setError("Выберите GRE-шлюз"); return false } + setError(null); return true + } + const handleSave = () => { + setTouched(true) + if (!validate_(form)) return + // Clear gateway fields when saving blackhole rule + onSave(form.action === "blackhole" + ? { ...form, gateway: "", gatewayTunnelId: "" } + : form + ) + } + + const isBlackhole = form.action === "blackhole" + + return ( + { if (!v) onClose() }}> + + + +
+
+ + {mode === "create" ? "Новое правило" : "Редактировать правило"} + + + Community → {isBlackhole ? "Blackhole" : "GRE-шлюз"} · {serversList.find(s => s.id === serverId)?.name} + +
+ +
+
+ +
+ + {/* ── BGP Community ── */} +
+ + + {form.community && COMMUNITY_NAMES[form.community] && ( +

+ {COMMUNITY_NAMES[form.community]} +

+ )} +

Формат: AS:NNN · RFC 1997 / RFC 7999

+
+ + + + {/* ── Action toggle ── */} +
+ +
+ {(["route", "blackhole"] as const).map(a => ( + + ))} +
+
+ + {/* ── GRE gateway (only for route action) ── */} + {!isBlackhole && ( + <> + +
+ + { set("gateway", gw); set("gatewayTunnelId", tid) }} /> + {touched && !form.gateway && ( +

+ Выберите шлюз +

+ )} +
+ + )} + + {/* ── Blackhole info panel ── */} + {isBlackhole && ( +
+

RTBH — Remote Triggered Black Hole

+

+ Маршруты с этим community будут установлены с типом blackhole в таблице маршрутизации RouterOS. + Трафик на совпадающие префиксы будет молча сброшен. +

+
+ if + {" (bgp-communities.has(\""} + {form.community || "AS:NNN"} + {"\")) {\n "} + set type blackhole + {";\n accept;\n}"} +
+
+ )} + + + +
+ + set("description", e.target.value)} + placeholder={isBlackhole ? "RTBH — нулевая маршрутизация DDoS-трафика" : "YouTube bypass через Франкфурт"} + className="h-9 text-sm" /> +
+ + {error && ( +
+ {error} +
+ )} +
+ + + + + +
+
+ ) +} + +// ── config preview modal ─────────────────────────────────────────────────────── + +function PreviewModal({ open, serverId, rulesets, onClose, serversList, tunnelsList }: { + open: boolean + serverId: string + rulesets: ServerFilterRuleset[] + onClose: () => void + serversList: Server[] + tunnelsList: GreTunnel[] +}) { + const [copied, setCopied] = useState(false) + + const singleRuleset = useMemo( + () => rulesets.filter(r => r.serverId === serverId), + [rulesets, serverId], + ) + const server = serversList.find(s => s.id === serverId) + const totalRules = singleRuleset.reduce((s, r) => s + r.rules.length, 0) + + const config = useMemo( + () => open ? generateCombinedRouterOSConfig(singleRuleset, serversList, tunnelsList) : "", + [open, singleRuleset, serversList, tunnelsList], + ) + + const handleCopy = () => { + navigator.clipboard.writeText(config).catch(() => {}) + setCopied(true); setTimeout(() => setCopied(false), 2000) + } + if (!open) return null + + return ( +
+
+
+ + {/* header */} +
+
+ + RouterOS config + {server && ( + + + {server.name} + · {totalRules} правил + + )} +
+ +
+ + {/* code */} +
+
+            {config.split("\n").map((line, i) => {
+              const trimmed = line.trimStart()
+              const cls =
+                // section dividers
+                trimmed.startsWith("# ═") || trimmed.startsWith("# ─")
+                  ? "text-[#444c56]" :
+                // inline comments inside rule body
+                trimmed.startsWith("# ")
+                  ? "text-[#8b949e]" :
+                // RouterOS scripting keywords
+                trimmed.startsWith(":local") || trimmed.startsWith(":log") || trimmed.startsWith(":foreach")
+                  ? "text-[#d2a8ff]" :
+                // :if identity branch / closing brace
+                trimmed.startsWith(":if") || (trimmed === "}" && line.length < 3)
+                  ? "text-[#ff7b72] font-semibold" :
+                // filter rule add command
+                trimmed.startsWith("/routing filter rule")
+                  ? "text-[#79c0ff]" :
+                // rule body: if/else if branches
+                trimmed.startsWith("if (") || trimmed.startsWith("} else if")
+                  ? "text-[#ff7b72]" :
+                // rule body: blackhole action
+                trimmed.startsWith("set type blackhole")
+                  ? "text-[#ff7b72] font-semibold" :
+                // rule body: set actions
+                trimmed.startsWith("set gateway") || trimmed.startsWith("set out-interface")
+                  ? "text-[#a5d6ff]" :
+                // rule body: accept / rule close
+                trimmed.startsWith("accept") || trimmed === `}"` || trimmed.startsWith(`rule="`)
+                  ? "text-[#79c0ff]" :
+                // named params
+                trimmed.match(/^(chain|comment|bgp-communities)=/)
+                  ? "text-[#a5d6ff]" :
+                  "text-[#c9d1d9]"
+              return {line || " "}
+            })}
+          
+
+ + {/* footer */} +
+
+

1. Скопируйте скрипт в буфер обмена

+

3. Вставьте в терминал и нажмите Enter

+

2. Подключитесь к любому MikroTik (SSH / Winbox)

+

4. Скрипт сам определит свои правила по identity

+
+
+ + +
+
+
+
+ ) +} + +// ── Copy Rules Sheet ────────────────────────────────────────────────────────── + +type CopyMode = "append" | "replace" + +/** Try to find an equivalent tunnel on the target server (same remoteAddress). */ +function mapTunnel( + sourceTunnelId: string, + targetServerId: string, + tunnelsList: GreTunnel[], +): { tunnelId: string; gateway: string } | null { + const src = tunnelsList.find(t => t.id === sourceTunnelId) + if (!src) return null + const dst = tunnelsList.find( + t => t.serverId === targetServerId && t.remoteAddress === src.remoteAddress, + ) + if (!dst) return null + return { tunnelId: dst.id, gateway: innerIpToGateway(dst.remoteInnerIp) } +} + +function CopyRulesSheet({ + open, onClose, + sourceServerId, sourceRules, + rulesets, onCopy, serversList, tunnelsList, +}: { + open: boolean; onClose: () => void + sourceServerId: string; sourceRules: FilterRule[] + rulesets: ServerFilterRuleset[] + onCopy: (targetServerId: string, rules: FilterRule[], mode: CopyMode) => void + serversList: Server[] + tunnelsList: GreTunnel[] +}) { + const [selected, setSelected] = useState>(new Set()) + const [targetId, setTargetId] = useState(null) + const [mode, setMode] = useState("append") + const [done, setDone] = useState(false) + + // Reset each time the sheet opens + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + if (!open) { setDone(false); return } + // eslint-disable-next-line react-hooks/set-state-in-effect + setSelected(new Set(sourceRules.map(r => r.id))) + // eslint-disable-next-line react-hooks/set-state-in-effect + setTargetId(null); setMode("append"); setDone(false) + }, [open]) + + const sourceServer = serversList.find(s => s.id === sourceServerId)! + const targetServers = serversList.filter(s => s.id !== sourceServerId) + + const selectedRules = sourceRules.filter(r => selected.has(r.id)) + const targetRules = targetId + ? (rulesets.find(r => r.serverId === targetId)?.rules ?? []) + : [] + + // Compute gateway mapping for each selected route rule + const mapping = useMemo(() => { + if (!targetId) return [] + return selectedRules + .filter(r => (r.action ?? "route") === "route" && r.gatewayTunnelId) + .map(r => { + const srcTunnel = tunnelsList.find(t => t.id === r.gatewayTunnelId) + const dstMapping = mapTunnel(r.gatewayTunnelId, targetId, tunnelsList) + const dstTunnel = dstMapping ? tunnelsList.find(t => t.id === dstMapping.tunnelId) : undefined + return { rule: r, srcTunnel, dstTunnel, mapped: !!dstMapping } + }) + // deduplicate by source tunnel + .filter((m, i, arr) => + arr.findIndex(x => x.srcTunnel?.id === m.srcTunnel?.id) === i + ) + }, [selectedRules, targetId]) + + const unmappedCount = mapping.filter(m => !m.mapped).length + + function toggleRule(id: string) { + setSelected(prev => { + const next = new Set(prev) + if (next.has(id)) next.delete(id); else next.add(id) + return next + }) + } + function toggleAll() { + setSelected(prev => + prev.size === sourceRules.length + ? new Set() + : new Set(sourceRules.map(r => r.id)) + ) + } + + function handleCopy() { + if (!targetId || selectedRules.length === 0) return + const copied: FilterRule[] = selectedRules.map(r => { + if ((r.action ?? "route") === "blackhole") { + return { ...r, id: newId() } + } + const m = mapTunnel(r.gatewayTunnelId, targetId, tunnelsList) + return { + ...r, + id: newId(), + gateway: m?.gateway ?? "", + gatewayTunnelId: m?.tunnelId ?? "", + } + }) + onCopy(targetId, copied, mode) + setDone(true) + setTimeout(() => onClose(), 900) + } + + const canCopy = targetId !== null && selectedRules.length > 0 && !done + + return ( + { if (!v) onClose() }}> + + + {/* header */} + +
+
+ + + Копировать правила + + + + {sourceServer.name} + + целевой сервер + +
+ +
+
+ +
+ + {/* ── rule selection ── */} +
+
+

Правила для копирования

+ +
+ +
+ {sourceRules.map(r => { + const isBlackhole = r.action === "blackhole" + const tunnel = !isBlackhole ? tunnelsList.find(t => t.id === r.gatewayTunnelId) : undefined + const isSelected = selected.has(r.id) + return ( + + ) + })} +
+ +

+ Выбрано: {selected.size} из {sourceRules.length} +

+
+ + {/* ── target server ── */} +
+

Целевой сервер

+
+ {targetServers.map(s => { + const ruleCount = rulesets.find(r => r.serverId === s.id)?.rules.length ?? 0 + const isTarget = targetId === s.id + return ( + + ) + })} +
+
+ + {/* ── copy mode ── */} + {targetId && ( +
+

Режим копирования

+
+ {([ + { v: "append" as CopyMode, label: "Добавить", sub: `+${selectedRules.length} к ${targetRules.length} существующим` }, + { v: "replace" as CopyMode, label: "Заменить", sub: `${targetRules.length} правил → ${selectedRules.length} новых` }, + ] as const).map(opt => ( + + ))} +
+ + {mode === "replace" && targetRules.length > 0 && ( +
+ + {targetRules.length} существующих правил на {serversList.find(s => s.id === targetId)?.name} будут удалены +
+ )} +
+ )} + + {/* ── gateway mapping preview ── */} + {targetId && mapping.length > 0 && ( +
+

+ Маппинг GRE-туннелей + {unmappedCount > 0 && ( + + · {unmappedCount} не найдено + + )} +

+
+ {mapping.map((m, i) => ( +
+ {/* source tunnel */} + {m.srcTunnel?.name ?? "—"} + + {/* target tunnel */} + {m.mapped ? ( + + {m.dstTunnel?.name} + + ) : ( + + туннель не найден — шлюз будет пустым + + )} + {m.mapped + ? + : + } +
+ ))} + {mapping.some(m => !m.mapped) && ( +

+ Правила с пустым шлюзом нужно отредактировать после копирования +

+ )} +
+
+ )} + +
+ + {/* footer */} + + + + +
+
+ ) +} + +// ── page ─────────────────────────────────────────────────────────────────────── + +type SortKey = "community" | "gateway" | "description" + +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 LiveFiltersResponse { + rulesets: ServerFilterRuleset[] + greTunnels: GreTunnel[] +} + +function buildRulesets(serverList: Server[], sourceRulesets: ServerFilterRuleset[]): ServerFilterRuleset[] { + return serverList.map(s => ({ + serverId: s.id, + rules: sourceRulesets.find(r => r.serverId === s.id)?.rules ?? [], + })) +} + +function makeApiFetch(backendUrl: string) { + return async function apiFetch(path: string, init?: RequestInit): Promise { + const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {} + const finalRes = await fetch(backendUrl.replace(/\/$/, "") + path, { + ...init, + headers: { ...headers, ...(init?.headers ?? {}) }, + }) + if (!finalRes.ok) { + const err = await finalRes.json().catch(() => ({ error: finalRes.statusText })) as { error?: string } + throw new Error(err.error ?? finalRes.statusText) + } + return finalRes.json() as Promise + } +} + +function SortIndicator({ k, sortKey, sortAsc }: { k: SortKey; sortKey: SortKey; sortAsc: boolean }) { + if (sortKey !== k) return + return sortAsc ? : +} + +export default function FiltersPage() { + const { mode, backendUrl, backendStatus } = useDataSource() + const isLive = mode === "live" && backendStatus === true + const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl]) + + const [liveServers, setLiveServers] = useState([]) + const [liveTunnels, setLiveTunnels] = useState([]) + const [rulesets, setRulesets] = useState( + () => buildRulesets(servers, serverFilterRulesets), + ) + const [selectedServerId, setSelectedServerId] = useState("srv1") + const [search, setSearch] = useState("") + const [sortKey, setSortKey] = useState("community") + const [sortAsc, setSortAsc] = useState(true) + const [sheetOpen, setSheetOpen] = useState(false) + const [sheetMode, setSheetMode] = useState<"create" | "edit">("create") + const [sheetInitial, setSheetInitial]= useState(emptyForm()) + const [editingId, setEditingId] = useState(null) + const [previewOpen, setPreviewOpen] = useState(false) + const [copyOpen, setCopyOpen] = useState(false) + const [syncBusy, setSyncBusy] = useState<"from" | "to" | null>(null) + + useEffect(() => { + if (!isLive) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setLiveServers([]) + // eslint-disable-next-line react-hooks/set-state-in-effect + setLiveTunnels([]) + // eslint-disable-next-line react-hooks/set-state-in-effect + setRulesets(buildRulesets(servers, serverFilterRulesets)) + // eslint-disable-next-line react-hooks/set-state-in-effect + setSelectedServerId(servers[0]?.id ?? "") + return + } + + Promise.all([ + apiFetch("/api/servers"), + apiFetch("/api/filters/rules"), + ]) + .then(([backendServers, liveFilters]) => { + const mappedServers: Server[] = backendServers.map(s => ({ + 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, + })) + + const normalizedRulesets = buildRulesets(mappedServers, liveFilters.rulesets) + setLiveServers(mappedServers) + setLiveTunnels(liveFilters.greTunnels) + setRulesets(normalizedRulesets) + setSelectedServerId(prev => ( + mappedServers.some(s => s.id === prev) ? prev : (mappedServers[0]?.id ?? "") + )) + }) + .catch(() => { + setLiveServers([]) + setLiveTunnels([]) + setRulesets(buildRulesets(servers, serverFilterRulesets)) + setSelectedServerId(servers[0]?.id ?? "") + }) + }, [isLive, apiFetch]) + + const allServers = isLive ? liveServers : servers + const allTunnels = isLive ? liveTunnels : greTunnels + const selectedServer = allServers.find(s => s.id === selectedServerId) ?? allServers[0] + const totalRules = rulesets.reduce((s, r) => s + r.rules.length, 0) + + const currentRules = useMemo( + () => rulesets.find(r => r.serverId === selectedServerId)?.rules ?? [], + [rulesets, selectedServerId], + ) + + const filteredRules = useMemo(() => { + const q = search.toLowerCase() + const list = q + ? currentRules.filter(r => + r.community.includes(q) || + (COMMUNITY_NAMES[r.community] ?? "").toLowerCase().includes(q) || + r.gateway.includes(q) || + r.description.toLowerCase().includes(q) + ) + : [...currentRules] + if (search) { + const mult = sortAsc ? 1 : -1 + list.sort((a, b) => { + if (sortKey === "community") return mult * a.community.localeCompare(b.community) + if (sortKey === "gateway") return mult * a.gateway.localeCompare(b.gateway) + return mult * a.description.localeCompare(b.description) + }) + } + return list + }, [currentRules, search, sortKey, sortAsc]) + + const updateRules = useCallback((serverId: string, updater: (rules: FilterRule[]) => FilterRule[]) => { + setRulesets(prev => { + const next = prev.map(rs => + rs.serverId === serverId ? { ...rs, rules: updater(rs.rules) } : rs + ) + if (isLive) { + void apiFetch<{ ok: boolean }>("/api/filters/rules", { + method: "PUT", + body: JSON.stringify({ rulesets: next }), + }).catch(() => {}) + } + return next + }) + }, [isLive, apiFetch]) + + const syncFromRouter = useCallback(async () => { + if (!isLive || syncBusy) return + setSyncBusy("from") + try { + await apiFetch<{ ok: boolean }>("/api/filters/sync/from-router", { + method: "POST", + body: JSON.stringify({ serverId: selectedServerId }), + }) + const fresh = await apiFetch("/api/filters/rules") + setLiveTunnels(fresh.greTunnels) + setRulesets(buildRulesets(allServers, fresh.rulesets)) + } finally { + setSyncBusy(null) + } + }, [isLive, syncBusy, apiFetch, allServers, selectedServerId]) + + const syncToRouter = useCallback(async () => { + if (!isLive || syncBusy) return + setSyncBusy("to") + try { + await apiFetch<{ ok: boolean }>("/api/filters/sync/to-router", { method: "POST" }) + } finally { + setSyncBusy(null) + } + }, [isLive, syncBusy, apiFetch]) + + const openCreate = () => { + setSheetInitial(emptyForm()); setSheetMode("create"); setEditingId(null); setSheetOpen(true) + } + const openEdit = (rule: FilterRule) => { + setSheetInitial({ + action: rule.action ?? "route", + community: rule.community, + gateway: rule.gateway, + gatewayTunnelId: rule.gatewayTunnelId, + description: rule.description, + }) + setSheetMode("edit"); setEditingId(rule.id); setSheetOpen(true) + } + + const handleSave = (form: RuleForm) => { + if (sheetMode === "create") { + updateRules(selectedServerId, rules => [ + ...rules, + { id: newId(), ...form, communityName: COMMUNITY_NAMES[form.community] }, + ]) + } else if (editingId) { + updateRules(selectedServerId, rules => + rules.map(r => r.id === editingId + ? { ...r, ...form, communityName: COMMUNITY_NAMES[form.community] } + : r + ) + ) + } + setSheetOpen(false) + } + + const handleDelete = (id: string) => updateRules(selectedServerId, rules => rules.filter(r => r.id !== id)) + + const handleCopyRules = useCallback((targetServerId: string, rules: FilterRule[], mode: CopyMode) => { + updateRules(targetServerId, existing => + mode === "replace" ? rules : [...existing, ...rules] + ) + }, [updateRules]) + const handleMoveUp = (index: number) => { + if (index === 0) return + updateRules(selectedServerId, rules => { + const n = [...rules]; [n[index - 1], n[index]] = [n[index], n[index - 1]]; return n + }) + } + const handleMoveDown = (index: number) => { + updateRules(selectedServerId, rules => { + if (index >= rules.length - 1) return rules + const n = [...rules]; [n[index], n[index + 1]] = [n[index + 1], n[index]]; return n + }) + } + + const toggleSort = (k: SortKey) => { + if (sortKey === k) setSortAsc(v => !v); else { setSortKey(k); setSortAsc(true) } + } + + if (!selectedServer) { + return ( +
+ Нет доступных серверов +
+ ) + } + + return ( +
+ + {isLive && ( + <> + + + + )} + + + + + } + /> + + {/* ── summary bar + server chips (same pattern as monitoring) ── */} +
+
+ Всего правил + {totalRules} +
+ {(() => { + const bhTotal = rulesets.reduce((s, r) => s + r.rules.filter(x => x.action === "blackhole").length, 0) + if (bhTotal === 0) return null + return ( + + ⊘ {bhTotal} blackhole + + ) + })()} + +
+ + {allServers.map(s => { + const count = rulesets.find(r => r.serverId === s.id)?.rules.length ?? 0 + const active = selectedServerId === s.id + return ( + + ) + })} +
+ + {/* ── toolbar ── */} +
+
+ + setSearch(e.target.value)} /> + {search && ( + + )} +
+ +

+ {filteredRules.length !== currentRules.length + ? `${filteredRules.length} из ${currentRules.length} правил` + : `${currentRules.length} правил` + } +

+
+ + {/* ── main content ── */} +
+
+ + {/* RouterOS 7.x BGP extensions info */} + {(() => { + const filter = filters.find(f => f.servers.includes(selectedServerId)) + const hasLarge = filter?.largeCommunities && filter.largeCommunities.length > 0 + const hasRpki = filter?.rpkiValid + const hasFlow = filter?.flowspecRules && filter.flowspecRules.length > 0 + if (!hasLarge && !hasRpki && !hasFlow) return null + return ( +
+ {hasRpki && ( + + RPKI · Только валидные маршруты + + )} + {hasLarge && ( + + Large Communities RFC 8092 · {filter?.largeCommunities?.join(", ")} + + )} + {hasFlow && ( + + Flowspec · {filter?.flowspecRules?.length} правил + + )} +
+ ) + })()} + + + + {/* selected server header */} +
+ + + {selectedServer.name} + + {selectedServer.host} + {selectedServer.asn} + {selectedServer.latency !== null && ( + 60 ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground", + )}>{selectedServer.latency}мс + )} +
+ {currentRules.length} правил +
+
+ + {currentRules.length === 0 ? ( + /* empty state */ +
+ +
+

Нет правил фильтрации

+

+ {allTunnels.some(t => t.serverId === selectedServerId) + ? "Добавьте правило: BGP community → GRE-шлюз" + : "Сначала добавьте GRE-туннели для этого сервера"} +

+
+ {allTunnels.some(t => t.serverId === selectedServerId) && ( + + )} +
+ ) : filteredRules.length === 0 ? ( + /* no search results */ +
+ +

Ничего не найдено

+
+ ) : ( + <> + {/* table header with sort */} +
+ # + + + + + +
+ + {/* rows */} +
+ {(search ? filteredRules : currentRules).map((rule, i, arr) => ( + openEdit(rule)} + onDelete={() => handleDelete(rule.id)} + onMoveUp={() => handleMoveUp(currentRules.findIndex(r => r.id === rule.id))} + onMoveDown={() => handleMoveDown(currentRules.findIndex(r => r.id === rule.id))} + /> + ))} +
+ + {/* footer hint */} +
+ + Последнее правило имеет наивысший приоритет в RouterOS +
+ + )} + + {/* add rule shortcut */} + +
+ +
+
+ + setSheetOpen(false)} + serversList={allServers} + tunnelsList={allTunnels} + /> + + setPreviewOpen(false)} + serversList={allServers} + tunnelsList={allTunnels} + /> + + setCopyOpen(false)} + sourceServerId={selectedServerId} + sourceRules={currentRules} + rulesets={rulesets} + onCopy={handleCopyRules} + serversList={allServers} + tunnelsList={allTunnels} + /> +
+ ) +} diff --git a/app/(main)/firewall/page.tsx b/app/(main)/firewall/page.tsx new file mode 100644 index 0000000..9d1423f --- /dev/null +++ b/app/(main)/firewall/page.tsx @@ -0,0 +1,2094 @@ +"use client" + +import { useEffect, useMemo, useRef, useState } from "react" +import { PageHeader } from "@/components/page-header" +import { firewallRules, type FirewallRule } 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, +} from "@/components/ui/dropdown-menu" +import { cn } from "@/lib/utils" +import { + PlusIcon, SearchIcon, ShieldIcon, ShieldOffIcon, + ListFilterIcon, ArrowRightLeftIcon, WrenchIcon, LayersIcon, + MoreHorizontalIcon, PencilIcon, Trash2Icon, CopyIcon, CodeXmlIcon, + CheckIcon, PowerIcon, CheckCircleIcon, + PlayIcon, SquareIcon, RotateCcwIcon, ZapIcon, + CheckCircle2Icon, XCircleIcon, MinusCircleIcon, SkipForwardIcon, + PackageIcon, SlidersHorizontalIcon, +} from "lucide-react" + +// ─── Types ──────────────────────────────────────────────────────────────────── + +type ChainGroup = "filter" | "nat" | "mangle" | "raw" | "address-lists" | "simulator" +type ChainFilter = "all" | string + +// ─── Simulator types ────────────────────────────────────────────────────────── + +interface PacketDef { + chain: string + proto: string + srcAddr: string + dstAddr: string + srcPort: string + dstPort: string + inIface: string + outIface: string + connState: string + srcAddrList: string + dstAddrList: string + tlsHost: string +} + +type SimSpeed = "slow" | "normal" | "fast" | "instant" + +interface SimCheck { + field: string + ruleVal: string + packetVal: string + passed: boolean +} + +interface SimStep { + rule: FirewallRule + index: number + status: "pending" | "evaluating" | "match" | "skip" | "disabled" | "passthrough" + checks: SimCheck[] + failedAt?: string +} + +// ─── Scenario types ─────────────────────────────────────────────────────────── + +interface ScenarioRule { + id: string + chain: string + action: string + proto: string + src: string + dst: string + port: string + iface: string + comment: string + enabled: boolean +} + +interface SimScenario { + id: string + name: string + description: string + packet: PacketDef + rules: ScenarioRule[] + createdAt: number +} + +// ─── Packet presets ─────────────────────────────────────────────────────────── + +const DEFAULT_PKT: PacketDef = { + chain: "forward", proto: "tcp", + srcAddr: "10.10.0.100", dstAddr: "142.250.74.110", + srcPort: "54321", dstPort: "443", + inIface: "lan", outIface: "wan-msk", + connState: "new", srcAddrList: "", dstAddrList: "", tlsHost: "", +} + +// ─── RouterOS traversal paths ──────────────────────────────────────────────── +// Maps packet "flow type" (= packet.chain value) → ordered chain names to evaluate. +// Mirrors the real RouterOS pipeline so Mangle, Raw, NAT rules are all included. + +const DIR_PATHS: Record = { + // Forwarded traffic (LAN → WAN, etc.) + forward: ["prerouting", "forward", "postrouting", "srcnat"], + // Traffic destined for the router itself + input: ["prerouting", "input"], + // Traffic originating from the router + output: ["output", "postrouting", "srcnat"], + // DNAT / port-forward + dstnat: ["prerouting", "dstnat", "forward", "postrouting", "srcnat"], +} + +// Human-readable table hint shown next to each chain header in the trace +const CHAIN_TABLE_HINT: Record = { + prerouting: "Raw + Mangle", + forward: "Mangle + Filter", + input: "Mangle + Filter", + output: "Mangle + Filter", + postrouting: "Mangle", + srcnat: "NAT", + dstnat: "NAT", +} + +// ─── Packet presets ─────────────────────────────────────────────────────────── + +const SIM_PRESETS: Array<{ + id: string; label: string; desc: string; emoji: string; pkt: Partial +}> = [ + { id: "https", label: "HTTPS → интернет", desc: "TCP 443, forward, новый", emoji: "🌐", + pkt: { chain: "forward", proto: "tcp", dstPort: "443", srcAddr: "10.10.0.100", dstAddr: "142.250.74.110", inIface: "lan", connState: "new" } }, + { id: "http", label: "HTTP → интернет", desc: "TCP 80, forward", emoji: "📡", + pkt: { chain: "forward", proto: "tcp", dstPort: "80", srcAddr: "10.10.0.100", dstAddr: "93.184.216.34", inIface: "lan", connState: "new" } }, + { id: "ssh-lan", label: "SSH управление", desc: "TCP 22, input, с LAN", emoji: "🔐", + pkt: { chain: "input", proto: "tcp", dstPort: "22", srcAddr: "10.10.0.5", dstAddr: "10.0.0.1", inIface: "lan", connState: "new" } }, + { id: "ssh-wan", label: "SSH с WAN", desc: "TCP 22, input, внешний", emoji: "🚨", + pkt: { chain: "input", proto: "tcp", dstPort: "22", srcAddr: "185.220.101.45", dstAddr: "10.0.0.1", inIface: "wan-msk", connState: "new" } }, + { id: "winbox", label: "WinBox", desc: "TCP 8291, input, с LAN", emoji: "🖥️", + pkt: { chain: "input", proto: "tcp", dstPort: "8291",srcAddr: "10.10.0.5", dstAddr: "10.0.0.1", inIface: "lan", connState: "new" } }, + { id: "youtube", label: "YouTube bypass", desc: "TCP 443, dst-list youtube", emoji: "▶️", + pkt: { chain: "forward", proto: "tcp", dstPort: "443", srcAddr: "10.10.0.100", dstAddr: "142.250.74.110", dstAddrList: "youtube-bypass", inIface: "lan", connState: "new" } }, + { id: "cdn", label: "CDN mark-routing", desc: "TCP, dst-list cdn-bypass", emoji: "🚀", + pkt: { chain: "forward", proto: "tcp", dstPort: "443", srcAddr: "10.10.0.100", dstAddr: "104.16.132.229", dstAddrList: "cdn-bypass", inIface: "lan", connState: "new" } }, + { id: "nat-out", label: "Masquerade", desc: "srcnat, LAN → WAN", emoji: "🔄", + pkt: { chain: "srcnat", proto: "tcp", srcAddr: "10.10.0.100", dstAddr: "8.8.8.8", dstPort: "443", outIface: "wan-msk" } }, + { id: "icmp", label: "Ping ICMP", desc: "ICMP echo, forward", emoji: "📶", + pkt: { chain: "forward", proto: "icmp", srcAddr: "10.10.0.100", dstAddr: "8.8.8.8", inIface: "lan" } }, + { id: "established", label: "Established flow", desc: "TCP established, reverse", emoji: "↩️", + pkt: { chain: "forward", proto: "tcp", dstPort: "54321", srcAddr: "142.250.74.110", dstAddr: "10.10.0.100", inIface: "wan-msk", connState: "established" } }, +] + +// ─── Matching engine ────────────────────────────────────────────────────────── + +function _ipNum(ip: string): number { + return ip.split(".").reduce((a, o) => (a << 8) + parseInt(o, 10), 0) >>> 0 +} +function _inCidr(ip: string, cidr: string): boolean { + if (!ip) return false + if (!cidr.includes("/")) return ip === cidr + const [net, b] = cidr.split("/"); const bits = parseInt(b) + if (bits === 0) return true + const mask = bits >= 32 ? 0xFFFFFFFF : (~(0xFFFFFFFF >>> bits)) >>> 0 + return (_ipNum(ip) & mask) === (_ipNum(net) & mask) +} +function _mAddr(pAddr: string, rAddr: string): boolean { + if (!rAddr || rAddr === "—" || rAddr === "") return true + return rAddr.includes("/") ? _inCidr(pAddr, rAddr) : pAddr === rAddr +} +function _mPort(pPort: string, rPort: string): boolean { + if (!rPort || rPort === "—") return true + if (!pPort) return false + const n = parseInt(pPort) + for (const p of rPort.split(",")) { + const t = p.trim() + if (t.includes("-")) { const [lo, hi] = t.split("-").map(Number); if (n >= lo && n <= hi) return true } + else if (Number(t) === n) return true + } + return false +} +function _mList(pList: string, rList: string): boolean { + return !rList || rList === "—" || pList === rList +} +function _mIface(pI: string, rI: string): boolean { + return !rI || rI === "—" || !pI || pI === rI || rI === "*" +} + +function evalRule(rule: FirewallRule, pkt: PacketDef): { + verdict: SimStep["status"]; checks: SimCheck[]; failedAt?: string +} { + if (!rule.enabled) return { verdict: "disabled", checks: [] } + + const checks: SimCheck[] = [] + const fail = (c: SimCheck) => { checks.push(c); return { verdict: "skip" as const, checks, failedAt: c.field } } + + if (rule.chain !== pkt.chain) return fail({ field: "chain", ruleVal: rule.chain, packetVal: pkt.chain, passed: false }) + checks.push({ field: "chain", ruleVal: rule.chain, packetVal: pkt.chain, passed: true }) + + if (rule.proto && rule.proto !== "all") { + const ok = pkt.proto === rule.proto + if (!ok) return fail({ field: "protocol", ruleVal: rule.proto, packetVal: pkt.proto, passed: false }) + checks.push({ field: "protocol", ruleVal: rule.proto, packetVal: pkt.proto, passed: true }) + } + + if (rule.src && rule.src !== "—") { + const ok = _mAddr(pkt.srcAddr, rule.src) || _mList(pkt.srcAddrList, rule.src) + if (!ok) return fail({ field: "src-address", ruleVal: rule.src, packetVal: pkt.srcAddr || "—", passed: false }) + checks.push({ field: "src-address", ruleVal: rule.src, packetVal: pkt.srcAddr || pkt.srcAddrList, passed: true }) + } + + if (rule.dst && rule.dst !== "—") { + const ok = _mAddr(pkt.dstAddr, rule.dst) || _mList(pkt.dstAddrList, rule.dst) + if (!ok) return fail({ field: "dst-address", ruleVal: rule.dst, packetVal: pkt.dstAddr || "—", passed: false }) + checks.push({ field: "dst-address", ruleVal: rule.dst, packetVal: pkt.dstAddr || pkt.dstAddrList, passed: true }) + } + + if (rule.port && rule.port !== "—") { + const ok = _mPort(pkt.dstPort, rule.port) + if (!ok) return fail({ field: "dst-port", ruleVal: rule.port, packetVal: pkt.dstPort || "—", passed: false }) + checks.push({ field: "dst-port", ruleVal: rule.port, packetVal: pkt.dstPort, passed: true }) + } + + if (rule.iface && rule.iface !== "—") { + const ok = _mIface(pkt.inIface, rule.iface) + if (!ok) return fail({ field: "in-interface", ruleVal: rule.iface, packetVal: pkt.inIface || "—", passed: false }) + checks.push({ field: "in-interface", ruleVal: rule.iface, packetVal: pkt.inIface, passed: true }) + } + + if (rule.tlsHost && pkt.tlsHost) { + const pat = "^" + rule.tlsHost.replace(/\./g, "\\.").replace(/\*/g, ".*") + "$" + const ok = new RegExp(pat).test(pkt.tlsHost) + if (!ok) return fail({ field: "tls-host", ruleVal: rule.tlsHost, packetVal: pkt.tlsHost, passed: false }) + checks.push({ field: "tls-host", ruleVal: rule.tlsHost, packetVal: pkt.tlsHost, passed: true }) + } + + return { verdict: rule.action === "passthrough" ? "passthrough" : "match", checks } +} + +interface AddressListEntry { + id: string + list: string + address: string + comment: string + disabled: boolean + timeout?: string +} + +// ─── Mock address-list data ─────────────────────────────────────────────────── + +const INIT_ADDRESS_LISTS: AddressListEntry[] = [ + { id: "al1", list: "youtube-bypass", address: "142.250.0.0/15", comment: "Google/YouTube AS15169", disabled: false }, + { id: "al2", list: "youtube-bypass", address: "172.217.0.0/16", comment: "Google/YouTube AS15169", disabled: false }, + { id: "al3", list: "cdn-bypass", address: "104.16.0.0/12", comment: "Cloudflare AS13335", disabled: false }, + { id: "al4", list: "cdn-bypass", address: "23.32.0.0/11", comment: "Akamai AS20940", disabled: false }, + { id: "al5", list: "cdn-bypass", address: "151.101.0.0/16", comment: "Fastly AS54113", disabled: false }, + { id: "al6", list: "streaming-eu", address: "54.246.0.0/16", comment: "Netflix AS2906", disabled: false }, + { id: "al7", list: "streaming-eu", address: "45.57.0.0/16", comment: "Netflix AS2906 NL", disabled: false }, + { id: "al8", list: "social-block", address: "157.240.0.0/17", comment: "Facebook/Meta AS32934", disabled: false }, + { id: "al9", list: "social-block", address: "31.13.64.0/18", comment: "Facebook/Meta AS32934", disabled: false }, + { id: "al10", list: "gaming-low-latency",address: "162.159.128.0/19", comment: "Discord/Cloudflare AS13335", disabled: false }, + { id: "al11", list: "management-access", address: "10.10.0.0/16", comment: "LAN", disabled: false }, + { id: "al12", list: "management-access", address: "192.168.100.0/24", comment: "MGMT VLAN", disabled: false }, + { id: "al13", list: "blocklist-dynamic", address: "185.220.101.45", comment: "Tor exit — dynamic ban", disabled: false, timeout: "01:00:00" }, + { id: "al14", list: "blocklist-dynamic", address: "80.82.70.118", comment: "Shodan scanner — dynamic ban", disabled: false, timeout: "00:42:18" }, +] + +// ─── IP family filter ───────────────────────────────────────────────────────── + +type IpFamily = "all" | "ip" | "ip6" + +const IP_FAMILY_LABELS: Record = { + all: "IPv4 + IPv6", + ip: "IPv4", + ip6: "IPv6", +} + +// ─── Chain groups config ───────────────────────────────────────────────────── + +const CHAIN_GROUPS: { + id: ChainGroup; label: string; icon: React.ReactNode; chains: string[]; ip6chains?: string[] +}[] = [ + { + id: "filter", label: "Filter", icon: , + chains: ["input","forward","output"], + ip6chains: ["ip6-input","ip6-forward","ip6-output"], + }, + { id: "nat", label: "NAT", icon: , chains: ["srcnat","dstnat"] }, + { id: "mangle", label: "Mangle", icon: , chains: ["prerouting","postrouting","forward","input","output"] }, + { id: "raw", label: "Raw", icon: , chains: ["prerouting","output"] }, + { id: "address-lists", label: "Адр. листы", icon: , chains: [] }, + { id: "simulator", label: "Симулятор", icon: , chains: [] }, +] + +// ─── Action styles ──────────────────────────────────────────────────────────── + +const ACTION_STYLES: Record = { + accept: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20", + drop: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20", + reject: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20", + masquerade: "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20", + "mark-routing": "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20", + "mark-conn": "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20", + "fasttrack-connection": "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20", + "dst-nat": "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20", + "src-nat": "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20", + "add-src-to-address-list": "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/20", +} + +const CHAIN_STYLES: Record = { + forward: "bg-foreground/5 text-foreground/70", + input: "bg-violet-500/10 text-violet-600 dark:text-violet-400", + output: "bg-sky-500/10 text-sky-600 dark:text-sky-400", + srcnat: "bg-blue-500/10 text-blue-600 dark:text-blue-400", + dstnat: "bg-blue-500/10 text-blue-600 dark:text-blue-400", + prerouting: "bg-amber-500/10 text-amber-600 dark:text-amber-400", + postrouting: "bg-amber-500/10 text-amber-600 dark:text-amber-400", + "ip6-input": "bg-violet-500/10 text-violet-600 dark:text-violet-400", + "ip6-forward": "bg-foreground/5 text-foreground/70", + "ip6-output": "bg-sky-500/10 text-sky-600 dark:text-sky-400", +} + +// ─── Default form ───────────────────────────────────────────────────────────── + +const defaultForm = { + chain: "forward", action: "accept", proto: "all", + src: "", dst: "", srcPort: "", dstPort: "", iface: "", + inIface: "", outIface: "", connState: "", + srcAddrList: "", dstAddrList: "", + comment: "", enabled: true, + log: false, logPrefix: "", +} + +type RuleForm = typeof defaultForm + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function fmtHits(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}М` + if (n >= 1_000) return `${(n / 1_000).toFixed(0)}к` + return String(n) +} + +function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { + return ( + + ) +} + +function ActionBadge({ action }: { action: string }) { + const cls = ACTION_STYLES[action] ?? "bg-muted text-muted-foreground border-border" + return ( + + {action} + + ) +} + +function ChainBadge({ chain }: { chain: string }) { + const cls = CHAIN_STYLES[chain] ?? "bg-muted text-muted-foreground" + return ( + + {chain} + + ) +} + +function Field({ label, hint, required, children }: { + label: string; hint?: string; required?: boolean; children: React.ReactNode +}) { + return ( +
+ + {children} + {hint &&

{hint}

} +
+ ) +} + +function SectionTitle({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+
+ ) +} + +function NativeSelect({ value, onChange, children, className }: { + value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string +}) { + return ( + + ) +} + +// ─── RSC Export generator ───────────────────────────────────────────────────── + +function generateRsc(rules: FirewallRule[], timestamp?: string): string { + const lines: string[] = [] + lines.push(`# RouterLists Firewall Export`) + if (timestamp) lines.push(`# Сгенерировано: ${timestamp}`) + lines.push(`# Правил: ${rules.length}`) + lines.push("") + + const byChain: Record = {} + for (const r of rules) { + if (!byChain[r.chain]) byChain[r.chain] = [] + byChain[r.chain].push(r) + } + + const TABLE_MAP: Record = { + input: "filter", forward: "filter", output: "filter", + srcnat: "nat", dstnat: "nat", + prerouting: "mangle", postrouting: "mangle", + } + + for (const [chain, chainRules] of Object.entries(byChain)) { + const table = TABLE_MAP[chain] ?? "filter" + lines.push(`# ── /ip firewall ${table} chain=${chain} ──────────────────────────────`) + for (const r of chainRules) { + const parts = [`/ip firewall ${table} add`] + parts.push(`chain=${r.chain}`) + parts.push(`action=${r.action}`) + if (r.proto && r.proto !== "all") parts.push(`protocol=${r.proto}`) + if (r.src && r.src !== "—") parts.push(`src-address-list=${r.src}`) + if (r.dst && r.dst !== "—") parts.push(`dst-address-list=${r.dst}`) + if (r.port && r.port !== "—") parts.push(`dst-port=${r.port}`) + if (r.iface && r.iface !== "—") parts.push(`in-interface=${r.iface}`) + if (r.comment) parts.push(`comment="${r.comment}"`) + if (!r.enabled) parts.push(`disabled=yes`) + lines.push(parts.join(" \\\n ")) + lines.push("") + } + } + + return lines.join("\n") +} + +// ─── Rule Sheet ─────────────────────────────────────────────────────────────── + +function RuleSheet({ open, onClose, initialRule, chainGroup }: { + open: boolean + onClose: () => void + initialRule: Partial | null + chainGroup: ChainGroup +}) { + const isNew = !initialRule?.id + const [form, setForm] = useState(() => ({ + ...defaultForm, + chain: CHAIN_GROUPS.find(g => g.id === chainGroup)?.chains[0] ?? "forward", + ...(initialRule ?? {}), + srcPort: "", + dstPort: initialRule?.port ?? "", + inIface: initialRule?.iface ?? "", + outIface: "", + connState: "", + srcAddrList: initialRule?.src ?? "", + dstAddrList: initialRule?.dst ?? "", + log: false, + logPrefix: "", + })) + + const set = (k: K, v: RuleForm[K]) => + setForm((f) => ({ ...f, [k]: v })) + + const chainsForGroup = CHAIN_GROUPS.find(g => g.id === chainGroup)?.chains ?? ["forward"] + const showNatActions = chainGroup === "nat" + const actions = showNatActions + ? ["masquerade", "dst-nat", "src-nat", "netmap", "same", "passthrough"] + : chainGroup === "mangle" + ? ["mark-routing", "mark-conn", "mark-packet", "strip-ipv4-options", "passthrough", "add-src-to-address-list", "add-dst-to-address-list"] + : ["accept", "drop", "reject", "fasttrack-connection", "add-src-to-address-list", "add-dst-to-address-list", "passthrough"] + + return ( + { if (!v) onClose() }}> + + + {isNew ? "Новое правило" : "Редактировать правило"} + + /ip firewall {chainGroup === "nat" ? "nat" : chainGroup === "mangle" ? "mangle" : "filter"} · RouterOS 7 + + + +
+ + {/* Chain + Action */} +
+ Цепочка и действие +
+ + set("chain", v)}> + {chainsForGroup.map((c) => )} + + + + set("action", v)}> + {actions.map((a) => )} + + +
+
+ + {/* Matching */} +
+ Условие совпадения + + set("proto", v)}> + {["all","tcp","udp","icmp","gre","esp","ah","ipencap","ospf"].map((p) => + + )} + + + +
+ + set("srcAddrList", e.target.value)} /> + + + set("dstAddrList", e.target.value)} /> + +
+ +
+ + set("srcPort", e.target.value)} /> + + + set("dstPort", e.target.value)} /> + +
+ +
+ + set("inIface", e.target.value)} /> + + + set("outIface", e.target.value)} /> + +
+ + + set("connState", e.target.value)} /> + +
+ + {/* Log + Comment */} +
+ Логирование и комментарий +
+
+

Log

+

Записывать совпадения в системный лог

+
+ set("log", v)} /> +
+ {form.log && ( + + set("logPrefix", e.target.value)} /> + + )} + + set("comment", e.target.value)} /> + +
+ + {/* Enabled */} +
+
+

Правило включено

+

Отключённые правила сохраняются, но не применяются

+
+ set("enabled", v)} /> +
+ + {/* CLI preview */} +
+

RouterOS CLI preview

+
+              {[
+                `/ip firewall filter add \\`,
+                `  chain=${form.chain} \\`,
+                `  action=${form.action}`,
+                form.proto !== "all" ? `  protocol=${form.proto} \\` : null,
+                form.srcAddrList ? `  src-address-list=${form.srcAddrList} \\` : null,
+                form.dstAddrList ? `  dst-address-list=${form.dstAddrList} \\` : null,
+                form.dstPort     ? `  dst-port=${form.dstPort} \\`             : null,
+                form.inIface     ? `  in-interface=${form.inIface} \\`         : null,
+                form.outIface    ? `  out-interface=${form.outIface} \\`        : null,
+                form.connState   ? `  connection-state=${form.connState} \\`   : null,
+                form.log         ? `  log=yes \\`                              : null,
+                form.log && form.logPrefix ? `  log-prefix="${form.logPrefix}" \\` : null,
+                form.comment     ? `  comment="${form.comment}"`               : null,
+                !form.enabled    ? `  disabled=yes`                            : null,
+              ].filter(Boolean).join("\n")}
+            
+
+
+ + + }>Отмена + + +
+
+ ) +} + +// ─── Export Sheet ───────────────────────────────────────────────────────────── + +function ExportSheet({ open, onClose, rules }: { + open: boolean; onClose: () => void; rules: FirewallRule[] +}) { + const [copied, setCopied] = useState(false) + + // Derived from open — new timestamp each time modal opens, undefined when closed + const timestamp = useMemo( + () => open ? new Date().toLocaleString("ru") : undefined, + [open], // eslint-disable-line react-hooks/exhaustive-deps + ) + + const code = useMemo(() => generateRsc(rules, timestamp), [rules, timestamp]) + + function handleCopy() { + const full = generateRsc(rules, new Date().toLocaleString("ru")) + navigator.clipboard.writeText(full).then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }) + } + + return ( + { if (!v) onClose() }}> + + +
+
+ Экспорт Firewall + RouterOS .rsc · /ip firewall filter, nat, mangle +
+ +
+
+
+
+            {code.split("\n").map((line, i) => {
+              const isComment = line.startsWith("#")
+              const isSection = isComment && line.includes("──")
+              const isKey     = /^\s+[a-z]/.test(line)
+              return (
+                
+                  {line}{"\n"}
+                
+              )
+            })}
+          
+
+ + }>Закрыть + + +
+
+ ) +} + +// ─── Address List tab ───────────────────────────────────────────────────────── + +function AddressListsTab({ entries, onAdd }: { + entries: AddressListEntry[] + onAdd: () => void +}) { + const [search, setSearch] = useState("") + const [listFilter, setListFilter] = useState("all") + + const lists = useMemo(() => { + const s = new Set(entries.map((e) => e.list)) + return ["all", ...Array.from(s)] + }, [entries]) + + const filtered = useMemo(() => { + return entries.filter((e) => { + if (listFilter !== "all" && e.list !== listFilter) return false + if (!search) return true + const q = search.toLowerCase() + return e.address.includes(q) || e.list.includes(q) || e.comment.toLowerCase().includes(q) + }) + }, [entries, search, listFilter]) + + // Group by list name for display + const grouped = useMemo(() => { + if (listFilter !== "all") return { [listFilter]: filtered } + const g: Record = {} + for (const e of filtered) { + if (!g[e.list]) g[e.list] = [] + g[e.list].push(e) + } + return g + }, [filtered, listFilter]) + + return ( +
+ {/* toolbar */} +
+
+ + setSearch(e.target.value)} /> +
+
+ {lists.map((l) => ( + + ))} +
+ {filtered.length} записей + +
+ + {/* groups */} +
+ {Object.entries(grouped).map(([listName, listEntries]) => ( + +
+
+ + {listName} + ({listEntries.length}) +
+ +
+
+ {listEntries.map((e) => ( +
+ + {e.address} + {e.comment} + {e.timeout && ( + + ⏱ {e.timeout} + + )} + + + + + } /> + + Редактировать + + Удалить + + +
+ ))} +
+
+ ))} +
+
+ ) +} + +// ─── Verdict helpers ────────────────────────────────────────────────────────── + +function getVerdictIcon(action: string) { + if (action === "drop" || action === "reject") return + if (action === "fasttrack-connection") return + if (action.startsWith("mark-")) return + return +} + +function getVerdictColors(action: string) { + if (action === "drop" || action === "reject") + return { bg: "bg-red-500/10", border: "border-red-500/30", text: "text-red-600 dark:text-red-400" } + if (action === "fasttrack-connection") + return { bg: "bg-violet-500/10", border: "border-violet-500/30", text: "text-violet-600 dark:text-violet-400" } + if (action === "masquerade" || action.endsWith("-nat")) + return { bg: "bg-blue-500/10", border: "border-blue-500/30", text: "text-blue-600 dark:text-blue-400" } + if (action.startsWith("mark-")) + return { bg: "bg-amber-500/10", border: "border-amber-500/30", text: "text-amber-600 dark:text-amber-400" } + return { bg: "bg-emerald-500/10", border: "border-emerald-500/30", text: "text-emerald-600 dark:text-emerald-400" } +} + +// ─── SimStepRow ─────────────────────────────────────────────────────────────── + +function SimStepRow({ step, index }: { step: SimStep; index: number }) { + const [manualExpanded, setExpanded] = useState(false) + // Auto-expand on match/evaluating; user can also toggle manually + const expanded = manualExpanded || step.status === "match" || step.status === "evaluating" + + const isClickable = step.checks.length > 0 + + return ( +
+ + + {/* Expanded checks */} + {expanded && step.checks.length > 0 && ( +
+ {step.checks.map((c, ci) => ( +
+ {c.passed + ? + : } + {c.field}: + {c.ruleVal} + {!c.passed && ( + <> + + {c.packetVal} + + )} +
+ ))} +
+ )} +
+ ) +} + +// ─── Scenario constants + helpers ──────────────────────────────────────────── + +const SCENARIO_STORAGE_KEY = "routerlists-fw-scenarios" + +const DEFAULT_SRULE: Omit = { + chain: "forward", action: "accept", proto: "all", + src: "", dst: "", port: "", iface: "", comment: "", enabled: true, +} + +function scenarioRuleToFw(r: ScenarioRule): FirewallRule { + return { ...r, hits: 0, action: r.action as FirewallRule["action"] } +} + +// ─── Scenario Sheet ─────────────────────────────────────────────────────────── + +function ScenarioSheet({ open, onClose, initial, onSave }: { + open: boolean + onClose: () => void + initial: SimScenario | null + onSave: (s: SimScenario) => void +}) { + const isNew = !initial + const [name, setName] = useState(initial?.name ?? "") + const [desc, setDesc] = useState(initial?.description ?? "") + const [pkt, setPkt2] = useState(initial?.packet ?? DEFAULT_PKT) + const [rules, setRules] = useState(initial?.rules ?? []) + const [addOpen, setAddOpen]= useState(false) + const [addForm, setAddForm]= useState>(DEFAULT_SRULE) + + // Reset form fields each time the sheet opens — standard modal initialization pattern + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + if (!open) return + // eslint-disable-next-line react-hooks/set-state-in-effect + setName(initial?.name ?? ""); setDesc(initial?.description ?? "") + // eslint-disable-next-line react-hooks/set-state-in-effect + setPkt2(initial?.packet ?? DEFAULT_PKT); setRules(initial?.rules ?? []) + // eslint-disable-next-line react-hooks/set-state-in-effect + setAddForm(DEFAULT_SRULE); setAddOpen(false) + }, [open]) + + const setP = (k: K, v: PacketDef[K]) => + setPkt2(p => ({ ...p, [k]: v })) + const setAF = >(k: K, v: Omit[K]) => + setAddForm(f => ({ ...f, [k]: v })) + + function addRule() { + setRules(prev => [...prev, { ...addForm, id: `sr-${Date.now()}` }]) + setAddForm(DEFAULT_SRULE) + setAddOpen(false) + } + function removeRule(id: string) { setRules(prev => prev.filter(r => r.id !== id)) } + function toggleEnabled(id: string) { + setRules(prev => prev.map(r => r.id === id ? { ...r, enabled: !r.enabled } : r)) + } + function moveRule(id: string, dir: -1 | 1) { + setRules(prev => { + const idx = prev.findIndex(r => r.id === id) + const swap = idx + dir + if (idx < 0 || swap < 0 || swap >= prev.length) return prev + const next = [...prev]; + [next[idx], next[swap]] = [next[swap], next[idx]] + return next + }) + } + function handleSave() { + if (!name.trim()) return + onSave({ + id: initial?.id ?? `scenario-${Date.now()}`, + name: name.trim(), description: desc.trim(), + packet: pkt, rules, + createdAt: initial?.createdAt ?? Date.now(), + }) + onClose() + } + + const CHAIN_OPTS = ["forward","input","output","dstnat","srcnat","prerouting","postrouting"] + const PROTO_OPTS = ["all","tcp","udp","icmp","gre","esp"] + const ACTION_OPTS = [ + "accept","drop","reject","mark-routing","mark-conn","masquerade", + "fasttrack-connection","passthrough","add-src-to-address-list","add-dst-to-address-list", + ] + + return ( + { if (!v) onClose() }}> + + + {isNew ? "Новый сценарий" : `Редактировать: ${initial?.name}`} + Набор правил firewall/mangle/nat + тестовый пакет + + +
+ + {/* Meta */} +
+ Название +
+ + setName(e.target.value)} /> + + + setDesc(e.target.value)} /> + +
+
+ + {/* Default packet */} +
+ Тестовый пакет по умолчанию +
+ + setP("chain", v)}> + + + + + + + + {["srcnat","prerouting","postrouting","ip6-forward","ip6-input","ip6-output"] + .map(c => )} + + + + + setP("proto", v)}> + {PROTO_OPTS.map(p => )} + + + + setP("connState", e.target.value)} /> + + + setP("srcAddr", e.target.value)} /> + + + setP("dstAddr", e.target.value)} /> + + + setP("dstPort", e.target.value)} /> + + + setP("inIface", e.target.value)} /> + + + setP("outIface", e.target.value)} /> + + + setP("dstAddrList", e.target.value)} /> + +
+
+ + {/* Rules */} +
+
+ + Правила сценария + + ({rules.length}) +
+ +
+ + {/* Add-rule form */} + {addOpen && ( +
+

Новое правило

+
+
+ + setAF("chain", v)}> + {CHAIN_OPTS.map(c => )} + +
+
+ + setAF("action", v)}> + {ACTION_OPTS.map(a => )} + +
+
+ + setAF("proto", v)}> + {PROTO_OPTS.map(p => )} + +
+
+ + setAF("port", e.target.value)} /> +
+
+ + setAF("src", e.target.value)} /> +
+
+ + setAF("dst", e.target.value)} /> +
+
+ + setAF("iface", e.target.value)} /> +
+
+ + setAF("comment", e.target.value)} /> +
+
+
+
+ setAF("enabled", v)} /> + Включено +
+ +
+
+ )} + + {/* Rules table */} + {rules.length > 0 ? ( +
+ + + + + + + + + + + + + + + {rules.map((r, i) => ( + + + + + + + + + + + + ))} + +
#ЦепочкаДействиеSrcDstПортIfaceКомментарий +
{i + 1}{r.src || "any"}{r.dst || "any"}{r.port || "—"}{r.iface || "—"}{r.comment || "—"} +
+ + + + +
+
+
+ ) : ( + !addOpen && ( +
+ Нет правил — нажмите «Добавить правило» +
+ ) + )} +
+ +
+ + + }>Отмена + + + + + ) +} + +// ─── Simulator Tab ──────────────────────────────────────────────────────────── + +const SIM_SPEED_MS: Record = { slow: 700, normal: 350, fast: 120, instant: 0 } +const SIM_SPEED_LABELS: Record = { + slow: "Медленно", normal: "Нормально", fast: "Быстро", instant: "Мгновенно", +} + +function SimulatorTab({ rules: allRules }: { rules: FirewallRule[] }) { + // ── Core sim state ────────────────────────────────────────────────────────── + const [packet, setPacket] = useState(DEFAULT_PKT) + const [activePreset, setPreset] = useState("https") + const [simState, setSimState] = useState<"idle" | "running" | "done">("idle") + const [steps, setSteps] = useState([]) + const [speed, setSpeed] = useState("normal") + const [verdict, setVerdict] = useState<{ action: string; ruleIndex: number | null } | null>(null) + const cancelRef = useRef(false) + + // ── Scenarios state ───────────────────────────────────────────────────────── + const [scenarios, setScenarios] = useState(() => { + if (typeof window === "undefined") return [] + try { return JSON.parse(localStorage.getItem(SCENARIO_STORAGE_KEY) ?? "[]") } + catch { return [] } + }) + const [activeScenarioId, setActiveScenarioId] = useState(null) + const [scenarioSheetOpen, setScenarioSheetOpen] = useState(false) + const [editingScenario, setEditingScenario] = useState(null) + + useEffect(() => { + localStorage.setItem(SCENARIO_STORAGE_KEY, JSON.stringify(scenarios)) + }, [scenarios]) + + const activeScenario = scenarios.find(s => s.id === activeScenarioId) ?? null + + // Live rules vs scenario rules + const effectiveRules: FirewallRule[] = useMemo( + () => activeScenario ? activeScenario.rules.map(scenarioRuleToFw) : allRules, + [activeScenario, allRules], + ) + + const setPkt = (k: K, v: PacketDef[K]) => + setPacket(p => ({ ...p, [k]: v })) + + // All rules in traversal order across every chain the packet touches + const simRules = useMemo(() => { + const path = DIR_PATHS[packet.chain] ?? [packet.chain] + return path.flatMap(chain => effectiveRules.filter(r => r.chain === chain)) + }, [effectiveRules, packet.chain]) + + // ── Handlers ──────────────────────────────────────────────────────────────── + function resetSim() { + cancelRef.current = true + setSimState("idle") + setSteps([]) + setVerdict(null) + } + + function applyPreset(p: typeof SIM_PRESETS[number]) { + setActiveScenarioId(null) + setPreset(p.id) + setPacket({ ...DEFAULT_PKT, ...p.pkt }) + cancelRef.current = true + setSimState("idle") + setSteps([]) + setVerdict(null) + } + + function activateScenario(id: string | null) { + setActiveScenarioId(id) + setPreset(null) + cancelRef.current = true + setSimState("idle") + setSteps([]) + setVerdict(null) + if (id) { + const sc = scenarios.find(s => s.id === id) + if (sc) setPacket(sc.packet) + } + } + + function saveScenario(sc: SimScenario) { + setScenarios(prev => { + const idx = prev.findIndex(x => x.id === sc.id) + if (idx >= 0) { const next = [...prev]; next[idx] = sc; return next } + return [...prev, sc] + }) + activateScenario(sc.id) + } + + function deleteScenario(id: string) { + setScenarios(prev => prev.filter(s => s.id !== id)) + if (activeScenarioId === id) { setActiveScenarioId(null); resetSim() } + } + + async function runSimulation() { + const path = DIR_PATHS[packet.chain] ?? [packet.chain] + const rules = path.flatMap(chain => effectiveRules.filter(r => r.chain === chain)) + if (rules.length === 0) { + setVerdict({ action: "accept (default)", ruleIndex: null }) + setSimState("done") + return + } + + cancelRef.current = false + setSimState("running") + setVerdict(null) + setSteps(rules.map((r, i) => ({ rule: r, index: i, status: "pending", checks: [] }))) + + const delay = SIM_SPEED_MS[speed] + let finalVerdict: { action: string; ruleIndex: number | null } | null = null + + for (let i = 0; i < rules.length; i++) { + if (cancelRef.current) return + setSteps(prev => prev.map((s, idx) => idx === i ? { ...s, status: "evaluating" } : s)) + if (delay > 0) await new Promise(res => setTimeout(res, delay)) + if (cancelRef.current) return + + const result = evalRule(rules[i], packet) + setSteps(prev => prev.map((s, idx) => + idx === i ? { ...s, status: result.verdict, checks: result.checks, failedAt: result.failedAt } : s, + )) + + if (result.verdict === "match") { + finalVerdict = { action: rules[i].action, ruleIndex: i } + if (delay > 0) await new Promise(res => setTimeout(res, Math.max(delay * 0.5, 200))) + break + } + if (delay > 0) await new Promise(res => setTimeout(res, Math.max(delay * 0.25, 40))) + } + + if (!cancelRef.current) { + setVerdict(finalVerdict ?? { action: "accept (default)", ruleIndex: null }) + setSimState("done") + } + } + + const verdictColors = verdict ? getVerdictColors(verdict.action) : null + + return ( +
+ + {/* ── Presets + Scenarios card ─────────────────────────────────────────── */} + + + + {/* Built-in traffic presets */} +
+

+ Пресеты трафика +

+
+ {SIM_PRESETS.map(p => ( + + ))} +
+
+ +
+ + {/* Saved scenarios */} +
+
+

+ Мои сценарии +

+ +
+ + {scenarios.length === 0 ? ( +

+ Нет сохранённых сценариев — создайте набор правил firewall/mangle/nat для тестирования +

+ ) : ( +
+ {scenarios.map(sc => { + const isActive = activeScenarioId === sc.id + return ( +
+
+ {sc.name} + + {sc.rules.length} правил · {sc.packet.chain} + {sc.description ? ` · ${sc.description}` : ""} + +
+
+ + + +
+
+ ) + })} +
+ )} +
+ + + + {/* ── Packet editor ───────────────────────────────────────────────────── */} + + + +
+

+ Параметры пакета +

+
+ + {activeScenario ? `🎭 ${activeScenario.name}` : "🔴 Живые правила"} + +
+ + {simRules.length} правил ·{" "} + {packet.chain} + + {DIR_PATHS[packet.chain] && ( + + {DIR_PATHS[packet.chain].join(" → ")} + + )} +
+
+
+ + {/* Row 1 */} +
+
+ + { setPkt("chain", v); resetSim() }}> + + + + + + + + + + + + + + + +
+
+ + setPkt("proto", v)}> + {["tcp","udp","icmp","gre","esp","ah"].map(p => )} + +
+
+ + setPkt("srcAddr", e.target.value)} /> +
+
+ + setPkt("dstAddr", e.target.value)} /> +
+
+ + setPkt("srcPort", e.target.value)} /> +
+
+ + setPkt("dstPort", e.target.value)} /> +
+
+ + {/* Row 2 */} +
+
+ + setPkt("inIface", e.target.value)} /> +
+
+ + setPkt("outIface", e.target.value)} /> +
+
+ + setPkt("connState", e.target.value)} /> +
+
+ + setPkt("dstAddrList", e.target.value)} /> +
+
+ + {/* Speed + Run controls */} +
+ +
+ {(["slow","normal","fast","instant"] as SimSpeed[]).map(s => ( + + ))} +
+
+ + {simState === "running" ? ( + + ) : ( + + )} +
+
+
+
+ + {/* ── Verdict banner ──────────────────────────────────────────────────── */} + {verdict && simState === "done" && verdictColors && ( +
+
{getVerdictIcon(verdict.action)}
+
+

+ {verdict.action === "drop" || verdict.action === "reject" + ? "Пакет заблокирован" + : verdict.ruleIndex === null ? "Пакет принят (по умолчанию)" : "Пакет принят"} + {verdict.ruleIndex !== null && ( + — правило #{verdict.ruleIndex + 1} + )} +

+

+ Действие: {verdict.action} + {verdict.ruleIndex === null && · ни одно правило не совпало} +

+
+
+ )} + + {/* ── Trace list ──────────────────────────────────────────────────────── */} + {steps.length > 0 && ( + +
+

+ Трассировка · цепочка: {packet.chain} + {activeScenario && ( + · {activeScenario.name} + )} +

+ + {steps.filter(s => s.status === "match").length} совпало + {" · "}{steps.filter(s => s.status === "skip").length} пропущено + {" · "}{steps.filter(s => s.status === "disabled").length} выкл + +
+
+ {steps.map((step, i) => { + const prevChain = i > 0 ? steps[i - 1].rule.chain : null + const showHeader = step.rule.chain !== prevChain + return ( +
+ {showHeader && ( +
+ + {step.rule.chain} + + {CHAIN_TABLE_HINT[step.rule.chain] && ( + + · {CHAIN_TABLE_HINT[step.rule.chain]} + + )} + + {steps.filter(s => s.rule.chain === step.rule.chain).length} правил + +
+ )} + +
+ ) + })} +
+
+ )} + + {/* ── Empty state ─────────────────────────────────────────────────────── */} + {simState === "idle" && simRules.length === 0 && ( +
+ +

Нет правил в цепочке «{packet.chain}»

+

+ {activeScenario + ? "Добавьте правила в сценарий через кнопку «Редактировать»" + : "Выберите другой пресет или измените цепочку"} +

+
+ )} + + {/* ── Scenario Sheet ───────────────────────────────────────────────────── */} + setScenarioSheetOpen(false)} + initial={editingScenario} + onSave={saveScenario} + /> + +
+ ) +} + +// ─── Rules Table ────────────────────────────────────────────────────────────── + +function RulesTable({ rules, onToggle, onEdit }: { + rules: FirewallRule[] + onToggle: (id: string) => void + onEdit: (r: FirewallRule) => void +}) { + if (rules.length === 0) { + return ( +
+ +

Правила не найдены

+

Попробуйте изменить фильтр или добавьте новое правило

+
+ ) + } + + return ( +
+ + + + + + + + + + + + + + + + + {rules.map((r, i) => ( + + + + + + + + + + + + + + ))} + +
#ЦепочкаДействиеИсточникНазначениеПротоколПортИнтерфейсПакетовВкл +
{i + 1} + {r.src || "any"} + + {r.dst || "any"} + {r.proto}{r.port || "—"}{r.iface || "—"} + 1_000_000 ? "text-emerald-600 dark:text-emerald-400 font-semibold" + : r.hits > 10_000 ? "text-foreground" + : "text-muted-foreground", + )}> + {fmtHits(r.hits)} + + + onToggle(r.id)} /> + + + + + + } /> + + onEdit(r)}> + Редактировать + + + Дублировать + + + onToggle(r.id)}> + + {r.enabled ? "Отключить" : "Включить"} + + + + Удалить правило + + + +
+
+ ) +} + +// ════════════════════════════════════════════════════════════════════════════ +export default function FirewallPage() { + const [rules, setRules] = useState(firewallRules) + const [addrLists] = useState(INIT_ADDRESS_LISTS) + + const [chainGroup, setChainGroup] = useState("filter") + const [chainFilter, setChainFilter] = useState("all") + const [ipFamily, setIpFamily] = useState("all") + const [search, setSearch] = useState("") + + const [editingRule, setEditingRule] = useState | null>(null) + const [ruleSheetOpen, setRuleSheetOpen] = useState(false) + const [exportOpen, setExportOpen] = useState(false) + + // ── derived ────────────────────────────────────────────────────────────── + + const chainsInGroup = useMemo(() => { + if (chainGroup === "address-lists") return [] + const grp = CHAIN_GROUPS.find((g) => g.id === chainGroup) + if (!grp) return [] + const v4 = grp.chains + const v6 = grp.ip6chains ?? [] + if (ipFamily === "ip") return v4 + if (ipFamily === "ip6") return v6 + return [...v4, ...v6] + }, [chainGroup, ipFamily]) + + const groupRules = useMemo(() => { + if (chainGroup === "address-lists") return [] + return rules.filter((r) => chainsInGroup.includes(r.chain)) + }, [rules, chainGroup, chainsInGroup]) + + const filteredRules = useMemo(() => { + return groupRules.filter((r) => { + if (chainFilter !== "all" && r.chain !== chainFilter) return false + // ipFamily filtering also respects rules with explicit family field + if (ipFamily === "ip" && r.family === "ip6") return false + if (ipFamily === "ip6" && r.family === "ip") return false + if (!search) return true + const q = search.toLowerCase() + return ( + r.chain.toLowerCase().includes(q) || + r.action.toLowerCase().includes(q) || + r.src.toLowerCase().includes(q) || + r.dst.toLowerCase().includes(q) || + r.comment.toLowerCase().includes(q) + ) + }) + }, [groupRules, chainFilter, ipFamily, search]) + + const chainCounts = useMemo(() => { + const counts: Record = { all: groupRules.length } + for (const r of groupRules) { + counts[r.chain] = (counts[r.chain] ?? 0) + 1 + } + return counts + }, [groupRules]) + + // ── stats ──────────────────────────────────────────────────────────────── + + const totalEnabled = rules.filter((r) => r.enabled).length + const totalHits = rules.reduce((s, r) => s + r.hits, 0) + const dropRules = rules.filter((r) => r.action === "drop" || r.action === "reject").length + + // ── handlers ───────────────────────────────────────────────────────────── + + function toggleRule(id: string) { + setRules((rs) => rs.map((r) => r.id === id ? { ...r, enabled: !r.enabled } : r)) + } + + function openAdd() { + setEditingRule(null) + setRuleSheetOpen(true) + } + + function openEdit(r: FirewallRule) { + setEditingRule(r) + setRuleSheetOpen(true) + } + + // ───────────────────────────────────────────────────────────────────────── + + return ( +
+ + + + + } + /> + +
+
+ + {/* Stats */} +
+ {[ + { label: "Всего правил", value: rules.length, icon: }, + { label: "Активных", value: totalEnabled, icon: }, + { label: "Блокирующих", value: dropRules, icon: }, + { label: "Срабатываний", value: fmtHits(totalHits), icon: }, + ].map((s) => ( + + +
+

{s.label}

+

{s.value}

+
+
{s.icon}
+
+
+ ))} +
+ + {/* Table group tabs */} +
+ {CHAIN_GROUPS.map((g) => ( + + ))} +
+ + {chainGroup === "address-lists" ? ( + {}} /> + ) : chainGroup === "simulator" ? ( + + ) : ( + + {/* toolbar */} +
+ {/* IP family selector */} +
+ {(["all", "ip", "ip6"] as IpFamily[]).map((f) => ( + + ))} +
+ + {/* chain sub-tabs */} +
+ {[{ value: "all", label: "Все" }, ...chainsInGroup.map((c) => ({ value: c, label: c }))].map((t) => ( + + ))} +
+ + {/* search */} +
+ + setSearch(e.target.value)} + /> +
+ + {filteredRules.length} правил +
+ + +
+ )} + + {/* RouterOS reference */} + + +

+ RouterOS 7.20+ · /ip firewall + /ipv6 firewall — цепочки и новые матчеры +

+
+ {[ + { + title: "/ip firewall filter", + lines: ["input — трафик на роутер", "forward — транзит", "output — от роутера"], + }, + { + title: "/ipv6 firewall filter", + lines: ["ip6-input", "ip6-forward", "ip6-output"], + }, + { + title: "Новые матчеры 7.x", + lines: [ + "tls-host=*.google.com", + "connection-rate=100/s", + "layer7-protocol=...", + ], + }, + { + title: "Новые действия 7.x", + lines: [ + "fasttrack-connection", + "nfqueue (queue-num=0)", + "return", + "passthrough", + ], + }, + ].map((t) => ( +
+

{t.title}

+
    + {t.lines.map((c) => ( +
  • + {c} +
  • + ))} +
+
+ ))} +
+
+
+ +
+
+ + {/* Sheet: Add/Edit Rule */} + setRuleSheetOpen(false)} + initialRule={editingRule} + chainGroup={chainGroup === "address-lists" ? "filter" : chainGroup} + /> + + {/* Sheet: Export */} + setExportOpen(false)} + rules={rules} + /> +
+ ) +} diff --git a/app/(main)/gre/page.tsx b/app/(main)/gre/page.tsx new file mode 100644 index 0000000..c9404c3 --- /dev/null +++ b/app/(main)/gre/page.tsx @@ -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 = { "aes-128": "AES-128", "aes-192": "AES-192", "aes-256": "AES-256" } +const AUTH_LABELS: Record = { sha1: "SHA-1", sha256: "SHA-256", sha512: "SHA-512" } +const DH_LABELS: Record = { + 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 = { ikev1: "IKEv1", ikev2: "IKEv2" } + +const ENC_ROS: Record = { "aes-128": "aes-128-cbc", "aes-192": "aes-192-cbc", "aes-256": "aes-256-cbc" } +const AUTH_ROS: Record = { sha1: "sha1", sha256: "sha256", sha512: "sha512" } + +const STATUS_MAP: Record = { + 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 ( + + + {s.label} + + ) +} + +function IpsecBadge({ secured }: { secured: boolean }) { + return secured ? ( + + IPsec + + ) : ( + + Открытый + + ) +} + +function Field({ label, hint, required, children }: { + label: string; hint?: string; required?: boolean; children: React.ReactNode +}) { + return ( +
+ + {children} + {hint &&

{hint}

} +
+ ) +} + +function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { + return ( + + ) +} + +function SectionTitle({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+
+ ) +} + +function SegmentedControl({ value, onChange, options }: { + value: T; onChange: (v: T) => void; options: { value: T; label: string }[] +}) { + return ( +
+ {options.map((o) => ( + + ))} +
+ ) +} + +// ─── 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("tunnels") + const [tabFilter, setTabFilter] = useState("all") + const [search, setSearch] = useState("") + + const [tunnelOpen, setTunnelOpen] = useState(false) + const [poolOpen, setPoolOpen] = useState(false) + const [codePreviewTunnel, setCodePreviewTunnel] = useState(null) + const [copied, setCopied] = useState(false) + + const [tForm, setTForm] = useState(defaultTunnelForm) + const [pForm, setPForm] = useState(defaultPoolForm) + + const setT = (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 ( +
+ + + + + } + /> + +
+
+ + {/* Legacy banner */} +
+ +
+

+ В RouterOS 7.x рекомендуется использовать WireGuard вместо GRE+IPsec +

+

+ WireGuard проще в настройке, обеспечивает лучшую производительность и современную криптографию (ChaCha20-Poly1305). + GRE-туннели остаются поддерживаемыми, но WireGuard — предпочтительный выбор для новых развёртываний. +

+
+
+ + {/* Stats */} +
+ {[ + { label: "Всего туннелей", value: greTunnels.length, icon: }, + { label: "Активно", value: upCount, icon: }, + { label: "Защищены IPsec", value: ipsecCount, icon: }, + { label: "IP-пулов", value: grePools.length, icon: }, + ].map((s) => ( + + +
+

{s.label}

+

{s.value}

+
+
{s.icon}
+
+
+ ))} +
+ + {/* Page tabs */} +
+ {(["tunnels", "pools"] as PageTab[]).map((tab) => ( + + ))} +
+ + {/* ── Tunnels ── */} + {pageTab === "tunnels" && ( + +
+
+ {tunnelTabs.map((t) => ( + + ))} +
+
+ + setSearch(e.target.value)} /> +
+ {filtered.length} туннелей +
+ +
+ + + + + + + + + + + + + + + + {filtered.map((t) => { + const srv = serverById[t.serverId] + const pool = poolById[t.poolId] + return ( + + + + + + + + + + + + {/* actions */} + + + ) + })} + +
Интерфейс / СерверЭндпоинтыВнутренний IPПулIPsecШифрованиеMTUKeepaliveСтатус +
+

{t.name}

+

+ {srv && } + {srv?.name ?? t.serverId} +

+
+

+ {t.localAddress === "0.0.0.0" ? авто : t.localAddress} +

+

→ {t.remoteAddress}

+
+

{t.localInnerIp}

+

{t.remoteInnerIp}

+
+ {pool?.name ?? "—"} + + {t.ipsec ? ( +
+ {ENC_LABELS[t.ipsec.encAlg]} / {AUTH_LABELS[t.ipsec.authAlg]} + + {DH_LABELS[t.ipsec.dhGroup].split(" ")[0]} · {IKE_LABELS[t.ipsec.ikeVersion]}{t.ipsec.pfs && " · PFS"} + +
+ ) : } +
{t.mtu} + {t.keepaliveInterval === 0 ? "откл." : `${t.keepaliveInterval}с / ${t.keepaliveRetries}`} + +
+ {/* Code preview button */} + + + {/* Actions dropdown */} + + + + + } /> + + + {t.name} + + + setCodePreviewTunnel(t)}> + Просмотр кода + + + Редактировать + + + + + {t.enabled ? "Выключить" : "Включить"} + + + + Удалить туннель + + + +
+
+
+
+ )} + + {/* ── IP Pools ── */} + {pageTab === "pools" && ( + +
+ {grePools.length} пула + +
+
+ + + + + + + + + + + + + {grePools.map((pool) => { + const pct = Math.round((pool.allocated / pool.total) * 100) + return ( + + + + + + + + + + ) + })} + +
Имя пулаДиапазон CIDRНазначено /30Доступно /30ИспользованиеНазначение +
{pool.name}{pool.cidr}{pool.allocated}{pool.total - pool.allocated} +
+
+
80 ? "bg-amber-500" : "bg-emerald-500"}`} style={{ width: `${pct}%` }} /> +
+ {pct}% +
+
{pool.comment} + + + + + } /> + + Редактировать + + Удалить пул + + +
+
+ +
+

Назначения по пулам

+
+ {grePools.map((pool) => { + const poolTunnels = greTunnels.filter((t) => t.poolId === pool.id) + return ( +
+

{pool.name}

+
+ {poolTunnels.map((t) => ( +
+ + {t.name} + {t.localInnerIp} ↔ {t.remoteInnerIp} + {t.ipsec && } +
+ ))} +
+
+ ) + })} +
+
+
+ )} + + {/* RouterOS reference */} + + +

RouterOS 7.20+ — параметры GRE-интерфейса

+
+ {[ + ["/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=", ""], + ].map(([cmd, desc], i) => ( +
+ {cmd} + {desc && # {desc}} +
+ ))} +
+
+
+
+
+ + {/* ══ Sheet: Code Preview ════════════════════════════════════════════════ */} + { if (!open) setCodePreviewTunnel(null) }}> + + {codePreviewTunnel && (() => { + const code = generateRosCommands(codePreviewTunnel) + return ( + <> + +
+
+ {codePreviewTunnel.name} + Команды RouterOS 7.20+ для создания туннеля +
+ +
+
+ +
+ {/* meta strip */} +
+ + + {STATUS_MAP[codePreviewTunnel.status].label} + + · + {serverById[codePreviewTunnel.serverId]?.name} + · + {codePreviewTunnel.localAddress === "0.0.0.0" ? "авто" : codePreviewTunnel.localAddress} → {codePreviewTunnel.remoteAddress} + {codePreviewTunnel.ipsec && ( + <> + · + IPsec {IKE_LABELS[codePreviewTunnel.ipsec.ikeVersion]} + + )} +
+ + {/* code block */} +
+                    {code.split("\n").map((line, i) => {
+                      const isComment  = line.startsWith("#")
+                      const isSection  = isComment && line.includes("──")
+                      const isKey      = /^\s+[a-z]/.test(line)
+                      return (
+                        
+                          {line}
+                          {"\n"}
+                        
+                      )
+                    })}
+                  
+
+ + + }>Закрыть + + + + ) + })()} +
+
+ + {/* ══ Sheet: Add Tunnel ══════════════════════════════════════════════════ */} + + + + Новый GRE-туннель + RouterOS 7.20+ · /interface gre add + + +
+
+ Основные + + setT("name", e.target.value)} /> + + + + + + setT("comment", e.target.value)} /> + +
+ Включён + setT("enabled", v)} /> +
+
+ +
+ Эндпоинты + + setT("localAddress", e.target.value)} /> + + + setT("remoteAddress", e.target.value)} /> + +
+ +
+ Внутренний IP + + + +
+ + setT("localInnerIp", e.target.value)} /> + + + setT("remoteInnerIp", e.target.value)} /> + +
+
+ +
+ IPsec +
+
+

Включить IPsec

+

RouterOS автоматически создаст peer, policy и proposal

+
+ setT("ipsecEnabled", v)} /> +
+ + {tForm.ipsecEnabled && ( +
+ +
+ setT("ipsecSecret", e.target.value)} /> + +
+
+ + setT("ipsecIkeVersion", v)} + options={[{ value: "ikev1", label: "IKEv1" }, { value: "ikev2", label: "IKEv2 (рек.)" }]} /> + +
+ + + + + + +
+ + + +
+ + setT("ipsecLifetime", e.target.value)} /> + +
+ PFS + setT("ipsecPfs", v)} /> +
+
+
+ )} +
+ +
+ + {tForm.showAdvanced && ( +
+
+ + setT("mtu", Number(e.target.value))} /> + + + setT("keepaliveInterval", Number(e.target.value))} /> + + + setT("keepaliveRetries", Number(e.target.value))} /> + +
+ + + + {[ + { key: "clampTcpMss" as const, label: "Clamp TCP MSS", desc: "Ограничить MSS до MTU туннеля" }, + { key: "allowFastPath" as const, label: "Allow Fast Path", desc: "Аппаратное ускорение трафика" }, + ].map(({ key, label, desc }) => ( +
+
+

{label}

+

{desc}

+
+ setT(key, v)} /> +
+ ))} +
+ )} +
+
+ + + }>Отмена + + +
+
+ + {/* ══ Sheet: Add Pool ════════════════════════════════════════════════════ */} + + + + Новый IP-пул + Пул адресов для назначения внутренних IP GRE-туннелям + +
+
+ Параметры пула + + setPForm((f) => ({ ...f, name: e.target.value }))} /> + + + setPForm((f) => ({ ...f, cidr: e.target.value }))} /> + + + setPForm((f) => ({ ...f, comment: e.target.value }))} /> + + {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 ? ( +
+

+ Доступно {blocks} блоков /30 + {" "}= до {blocks} туннелей +

+
+ ) : null + })()} +
+
+ + }>Отмена + + +
+
+
+ ) +} diff --git a/app/(main)/ip-ranges/page.tsx b/app/(main)/ip-ranges/page.tsx new file mode 100644 index 0000000..d3adc77 --- /dev/null +++ b/app/(main)/ip-ranges/page.tsx @@ -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 ( +
+ + + + + + } + /> +
+
+
+

IP-диапазоны

+

+ CIDR-блоки, отправляемые в address-list MikroTik по всем настроенным фильтрам +

+
+ {d.cidr}, + }, + { + key: "asn", + label: "ASN", + render: (d) => {d.asn}, + }, + { + key: "country", + label: "Страна", + render: (d) => {d.country}, + }, + { + key: "purpose", + label: "Назначение", + render: (d) => {d.purpose}, + }, + { + key: "filter", + label: "Фильтр", + render: (d) => ( + + {d.filter} + + ), + }, + { + key: "updated", + label: "Обновлён", + render: (d) => {d.updated}, + }, + { + key: "enabled", + label: "Статус", + render: (d) => ( + + {d.enabled ? "Активен" : "Отключён"} + + ), + }, + ]} + /> +
+
+
+ ) +} diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx new file mode 100644 index 0000000..be17fdc --- /dev/null +++ b/app/(main)/layout.tsx @@ -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 ( + + + + {children} + + + + ) +} diff --git a/app/(main)/network-map/page.tsx b/app/(main)/network-map/page.tsx new file mode 100644 index 0000000..9f7e856 --- /dev/null +++ b/app/(main)/network-map/page.tsx @@ -0,0 +1,1479 @@ +"use client" + +import { useState, useRef, useEffect } from "react" +import { PageHeader } from "@/components/page-header" +import { servers, greTunnels, type Server, type ServerType } from "@/lib/data" +import { Button } from "@/components/ui/button" +import { StatusBadge } from "@/components/status-badge" +import { + DownloadIcon, XIcon, LockIcon, LockOpenIcon, WifiIcon, + ZoomInIcon, ZoomOutIcon, Maximize2Icon, SearchIcon, + LayersIcon, MapIcon, HomeIcon, TerminalIcon, ShieldIcon, + CableIcon, CopyIcon, ActivityIcon, ExternalLinkIcon, +} from "lucide-react" +import { cn } from "@/lib/utils" + +// ─── Resource metrics (mirrors uptime/page.tsx INIT_RESOURCES formula) ─────── + +interface SrvRes { + cpu: number + cpuHistory: number[] + ramUsed: number; ramTotal: number + hddUsed: number; hddTotal: number + uptimeSeconds: number + temp?: number +} + +const BOARD_MAP: Record = { + "jump-host": "RB5009UG+S+IN", + "exit-node": "RB4011iGS+RM", + "home-router": "hAP ax²", +} + +const SERVER_RESOURCES: Record = Object.fromEntries( + servers.map(s => { + const h = s.id.split("").reduce((a, c) => a + c.charCodeAt(0), 0) + const cpu = 4 + (h % 68) + const ramTotal = s.type === "jump-host" ? 8192 : s.type === "exit-node" ? 4096 : 1024 + const hddTotal = s.type === "home-router" ? 2048 : 16384 + const ramPct = 12 + (h % 72) + const hddPct = 8 + (h % 75) + return [s.id, { + cpu, + cpuHistory: Array.from({ length: 32 }, (_, i) => + Math.max(1, Math.min(99, cpu + Math.round(Math.sin(i * 0.7 + h * 0.1) * 15))), + ), + ramUsed: Math.round(ramTotal * ramPct / 100), ramTotal, + hddUsed: Math.round(hddTotal * hddPct / 100), hddTotal, + uptimeSeconds: (1 + h % 200) * 86400 + (h % 24) * 3600 + (h % 60) * 60, + temp: s.type !== "home-router" ? 34 + (h % 32) : undefined, + } satisfies SrvRes] + }) +) + +function fmtMB(mb: number): string { + if (mb >= 1024) return `${(mb / 1024).toFixed(mb >= 10240 ? 0 : 1)} ГБ` + return `${mb} МБ` +} +function fmtUptime(sec: number): string { + const d = Math.floor(sec / 86400) + const h = Math.floor((sec % 86400) / 3600) + const m = Math.floor((sec % 3600) / 60) + if (d > 0) return `${d}д ${h}ч` + if (h > 0) return `${h}ч ${m}м` + return `${m}м` +} +function resBarColor(pct: number): string { + if (pct >= 85) return "bg-red-500" + if (pct >= 70) return "bg-amber-500" + return "bg-emerald-500" +} +function resPctColor(pct: number): string { + if (pct >= 85) return "text-red-400" + if (pct >= 70) return "text-amber-400" + return "text-emerald-400" +} + +function MiniBar({ pct }: { pct: number }) { + return ( +
+
+
+ ) +} + +function Sparkline({ history }: { history: number[] }) { + const max = Math.max(...history, 1) + const W = 80, H = 24 + const pts = history.map((v, i) => { + const x = (i / (history.length - 1)) * W + const y = H - (v / max) * H + return `${x},${y}` + }).join(" ") + return ( + + + + ) +} + +// ─── Canvas dimensions ──────────────────────────────────────────────────────── + +const W = 1060 +const H = 580 +const ZOOM_MIN = 0.2 +const ZOOM_MAX = 6 + +// ─── Visual config ──────────────────────────────────────────────────────────── + +const STATUS_STYLE = { + online: { fill: "#0f2d1f", stroke: "#4ade80", text: "#4ade80", glow: "rgba(74,222,128,0.15)" }, + degraded: { fill: "#2d1e06", stroke: "#fbbf24", text: "#fbbf24", glow: "rgba(251,191,36,0.15)" }, + offline: { fill: "#2d0f0f", stroke: "#f87171", text: "#f87171", glow: "transparent" }, +} + +const TYPE_STYLE: Record = { + "jump-host": { label: "JH", fill: "#7c3aed", r: 36 }, + "exit-node": { label: "EN", fill: "#0369a1", r: 36 }, + "home-router": { label: "HR", fill: "#16a34a", r: 30 }, +} + +const WAN_COLORS = ["#0ea5e9", "#f97316", "#a855f7", "#ec4899", "#14b8a6"] + +const TUNNEL_STYLE = { + up: { stroke: "#4ade80", opacity: 0.5, dotOpacity: 0.85 }, + degraded: { stroke: "#fbbf24", opacity: 0.4, dotOpacity: 0.7 }, + down: { stroke: "#f87171", opacity: 0.15, dotOpacity: 0 }, +} + +// ─── Positions ──────────────────────────────────────────────────────────────── + +const POS: Record = { + srv1: { x: 500, y: 170 }, + srv2: { x: 130, y: 75 }, + srv3: { x: 820, y: 75 }, + srv4: { x: 910, y: 265 }, + srv5: { x: 700, y: 385 }, + srv6: { x: 310, y: 345 }, + srv7: { x: 125, y: 255 }, + home1: { x: 270, y: 470 }, + home2: { x: 790, y: 470 }, +} + +const WAN_SAT_POS: Record = { + home1: [{ x: 155, y: 510 }, { x: 385, y: 510 }], + home2: [{ x: 665, y: 510 }, { x: 915, y: 510 }], +} + +// ─── WAN→JH edges ──────────────────────────────────────────────────────────── + +interface WanJhEdge { + homeId: string; wanIdx: number; jhId: string + pingMs: number; dlMbps: number; active: boolean +} + +const WAN_JH_EDGES: WanJhEdge[] = [ + { homeId: "home1", wanIdx: 0, jhId: "srv1", pingMs: 4, dlMbps: 480, active: true }, + { homeId: "home1", wanIdx: 0, jhId: "srv7", pingMs: 6, dlMbps: 480, active: true }, + { homeId: "home1", wanIdx: 1, jhId: "srv1", pingMs: 12, dlMbps: 95, active: false }, + { homeId: "home2", wanIdx: 0, jhId: "srv1", pingMs: 18, dlMbps: 280, active: true }, + { homeId: "home2", wanIdx: 0, jhId: "srv7", pingMs: 20, dlMbps: 278, active: false }, + { homeId: "home2", wanIdx: 1, jhId: "srv1", pingMs: 22, dlMbps: 185, active: false }, +] + +// ─── Tunnel probes ──────────────────────────────────────────────────────────── + +interface TunnelProbe { pingMs: number | null; dlMbps: number | null; ulMbps: number | null } + +const TUNNEL_PROBES: Record = { + gre1: { pingMs: 28, dlMbps: 614, ulMbps: 421 }, + gre2: { pingMs: 41, dlMbps: 488, ulMbps: 352 }, + gre3: { pingMs: 88, dlMbps: 143, ulMbps: 104 }, + gre4: { pingMs: null, dlMbps: null, ulMbps: null }, + gre5: { pingMs: 14, dlMbps: 578, ulMbps: 412 }, + gre6: { pingMs: 92, dlMbps: 155, ulMbps: 112 }, +} + +const TYPE_LABELS: Record = { + "jump-host": "JumpHost", + "exit-node": "Exit Node", + "home-router": "Home Router", +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function pingColor(ms: number | null) { + if (ms == null) return "#f87171" + if (ms < 15) return "#4ade80" + if (ms < 50) return "#fbbf24" + return "#fb923c" +} +function latencyColor(ms: number | null) { + if (ms == null) return "#f87171" + if (ms > 60) return "#fbbf24" + return "#4ade80" +} +function shortName(name: string) { + return name.replace(/^(home|mt)-/, "").split("-").slice(0, 3).join("-") +} + +// ─── Filter ─────────────────────────────────────────────────────────────────── + +type FilterKey = "all" | "online" | "degraded" | "offline" | "jump-host" | "exit-node" | "home-router" + +// ─── SVG sub-components ─────────────────────────────────────────────────────── + +function PingBadge({ mx, my, ping, dl, ul, color }: { + mx: number; my: number + ping: number | null; dl?: number | null; ul?: number | null + color: string +}) { + const hasSpeed = dl != null && dl > 0 + const h = hasSpeed ? 36 : 20 + return ( + + + + {ping == null ? "timeout" : `${ping} мс`} + + {hasSpeed && ( + + {`↓${dl} ↑${ul}`} + + )} + + ) +} + +function SvgTooltip({ n }: { n: Server & { x: number; y: number } }) { + const ss = STATUS_STYLE[n.status] + const ts = TYPE_STYLE[n.type] + const lc = latencyColor(n.latency) + const ox = n.x + ts.r + 14 + const oy = n.y - 32 + const lines = [ + n.name, + `${TYPE_LABELS[n.type]} · ${n.site}`, + n.latency != null ? `${n.latency} мс` : "Офлайн", + n.model ?? "", + ].filter(Boolean) + const bw = 148, bh = lines.length * 14 + 14 + return ( + + + {/* colour accent line */} + + {lines.map((l, i) => ( + + {l} + + ))} + + ) +} + +function ServerNode({ n, isSel, isVis, isDragged, onClick, onDblClick, onHoverChange, onContextMenu, onMouseDown }: { + n: Server & { x: number; y: number } + isSel: boolean; isVis: boolean; isDragged: boolean + onClick: () => void + onDblClick: () => void + onHoverChange: (hov: boolean) => void + onContextMenu: (e: React.MouseEvent) => void + onMouseDown: (e: React.MouseEvent) => void +}) { + const ss = STATUS_STYLE[n.status] + const ts = TYPE_STYLE[n.type] + const lc = latencyColor(n.latency) + + return ( + { e.stopPropagation(); onMouseDown(e) }} + onClick={e => { e.stopPropagation(); onClick() }} + onDoubleClick={e => { e.stopPropagation(); onDblClick() }} + onContextMenu={e => { e.preventDefault(); e.stopPropagation(); onContextMenu(e) }} + onMouseEnter={() => onHoverChange(true)} + onMouseLeave={() => onHoverChange(false)} + > + {n.status === "online" && isVis && ( + + + + + )} + {n.status === "degraded" && isVis && ( + + )} + {isSel && ( + + )} + + + + {ts.label} + + 32 ? "13" : "11"} fontWeight="700" + fill="#f1f5f9" fontFamily="ui-monospace,monospace">{n.site} + + {n.latency == null ? "офлайн" : `${n.latency} мс`} + + {shortName(n.name)} + + ) +} + +function WanSatNode({ x, y, wan, color, active, isSel, isDragged, onSelect, onMouseDown }: { + x: number; y: number + wan: { name: string; isp: string; maxDl: number; maxUl: number } + color: string; active: boolean; isSel: boolean; isDragged: boolean + onSelect: () => void + onMouseDown: (e: React.MouseEvent) => void +}) { + const r = 18 + return ( + { e.stopPropagation(); onMouseDown(e) }} + onClick={e => { e.stopPropagation(); onSelect() }}> + {active && ( + + + + )} + {isSel && ( + + )} + + + + + + + {wan.name} + + {wan.isp.length > 10 ? wan.isp.slice(0, 9) + "…" : wan.isp} + + + ↓{wan.maxDl} ↑{wan.maxUl} + + + ) +} + +// ─── Context menu ───────────────────────────────────────────────────────────── + +interface CtxMenu { x: number; y: number; server: Server } + +function ContextMenu({ menu, onClose }: { menu: CtxMenu; onClose: () => void }) { + const items = [ + { + icon: , + label: "Открыть терминал", + href: `/terminal?host=${menu.server.host}`, + }, + { + icon: , + label: "Firewall", + href: `/firewall?server=${menu.server.id}`, + }, + { + icon: , + label: "GRE-туннели", + href: `/gre?server=${menu.server.id}`, + }, + { + icon: , + label: "Трафик", + href: `/traffic?server=${menu.server.id}`, + }, + ] + + return ( + <> + {/* backdrop */} +
+
+ {/* header */} +
+ + {menu.server.name} + {menu.server.host} +
+ + {items.map(item => ( + + {item.icon} + {item.label} + + + ))} + +
+ +
+
+ + ) +} + +// ─── Minimap ────────────────────────────────────────────────────────────────── + +const MM_W = 172, MM_H = 94 + +function Minimap({ pan, zoom, nodes, greEdges, satPos, onClose, onPan }: { + pan: { x: number; y: number }; zoom: number + nodes: (Server & { x: number; y: number })[] + greEdges: { tunnel: typeof greTunnels[0]; from: typeof nodes[0]; to: typeof nodes[0] }[] + satPos: Record + onClose: () => void + onPan: (x: number, y: number) => void +}) { + const vpW = Math.min(W, W / zoom) + const vpH = Math.min(H, H / zoom) + + function handleClick(e: React.MouseEvent) { + e.stopPropagation() + const rect = e.currentTarget.getBoundingClientRect() + const svgX = (e.clientX - rect.left) / rect.width * W + const svgY = (e.clientY - rect.top) / rect.height * H + onPan(svgX - vpW / 2, svgY - vpH / 2) + } + + return ( +
+ + + {/* simplified edges */} + {greEdges.map(e => ( + + ))} + {/* WAN edges */} + {WAN_JH_EDGES.map((edge, i) => { + const sat = satPos[edge.homeId]?.[edge.wanIdx] + const jh = nodes.find(n => n.id === edge.jhId) + if (!sat || !jh) return null + return + })} + {/* nodes */} + {nodes.map(n => ( + + ))} + {/* WAN satellites */} + {servers.filter(s => s.type === "home-router").flatMap(r => + (satPos[r.id] ?? []).map((p, i) => ( + + )) + )} + {/* viewport rect */} + + + + minimap +
+ ) +} + +// ─── Zoom controls overlay ──────────────────────────────────────────────────── + +function ZoomControls({ zoom, onZoomIn, onZoomOut, onFit }: { + zoom: number + onZoomIn: () => void + onZoomOut: () => void + onFit: () => void +}) { + return ( +
+ + + {Math.round(zoom * 100)}% + + +
+ +
+ ) +} + +// ════════════════════════════════════════════════════════════════════════════ + +export default function NetworkMapPage() { + + // ── View state ────────────────────────────────────────────────────────────── + const [pan, setPan] = useState({ x: 0, y: 0 }) + const [zoom, setZoom] = useState(1) + const [isDragging, setIsDragging] = useState(false) + + // ── Interaction ───────────────────────────────────────────────────────────── + const [selected, setSelected] = useState(null) + const [selWanIdx, setSelWanIdx] = useState(null) + const [hoveredId, setHoveredId] = useState(null) + + // ── Node positions (overrides POS defaults) ───────────────────────────────── + const [nodePositions, setNodePositions] = useState>({}) + const [satPositions, setSatPositions] = useState>({}) + + // ── Context menu ──────────────────────────────────────────────────────────── + const [ctxMenu, setCtxMenu] = useState(null) + + // ── Toggles ───────────────────────────────────────────────────────────────── + const [filter, setFilter] = useState("all") + const [search, setSearch] = useState("") + const [showPingBadges, setShowPingBadges] = useState(true) + const [showAnimDots, setShowAnimDots] = useState(true) + const [showMinimap, setShowMinimap] = useState(true) + const [showHints, setShowHints] = useState(false) + const [showLayers, setShowLayers] = useState(false) + + // ── Refs ───────────────────────────────────────────────────────────────────── + const svgRef = useRef(null) + + // Canvas pan drag + const dragRef = useRef<{ + startX: number; startY: number + panX: number; panY: number + moved: boolean + } | null>(null) + + // Node / WAN-sat drag + type NodeDrag = + | { kind: "server"; nodeId: string; startX: number; startY: number; origX: number; origY: number; moved: boolean } + | { kind: "wan-sat"; homeId: string; wanIdx: number; startX: number; startY: number; origX: number; origY: number; moved: boolean } + const nodeDragRef = useRef(null) + const suppressClickRef = useRef(false) + const [draggedNodeId, setDraggedNodeId] = useState(null) + const [draggedSatKey, setDraggedSatKey] = useState(null) // `${homeId}-${wanIdx}` + + const zoomRef = useRef(zoom) + const panRef = useRef(pan) + zoomRef.current = zoom + panRef.current = pan + + // ── SVG export ─────────────────────────────────────────────────────────── + function exportSvg() { + const svg = svgRef.current + if (!svg) return + // Clone with full viewBox (reset pan/zoom) + const clone = svg.cloneNode(true) as SVGSVGElement + clone.setAttribute("viewBox", `0 0 ${W} ${H}`) + clone.setAttribute("width", String(W)) + clone.setAttribute("height", String(H)) + const blob = new Blob( + [`\n`, clone.outerHTML], + { type: "image/svg+xml;charset=utf-8" }, + ) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = `network-map-${new Date().toISOString().slice(0, 10)}.svg` + a.click() + URL.revokeObjectURL(url) + } + + // ── Non-passive wheel zoom ──────────────────────────────────────────────── + useEffect(() => { + const svg = svgRef.current + if (!svg) return + const handler = (e: WheelEvent) => { + e.preventDefault() + const z = zoomRef.current + const p = panRef.current + const rect = svg.getBoundingClientRect() + const svgX = p.x + (e.clientX - rect.left) / rect.width * (W / z) + const svgY = p.y + (e.clientY - rect.top) / rect.height * (H / z) + // trackpad pinch sends ctrlKey, use smaller step + const step = e.ctrlKey ? 1.06 : 1.14 + const factor = e.deltaY < 0 ? step : 1 / step + const newZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z * factor)) + setPan({ + x: svgX - (e.clientX - rect.left) / rect.width * (W / newZoom), + y: svgY - (e.clientY - rect.top) / rect.height * (H / newZoom), + }) + setZoom(newZoom) + } + svg.addEventListener("wheel", handler, { passive: false }) + return () => svg.removeEventListener("wheel", handler) + }, []) + + // ── Keyboard shortcuts ──────────────────────────────────────────────────── + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return + if (e.key === "Escape") { setSelected(null); setSelWanIdx(null) } + if (e.key === "=" || e.key === "+") applyZoomCenter(1.25) + if (e.key === "-") applyZoomCenter(1 / 1.25) + if (e.key === "0" || e.key.toLowerCase() === "f") fitView() + if (e.key.toLowerCase() === "m") setShowMinimap(v => !v) + if (e.key.toLowerCase() === "p") setShowPingBadges(v => !v) + } + window.addEventListener("keydown", onKey) + return () => window.removeEventListener("keydown", onKey) + }, [zoom, pan]) // re-register when zoom/pan change so closure has fresh values + + // ── Zoom helpers ────────────────────────────────────────────────────────── + function applyZoomCenter(factor: number) { + const newZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * factor)) + setPan(p => ({ + x: p.x + (W / zoom - W / newZoom) / 2, + y: p.y + (H / zoom - H / newZoom) / 2, + })) + setZoom(newZoom) + } + + function fitView() { + setPan({ x: 0, y: 0 }) + setZoom(1) + } + + function focusNode(x: number, y: number) { + const newZoom = Math.min(ZOOM_MAX, Math.max(zoom * 1.8, 2)) + setPan({ x: x - W / (2 * newZoom), y: y - H / (2 * newZoom) }) + setZoom(newZoom) + } + + // ── Drag ───────────────────────────────────────────────────────────────── + function onSvgMouseDown(e: React.MouseEvent) { + // Only pan on background clicks (node mousedown stops propagation) + dragRef.current = { startX: e.clientX, startY: e.clientY, panX: pan.x, panY: pan.y, moved: false } + setIsDragging(true) + } + + function onSvgMouseMove(e: React.MouseEvent) { + const nd = nodeDragRef.current + if (nd) { + const dx = e.clientX - nd.startX + const dy = e.clientY - nd.startY + if (Math.abs(dx) > 2 || Math.abs(dy) > 2) nd.moved = true + const rect = e.currentTarget.getBoundingClientRect() + const svgDx = dx * (W / zoom) / rect.width + const svgDy = dy * (H / zoom) / rect.height + const nx = nd.origX + svgDx + const ny = nd.origY + svgDy + + if (nd.kind === "server") { + setNodePositions(prev => ({ ...prev, [nd.nodeId]: { x: nx, y: ny } })) + } else { + setSatPositions(prev => { + const arr = [...(prev[nd.homeId] ?? (WAN_SAT_POS[nd.homeId] ?? []))] + arr[nd.wanIdx] = { x: nx, y: ny } + return { ...prev, [nd.homeId]: arr } + }) + } + return + } + + if (!dragRef.current) return + const dx = e.clientX - dragRef.current.startX + const dy = e.clientY - dragRef.current.startY + if (Math.abs(dx) > 4 || Math.abs(dy) > 4) dragRef.current.moved = true + const rect = e.currentTarget.getBoundingClientRect() + setPan({ + x: dragRef.current.panX - dx * (W / zoom / rect.width), + y: dragRef.current.panY - dy * (H / zoom / rect.height), + }) + } + + function finishNodeDrag() { + if (!nodeDragRef.current) return false + suppressClickRef.current = nodeDragRef.current.moved + nodeDragRef.current = null + setDraggedNodeId(null) + setDraggedSatKey(null) + return true + } + + function onSvgMouseUp() { + if (finishNodeDrag()) return + const moved = dragRef.current?.moved ?? false + dragRef.current = null + setIsDragging(false) + if (!moved) { setSelected(null); setSelWanIdx(null) } + } + + // ── Node drag start ────────────────────────────────────────────────────── + function onNodeMouseDown(e: React.MouseEvent, n: Server & { x: number; y: number }) { + nodeDragRef.current = { kind: "server", nodeId: n.id, startX: e.clientX, startY: e.clientY, origX: n.x, origY: n.y, moved: false } + setDraggedNodeId(n.id) + } + function onSatMouseDown(e: React.MouseEvent, homeId: string, wanIdx: number, x: number, y: number) { + nodeDragRef.current = { kind: "wan-sat", homeId, wanIdx, startX: e.clientX, startY: e.clientY, origX: x, origY: y, moved: false } + setDraggedSatKey(`${homeId}-${wanIdx}`) + } + + // ── Data ───────────────────────────────────────────────────────────────── + const nodes = servers.map(s => { + const pos = nodePositions[s.id] ?? POS[s.id] ?? { x: 0, y: 0 } + return { ...s, ...pos } + }) + const nodeById = Object.fromEntries(nodes.map(n => [n.id, n])) + + // Satellite positions merging defaults with overrides + const effectiveSatPos: Record = {} + servers.filter(s => s.type === "home-router").forEach(r => { + effectiveSatPos[r.id] = (WAN_SAT_POS[r.id] ?? []).map((def, i) => + satPositions[r.id]?.[i] ?? def + ) + }) + + const greEdges = greTunnels.flatMap(t => { + const from = nodes.find(n => n.id === t.serverId) + const to = nodes.find(n => n.host === t.remoteAddress) + if (!from || !to || from.type === "home-router" || to.type === "home-router") return [] + return [{ tunnel: t, from, to }] + }) + + // ── Visibility / search ────────────────────────────────────────────────── + const sq = search.toLowerCase().trim() + const matchesSearch = (n: typeof nodes[0]) => + !sq || + n.name.toLowerCase().includes(sq) || + n.site.toLowerCase().includes(sq) || + n.model?.toLowerCase().includes(sq) || + n.os?.toLowerCase().includes(sq) + + const isVisible = (n: typeof nodes[0]) => { + if (!matchesSearch(n)) return false + if (filter === "all") return true + if (filter === "online" || filter === "degraded" || filter === "offline") return n.status === filter + return n.type === filter + } + + // ── Side panel ──────────────────────────────────────────────────────────── + function selectServer(s: Server) { + setSelected(prev => prev?.id === s.id ? null : s) + setSelWanIdx(null) + setHoveredId(null) + } + function selectWan(s: Server, wanIdx: number) { + setSelected(s) + setSelWanIdx(prev => prev === wanIdx && selected?.id === s.id ? null : wanIdx) + } + + const connectedTunnels = selected && selected.type !== "home-router" + ? greTunnels.filter(t => + t.serverId === selected.id || + nodes.find(n => n.id === selected.id)?.host === t.remoteAddress + ) + : [] + + const hoveredNode = hoveredId ? nodes.find(n => n.id === hoveredId) : null + + const filterBtns: { value: FilterKey; label: string }[] = [ + { value: "all", label: "Все" }, + { value: "online", label: "Онлайн" }, + { value: "degraded", label: "Внимание" }, + { value: "offline", label: "Оффлайн" }, + { value: "jump-host", label: "JumpHost" }, + { value: "exit-node", label: "Exit Node" }, + { value: "home-router", label: "Home Router"}, + ] + + // ═════════════════════════════════════════════════════════════════════════ + + return ( +
+ + Экспорт SVG + + } + /> + + {/* ── Toolbar ── */} +
+ + {/* Filter pills */} +
+ {filterBtns.map(b => ( + + ))} +
+ + {/* Search */} +
+ + setSearch(e.target.value)} + placeholder="Поиск узла…" + className="h-7 pl-6 pr-3 text-xs rounded-md border border-border bg-muted/40 + placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-ring w-44" + /> + {search && ( + + )} +
+ + {/* Layers dropdown */} +
+ + {showLayers && ( +
setShowLayers(false)}> + {([ + { key: "showPingBadges", label: "Ping-значки", val: showPingBadges, set: setShowPingBadges, hint: "P" }, + { key: "showAnimDots", label: "Анимация трафика", val: showAnimDots, set: setShowAnimDots, hint: "" }, + { key: "showMinimap", label: "Минимап", val: showMinimap, set: setShowMinimap, hint: "M" }, + { key: "showHints", label: "Горячие клавиши", val: showHints, set: setShowHints, hint: "" }, + ] as const).map(item => ( + + ))} + {(Object.keys(nodePositions).length > 0 || Object.keys(satPositions).length > 0) && ( +
+ +
+ )} +
+ )} +
+ + {/* Stats */} +
+ + {servers.filter(s => s.status === "online").length} онлайн + + + {servers.filter(s => s.status === "degraded").length} с проблемами + + + {servers.filter(s => s.status === "offline").length} оффлайн + + + {greTunnels.length} GRE + +
+
+ + {/* ── Main area ── */} +
+ + {/* ── Canvas ── */} +
+ { e.preventDefault(); onSvgMouseDown(e) }} + onMouseMove={onSvgMouseMove} + onMouseUp={onSvgMouseUp} + onMouseLeave={() => { + finishNodeDrag() + dragRef.current = null; setIsDragging(false); setHoveredId(null) + }} + > + + + + + {WAN_COLORS.map((_, i) => ( + + + + + ))} + + + {/* Background grid */} + + + {/* ── GRE edges ── */} + {greEdges.map((e, i) => { + const ts = TUNNEL_STYLE[e.tunnel.status] + const dimmed = filter !== "all" && !isVisible(e.from) && !isVisible(e.to) + const probe = TUNNEL_PROBES[e.tunnel.id] + const pc = pingColor(probe?.pingMs ?? null) + const mx = (e.from.x + e.to.x) / 2 + const my = (e.from.y + e.to.y) / 2 + return ( + + + {showAnimDots && ts.dotOpacity > 0 && ( + + + + )} + {showPingBadges && probe && ( + + )} + + ) + })} + + {/* ── Home Router → WAN satellite connectors ── */} + {servers.filter(s => s.type === "home-router").flatMap(router => { + const rNode = nodes.find(n => n.id === router.id) + if (!rNode) return [] + return (router.wanUplinks ?? []).map((wan, wIdx) => { + const sat = effectiveSatPos[router.id]?.[wIdx] + const color = WAN_COLORS[wIdx] ?? "#888" + const vis = filter === "all" || filter === "home-router" || filter === "online" + if (!sat) return null + return ( + + + + ) + }).filter(Boolean) + })} + + {/* ── WAN→JH edges ── */} + {WAN_JH_EDGES.map((edge, i) => { + const jh = nodeById[edge.jhId] + const satPos = effectiveSatPos[edge.homeId]?.[edge.wanIdx] + if (!jh || !satPos) return null + const color = WAN_COLORS[edge.wanIdx] ?? "#888" + const vis = filter === "all" || filter === "home-router" || filter === "jump-host" || filter === "online" + const mx = (satPos.x + jh.x) / 2 + const my = (satPos.y + jh.y) / 2 + const isHL = selected?.id === edge.homeId && (selWanIdx === null || selWanIdx === edge.wanIdx) + return ( + + + {showAnimDots && edge.active && ( + + + + )} + {showPingBadges && ( + + )} + + ) + })} + + {/* ── Server nodes ── */} + {nodes.map(n => ( + { + if (suppressClickRef.current) { suppressClickRef.current = false; return } + selectServer(n) + }} + onDblClick={() => { selectServer(n); focusNode(n.x, n.y) }} + onHoverChange={h => setHoveredId(h ? n.id : null)} + onContextMenu={e => setCtxMenu({ x: e.clientX, y: e.clientY, server: n })} + onMouseDown={e => onNodeMouseDown(e, n)} + /> + ))} + + {/* ── WAN satellite nodes ── */} + {servers.filter(s => s.type === "home-router").flatMap(router => { + const vis = filter === "all" || filter === "home-router" || filter === "online" + return (router.wanUplinks ?? []).map((wan, wIdx) => { + const sat = effectiveSatPos[router.id]?.[wIdx] + if (!sat) return null + const color = WAN_COLORS[wIdx] ?? "#888" + const isActive = WAN_JH_EDGES.filter(e => e.homeId === router.id && e.wanIdx === wIdx).some(e => e.active) + const isSatSel = selected?.id === router.id && selWanIdx === wIdx + const satKey = `${router.id}-${wIdx}` + return ( + + { + if (suppressClickRef.current) { suppressClickRef.current = false; return } + selectWan(router, wIdx) + }} + onMouseDown={e => onSatMouseDown(e, router.id, wIdx, sat.x, sat.y)} + /> + + ) + }).filter(Boolean) + })} + + {/* ── Hover tooltip ── */} + {hoveredNode && !isDragging && ( + + )} + + {/* ── Legend (viewport-fixed) ── */} + + + ЛЕГЕНДА + + {(["online","degraded","offline"] as const).map((s, i) => ( + + + + {s === "online" ? "Онлайн" : s === "degraded" ? "Внимание" : "Оффлайн"} + + + ))} + + + + {[ + { fill: "#7c3aed", label: "JH", text: "JumpHost" }, + { fill: "#0369a1", label: "EN", text: "Exit Node" }, + { fill: "#16a34a", label: "HR", text: "Home Router" }, + ].map((t, i) => ( + + + {t.label} + {t.text} + + ))} + + + + WAN АПЛИНКИ + {WAN_COLORS.slice(0, 2).map((c, i) => ( + + + + WAN{i + 1} + + + + {i === 0 ? "primary" : "backup"} + + + ))} + + + {/* ── Keyboard hints (viewport-fixed) ── */} + {showHints && ( + + {[ + ["Scroll", "zoom"], + ["+/-", "zoom in/out"], + ["F / 0", "вписать"], + ["M", "minimap"], + ["P", "ping"], + ["Esc", "снять выбор"], + ["2× клик", "навигация"], + ].reverse().map(([k, l], i) => ( + + + + {k} {l} + + + ))} + + )} + + + {/* ── Zoom controls ── */} + applyZoomCenter(1.3)} + onZoomOut={() => applyZoomCenter(1 / 1.3)} + onFit={fitView} + /> + + {/* ── Minimap ── */} + {showMinimap && ( + setShowMinimap(false)} + onPan={(x, y) => setPan({ x, y })} + /> + )} + + {/* ── "Show minimap" button when hidden ── */} + {!showMinimap && ( + + )} + + {/* ── Search result count ── */} + {sq && ( +
+ {nodes.filter(isVisible).length === 0 + ? "Ничего не найдено" + : `Найдено: ${nodes.filter(isVisible).length} узл${nodes.filter(isVisible).length === 1 ? "" : nodes.filter(isVisible).length < 5 ? "а" : "ов"}`} +
+ )} +
+ + {/* ── Side panel ── */} + {selected && ( +
+
+
+

{selected.name}

+

{selected.host}

+
+
+ + +
+
+ +
+ {/* Badges */} +
+ + + {TYPE_STYLE[selected.type].label} · {TYPE_LABELS[selected.type]} + +
+ + {/* Info rows */} +
+ {[ + ["Площадка", selected.site], + ["Модель", selected.model], + ["RouterOS", selected.os], + ...(selected.type !== "home-router" + ? [["ASN", selected.asn], ["Сессий", String(selected.sessions)]] + : [["LAN", selected.lanSubnet ?? "—"]]), + ].map(([k, v]) => ( +
+ {k} + {v} +
+ ))} +
+ Задержка + + {selected.latency == null ? "— офлайн" : `${selected.latency} мс`} + +
+
+ + {/* WAN uplinks */} + {selected.type === "home-router" && selected.wanUplinks && ( +
+

+ WAN-аплинки ({selected.wanUplinks.length}) +

+
+ {selected.wanUplinks.map((wan, wIdx) => { + const color = WAN_COLORS[wIdx] ?? "#888" + const myEdges = WAN_JH_EDGES.filter(e => e.homeId === selected.id && e.wanIdx === wIdx) + const isActive = myEdges.some(e => e.active) + const isSel = selWanIdx === wIdx + return ( +
setSelWanIdx(prev => prev === wIdx ? null : wIdx)} + className={cn( + "rounded-lg border p-3 cursor-pointer transition-all", + isSel ? "bg-muted/60" : "bg-muted/20 hover:bg-muted/40", + )} + style={{ borderColor: isSel ? color : undefined }}> +
+ + {wan.name} + + {isActive ? "primary" : "backup"} + +
+
+ {[["ISP", wan.isp], ["Iface", wan.iface], ["IP", wan.ip], + ["BW", `↓${wan.maxDl} ↑${wan.maxUl} Мбит`]].map(([k, v]) => ( +
+ {k} + {v} +
+ ))} +
+ {myEdges.length > 0 && ( +
+

+ Подключения к JH +

+ {myEdges.map((e, ei) => { + const jh = servers.find(s => s.id === e.jhId) + return ( +
+ + → {jh?.name ?? e.jhId} + + + {e.pingMs} мс + +
+ ) + })} +
+ )} +
+ ) + })} +
+
+ )} + + {/* GRE tunnels */} + {connectedTunnels.length > 0 && ( +
+

+ GRE-туннели ({connectedTunnels.length}) +

+
+ {connectedTunnels.map(t => { + const ts = TUNNEL_STYLE[t.status] + const peer = servers.find(s => s.host === t.remoteAddress && s.id !== selected.id) + || servers.find(s => s.id === t.serverId && s.id !== selected.id) + const probe = TUNNEL_PROBES[t.id] + const pc = pingColor(probe?.pingMs ?? null) + return ( +
+
+ {t.name} + + {t.status === "up" ? "Up" : t.status === "degraded" ? "Degraded" : "Down"} + +
+
+ + → {peer?.site ?? t.remoteAddress} + + {t.ipsec + ? IPsec + : Plain} +
+ {probe?.pingMs != null && ( +
+ ping / speed + + {probe.pingMs} мс + {probe.dlMbps != null && ( + + ↓{probe.dlMbps} ↑{probe.ulMbps} + + )} + +
+ )} +
+ ) + })} +
+
+ )} + + {/* Resources */} + {(() => { + const res = SERVER_RESOURCES[selected.id] + if (!res) return null + const cpuPct = res.cpu + const ramPct = Math.round(res.ramUsed / res.ramTotal * 100) + const hddPct = Math.round(res.hddUsed / res.hddTotal * 100) + return ( +
+

+ Ресурсы +

+ + {/* CPU with sparkline */} +
+
+ CPU + + {cpuPct}% + +
+
+ + +
+
+ + {/* RAM */} +
+
+ RAM + + {fmtMB(res.ramUsed)} / {fmtMB(res.ramTotal)} + +
+ +
+ + {/* HDD */} +
+
+ HDD + + {fmtMB(res.hddUsed)} / {fmtMB(res.hddTotal)} + +
+ +
+ + {/* Misc metrics */} +
+
+ Uptime + {fmtUptime(res.uptimeSeconds)} +
+ {res.temp != null && ( +
+ Температура + = 70 ? "text-red-400" : res.temp >= 55 ? "text-amber-400" : "text-emerald-400", + )}> + {res.temp}°C + +
+ )} +
+ Плата + {BOARD_MAP[selected.type]} +
+
+
+ ) + })()} + + {/* Navigate hint */} + +
+
+ )} +
+ + {/* ── Context menu ── */} + {ctxMenu && setCtxMenu(null)} />} +
+ ) +} diff --git a/app/(main)/ospf/page.tsx b/app/(main)/ospf/page.tsx new file mode 100644 index 0000000..3d44e8f --- /dev/null +++ b/app/(main)/ospf/page.tsx @@ -0,0 +1,1395 @@ +"use client" + +import { useMemo, useState, useEffect } from "react" +import { PageHeader } from "@/components/page-header" +import { Card, CardContent } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Separator } from "@/components/ui/separator" +import { + RefreshCwIcon, WandSparklesIcon, SaveIcon, GripVerticalIcon, + CheckIcon, NetworkIcon, RouteIcon, ShieldIcon, ActivityIcon, XIcon, +} from "lucide-react" +import { cn } from "@/lib/utils" +import { useDataSource } from "@/lib/data-source" +import { Flag } from "@/components/flag" + +// ─── types ──────────────────────────────────────────────────────────────────── + +type OspfTab = "interfaces" | "neighbors" | "routes" | "bfd" + +interface OspfServerInfo { + id: string // String(serverId) in live mode, routerKey in mock + label: string // serverName / routerLabel + site: string // "MSK", "SPB", … + country: string // ISO alpha-2 or "" +} + +interface OspfItem { + key: string + routerKey: string + routerLabel: string + serverId: string + interfaceName: string + area: string + cost: number + active: boolean + type?: string + useBfd?: boolean + helloInterval?: number + deadInterval?: number +} + +interface OspfNeighbor { + id: string + localRouter: string + localLabel: string + localIface: string + remoteRouter: string + remoteLabel: string + remoteRouterId: string + area: string + state: "Full" | "2-Way" | "ExStart" | "Down" + cost: number + uptime: string + priority: number +} + +interface OspfRoute { + id: string + destination: string + type: "O" | "O IA" | "O E1" | "O E2" + cost: number + nextHop: string + via: string + serverId: string + serverLabel: string + area: string +} + +interface BfdSession { + id: string + serverId: string + serverLabel: string + localAddr: string + remoteAddr: string + state: "Up" | "Down" | "Init" | "AdminDown" + interval: number // txInterval ms + multiplier: number + iface: string + uptime: string | null + multihop: boolean + rxInterval: number // ms + holdTime: number // ms + packetsRx: number + packetsTx: number + stateChanges: number +} + +// ─── backend types ──────────────────────────────────────────────────────────── + +interface BackendNeighbor { + id: string; serverId: number; serverName: string; serverSite: string; serverCountry: string + address: string; routerId: string; instance: string; area: string; areaId: string + interface: string; state: string; uptime: string | null; stateChanges: number; priority: number +} + +interface BackendInterface { + id: string; serverId: number; serverName: string; serverSite: string; serverCountry: string + instance: string; area: string; areaId: string; interface: string; cost: number + type: string; disabled: boolean; inactive: boolean; priority: number + helloInterval: number; deadInterval: number; useBfd: boolean +} + +interface BackendInstance { + id: string; serverId: number; serverName: string; serverSite: string; serverCountry: string + name: string; routerId: string; version: number; disabled: boolean; inactive: boolean; redistribute: string +} + +interface BackendBfdSession { + id: string; serverId: number; serverName: string; serverSite: string; serverCountry: string + localAddr: string; remoteAddr: string; interface: string; state: string; uptime: string | null + multihop: boolean; multiplier: number; txInterval: number; rxInterval: number; holdTime: number + packetsRx: number; packetsTx: number; stateChanges: number +} + +interface BackendOspfAll { + neighbors: BackendNeighbor[] + interfaces: BackendInterface[] + instances: BackendInstance[] + bfdSessions: BackendBfdSession[] +} + +// ─── backend → frontend mappers ─────────────────────────────────────────────── + +function backendToNeighbor(b: BackendNeighbor, ifaceMap: Map): OspfNeighbor { + const validStates = ["Full", "2-Way", "ExStart", "Down"] + const state = validStates.includes(b.state) ? b.state as OspfNeighbor["state"] : "Down" + const costKey = `${b.serverId}::${b.interface}` + return { + id: `${b.serverId}-${b.id}`, + localRouter: String(b.serverId), + localLabel: b.serverName, + localIface: b.interface, + remoteRouter: b.routerId, + remoteLabel: b.routerId, // no server name for remote peers + remoteRouterId: b.routerId, + area: b.areaId !== b.area ? `${b.area} (${b.areaId})` : b.area, + state, + cost: ifaceMap.get(costKey) ?? 10, + uptime: b.uptime ?? "—", + priority: b.priority, + } +} + +function backendToItem(b: BackendInterface): OspfItem { + return { + key: `${b.serverId}-${b.id}`, + routerKey: String(b.serverId), + routerLabel: b.serverName, + serverId: String(b.serverId), + interfaceName: b.interface, + area: b.areaId, + cost: b.cost, + active: !b.disabled && !b.inactive, + type: b.type, + useBfd: b.useBfd, + helloInterval: b.helloInterval, + deadInterval: b.deadInterval, + } +} + +function backendToBfdSession(b: BackendBfdSession): BfdSession { + const validStates = ["Up", "Down", "Init", "AdminDown"] + const state = validStates.includes(b.state) ? b.state as BfdSession["state"] : "Down" + return { + id: `${b.serverId}-${b.id}`, + serverId: String(b.serverId), + serverLabel: b.serverName, + localAddr: b.localAddr, + remoteAddr: b.remoteAddr, + state, + interval: b.txInterval, + multiplier: b.multiplier, + iface: b.interface, + uptime: b.uptime, + multihop: b.multihop, + rxInterval: b.rxInterval, + holdTime: b.holdTime, + packetsRx: b.packetsRx, + packetsTx: b.packetsTx, + stateChanges: b.stateChanges, + } +} + +// ─── mock data ──────────────────────────────────────────────────────────────── + +const COST_STEP = 10 + +const OPTIMIZER_PROB: Record = { + "srv1::GRE-MSK-SPB": 68, + "srv1::GRE-MSK-FRA": 18, + "srv1::GRE-MSK-AMS": 10, + "srv1::GRE-MSK-SGP": 4, + "srv1::ETHER1-WAN": 100, + "srv1::ETHER2-LAN": 100, + "srv7::GRE-LAB-SPB": 41, + "srv7::GRE-LAB-CORE": 59, + "srv7::ETHER1-UPLINK":100, + "srv6::GRE-AMS-CORE": 68, + "srv6::GRE-AMS-FRA": 32, +} + +const MOCK_ITEMS: OspfItem[] = [ + { key: "srv1-bb-gre-msk-spb", routerKey: "srv1", routerLabel: "mt-msk-core-01", serverId: "srv1", interfaceName: "gre-msk-spb", area: "0.0.0.0", cost: 10, active: true }, + { key: "srv1-bb-gre-msk-fra", routerKey: "srv1", routerLabel: "mt-msk-core-01", serverId: "srv1", interfaceName: "gre-msk-fra", area: "0.0.0.0", cost: 20, active: true }, + { key: "srv1-bb-gre-msk-ams", routerKey: "srv1", routerLabel: "mt-msk-core-01", serverId: "srv1", interfaceName: "gre-msk-ams", area: "0.0.0.0", cost: 30, active: true }, + { key: "srv1-bb-gre-msk-sgp", routerKey: "srv1", routerLabel: "mt-msk-core-01", serverId: "srv1", interfaceName: "gre-msk-sgp", area: "0.0.0.0", cost: 40, active: false }, + { key: "srv1-a1-ether1", routerKey: "srv1", routerLabel: "mt-msk-core-01", serverId: "srv1", interfaceName: "ether1-wan", area: "0.0.0.1", cost: 10, active: true }, + { key: "srv1-a1-ether2", routerKey: "srv1", routerLabel: "mt-msk-core-01", serverId: "srv1", interfaceName: "ether2-lan", area: "0.0.0.1", cost: 20, active: true }, + { key: "srv7-bb-gre-lab-core", routerKey: "srv7", routerLabel: "mt-msk-lab-01", serverId: "srv7", interfaceName: "gre-lab-core", area: "0.0.0.0", cost: 10, active: true }, + { key: "srv7-bb-gre-lab-spb", routerKey: "srv7", routerLabel: "mt-msk-lab-01", serverId: "srv7", interfaceName: "gre-lab-spb", area: "0.0.0.0", cost: 20, active: true }, + { key: "srv7-bb-ether1", routerKey: "srv7", routerLabel: "mt-msk-lab-01", serverId: "srv7", interfaceName: "ether1-uplink", area: "0.0.0.0", cost: 30, active: true }, + { key: "srv6-bb-gre-ams-core", routerKey: "srv6", routerLabel: "mt-ams-test-01", serverId: "srv6", interfaceName: "gre-ams-core", area: "0.0.0.0", cost: 10, active: true }, + { key: "srv6-bb-gre-ams-fra", routerKey: "srv6", routerLabel: "mt-ams-test-01", serverId: "srv6", interfaceName: "gre-ams-fra", area: "0.0.0.0", cost: 20, active: true }, +] + +const MOCK_NEIGHBORS: OspfNeighbor[] = [ + { id: "n1", localRouter: "srv1", localLabel: "mt-msk-core-01", localIface: "gre-msk-spb", remoteRouter: "srv2", remoteLabel: "mt-spb-edge-01", remoteRouterId: "10.0.0.2", area: "0.0.0.0", state: "Full", cost: 10, uptime: "14d 6h 22m", priority: 1 }, + { id: "n2", localRouter: "srv1", localLabel: "mt-msk-core-01", localIface: "gre-msk-fra", remoteRouter: "srv3", remoteLabel: "mt-fra-edge-01", remoteRouterId: "10.0.0.3", area: "0.0.0.0", state: "Full", cost: 20, uptime: "12d 3h 11m", priority: 1 }, + { id: "n3", localRouter: "srv1", localLabel: "mt-msk-core-01", localIface: "gre-msk-ams", remoteRouter: "srv4", remoteLabel: "mt-ams-edge-01", remoteRouterId: "10.0.0.4", area: "0.0.0.0", state: "2-Way", cost: 30, uptime: "2d 1h 4m", priority: 0 }, + { id: "n4", localRouter: "srv1", localLabel: "mt-msk-core-01", localIface: "gre-lab-core", remoteRouter: "srv7", remoteLabel: "mt-msk-lab-01", remoteRouterId: "10.0.0.7", area: "0.0.0.0", state: "Full", cost: 10, uptime: "8d 14h 5m", priority: 1 }, + { id: "n5", localRouter: "srv7", localLabel: "mt-msk-lab-01", localIface: "gre-lab-spb", remoteRouter: "srv2", remoteLabel: "mt-spb-edge-01", remoteRouterId: "10.0.0.2", area: "0.0.0.0", state: "Full", cost: 20, uptime: "8d 14h 4m", priority: 1 }, + { id: "n6", localRouter: "srv6", localLabel: "mt-ams-test-01", localIface: "gre-ams-core", remoteRouter: "srv1", remoteLabel: "mt-msk-core-01", remoteRouterId: "10.0.0.1", area: "0.0.0.0", state: "Full", cost: 10, uptime: "5d 9h 17m", priority: 1 }, + { id: "n7", localRouter: "srv6", localLabel: "mt-ams-test-01", localIface: "gre-ams-fra", remoteRouter: "srv3", remoteLabel: "mt-fra-edge-01", remoteRouterId: "10.0.0.3", area: "0.0.0.0", state: "Full", cost: 20, uptime: "5d 9h 12m", priority: 1 }, +] + +const MOCK_ROUTES: OspfRoute[] = [ + { id: "r1", destination: "10.0.0.0/8", type: "O", cost: 10, nextHop: "10.200.0.1", via: "gre-msk-spb", serverId: "srv1", serverLabel: "mt-msk-core-01", area: "0.0.0.0" }, + { id: "r2", destination: "172.16.0.0/12", type: "O", cost: 20, nextHop: "10.200.1.1", via: "gre-msk-fra", serverId: "srv1", serverLabel: "mt-msk-core-01", area: "0.0.0.0" }, + { id: "r3", destination: "192.168.1.0/24", type: "O IA", cost: 30, nextHop: "10.200.2.1", via: "gre-msk-ams", serverId: "srv1", serverLabel: "mt-msk-core-01", area: "0.0.0.1" }, + { id: "r4", destination: "10.10.0.0/16", type: "O E2", cost: 20, nextHop: "10.200.0.1", via: "gre-msk-spb", serverId: "srv1", serverLabel: "mt-msk-core-01", area: "0.0.0.0" }, + { id: "r5", destination: "10.50.0.0/16", type: "O IA", cost: 20, nextHop: "10.100.0.1", via: "gre-lab-spb", serverId: "srv7", serverLabel: "mt-msk-lab-01", area: "0.0.0.0" }, + { id: "r6", destination: "192.168.100.0/24", type: "O", cost: 10, nextHop: "10.100.0.1", via: "gre-lab-core", serverId: "srv7", serverLabel: "mt-msk-lab-01", area: "0.0.0.0" }, + { id: "r7", destination: "192.168.200.0/24", type: "O", cost: 10, nextHop: "10.150.0.1", via: "gre-ams-core", serverId: "srv6", serverLabel: "mt-ams-test-01", area: "0.0.0.0" }, + { id: "r8", destination: "10.80.0.0/16", type: "O E1", cost: 30, nextHop: "10.150.1.1", via: "gre-ams-fra", serverId: "srv6", serverLabel: "mt-ams-test-01", area: "0.0.0.0" }, +] + +const MOCK_BFD: BfdSession[] = [ + { id: "b1", serverId: "srv1", serverLabel: "mt-msk-core-01", localAddr: "10.200.0.0", remoteAddr: "10.200.0.1", state: "Up", interval: 200, multiplier: 3, iface: "gre-msk-spb", uptime: "14d 6h 22m", multihop: false, rxInterval: 200, holdTime: 600, packetsRx: 14400, packetsTx: 14400, stateChanges: 1 }, + { id: "b2", serverId: "srv1", serverLabel: "mt-msk-core-01", localAddr: "10.200.1.0", remoteAddr: "10.200.1.1", state: "Up", interval: 200, multiplier: 3, iface: "gre-msk-fra", uptime: "12d 3h 11m", multihop: false, rxInterval: 200, holdTime: 600, packetsRx: 12520, packetsTx: 12519, stateChanges: 2 }, + { id: "b3", serverId: "srv1", serverLabel: "mt-msk-core-01", localAddr: "10.200.2.0", remoteAddr: "10.200.2.1", state: "Down", interval: 200, multiplier: 3, iface: "gre-msk-ams", uptime: null, multihop: false, rxInterval: 200, holdTime: 600, packetsRx: 0, packetsTx: 0, stateChanges: 5 }, + { id: "b4", serverId: "srv7", serverLabel: "mt-msk-lab-01", localAddr: "10.100.0.0", remoteAddr: "10.100.0.1", state: "Up", interval: 200, multiplier: 5, iface: "gre-lab-core", uptime: "8d 14h 5m", multihop: false, rxInterval: 200, holdTime: 1000, packetsRx: 9200, packetsTx: 9200, stateChanges: 1 }, + { id: "b5", serverId: "srv6", serverLabel: "mt-ams-test-01", localAddr: "10.150.0.0", remoteAddr: "10.150.0.1", state: "Up", interval: 200, multiplier: 3, iface: "gre-ams-core", uptime: "5d 9h 17m", multihop: false, rxInterval: 200, holdTime: 600, packetsRx: 5700, packetsTx: 5700, stateChanges: 1 }, + { id: "b6", serverId: "srv6", serverLabel: "mt-ams-test-01", localAddr: "10.150.1.0", remoteAddr: "10.150.1.1", state: "Init", interval: 200, multiplier: 3, iface: "gre-ams-fra", uptime: null, multihop: false, rxInterval: 200, holdTime: 600, packetsRx: 0, packetsTx: 3, stateChanges: 3 }, +] + +// ─── topology constants ──────────────────────────────────────────────────────── + +const TOPO_W = 900 +const TOPO_H = 360 +const NODE_R = 34 + +const NODE_THEME = { + Full: { fill: "#0f2d1f", stroke: "#4ade80", glow: "rgba(74,222,128,0.18)" }, + "2-Way":{ fill: "#2d1e06", stroke: "#fbbf24", glow: "rgba(251,191,36,0.14)" }, + Down: { fill: "#2d0f0f", stroke: "#f87171", glow: "transparent" }, + none: { fill: "#111827", stroke: "#374151", glow: "transparent" }, +} + +const EDGE_COLOR: Record = { + Full: "#4ade80", + "2-Way":"#fbbf24", + Down: "#f87171", +} + +interface GraphNode { id: string; label: string; short: string; site: string; x: number; y: number } +interface GraphEdge { from: string; to: string; state: OspfNeighbor["state"]; cost: number } + +// Mock topology (static layout for mock mode) +const MOCK_GRAPH_NODES: GraphNode[] = [ + { id: "srv1", label: "mt-msk-core-01", short: "CORE-01", site: "MSK", x: 450, y: 180 }, + { id: "srv2", label: "mt-spb-edge-01", short: "EDGE-01", site: "SPB", x: 730, y: 80 }, + { id: "srv3", label: "mt-fra-edge-01", short: "EDGE-01", site: "FRA", x: 730, y: 290 }, + { id: "srv4", label: "mt-ams-edge-01", short: "EDGE-01", site: "AMS", x: 600, y: 180 }, + { id: "srv7", label: "mt-msk-lab-01", short: "LAB-01", site: "MSK", x: 170, y: 80 }, + { id: "srv6", label: "mt-ams-test-01", short: "TEST-01", site: "AMS", x: 170, y: 290 }, +] + +const MOCK_GRAPH_EDGES: GraphEdge[] = [ + { from: "srv1", to: "srv2", state: "Full", cost: 10 }, + { from: "srv1", to: "srv3", state: "Full", cost: 20 }, + { from: "srv1", to: "srv4", state: "2-Way", cost: 30 }, + { from: "srv1", to: "srv7", state: "Full", cost: 10 }, + { from: "srv7", to: "srv2", state: "Full", cost: 20 }, + { from: "srv6", to: "srv1", state: "Full", cost: 10 }, + { from: "srv6", to: "srv3", state: "Full", cost: 20 }, +] + +const MOCK_ROUTER_IDS: Record = { + srv1: "10.0.0.1", srv2: "10.0.0.2", srv3: "10.0.0.3", + srv4: "10.0.0.4", srv6: "10.0.0.6", srv7: "10.0.0.7", +} + +/** Build graph nodes and edges from live data */ +function buildLiveGraph( + neighbors: OspfNeighbor[], +): { nodes: GraphNode[]; edges: GraphEdge[] } { + // Collect unique local routers + const localMap = new Map() + for (const n of neighbors) { + if (!localMap.has(n.localRouter)) localMap.set(n.localRouter, { label: n.localLabel }) + } + + // Collect unique remote peers (that are NOT already local) + const remoteMap = new Map() + for (const n of neighbors) { + const remId = `remote-${n.remoteRouterId}` + if (!localMap.has(n.remoteRouter) && !remoteMap.has(remId)) { + remoteMap.set(remId, { label: n.remoteRouterId }) + } + } + + const allIds = [...localMap.keys(), ...remoteMap.keys()] + const total = allIds.length + const cx = TOPO_W / 2, cy = TOPO_H / 2 + const radius = Math.min(TOPO_W, TOPO_H) * 0.38 + + const nodes: GraphNode[] = allIds.map((id, i) => { + const angle = (2 * Math.PI * i / total) - Math.PI / 2 + const x = cx + radius * Math.cos(angle) + const y = cy + radius * Math.sin(angle) + const label = localMap.get(id)?.label ?? remoteMap.get(id)?.label ?? id + const short = label.replace(/^mt-/, "").toUpperCase().slice(0, 8) + return { id, label, short, site: "—", x, y } + }) + + const edges: GraphEdge[] = neighbors.map(n => ({ + from: n.localRouter, + to: localMap.has(n.remoteRouter) ? n.remoteRouter : `remote-${n.remoteRouterId}`, + state: n.state, + cost: n.cost, + })) + + return { nodes, edges } +} + +// ─── helpers ────────────────────────────────────────────────────────────────── + +function moveInArray(arr: T[], from: number, to: number): T[] { + if (from < 0 || to < 0 || from >= arr.length || to >= arr.length) return arr + const copy = [...arr] + const [moved] = copy.splice(from, 1) + copy.splice(to, 0, moved) + return copy +} + +function stateClass(state: OspfNeighbor["state"] | BfdSession["state"]) { + if (state === "Full" || state === "Up") + return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/25" + if (state === "2-Way" || state === "Init") + return "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/25" + return "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/25" +} + +function routeTypeClass(type: OspfRoute["type"]) { + if (type === "O") return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/25" + if (type === "O IA") return "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/25" + if (type === "O E1") return "bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/25" + return "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/25" +} + +function Chip({ children, color }: { children: React.ReactNode; color?: string }) { + return ( + + {children} + + ) +} + +// ─── topology SVG ───────────────────────────────────────────────────────────── + +interface TopologyGraphProps { + graphNodes: GraphNode[] + graphEdges: GraphEdge[] + routerIds: Record + neighbors: OspfNeighbor[] + highlightId: string | null + selectedId: string | null + showDots: boolean + onNodeClick: (id: string | null) => void +} + +function TopologyGraph({ graphNodes, graphEdges, highlightId, selectedId, showDots, onNodeClick }: TopologyGraphProps) { + const nodeMap = Object.fromEntries(graphNodes.map(n => [n.id, n])) + + function nodeState(id: string): keyof typeof NODE_THEME { + const involved = graphEdges.filter(e => e.from === id || e.to === id) + if (!involved.length) return "none" + if (involved.some(e => e.state === "Full")) return "Full" + if (involved.some(e => e.state === "2-Way")) return "2-Way" + return "Down" + } + + return ( + onNodeClick(null)} + > + + + + + + + + + {graphEdges.map((e, i) => { + const a = nodeMap[e.from] + const b = nodeMap[e.to] + if (!a || !b) return null + const color = EDGE_COLOR[e.state] ?? "#64748b" + const isDashed = e.state !== "Full" + const active = highlightId === e.from || highlightId === e.to + || selectedId === e.from || selectedId === e.to + const opacity = active ? 1 : 0.38 + const strokeW = active ? 2.4 : 1.4 + const mx = (a.x + b.x) / 2 + const my = (a.y + b.y) / 2 + return ( + + + {showDots && e.state === "Full" && ( + + + + )} + + + {e.cost} + + + ) + })} + + {graphNodes.map(node => { + const state = nodeState(node.id) + const theme = NODE_THEME[state] + const isHov = highlightId === node.id + const isSel = selectedId === node.id + const dimmed = (selectedId !== null || highlightId !== null) + && !isSel && !isHov + && !graphEdges.some(e => + (e.from === selectedId && e.to === node.id) || + (e.to === selectedId && e.from === node.id) || + (e.from === highlightId && e.to === node.id) || + (e.to === highlightId && e.from === node.id)) + return ( + { e.stopPropagation(); onNodeClick(isSel ? null : node.id) }}> + {state === "Full" && !dimmed && ( + + + + + )} + {state === "2-Way" && !dimmed && } + {isSel && } + {isHov && !isSel && } + + {node.site !== "—" && ( + + + + {node.site.slice(0,3)} + + + )} + + {node.site !== "—" ? node.site : "?"} + + {node.short} + + {node.label.replace(/^mt-/, "")} + + + ) + })} + + {/* legend */} + + + ЛЕГЕНДА OSPF + {([ + { state: "Full", label: "Full adjacency" }, + { state: "2-Way", label: "2-Way partial" }, + { state: "Down", label: "Down" }, + ] as const).map(({ state, label }, i) => { + const c = EDGE_COLOR[state] ?? "#64748b" + return ( + + + + {label} + + ) + })} + + + клик по узлу — подробности · цифра = cost + + + + ) +} + +// ─── node detail panel ──────────────────────────────────────────────────────── + +function NodeDetailPanel({ + nodeId, graphNodes, routerIds, neighbors, items, onClose, +}: { + nodeId: string; graphNodes: GraphNode[]; routerIds: Record + neighbors: OspfNeighbor[]; items: OspfItem[]; onClose: () => void +}) { + const node = graphNodes.find(n => n.id === nodeId) + if (!node) return null + + const routerId = routerIds[nodeId] ?? "—" + const adjacencies = neighbors.filter(n => n.localRouter === nodeId || n.remoteRouter === nodeId) + .map(n => { + if (n.localRouter === nodeId) + return { iface: n.localIface, peerLabel: n.remoteLabel, peerId: n.remoteRouterId, state: n.state, cost: n.cost, uptime: n.uptime } + return { iface: n.localIface, peerLabel: n.localLabel, peerId: routerId, state: n.state, cost: n.cost, uptime: n.uptime } + }) + const ifaces = items.filter(i => i.routerKey === nodeId) + const fullCnt = adjacencies.filter(a => a.state === "Full").length + const partCnt = adjacencies.filter(a => a.state === "2-Way").length + + // derive state from edges + let panelState: keyof typeof NODE_THEME = "none" + if (adjacencies.some(a => a.state === "Full")) panelState = "Full" + else if (adjacencies.some(a => a.state === "2-Way")) panelState = "2-Way" + else if (adjacencies.length) panelState = "Down" + + const theme = NODE_THEME[panelState] + const siteFill = + node.site === "MSK" ? "#7c3aed" : node.site === "SPB" ? "#0369a1" : + node.site === "FRA" ? "#b45309" : "#065f46" + + return ( +
+
+
+ {node.site !== "—" ? node.site.slice(0,3) : "?"} +
+
+

{node.label}

+

Router-ID: {routerId}

+
+ +
+ +
+ + {panelState} +
+ {fullCnt > 0 && Full ×{fullCnt}} + {partCnt > 0 && 2-Way ×{partCnt}} +
+
+ +
+

Соседи ({adjacencies.length})

+ {adjacencies.length === 0 &&

Нет активных adjacency

} +
+ {adjacencies.map((adj, i) => { + const ec = EDGE_COLOR[adj.state] ?? "#64748b" + return ( +
+ +
+

{adj.peerLabel}

+

{adj.iface} · cost {adj.cost}

+

{adj.uptime}

+
+ {adj.state} +
+ ) + })} +
+
+ +
+

+ Интерфейсы OSPF{ifaces.length > 0 && ({ifaces.length})} +

+ {ifaces.length === 0 &&

Нет данных

} +
+ {ifaces.map(iface => ( +
+ + {iface.interfaceName} + + {iface.area === "0.0.0.0" ? "backbone" : `area ${iface.area}`} + + cost {iface.cost} +
+ ))} +
+
+
+ ) +} + +// ─── interfaces tab ─────────────────────────────────────────────────────────── + +function InterfacesTab({ items: initialItems, isLive }: { items: OspfItem[]; isLive: boolean }) { + const [items, setItems] = useState(initialItems) + const [dragging, setDragging] = useState(null) + const [dragOver, setDragOver] = useState(null) + const [toast, setToast] = useState(null) + + // Sync with live data when it changes + useEffect(() => { setItems(initialItems) }, [initialItems]) + + function showToast(msg: string) { setToast(msg); setTimeout(() => setToast(null), 2500) } + + const grouped = useMemo(() => { + const byRouter: Record }> = {} + items.forEach(item => { + if (!byRouter[item.routerKey]) + byRouter[item.routerKey] = { routerKey: item.routerKey, routerLabel: item.routerLabel, areas: {} } + if (!byRouter[item.routerKey].areas[item.area]) + byRouter[item.routerKey].areas[item.area] = [] + byRouter[item.routerKey].areas[item.area].push(item) + }) + return Object.values(byRouter).map(r => ({ + ...r, + areas: Object.entries(r.areas) + .map(([area, areaItems]) => ({ area, items: [...areaItems].sort((a, b) => a.cost - b.cost) })) + .sort((a, b) => a.area.localeCompare(b.area)), + })) + }, [items]) + + // Optimizer hints only work meaningfully for mock data + const hints = useMemo(() => { + if (isLive) return {} + const out: Record = {} + grouped.forEach(router => { + router.areas.forEach(ag => { + const ranked = [...ag.items] + .map(item => ({ item, prob: OPTIMIZER_PROB[`${router.routerKey}::${item.interfaceName.toUpperCase()}`] ?? 0 })) + .sort((a, b) => b.prob - a.prob || a.item.interfaceName.localeCompare(b.item.interfaceName)) + ranked.forEach(({ item, prob }, idx) => { out[item.key] = { optimalCost: (idx + 1) * COST_STEP, prob } }) + }) + }) + return out + }, [grouped, isLive]) + + const needsOptimize = !isLive && items.some(item => hints[item.key] && hints[item.key].optimalCost !== item.cost) + + function onDrop(routerKey: string, area: string, targetKey: string) { + if (!dragging || dragging === targetKey) { setDragging(null); setDragOver(null); return } + setItems(prev => { + const areaItems = prev.filter(i => i.routerKey === routerKey && i.area === area).sort((a, b) => a.cost - b.cost) + const fromIdx = areaItems.findIndex(i => i.key === dragging) + const toIdx = areaItems.findIndex(i => i.key === targetKey) + if (fromIdx < 0 || toIdx < 0) return prev + const reordered = moveInArray(areaItems, fromIdx, toIdx).map((item, idx) => ({ ...item, cost: (idx + 1) * COST_STEP })) + return [...prev.filter(i => !(i.routerKey === routerKey && i.area === area)), ...reordered] + }) + setDragging(null); setDragOver(null) + } + + function handleOptimize() { + setItems(prev => prev.map(item => { + const h = hints[item.key] + return h && item.cost !== h.optimalCost ? { ...item, cost: h.optimalCost } : item + })) + showToast("Costs оптимизированы по рекомендациям оптимизатора") + } + + return ( +
+
+ + {needsOptimize && ( + + )} + {!isLive && ( + + )} +
+ + {toast && ( +
+ {toast} +
+ )} + + {grouped.length === 0 && ( +
+ OSPF интерфейсы не настроены ни на одном сервере +
+ )} + +
+ {grouped.map(router => { + const totalIfaces = router.areas.reduce((s, a) => s + a.items.length, 0) + return ( + +
+ +
+

{router.routerLabel}

+

{router.routerKey}

+
+ {totalIfaces} iface · {router.areas.length} area +
+ + {router.areas.map((ag, aIdx) => ( +
+ {aIdx > 0 && } +
+ Area + {ag.area} + {ag.area === "0.0.0.0" && Backbone} +
+
+ {ag.items.map(item => { + const hint = hints[item.key] + const matches = hint && hint.optimalCost === item.cost + return ( +
!isLive && setDragging(item.key)} + onDragEnd={() => { setDragging(null); setDragOver(null) }} + onDragOver={e => { e.preventDefault(); setDragOver(item.key) }} + onDrop={() => !isLive && onDrop(router.routerKey, ag.area, item.key)} + className={cn( + "flex items-center gap-3 px-4 py-2.5 select-none transition-colors", + !isLive && "cursor-grab", + dragging === item.key && "opacity-40", + dragOver === item.key && dragging !== item.key && "bg-primary/5", + dragging !== item.key && dragOver !== item.key && "hover:bg-muted/40", + )}> + + + {item.interfaceName} + {item.type && item.type !== "broadcast" && ( + {item.type} + )} + {item.useBfd && ( + BFD + )} + {hint && hint.prob > 0 && !isLive && ( + opt {hint.optimalCost} + )} + cost {item.cost} +
+ ) + })} +
+
+ ))} +
+ ) + })} +
+ + {!isLive && ( +
+ Легенда: + + opt 10 + совпадает с оптимальным + + + opt 20 + рекомендован другой cost + + + перетащите для изменения приоритета + +
+ )} +
+ ) +} + +// ─── neighbors tab ──────────────────────────────────────────────────────────── + +function NeighborsTab({ + neighbors, items, graphNodes, graphEdges, routerIds, +}: { + neighbors: OspfNeighbor[] + items: OspfItem[] + graphNodes: GraphNode[] + graphEdges: GraphEdge[] + routerIds: Record +}) { + const [highlightId, setHighlightId] = useState(null) + const [selectedId, setSelectedId] = useState(null) + const [showDots, setShowDots] = useState(true) + + const fullCount = neighbors.filter(n => n.state === "Full").length + + return ( +
+
+ {[ + { label: "Всего соседей", value: neighbors.length, color: "" }, + { label: "Full", value: fullCount, color: "text-emerald-600 dark:text-emerald-400" }, + { label: "Не Full", value: neighbors.length - fullCount, color: neighbors.length - fullCount > 0 ? "text-amber-500" : "text-muted-foreground" }, + ].map(s => ( + + +

{s.label}

+

{s.value}

+
+
+ ))} +
+ + {graphNodes.length > 0 && ( +
+
+ + + {!selectedId && ( +
+ кликните по узлу для деталей +
+ )} +
+ {selectedId && ( + setSelectedId(null)} + /> + )} +
+ )} + + +
+ + + + {["Роутер", "Интерфейс", "Сосед (Router ID)", "Область", "Состояние", "Cost", "Uptime", "Prio"].map(h => ( + + ))} + + + + {neighbors.length === 0 ? ( + + + + ) : neighbors.map(n => { + const isHighlighted = selectedId === n.localRouter || selectedId === n.remoteRouter + return ( + setHighlightId(n.localRouter)} + onMouseLeave={() => setHighlightId(null)} + onClick={() => setSelectedId(prev => prev === n.localRouter ? null : n.localRouter)} + className={cn( + "transition-colors cursor-pointer", + isHighlighted ? "bg-primary/5 hover:bg-primary/8" : "hover:bg-muted/30", + )}> + + + + + + + + + + ) + })} + +
{h}
+ Нет OSPF-соседей +
{n.localLabel}{n.localIface} +
+ {n.remoteLabel !== n.remoteRouterId ? n.remoteLabel : n.remoteRouterId} + {n.remoteLabel !== n.remoteRouterId && ( + {n.remoteRouterId} + )} +
+
{n.area} + + {n.state} + + {n.cost}{n.uptime}{n.priority}
+
+
+
+ ) +} + +// ─── routes tab (mock) ──────────────────────────────────────────────────────── + +function RoutesTab({ routes }: { routes: OspfRoute[] }) { + const typeCount = Object.entries( + routes.reduce>((acc, r) => { acc[r.type] = (acc[r.type] ?? 0) + 1; return acc }, {}) + ) + return ( +
+
+ {routes.length} маршрутов + {typeCount.map(([type, count]) => ( + + {type}: {count} + + ))} +
+ +
+ + + + {["Назначение", "Тип", "Cost", "Следующий хоп", "Интерфейс", "Роутер", "Область"].map(h => ( + + ))} + + + + {routes.map(r => ( + + + + + + + + + + ))} + +
{h}
{r.destination} + + {r.type} + + {r.cost}{r.nextHop}{r.via}{r.serverLabel}{r.area}
+
+
+
+ Типы: + {([ + { type: "O", desc: "Internal (intra-area)" }, + { type: "O IA", desc: "Inter-Area" }, + { type: "O E1", desc: "External Type 1" }, + { type: "O E2", desc: "External Type 2" }, + ] as const).map(({ type, desc }) => ( + + {type} + {desc} + + ))} +
+
+ ) +} + +// ─── BFD tab ────────────────────────────────────────────────────────────────── + +function BfdTab({ sessions }: { sessions: BfdSession[] }) { + const upCount = sessions.filter(b => b.state === "Up").length + const downCount = sessions.filter(b => b.state === "Down" || b.state === "AdminDown").length + const initCount = sessions.filter(b => b.state === "Init").length + const totalPkts = sessions.reduce((s, b) => s + b.packetsRx, 0) + + function fmtMs(ms: number) { + if (!ms) return "—" + if (ms < 1000) return `${ms}ms` + return `${(ms / 1000).toFixed(1)}s` + } + + return ( +
+
+ {[ + { label: "Сессий BFD", value: sessions.length, color: "" }, + { label: "Up", value: upCount, color: "text-emerald-600 dark:text-emerald-400" }, + { label: "Down / Admin", value: downCount, color: downCount > 0 ? "text-red-500" : "text-muted-foreground" }, + { label: "Init / другие", value: initCount, color: initCount > 0 ? "text-amber-500" : "text-muted-foreground" }, + ].map(s => ( + + +

{s.label}

+

{s.value}

+
+
+ ))} +
+ + {sessions.length === 0 && ( +
+ BFD-сессий не обнаружено ни на одном сервере +
+ )} + + {sessions.length > 0 && ( + +
+ + + + {[ + "Роутер", "Интерфейс", "Локальный", "Удалённый", + "Состояние", "Uptime", "Tx / Rx", "Hold", "Mult", + "Пакеты Rx", "Пакеты Tx", "Переходы", + ].map(h => ( + + ))} + + + + {sessions.map(b => ( + + + + + + + + + + + + + + + ))} + +
{h}
+
+ {b.serverLabel} + {b.multihop && ( + multihop + )} +
+
{b.iface || "—"}{b.localAddr}{b.remoteAddr} + + {b.state} + + + {b.uptime ?? "—"} + + {fmtMs(b.interval)} / {fmtMs(b.rxInterval)} + + {fmtMs(b.holdTime)} + {b.multiplier} + {b.packetsRx.toLocaleString()} + + {b.packetsTx.toLocaleString()} + + 3 ? "text-amber-500" : "text-muted-foreground")}> + {b.stateChanges} + +
+
+
+ )} + + {sessions.length > 0 && ( +
+

+ BFD обнаруживает сбои быстрее OSPF Hello/Dead таймеров. + Tx / Rx — интервалы отправки и приёма контрольных пакетов. + {totalPkts > 0 && Всего получено: {totalPkts.toLocaleString()} пакетов.} +

+
+ )} +
+ ) +} + +// ─── page ───────────────────────────────────────────────────────────────────── + +const TABS: Array<{ id: OspfTab; label: string; icon: React.ReactNode }> = [ + { id: "interfaces", label: "Интерфейсы", icon: }, + { id: "neighbors", label: "Соседи", icon: }, + { id: "routes", label: "Маршруты", icon: }, + { id: "bfd", label: "BFD", icon: }, +] + +export default function OspfPage() { + const [activeTab, setActiveTab] = useState("interfaces") + const [filterServerId, setFilterServerId] = useState("all") + + const { mode, backendUrl, backendStatus } = useDataSource() + const isLive = mode === "live" && backendStatus === true + + const [liveData, setLiveData] = useState(null) + const [loading, setLoading] = useState(false) + const [fetchedAt, setFetchedAt] = useState(null) + const [liveError, setLiveError] = useState(null) + const [fetchTick, setFetchTick] = useState(0) + + // Reset server filter when switching live ↔ mock + useEffect(() => { setFilterServerId("all") }, [isLive]) + + useEffect(() => { + if (!isLive) { setLiveData(null); return } + let cancelled = false + setLoading(true); setLiveError(null) + fetch(`${backendUrl}/api/ospf/all`) + .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() as Promise }) + .then(data => { + if (cancelled) return + setLiveData(data); 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]) + + // Derive frontend types from backend data or use mocks + const { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions } = useMemo(() => { + if (isLive && liveData) { + // Build interface→cost map for neighbor cost lookup + const ifaceMap = new Map() + for (const iface of liveData.interfaces) { + ifaceMap.set(`${iface.serverId}::${iface.interface}`, iface.cost) + } + + const items = liveData.interfaces.map(backendToItem) + const neighbors = liveData.neighbors.map(b => backendToNeighbor(b, ifaceMap)) + const bfdSessions = (liveData.bfdSessions ?? []).map(backendToBfdSession) + + // Build routerIds from instances + const routerIds: Record = {} + for (const inst of liveData.instances) { + if (!routerIds[String(inst.serverId)]) { + routerIds[String(inst.serverId)] = inst.routerId + } + } + + const { nodes: graphNodes, edges: graphEdges } = buildLiveGraph(neighbors) + return { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions } + } + return { + items: MOCK_ITEMS, + neighbors: MOCK_NEIGHBORS, + graphNodes: MOCK_GRAPH_NODES, + graphEdges: MOCK_GRAPH_EDGES, + routerIds: MOCK_ROUTER_IDS, + bfdSessions: MOCK_BFD, + } + }, [isLive, liveData]) + + // ── server list for chips bar ──────────────────────────────────────────────── + const ospfServers = useMemo((): OspfServerInfo[] => { + if (isLive && liveData) { + const seen = new Map() + for (const inst of liveData.instances) { + const id = String(inst.serverId) + if (!seen.has(id)) + seen.set(id, { id, label: inst.serverName, site: inst.serverSite, country: inst.serverCountry }) + } + // Fallback: pick up servers that appear in neighbors/interfaces but not in instances + for (const n of liveData.neighbors) { + const id = String(n.serverId) + if (!seen.has(id)) + seen.set(id, { id, label: n.serverName, site: n.serverSite, country: n.serverCountry }) + } + return [...seen.values()] + } + // Mock: derive from MOCK_GRAPH_NODES that actually have interfaces + const activeIds = new Set(MOCK_ITEMS.map(i => i.routerKey)) + const siteCountry: Record = { MSK: "RU", SPB: "RU", FRA: "DE", AMS: "NL", SGP: "SG" } + return MOCK_GRAPH_NODES + .filter(n => activeIds.has(n.id)) + .map(n => ({ id: n.id, label: n.label, site: n.site, country: siteCountry[n.site] ?? "" })) + }, [isLive, liveData]) + + // Per-server counts for chips (all data, before filtering) + const serverCounts = useMemo(() => { + const out: Record = {} + const ensure = (id: string) => { if (!out[id]) out[id] = { ifaces: 0, neighbors: 0, bfd: 0 } } + for (const item of items) { ensure(item.routerKey); out[item.routerKey].ifaces++ } + for (const n of neighbors) { ensure(n.localRouter); out[n.localRouter].neighbors++ } + for (const b of bfdSessions) { ensure(b.serverId); out[b.serverId].bfd++ } + return out + }, [items, neighbors, bfdSessions]) + + // ── filtered display data ───────────────────────────────────────────────────── + const displayItems = filterServerId === "all" ? items : items.filter(i => i.routerKey === filterServerId) + const displayNeighbors = filterServerId === "all" ? neighbors : neighbors.filter(n => n.localRouter === filterServerId) + const displayBfdSessions = filterServerId === "all" ? bfdSessions : bfdSessions.filter(b => b.serverId === filterServerId) + + // Graph always shows full topology (highlight is handled by node click inside tab) + // KPIs reflect the current filter + const totalRouters = new Set(displayItems.map(i => i.routerKey)).size + const totalInterfaces = displayItems.length + const totalAreas = new Set(displayItems.map(i => i.area)).size + + return ( +
+ setFetchTick(t => t + 1)}> + + Обновить + + } + /> + +
+
+ {TABS.map(t => ( + + ))} +
+
+ + {/* ── server filter chips (same pattern as Filters page) ─────────────── */} + {ospfServers.length > 0 && ( +
+ {/* "All" chip */} + + +
+ + {ospfServers.map(s => { + const counts = serverCounts[s.id] + const active = filterServerId === s.id + return ( + + ) + })} +
+ )} + +
+
+ + {/* data source banner */} + {isLive && loading && ( +
+ Загрузка OSPF данных… +
+ )} + {isLive && !loading && fetchedAt && !liveError && ( +
+ + + Живые данные · обновлено {fetchedAt.toLocaleTimeString("ru")} + + +
+ )} + {isLive && liveError && ( +
+ +

Ошибка загрузки: {liveError}

+
+ )} + {mode === "mock" && ( + + Моковые данные + + )} + + {/* KPI strip */} +
+ {[ + { label: "Роутеров", value: totalRouters }, + { label: "Интерфейсов", value: totalInterfaces }, + { label: "Зон (Area)", value: totalAreas }, + ].map(s => ( + + +

{s.label}

+

{s.value}

+
+
+ ))} +
+ + {activeTab === "interfaces" && } + {activeTab === "neighbors" && ( + + )} + {activeTab === "routes" && } + {activeTab === "bfd" && } + +
+
+
+ ) +} diff --git a/app/(main)/probes/page.tsx b/app/(main)/probes/page.tsx new file mode 100644 index 0000000..c152ba6 --- /dev/null +++ b/app/(main)/probes/page.tsx @@ -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 + 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 = { 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 ( + + ) +} + +function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { + return ( + + ) +} + +function OptionLabel({ children }: { children: React.ReactNode }) { + return

{children}

+} + +function SegBtn({ value, current, onClick, children }: { + value: T; current: T; onClick: (v: T) => void; children: React.ReactNode +}) { + return ( + + ) +} + +// ─── terminal output ────────────────────────────────────────────────────────── + +function TerminalOutput({ test }: { test: DiagTest }) { + const ref = useRef(null) + const visible = test.lines.slice(0, test.totalLines) + + useEffect(() => { + if (ref.current) ref.current.scrollTop = ref.current.scrollHeight + }, [visible.length]) + + return ( +
+ {/* command line */} +
+ [{test.srcServerName}] + $ + {test.command} + {test.status === "running" && ( + + )} +
+ {/* output */} + {visible.map((line, i) => ( +
+ {line.text || " "} +
+ ))} + {test.status === "running" && visible.length < test.lines.length && ( +
+ )} +
+ ) +} + +// ─── 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> +}) { + const [showAdd, setShowAdd] = useState(false) + const [addSrc, setAddSrc] = useState("srv1") + const [addTun, setAddTun] = useState("gre1") + const [addType, setAddType] = useState("ping") + const [addMin, setAddMin] = useState(10) + const addTunnels = useMemo(() => greTunnels.filter(t => t.serverId === addSrc), [addSrc]) + + const typeLabel: Record = { ping: "Ping", bandwidth: "BW-тест", both: "Ping + BW" } + + return ( +
+ + {rules.length === 0 ? ( +
+ +

Нет правил расписания

+
+ ) : ( + <> +
+ + Туннель + Сервер + Тип + Интервал + Последний / следующий + +
+
+ {rules.map(rule => { + const src = servers.find(s => s.id === rule.srcId) + const tun = greTunnels.find(t => t.id === rule.tunnelId) + return ( +
+ setRules(p => p.map(r => r.id === rule.id ? { ...r, enabled: v } : r))} /> + {tun?.name ?? rule.tunnelId} + {src?.name ?? rule.srcId} + {typeLabel[rule.type]} + каждые {rule.intervalMin} мин +
+ {rule.lastRun && {rule.lastRun}} + {rule.nextRunMin != null && rule.enabled && ( + · через {rule.nextRunMin} мин + )} +
+ +
+ ) + })} +
+ + )} +
+ {showAdd ? ( + +
+ Новое правило +
+
+
Сервер + + {servers.filter(s => s.enabled).map(s => )} + +
+
GRE-туннель + + {addTunnels.map(t => )} + +
+
Тип + setAddType(v as SchedType)}> + + + + +
+
Интервал (мин) + setAddMin(Math.max(1, parseInt(e.target.value) || 1))} + className="w-20 font-mono text-sm" /> +
+ + +
+
+ ) : ( + + )} +
+ ) +} + +// ─── page ───────────────────────────────────────────────────────────────────── + +export default function ProbesPage() { + // ── tool config ── + const [tool, setTool] = useState("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("icmp") + const [traceHops] = useState(30) + const [dnsType, setDnsType] = useState("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([]) + const [tab, setTab] = useState<"history" | "schedule">("history") + const [rules, setRules] = useState(INIT_RULES) + const nextId = useRef(1) + const tickRef = useRef | 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 ( +
+ 0 + ? + : undefined + } + /> + +
+
+ + {/* ── tool selector + config ── */} + + + + {/* tool chips */} +
+ {(Object.entries(TOOL_META) as [DiagTool, typeof TOOL_META[DiagTool]][]).map(([t, m]) => { + const active = tool === t + return ( + + ) + })} +
+ + {/* main config row */} +
+ + {/* source server */} +
+ Источник + + {servers.filter(s => s.enabled).map(s => ( + + ))} + +
+ + {/* target — all tools except bandwidth */} + {tool !== "bandwidth" && ( +
+ + {tool === "dns" ? "Домен" : tool === "route" ? "Destination IP" : "Цель (IP или домен)"} + + 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()} + /> +
+ )} + + {/* bandwidth: select GRE tunnel */} + {tool === "bandwidth" && ( +
+ GRE-туннель (цель) + + {bwTunnels.map(t => )} + +
+ )} + + {/* inline quick options */} + {tool === "ping" && ( + <> +
+ Кол-во +
+ {[5, 10, 25, 100].map(n => {n})} +
+
+
+ Размер (байт) +
+ {[64, 128, 512, 1472].map(n => {n})} +
+
+ + )} + + {tool === "traceroute" && ( +
+ Протокол +
+ {(["icmp", "udp", "tcp"] as TraceProto[]).map(p => {p.toUpperCase()})} +
+
+ )} + + {tool === "dns" && ( +
+ Тип записи +
+ {(["A", "AAAA", "MX", "NS", "TXT", "PTR"] as DnsType[]).map(t => {t})} +
+
+ )} + + {tool === "bandwidth" && ( + <> +
+ Протокол +
+ {(["tcp", "udp"] as const).map(p => {p.toUpperCase()})} +
+
+
+ Длительность +
+ {[5, 10, 30].map(n => {n}с)} +
+
+ + )} + + +
+ + {/* RouterOS command preview */} +
+ + [{srcServer?.name}] + $ + {cmdPreview} + +
+
+
+ + {/* ── tabs ── */} +
+
+ {([ + ["history", "История тестов"], + ["schedule", "Расписание"], + ] as const).map(([t, label]) => ( + + ))} +
+ +
+ {/* history */} + {tab === "history" && ( + tests.length === 0 ? ( +
+ +

Запустите тест — результаты появятся здесь

+
+ ) : ( +
+ {tests.map(test => { + const { Icon, color, label } = TOOL_META[test.tool] + return ( + + {/* header */} +
+
+ + {label} + {test.target} + ← {test.srcServerName} +
+
+ {test.status === "running" && ( + <> + + + запущен + + + + )} + {test.status === "done" && ( + + {((test.lines.length * 0.4)).toFixed(1)}с + + )} + + +
+
+ {/* terminal output */} +
+ +
+
+ ) + })} +
+ ) + )} + + {/* schedule */} + {tab === "schedule" && } +
+
+
+
+
+ ) +} diff --git a/app/(main)/recursive-routes/page.tsx b/app/(main)/recursive-routes/page.tsx new file mode 100644 index 0000000..e4f07a3 --- /dev/null +++ b/app/(main)/recursive-routes/page.tsx @@ -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(path: string, init?: RequestInit): Promise { + 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 + } +} + +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 ( +
+ + {children} + {hint &&

{hint}

} +
+ ) +} + +function SectionTitle({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+
+ ) +} + +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 ( + <> + + +
+ {expanded + ? + : } +
+

{group.dstAddress}

+

{group.comment || "—"}

+
+
+ + +
+ {sorted.map((ep, idx) => { + const code = ep.country || inferCountry(ep.gateway) + return ( +
+ + {code ? : ?} + {ep.gateway} +
+ ) + })} +
+ + {group.endpoints.length} + d{bestDistance} + {group.routingTable || "main"} + e.stopPropagation()}> +
+ + +
+ + + {expanded && ( + + +
+
+ Route: {group.dstAddress} + Table: {group.routingTable || "main"} + {group.comment && {group.comment}} +
+
+ {sorted.map((ep, idx) => ( +
+
+
+ {(ep.country || inferCountry(ep.gateway)) && ( + + )} + Endpoint {idx + 1} +
+ distance: {ep.distance} +
+

{ep.gateway}

+
+ scope: {ep.scope ?? "—"} + t.scope: {ep.targetScope ?? "—"} + check: {ep.checkGateway || "—"} +
+
+ ))} +
+
+ + + )} + + ) +} + +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 ( +
+ +
+
+ { + 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 && ( + + + + )} +
+ {value && ( + + {COUNTRY_OPTIONS.find(c => c.code === value)?.label ?? value} + + )} +
+
+ {visible.map(c => ( + + ))} +
+
+ ) +} + +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(initial) + const [error, setError] = useState(null) + + // eslint-disable-next-line react-hooks/set-state-in-effect + useEffect(() => { setForm(initial); setError(null) }, [initial, open]) + const set = (k: K, v: RouteForm[K]) => setForm(f => ({ ...f, [k]: v })) + const setEp = (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 ( + { if (!v) onClose() }}> + + +
+
+ {mode === "create" ? "Новый маршрут" : "Редактировать маршрут"} + Рекурсивный статический маршрут +
+ +
+
+
+
+ Основные + + set("dstAddress", e.target.value)} /> + +
+ +
+ Конечные точки +
+ {form.endpoints.map((ep, idx) => ( +
+
+ + Endpoint {idx + 1} + + +
+ + setEp(ep.id, "country", v)} /> + + + setEp(ep.id, "gateway", e.target.value)} /> + + +
+ + setEp(ep.id, "distance", Number(e.target.value) || 1)} /> + + + setEp(ep.id, "checkGateway", e.target.value)} /> + +
+ +
+ + setEp(ep.id, "scope", e.target.value ? Number(e.target.value) : null)} /> + + + setEp(ep.id, "targetScope", e.target.value ? Number(e.target.value) : null)} /> + +
+ +
+ {gateways.map((g) => { + const value = `${g.ip}%${g.name}` + const selected = ep.gateway === value + const country = inferCountry(g.name) + return ( + + ) + })} +
+
+ ))} + + {gateways.length === 0 && ( +
+ Нет доступных шлюзов на выбранном роутере. +
+ )} +
+
+ +
+ Параметры + set("routingTable", e.target.value)} /> + + set("comment", e.target.value)} /> + +
+ {error &&
{error}
} +
+ + + + +
+
+ ) +} + +function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" }) { + return ( + + {type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"} + + ) +} + +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(mockServers) + const [selectedServerId, setSelectedServerId] = useState(mockServers[0]?.id ?? "") + const [rows, setRows] = useState([]) + 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(emptyForm()) + const [editingGroupKey, setEditingGroupKey] = useState(null) + const [gatewayOptions, setGatewayOptions] = useState([]) + const [expandedGroupKey, setExpandedGroupKey] = useState(null) + const [opError, setOpError] = useState(null) + + useEffect(() => { + if (!isLive) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setServers(mockServers) + setSelectedServerId(mockServers[0]?.id ?? "") + setRows([]) + return + } + apiFetch("/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() + 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 ( +
+ + + + + + + } + /> + +
+
+ Всего маршрутов + {rows.length} +
+
+ {servers.map((s) => { + const count = s.id === selectedServerId ? rows.length : 0 + const active = selectedServerId === s.id + return ( + + ) + })} +
+ +
+
+ + setSearch(e.target.value)} /> + {search && ( + + )} +
+

+ {filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`} +

+ {opError && ( +
+ {opError} +
+ )} +
+ +
+ {!isLive ? ( + + Раздел работает в режиме "Живые данные". Переключи источник данных в настройках. + + ) : ( + + {currentServer && ( +
+ + + {currentServer.name} + + {currentServer.host} + {currentServer.asn} + {currentServer.latency !== null && ( + 60 ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground", + )}>{currentServer.latency}мс + )} +
+ {rows.length} маршрутов +
+
+ )} + +
+ + + + + + + + + + + + {groupedRoutes.map((g) => ( + setExpandedGroupKey(prev => prev === g.key ? null : g.key)} + onEdit={() => openEdit(g)} + onDelete={() => setRows(prev => prev.filter(r => groupKeyOf(r) !== g.key))} + /> + ))} + +
Route / CommentGatewaysEPPriorityTable +
+ {groupedRoutes.length === 0 && ( +
+ Нет маршрутов в БД для этого сервера. Нажми "Router => DB" для загрузки. +
+ )} +
+ +
+ )} +
+ + setSheetOpen(false)} + gateways={gatewayOptions} + /> +
+ ) +} diff --git a/app/(main)/route-optimizer/page.tsx b/app/(main)/route-optimizer/page.tsx new file mode 100644 index 0000000..8b365ab --- /dev/null +++ b/app/(main)/route-optimizer/page.tsx @@ -0,0 +1,1193 @@ +"use client" + +import { useCallback, useEffect, useState, useMemo } 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 { Flag } from "@/components/flag" +import { cn } from "@/lib/utils" +import { servers } from "@/lib/data" +import { + RefreshCwIcon, AlertCircleIcon, ArrowRightIcon, + SettingsIcon, ChevronDownIcon, ChevronUpIcon, PinIcon, + ZapIcon, PlayIcon, CheckCircleIcon, NetworkIcon, WifiIcon, + MonitorIcon, ServerIcon, GitMergeIcon, ShieldIcon, LayersIcon, + InfoIcon, +} from "lucide-react" + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface WanUplink { + id: string + name: string // "WAN1-RT" + isp: string // "Rostelecom" + iface: string // "ether1" + ip: string // external IP + maxDl: number // Mbps + maxUl: number // Mbps +} + +interface HomeRouter { + id: string + label: string // "home-msk-01" + site: string // "MSK" + country: string + model: string + ip: string // LAN management IP + wans: WanUplink[] +} + +interface JumpHost { + id: string + label: string + site: string + country: string + ip: string +} + +interface ExitNode { + id: string + label: string + site: string + country: string + ip: string +} + +// WAN→JH measurement (per uplink) +interface WanJhLeg { + wanId: string + jhId: string + pingMs: number + dlMbps: number + ulMbps: number + score: number + loss: number +} + +// JH→Exit measurement +interface JhExLeg { + jhId: string + exitId: string + pingMs: number + dlMbps: number + ulMbps: number +} + +// Full route: HomeRouter + WAN → JH → Exit +interface FullRoute { + id: string + homeId: string + wan: WanUplink + jh: JumpHost + exit: ExitNode + hw: WanJhLeg + je: JhExLeg + score: number + confidence: "HIGH" | "MEDIUM" | "LOW" + probabilityOptimal: number +} + +// BGP community recommendation +interface CommRec { + community: string + communityName: string + current: { wan: string; jh: string; exit: string; gateway: string; prob: number } | null + recommended: { wan: string; jh: string; exit: string; gateway: string; prob: number } | null + shouldSwitch: boolean + pinnedBySettings: boolean +} + +interface HomeEntry { + home: HomeRouter + wanJhLegs: WanJhLeg[] // all (WAN × JH) measurements + fullRoutes: FullRoute[] // sorted by probabilityOptimal + bestRoute: FullRoute | null + commRecs: CommRec[] +} + +interface OptimizerData { + updatedAt: string + homes: HomeEntry[] +} + +interface OptimizerSettings { + switchThreshold: number + hysteresisThreshold: number + pingWeight: number + probeIntervalMin: number + autoApply: boolean + autoApplyIntervalMin: number +} + +// ─── Topology derived from lib/data servers ─────────────────────────────────── + +const HOME_ROUTERS: HomeRouter[] = servers + .filter(s => s.type === "home-router" && s.enabled) + .map(s => ({ + id: s.id, + label: s.name, + site: s.site, + country: s.country, + model: s.model, + ip: s.host, + wans: (s.wanUplinks ?? []).map(w => ({ + id: w.id, name: w.name, isp: w.isp, + iface: w.iface, ip: w.ip, maxDl: w.maxDl, maxUl: w.maxUl, + })), + })) + +const JUMPHOSTS: JumpHost[] = servers + .filter(s => s.type === "jump-host" && s.status !== "offline") + .map(s => ({ id: s.id, label: s.name, site: s.site, country: s.country, ip: s.host })) + +const EXIT_NODES: ExitNode[] = servers + .filter(s => s.type === "exit-node" && s.status !== "offline") + .map(s => ({ id: s.id, label: s.name, site: s.site, country: s.country, ip: s.host })) + +const VRF_NAMES: string[] = ["main", ...Array.from( + new Set(servers.flatMap(s => s.vrfNames ?? [])) +).sort()] + +// ─── Mock data generator ────────────────────────────────────────────────────── + +function jitter(base: number, range: number) { + return Math.max(1, Math.round(base + (Math.random() - 0.5) * range * 2)) +} + +function calcScore(pingMs: number, dlMbps: number, ulMbps: number, pw: number) { + const pingScore = Math.max(0, 100 - pingMs * 0.6) + const speedScore = Math.min(100, (dlMbps + ulMbps) / 18) + const w = pw / 100 + return Math.round(w * pingScore + (1 - w) * speedScore) +} + +function confidence(prob: number): "HIGH" | "MEDIUM" | "LOW" { + return prob >= 55 ? "HIGH" : prob >= 30 ? "MEDIUM" : "LOW" +} + +// Base latencies and bandwidths for each (WAN, JH) pair +const HW_BASE: Record = { + "w1a-jh1": { ping: 4, dl: 480, ul: 420, loss: 0 }, + "w1a-jh2": { ping: 6, dl: 480, ul: 420, loss: 0 }, + "w1b-jh1": { ping: 12, dl: 95, ul: 90, loss: 0 }, + "w1b-jh2": { ping: 14, dl: 94, ul: 88, loss: 0.5 }, + "w2a-jh1": { ping: 18, dl: 280, ul: 270, loss: 0 }, + "w2a-jh2": { ping: 20, dl: 278, ul: 265, loss: 0 }, + "w2b-jh1": { ping: 22, dl: 185, ul: 140, loss: 0 }, + "w2b-jh2": { ping: 25, dl: 182, ul: 138, loss: 1 }, +} + +// Base latencies for (JH, Exit) pairs +const JE_BASE: Record = { + "jh1-ex1": { ping: 22, dl: 880, ul: 820 }, + "jh1-ex2": { ping: 38, dl: 740, ul: 700 }, + "jh1-ex3": { ping: 85, dl: 580, ul: 540 }, + "jh1-ex4": { ping: 120, dl: 320, ul: 290 }, + "jh2-ex1": { ping: 28, dl: 820, ul: 760 }, + "jh2-ex2": { ping: 42, dl: 700, ul: 660 }, + "jh2-ex3": { ping: 90, dl: 540, ul: 500 }, + "jh2-ex4": { ping: 130, dl: 300, ul: 270 }, +} + +function buildMockData(settings: OptimizerSettings): OptimizerData { + const pw = settings.pingWeight + + // Pre-compute (JH, Exit) legs — shared across all homes + const jhExLegs: JhExLeg[] = [] + for (const jh of JUMPHOSTS) { + for (const ex of EXIT_NODES) { + const base = JE_BASE[`${jh.id}-${ex.id}`] + if (!base) continue + jhExLegs.push({ + jhId: jh.id, exitId: ex.id, + pingMs: jitter(base.ping, 4), dlMbps: jitter(base.dl, 30), ulMbps: jitter(base.ul, 25), + }) + } + } + + function getJhEx(jhId: string, exitId: string) { + return jhExLegs.find(l => l.jhId === jhId && l.exitId === exitId)! + } + + const homes: HomeEntry[] = HOME_ROUTERS.map((home) => { + // (WAN × JH) legs + const wanJhLegs: WanJhLeg[] = [] + for (const wan of home.wans) { + for (const jh of JUMPHOSTS) { + const base = HW_BASE[`${wan.id}-${jh.id}`] + if (!base) continue + const ping = jitter(base.ping, 3) + const dl = Math.min(wan.maxDl, jitter(base.dl, 20)) + const ul = Math.min(wan.maxUl, jitter(base.ul, 15)) + wanJhLegs.push({ + wanId: wan.id, jhId: jh.id, pingMs: ping, dlMbps: dl, ulMbps: ul, + loss: base.loss > 0 ? +(base.loss + (Math.random() - 0.5) * 0.5).toFixed(1) : 0, + score: calcScore(ping, dl, ul, pw), + }) + } + } + + // Full routes: every (WAN × JH × Exit) combo + const fullRoutes: FullRoute[] = [] + for (const wan of home.wans) { + for (const jh of JUMPHOSTS) { + for (const ex of EXIT_NODES) { + const hw = wanJhLegs.find(l => l.wanId === wan.id && l.jhId === jh.id) + const je = getJhEx(jh.id, ex.id) + if (!hw || !je) continue + const totalPing = hw.pingMs + je.pingMs + const dl = Math.min(hw.dlMbps, je.dlMbps) + const ul = Math.min(hw.ulMbps, je.ulMbps) + const score = calcScore(totalPing, dl, ul, pw) + fullRoutes.push({ + id: `${home.id}-${wan.id}-${jh.id}-${ex.id}`, + homeId: home.id, wan, jh, exit: ex, hw, je, + score, + confidence: confidence(score), + probabilityOptimal: 0, // assigned after ranking + }) + } + } + } + + // Assign probabilityOptimal proportional to score rank + const sorted = [...fullRoutes].sort((a, b) => b.score - a.score) + const topScore = sorted[0]?.score ?? 1 + sorted.forEach((r, i) => { + r.probabilityOptimal = Math.max(1, Math.round((topScore - i * 6 + jitter(0, 3)) * (i === 0 ? 1 : 0.85 ** i))) + }) + sorted.sort((a, b) => b.probabilityOptimal - a.probabilityOptimal) + + // BGP community recommendations (per home) + const best = sorted[0] ?? null + const commRecs: CommRec[] = [ + { community: "65001:100", communityName: "youtube-bypass", + current: { wan: "WAN1-RT", jh: "mt-msk-core-01", exit: "mt-spb-edge-01", gateway: "10.0.1.1", prob: jitter(41, 5) }, + recommended: best ? { wan: best.wan.name, jh: best.jh.label, exit: best.exit.label, gateway: best.je.pingMs < 30 ? "10.0.1.1" : "10.0.2.1", prob: jitter(55, 5) } : null, + shouldSwitch: false, pinnedBySettings: false, + }, + { community: "65001:200", communityName: "streaming-eu", + current: { wan: "WAN1-RT", jh: "mt-msk-core-01", exit: "mt-fra-edge-01", gateway: "10.0.2.1", prob: jitter(70, 5) }, + recommended: { wan: "WAN1-RT", jh: "mt-msk-core-01", exit: "mt-fra-edge-01", gateway: "10.0.2.1", prob: jitter(74, 4) }, + shouldSwitch: false, pinnedBySettings: false, + }, + { community: "65001:300", communityName: "cdn-bypass", + current: { wan: "WAN2-BL", jh: "mt-msk-core-01", exit: "mt-fra-edge-01", gateway: "10.0.2.1", prob: jitter(28, 5) }, + recommended: best ? { wan: best.wan.name, jh: best.jh.label, exit: "mt-spb-edge-01", gateway: "10.0.1.1", prob: jitter(62, 5) } : null, + shouldSwitch: true, pinnedBySettings: false, + }, + ].map(r => ({ + ...r, + shouldSwitch: r.shouldSwitch && + ((r.recommended?.prob ?? 0) - (r.current?.prob ?? 0)) >= settings.switchThreshold, + })) + + return { home, wanJhLegs, fullRoutes: sorted, bestRoute: sorted[0] ?? null, commRecs } + }) + + return { updatedAt: new Date().toLocaleTimeString("ru-RU"), homes } +} + +// ─── Small UI helpers ───────────────────────────────────────────────────────── + +function Chip({ children, color }: { children: React.ReactNode; color?: string }) { + return ( + {children} + ) +} + +function ProbChip({ prob, best }: { prob: number; best?: boolean }) { + return ( + {prob}% + ) +} + +function ConfChip({ conf }: { conf: string }) { + const map: Record = { + HIGH: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20", + MEDIUM: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20", + LOW: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20", + } + return {conf} +} + +function LossChip({ loss }: { loss: number }) { + if (loss === 0) return 0% + return ( + 1 ? "text-red-500" : "text-amber-500")}> + {loss}% + + ) +} + +function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { + return ( + + ) +} + +function NInput({ value, onChange, min, max }: { value: number; onChange: (v: number) => void; min?: number; max?: number }) { + return ( + onChange(Math.max(min ?? 0, Math.min(max ?? 9999, parseInt(e.target.value) || 0)))} + className="w-16 h-7 text-xs font-mono text-right" /> + ) +} + +function SettingRow({ label, unit, children }: { label: string; unit?: string; children: React.ReactNode }) { + return ( +
+ {label} +
+ {children} + {unit && {unit}} +
+
+ ) +} + +// ─── WAN Matrix table ───────────────────────────────────────────────────────── +// Rows = WANs, Columns = JHs, cells show ping / bw / score + +function WanMatrix({ home, legs, pw: _pw }: { + home: HomeRouter + legs: WanJhLeg[] + pw: number +}) { + // find best leg overall + const bestScore = Math.max(...legs.map(l => l.score)) + + return ( +
+ + + + + + + {JUMPHOSTS.map(jh => ( + + ))} + + + + {home.wans.map(wan => ( + + {/* WAN name */} + + {/* ISP */} + + {/* Max bandwidth */} + + {/* Per-JH cells */} + {JUMPHOSTS.map(jh => { + const leg = legs.find(l => l.wanId === wan.id && l.jhId === jh.id) + if (!leg) return + const isBest = leg.score === bestScore + return ( + + ) + })} + + ))} + +
WAN-аплинкISP / IPМакс. полоса +
{jh.label}
+
+ + {jh.site} · {jh.ip} +
+
+
+ +
+

{wan.name}

+

{wan.iface}

+
+
+
+

{wan.isp}

+

{wan.ip}

+
+

↓{wan.maxDl}

+

↑{wan.maxUl} Мбит

+
+
+ {isBest && ( + + ★ ЛУЧШИЙ + + )} + + {leg.pingMs} мс + + + ↓{leg.dlMbps} ↑{leg.ulMbps} + +
+ + score {leg.score} + + {leg.loss > 0 && } +
+
+
+
+ ) +} + +// ─── Full routes table ──────────────────────────────────────────────────────── + +function FullRoutesTable({ routes, bestId }: { routes: FullRoute[]; bestId?: string }) { + const [expanded, setExpanded] = useState(false) + const visible = expanded ? routes : routes.slice(0, 5) + + return ( +
+
+ + + + + + + + + + + + + + + {visible.map((r, i) => { + const isBest = r.id === bestId || i === 0 + const totalPing = r.hw.pingMs + r.je.pingMs + const minDl = Math.min(r.hw.dlMbps, r.je.dlMbps) + const minUl = Math.min(r.hw.ulMbps, r.je.ulMbps) + return ( + + + + + + + + + + + ) + })} + +
# МаршрутWAN → JHJH → ExitPing (итого)BW (мин)ScoreP(opt)Conf.
+
+ {i + 1} + {isBest && ( + + Лучший + + )} +
+
+
+ {r.wan.name} + +
+
{r.jh.label}
+
{r.hw.pingMs} мс · ↓{r.hw.dlMbps} ↑{r.hw.ulMbps}
+
+
+
+
+
+
+ + {r.exit.label} + ({r.exit.site}) +
+
{r.je.pingMs} мс · ↓{r.je.dlMbps} ↑{r.je.ulMbps}
+
+
+
+ {totalPing} мс + +
↓{minDl}
+
↑{minUl}
+
{r.score}
+
+ {routes.length > 5 && ( + + )} +
+ ) +} + +// ─── Community recs table ───────────────────────────────────────────────────── + +function CommRecsTable({ recs, homeId, pinned, applied, applying, onPin, onApply, threshold: _threshold }: { + recs: CommRec[] + homeId: string + pinned: Set + applied: Set + applying: Set + onPin: (k: string) => void + onApply: (comm: string, homeId: string) => void + threshold: number +}) { + return ( +
+ + + + + + + + + + + + {recs.map(r => { + const pinKey = `${homeId}::${r.community}` + const isPinned = pinned.has(pinKey) + const isApplied = applied.has(pinKey) + const isApplying = applying.has(pinKey) + const canApply = r.shouldSwitch && !isPinned && !isApplied + + return ( + + {/* community */} + + + {/* current route */} + + + {/* recommended */} + + + {/* probability */} + + + {/* actions */} + + + ) + })} + +
CommunityТекущий (WAN → JH → Exit)РекомендуемыйP(тек / рек)Действие
+
{r.community}
+
{r.communityName}
+
+ {r.current ? ( +
+ {r.current.wan} + + {r.current.jh} + + {r.current.exit} + ({r.current.gateway}) +
+ ) : } +
+ {r.recommended ? ( +
+ {r.recommended.wan} + + {r.recommended.jh} + + {r.recommended.exit} + {r.shouldSwitch && !isPinned && ( + + +{(r.recommended.prob ?? 0) - (r.current?.prob ?? 0)}% + + )} +
+ ) : } +
+
+ + / + +
+
+
+ {isPinned && } + + {canApply && ( + + )} + {isApplied && ( + + Применено + + )} +
+
+
+ ) +} + +// ─── Home Router card ───────────────────────────────────────────────────────── + +type HomeTab = "wan-matrix" | "full-routes" | "bgp-community" + +function HomeRouterCard({ entry, settings, pinned, applied, applying, onPin, onApply }: { + entry: HomeEntry + settings: OptimizerSettings + pinned: Set + applied: Set + applying: Set + onPin: (k: string) => void + onApply: (comm: string, homeId: string) => void +}) { + const [tab, setTab] = useState("wan-matrix") + const { home, wanJhLegs, fullRoutes, bestRoute, commRecs } = entry + const switchCount = commRecs.filter(r => r.shouldSwitch && !pinned.has(`${home.id}::${r.community}`)).length + + return ( + + {/* Header */} +
+ +
+
+ + {home.label} + {home.site} + {home.model} +
+
+ {home.wans.map(w => `${w.name} (${w.isp} · ${w.ip})`).join(" · ")} +
+
+ + {/* Best route summary */} + {bestRoute && ( +
+ {bestRoute.wan.name} + + {bestRoute.jh.label} + + {bestRoute.exit.label} +
+ )} + +
+ {switchCount > 0 && ( + + {switchCount} переключений + + )} + {bestRoute && ( + <> + + + + )} +
+
+ + {/* Sub-tabs */} +
+ {([ + { id: "wan-matrix", label: `WAN × JH (${home.wans.length}×${JUMPHOSTS.length})` }, + { id: "full-routes", label: `Маршруты (${fullRoutes.length})` }, + { id: "bgp-community", label: `BGP community (${commRecs.length})` }, + ] as { id: HomeTab; label: string }[]).map(t => ( + + ))} +
+ + {/* Tab content */} + {tab === "wan-matrix" && ( + + )} + {tab === "full-routes" && ( + + )} + {tab === "bgp-community" && ( + + )} +
+ ) +} + +// ─── Topology legend ────────────────────────────────────────────────────────── + +function TopologyBar() { + return ( +
+
+ + Home Router +
+ +
+ + WAN1 / WAN2 +
+ +
+ + JumpHost +
+ +
+ + Exit Node +
+
+ ) +} + +// ════════════════════════════════════════════════════════════════════════════ + +const DEFAULT_SETTINGS: OptimizerSettings = { + switchThreshold: 15, + hysteresisThreshold: 10, + pingWeight: 60, + probeIntervalMin: 15, + autoApply: false, + autoApplyIntervalMin: 60, +} + +export default function RouteOptimizerPage() { + const [loading, setLoading] = useState(true) + const [data, setData] = useState(null) + const [error, setError] = useState("") + const [settings, setSettings] = useState(DEFAULT_SETTINGS) + const [showSettings, setShowSettings] = useState(false) + const [pinned, setPinned] = useState>(new Set()) + const [applied, setApplied] = useState>(new Set()) + const [applying, setApplying] = useState>(new Set()) + // ECMP / RPF / VRF + const [ecmpEnabled, setEcmpEnabled] = useState(false) + const [ecmpMaxPaths, setEcmpMaxPaths] = useState(4) + const [ecmpAlgo, setEcmpAlgo] = useState<"per-dst" | "per-conn" | "per-packet">("per-dst") + const [rpfMode, setRpfMode] = useState<"disabled" | "loose" | "strict">("disabled") + const [selectedVrf, setSelectedVrf] = useState("main") + + const load = useCallback((s: OptimizerSettings = settings) => { + setLoading(true); setError("") + setTimeout(() => { + try { setData(buildMockData(s)) } + catch { setError("Ошибка расчёта маршрутов") } + finally { setLoading(false) } + }, 600) + }, [settings]) + + // Initial load + polling every 30 s — load() calls setState internally (standard data-fetching pattern) + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + load() + const id = setInterval(() => load(), 30_000) + return () => clearInterval(id) + }, []) + + function togglePin(key: string) { + setPinned(prev => { const n = new Set(prev); if (n.has(key)) n.delete(key); else n.add(key); return n }) + } + + function applyRec(community: string, homeId: string) { + const key = `${homeId}::${community}` + setApplying(prev => new Set(prev).add(key)) + setTimeout(() => { + setApplied(prev => new Set(prev).add(key)) + setApplying(prev => { const n = new Set(prev); n.delete(key); return n }) + }, 1200) + } + + const totalSwitches = useMemo(() => + data?.homes.flatMap(h => h.commRecs) + .filter(r => r.shouldSwitch && !pinned.has(r.community)).length ?? 0 + , [data, pinned]) + + function applyAll() { + data?.homes.forEach(h => + h.commRecs + .filter(r => r.shouldSwitch && !pinned.has(`${h.home.id}::${r.community}`)) + .forEach(r => applyRec(r.community, h.home.id)) + ) + } + + const set = (k: K, v: OptimizerSettings[K]) => + setSettings(prev => ({ ...prev, [k]: v })) + + return ( +
+ + {totalSwitches > 0 && ( + + )} + + + } + /> + +
+
+ + {/* Status row */} +
+ + · + Обновлено: {data?.updatedAt ?? "—"} + · + Авто 30 с + симуляция +
+ + {/* Stats chips */} +
+ {[ + { label: "Home роутеров", value: HOME_ROUTERS.length, + sub: `${HOME_ROUTERS.reduce((s, h) => s + h.wans.length, 0)} WAN-аплинков`, + icon: }, + { label: "JumpHost", value: JUMPHOSTS.length, + sub: JUMPHOSTS.map(j => j.site).join(" · "), + icon: }, + { label: "Exit Node", value: EXIT_NODES.length, + sub: EXIT_NODES.map(e => e.site).join(" · "), + icon: }, + { label: "Переключений", value: totalSwitches, + sub: totalSwitches > 0 ? "требуют применения" : "всё оптимально", + icon: 0 ? "text-amber-500" : "text-muted-foreground")} /> }, + ].map(s => ( + + +
+

{s.label}

+

{s.value}

+

{s.sub}

+
+
{s.icon}
+
+
+ ))} +
+ + {error && ( +
+ {error} +
+ )} + + {loading && !data && ( +
+ Расчёт оптимальных маршрутов… +
+ )} + + {/* Settings */} + +
setShowSettings(v => !v)} + > + + Настройки оптимизатора + + порог {settings.switchThreshold}% · ping {settings.pingWeight}% · зондирование {settings.probeIntervalMin} мин + + {showSettings ? : } +
+ + {showSettings && ( + +
+
+

Пороги переключения

+ + set("switchThreshold", v)} min={0} max={50} /> + + + set("hysteresisThreshold", v)} min={0} max={50} /> + +
+
+

Веса метрик

+ + set("pingWeight", Math.min(100, v))} min={0} max={100} /> + +
+
+
+
+ Ping {settings.pingWeight}% + BW {100 - settings.pingWeight}% +
+ +
+ {[5, 10, 15, 30].map(v => ( + + ))} +
+
+
+
+

Автоприменение

+
+ Применять автоматически + set("autoApply", v)} /> +
+ {settings.autoApply && ( + <> + + set("autoApplyIntervalMin", v)} min={5} max={1440} /> + +
+ + Изменения каждые {settings.autoApplyIntervalMin} мин +
+ + )} +
+
+
+ + +
+ + )} + + + {/* ─── ECMP / RPF / VRF section ──────────────────────────────── */} +
+ + {/* ECMP Card */} + + +
+ + ECMP + Equal-Cost Multi-Path +
+ +
+
+ +
+
+ Макс. путей +
+ setEcmpMaxPaths(Number(e.target.value))} + className="flex-1 accent-violet-500 h-1.5" /> + {ecmpMaxPaths} +
+
+ +
+ Алгоритм балансировки +
+ {([["per-dst","По dst"],["per-conn","По conn"],["per-packet","По пакет"]] as const).map(([v, l]) => ( + + ))} +
+
+ +
+ + RouterOS 7.x: /routing/rule add ecmp=yes +
+
+
+
+ + {/* RPF Card */} + + +
+ + RPF + Reverse Path Forwarding +
+ +
+ Режим проверки источника +
+ {([ + { v: "disabled", l: "Отключён", d: "Проверка не выполняется", cls: "text-muted-foreground" }, + { v: "loose", l: "Loose", d: "Маршрут к источнику существует", cls: "text-amber-500" }, + { v: "strict", l: "Strict", d: "Пакет пришёл по лучшему маршруту", cls: "text-emerald-500" }, + ] as const).map(({ v, l, d, cls }) => ( + + ))} +
+
+ + {rpfMode !== "disabled" && ( +
+ + /ip settings set rp-filter={rpfMode} +
+ )} +
+
+ + {/* VRF Card */} + + +
+ + VRF + Virtual Routing +
+ +
+ Контекст оптимизации маршрутов +
+ {VRF_NAMES.map(vrf => { + const isSel = selectedVrf === vrf + const isMain = vrf === "main" + return ( + + ) + })} +
+
+ +
+ + Оптимизатор работает в VRF {selectedVrf} +
+
+
+ +
+ + {/* Per-home-router cards */} + {data?.homes.map(entry => ( + + ))} + +
+
+
+ ) +} diff --git a/app/(main)/servers/page.tsx b/app/(main)/servers/page.tsx new file mode 100644 index 0000000..d5630ee --- /dev/null +++ b/app/(main)/servers/page.tsx @@ -0,0 +1,1127 @@ +"use client" + +import { Fragment, useEffect, useMemo, useState } from "react" +import { PageHeader } from "@/components/page-header" +import { StatusBadge } from "@/components/status-badge" +import { servers as initialServers } from "@/lib/data" +import type { ServerType, Server, WanUplink } from "@/lib/data" + +// ─── Backend integration ────────────────────────────────────────────────────── + +import { useDataSource } from "@/lib/data-source" + +interface BackendServer { + id: number; name: string; host: string; port: number + useSsl: boolean; verifySsl: boolean; username: string; password: string + type: ServerType; site: string; country: string; asn: string + comment: string; enabled: boolean + status: "online" | "offline" | null; latency: number | null + os: string | null; model: string | null; uptime: string | null + cpuLoad: number | null; freeMemory: number | null; totalMemory: number | null + identityName: string | null + sessions: number; polledAt: string | null + createdAt: string; updatedAt: string +} + +function toFrontend(s: BackendServer): Server { + return { + id: String(s.id), + name: s.name || s.host, + host: s.host, + type: s.type, + site: s.site, + country: s.country, + asn: s.asn, + model: s.model ?? "—", + os: s.os ?? "—", + enabled: s.enabled, + status: s.status ?? "offline", + latency: s.latency != null ? Math.round(s.latency) : null, + sessions: s.sessions ?? 0, + comment: s.comment || undefined, + // carry extra fields needed for expanded view + uptime: s.uptime ?? undefined, + cpuLoad: s.cpuLoad ?? undefined, + polledAt: s.polledAt ?? undefined, + } +} + +function makeApiFetch(backendUrl: string) { + return async function apiFetch(path: string, init?: RequestInit): Promise { + const headers: HeadersInit = init?.body + ? { "Content-Type": "application/json" } + : {} + const res = await fetch(backendUrl.replace(/\/$/, "") + path, { + ...init, + headers: { ...headers, ...(init?.headers ?? {}) }, + }) + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })) as { error?: string } + throw new Error(err.error ?? res.statusText) + } + // 204 No Content + if (res.status === 204) return undefined as T + return res.json() as Promise + } +} +import { Flag } from "@/components/flag" +import { cn } from "@/lib/utils" +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 { + SearchIcon, RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon, + MoreHorizontalIcon, EyeIcon, EyeOffIcon, + ChevronRightIcon, ChevronDownIcon, + CheckCircleIcon, XCircleIcon, LoaderCircleIcon, + ShieldIcon, WifiIcon, PencilIcon, PowerIcon, Trash2Icon, ExternalLinkIcon, + HomeIcon, ServerIcon, NetworkIcon, +} from "lucide-react" + +// ─── RouterOS version utilities ─────────────────────────────────────────────── + +/** Numeric version score: "7.20.1 (stable)" → 720, "7.14.2" → 714, "7.9" → 709 */ +function rosVer(os: string): number { + const m = os.match(/(\d+)\.(\d+)/) + if (!m) return 0 + return parseInt(m[1], 10) * 100 + parseInt(m[2], 10) +} + +interface RosFeature { name: string; minVer: number; label: string; desc: string } + +const ROS_FEATURES: RosFeature[] = [ + { name: "WireGuard", minVer: 701, label: "7.1+", desc: "WireGuard VPN туннели" }, + { name: "Container", minVer: 704, label: "7.4+", desc: "Docker-совместимые контейнеры" }, + { name: "BFD", minVer: 705, label: "7.5+", desc: "Bidirectional Forwarding Detection" }, + { name: "Large Communities", minVer: 707, label: "7.7+", desc: "BGP Large Communities (RFC 8092)" }, + { name: "VXLAN", minVer: 710, label: "7.10+", desc: "VXLAN overlay туннели" }, + { name: "RPKI", minVer: 713, label: "7.13+", desc: "Route Origin Validation" }, + { name: "BGP Flowspec", minVer: 714, label: "7.14+", desc: "BGP Flow Spec (RFC 8955)" }, + { name: "IPv6 Firewall", minVer: 715, label: "7.15+", desc: "Расширенный IPv6 Firewall" }, + { name: "REST API v2", minVer: 716, label: "7.16+", desc: "Обновлённый REST API" }, + { name: "VRF Enhanced", minVer: 717, label: "7.17+", desc: "Расширенная поддержка VRF" }, +] + +function RosBadge({ os }: { os: string }) { + const v = rosVer(os) + const cls = v >= 715 + ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20" + : v >= 710 + ? "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20" + : "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20" + return ( + + {os} + + ) +} + +// ─── Countries ─────────────────────────────────────────────────────────────── + +const COUNTRIES = [ + { 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: "Норвегия" }, +] + +// ─── Type config ───────────────────────────────────────────────────────────── + +const TYPE_LABELS: Record = { + "jump-host": "JumpHost", + "exit-node": "Exit Node", + "home-router": "Home Router", +} + +const TYPE_STYLES: Record = { + "jump-host": "bg-violet-500/10 text-violet-400 border-violet-500/20", + "exit-node": "bg-sky-500/10 text-sky-400 border-sky-500/20", + "home-router": "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", +} + +const TYPE_ICONS: Record = { + "jump-host": , + "exit-node": , + "home-router": , +} + +function TypeBadge({ type }: { type: ServerType }) { + return ( + + {TYPE_ICONS[type]}{TYPE_LABELS[type]} + + ) +} + +// ─── Shared small components ────────────────────────────────────────────────── + +function Field({ label, hint, required, children }: { + label: string; hint?: string; required?: boolean; children: React.ReactNode +}) { + return ( +
+ + {children} + {hint &&

{hint}

} +
+ ) +} + +function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { + return ( + + ) +} + +function SectionTitle({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+
+ ) +} + +function SegmentedControl({ value, onChange, options }: { + value: T; onChange: (v: T) => void; options: { value: T; label: string }[] +}) { + return ( +
+ {options.map((o) => ( + + ))} +
+ ) +} + +// ─── Country field ──────────────────────────────────────────────────────────── + +function CountryField({ value, onChange }: { value: string; onChange: (v: string) => void }) { + const [query, setQuery] = useState("") + const q = query.trim().toUpperCase() + const visible = q.length === 0 + ? COUNTRIES + : COUNTRIES.filter(c => c.code.startsWith(q) || c.label.toLowerCase().includes(query.trim().toLowerCase())) + + return ( +
+ +
+
+ { + const v = e.target.value.toUpperCase().slice(0, 3) + setQuery(v) + if (v.length === 2) { + const match = COUNTRIES.find(c => c.code === v) + if (match) onChange(match.code) + } + }} + className="font-mono pr-10 h-8 text-sm" /> + {value && ( + + + + )} +
+ {value && ( + + {COUNTRIES.find(c => c.code === value)?.label ?? value} + + )} +
+
+ {visible.map(c => ( + + ))} +
+
+ ) +} + +// ─── WAN uplinks editor ─────────────────────────────────────────────────────── + +function newWan(): WanUplink { + return { id: `w${Date.now()}`, name: "", isp: "", iface: "ether1", ip: "", maxDl: 100, maxUl: 100 } +} + +function WanUplinkEditor({ wans, onChange }: { + wans: WanUplink[] + onChange: (wans: WanUplink[]) => void +}) { + function updateWan(id: string, patch: Partial) { + onChange(wans.map(w => w.id === id ? { ...w, ...patch } : w)) + } + function removeWan(id: string) { onChange(wans.filter(w => w.id !== id)) } + function addWan() { onChange([...wans, newWan()]) } + + return ( +
+ {wans.map((wan, idx) => ( +
+
+ + WAN {idx + 1} + + +
+
+ + updateWan(wan.id, { name: e.target.value })} /> + + + updateWan(wan.id, { iface: e.target.value })} /> + + + updateWan(wan.id, { isp: e.target.value })} /> + + + updateWan(wan.id, { ip: e.target.value })} /> + + + updateWan(wan.id, { maxDl: Number(e.target.value) })} /> + + + updateWan(wan.id, { maxUl: Number(e.target.value) })} /> + +
+
+ ))} + +
+ ) +} + +// ─── Form state ─────────────────────────────────────────────────────────────── + +const defaultForm = { + name: "", type: "exit-node" as ServerType, site: "", country: "RU", asn: "", enabled: true, + host: "", port: "443", proto: "https" as "http" | "https", + verifySsl: true, timeout: 10, + apiPath: "/rest", username: "", password: "", showPassword: false, + showAdvanced: false, sshPort: 22, winboxPort: 8291, comment: "", + // home-router extras + lanSubnet: "", + wanUplinks: [] as WanUplink[], +} + +type FormState = typeof defaultForm +type TestState = "idle" | "testing" | "ok" | "error" +type TypeFilter = "all" | ServerType +type SheetMode = "add" | "edit" + +// ════════════════════════════════════════════════════════════════════════════ +export default function ServersPage() { + const { mode, backendUrl, backendStatus } = useDataSource() + const isLive = mode === "live" && backendStatus === true + const apiFetch = makeApiFetch(backendUrl) + + const [serverList, setServerList] = useState(initialServers) + const [_backendOk, setBackendOk] = useState(false) + const [search, setSearch] = useState("") + const [typeFilter, setTypeFilter] = useState("all") + const [open, setOpen] = useState(false) + const [sheetMode, setSheetMode] = useState("add") + const [editingId, setEditingId] = useState(null) + const [expandedId, setExpandedId] = useState(null) + const [form, setForm] = useState(defaultForm) + const [testState, setTestState] = useState("idle") + const [testMsg, setTestMsg] = useState("") + const [pollingIds, setPollingIds] = useState>(new Set()) + const [pollAllBusy, setPollAllBusy] = useState(false) + + const set = (k: K, v: FormState[K]) => + setForm((f) => ({ ...f, [k]: v })) + + // ── load from backend when mode/url changes ─────────────────────────────── + + useEffect(() => { + if (mode !== "live") { + setServerList(initialServers) + setBackendOk(false) + return + } + apiFetch("/api/servers") + .then(data => { + setBackendOk(true) + setServerList(data.map(toFrontend)) + }) + .catch(() => { + setBackendOk(false) + setServerList(initialServers) + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mode, backendUrl]) + + // ── actions ────────────────────────────────────────────────────────────── + + function openAdd() { + setSheetMode("add"); setEditingId(null) + setForm(defaultForm); setTestState("idle"); setTestMsg("") + setOpen(true) + } + + async function openEdit(s: Server) { + setSheetMode("edit"); setEditingId(s.id) + setForm({ + ...defaultForm, + name: s.name, type: s.type, site: s.site, country: s.country ?? "RU", + asn: s.asn ?? "", enabled: s.status !== "offline", host: s.host, + comment: s.comment ?? "", + lanSubnet: s.lanSubnet ?? "", + wanUplinks: s.wanUplinks ? JSON.parse(JSON.stringify(s.wanUplinks)) : [], + }) + setTestState("idle"); setTestMsg(""); setOpen(true) + + // Fetch full server details (including credentials) from backend + if (isLive) { + try { + const full = await apiFetch(`/api/servers/${s.id}`) + setForm(prev => ({ + ...prev, + username: full.username ?? "", + password: full.password ?? "", + port: String(full.port ?? 443), + proto: full.useSsl ? "https" : "http", + verifySsl: full.verifySsl ?? false, + })) + } catch { + // ignore — user can fill in manually + } + } + } + + async function handleSave() { + const payload = { + host: form.host, + port: Number(form.port) || (form.proto === "https" ? 443 : 80), + username: form.username, + password: form.password, + useSsl: form.proto === "https", + verifySsl: form.verifySsl, + name: form.name, + type: form.type, + site: form.site, + country: form.country, + asn: form.asn, + comment: form.comment, + enabled: form.enabled, + } + + if (isLive) { + try { + if (sheetMode === "edit" && editingId) { + const updated = await apiFetch( + `/api/servers/${editingId}`, + { method: "PUT", body: JSON.stringify(payload) }, + ) + setServerList(list => list.map(s => s.id === editingId ? toFrontend(updated) : s)) + } else { + const created = await apiFetch( + "/api/servers", + { method: "POST", body: JSON.stringify(payload) }, + ) + setServerList(list => [...list, toFrontend(created)]) + } + } catch (e) { + console.error("Ошибка сохранения:", e) + } + } else { + // offline mode — update local state only + if (sheetMode === "edit" && editingId) { + setServerList(list => list.map(s => + s.id === editingId + ? { ...s, name: form.name, type: form.type, site: form.site, country: form.country, + host: form.host, asn: form.asn, comment: form.comment, + status: form.enabled ? (s.status === "offline" ? "online" : s.status) : "offline" } + : s + )) + } else { + setServerList(list => [...list, { + id: `srv${Date.now()}`, name: form.name || form.host, + host: form.host, type: form.type, site: form.site, + country: form.country, asn: form.asn, model: "Unknown", os: "—", + status: form.enabled ? "online" : "offline", + enabled: form.enabled, latency: null, sessions: 0, + comment: form.comment || undefined, + }]) + } + } + + setOpen(false) + } + + function handleToggleStatus(id: string) { + setServerList(list => list.map(s => + s.id === id ? { ...s, status: s.status === "offline" ? "online" : "offline" } : s + )) + } + + async function handleDelete(id: string) { + if (isLive) { + try { + await apiFetch(`/api/servers/${id}`, { method: "DELETE" }) + } catch (e) { + console.error("Ошибка удаления:", e) + } + } + setServerList(list => list.filter(s => s.id !== id)) + } + + async function handlePoll(id: string) { + if (!isLive) return + setPollingIds(p => new Set(p).add(id)) + try { + await apiFetch(`/api/servers/${id}/poll`, { method: "POST" }) + // Reload full list to get updated status/version + const data = await apiFetch("/api/servers") + setServerList(data.map(toFrontend)) + } catch (e) { + console.error("Poll error:", e) + } finally { + setPollingIds(p => { const n = new Set(p); n.delete(id); return n }) + } + } + + async function handlePollAll() { + if (!isLive) return + setPollAllBusy(true) + try { + const ids = serverList.map(s => s.id) + await Promise.all(ids.map(id => apiFetch(`/api/servers/${id}/poll`, { method: "POST" }).catch(() => {}))) + const data = await apiFetch("/api/servers") + setServerList(data.map(toFrontend)) + } finally { + setPollAllBusy(false) + } + } + + async function handleTest() { + if (!form.host || !form.username) { + setTestState("error"); setTestMsg("Заполните Хост и Имя пользователя"); return + } + setTestState("testing"); setTestMsg("") + try { + const res = await fetch(backendUrl.replace(/\/$/, "") + "/api/servers/test-connection", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + host: form.host, + port: Number(form.port) || (form.proto === "https" ? 443 : 80), + useSsl: form.proto === "https", + verifySsl: form.verifySsl, + apiPath: form.apiPath || "/rest", + username: form.username, + password: form.password, + }), + }) + const data = await res.json() as { success: boolean; message: string } + if (data.success) { + setTestState("ok"); setTestMsg(data.message) + } else { + setTestState("error"); setTestMsg(data.message) + } + } catch { + setTestState("error") + setTestMsg(`Не удалось связаться с бекендом — убедитесь, что он запущен на ${backendUrl}`) + } + } + + // ── derived ────────────────────────────────────────────────────────────── + + const filtered = useMemo(() => { + return serverList.filter(sv => { + if (typeFilter !== "all" && sv.type !== typeFilter) return false + if (!search) return true + const q = search.toLowerCase() + return sv.name.toLowerCase().includes(q) || sv.host.includes(q) || sv.site.toLowerCase().includes(q) + }) + }, [serverList, search, typeFilter]) + + const counts = useMemo(() => ({ + all: serverList.length, + "jump-host": serverList.filter(s => s.type === "jump-host").length, + "exit-node": serverList.filter(s => s.type === "exit-node").length, + "home-router":serverList.filter(s => s.type === "home-router").length, + online: serverList.filter(s => s.status === "online").length, + }), [serverList]) + + const tabs: { value: TypeFilter; label: string }[] = [ + { value: "all", label: "Все" }, + { value: "jump-host", label: "JumpHost" }, + { value: "exit-node", label: "Exit Node" }, + { value: "home-router", label: "Home Router" }, + ] + + const isHomeRouter = form.type === "home-router" + + // ───────────────────────────────────────────────────────────────────────── + + return ( +
+ + + + + + } + /> + +
+
+ + {/* Stats */} +
+ {[ + { label: "Всего серверов", value: counts.all, icon: }, + { label: "Онлайн", value: counts.online, icon: }, + { label: "JH + Exit Node", value: counts["jump-host"] + counts["exit-node"], icon: }, + { label: "Home Router", value: counts["home-router"],icon: }, + ].map(s => ( + + +
+

{s.label}

+

{s.value}

+
+
{s.icon}
+
+
+ ))} +
+ + {/* Table */} + +
+
+ {tabs.map(tab => ( + + ))} +
+
+ + setSearch(e.target.value)} /> +
+ {filtered.length} серверов +
+ +
+ + + + + + + + + + + + + + + {filtered.map(s => { + const isExpanded = expandedId === s.id + const ver = rosVer(s.os) + return ( + + setExpandedId(prev => prev === s.id ? null : s.id)} + > + {/* Expand chevron + name */} + + + + + + {/* WAN / LAN column */} + + + + + + + {/* ── Expandable detail row ── */} + {isExpanded && ( + + + + )} + + ) + })} + +
Имя / ХостТипМодельRouterOSПлощадкаWAN / LANЗадержкаСтатус +
+
+ {isExpanded + ? + : } +
+

{s.name}

+

{s.host}

+ {s.ipv6Address && ( +

+ {s.ipv6Address} +

+ )} +
+
+
{s.model} +
+ + {s.site} +
+
+ {s.type === "home-router" && s.wanUplinks?.length ? ( +
+ {s.wanUplinks.map(w => ( +
+ + {w.name} + {w.isp} + ↓{w.maxDl}↑{w.maxUl} +
+ ))} + {s.lanSubnet && ( +
+ LAN {s.lanSubnet} +
+ )} +
+ ) : ( +
+ {s.wireGuardIfaces && s.wireGuardIfaces.length > 0 && ( +
+ + WG: {s.wireGuardIfaces.length} iface · {s.wireGuardIfaces.reduce((n, i) => n + i.peers.length, 0)} peers +
+ )} + {s.rpkiEnabled && ( +
RPKI ✓
+ )} + {!s.wireGuardIfaces?.length && !s.rpkiEnabled && ( + + )} +
+ )} +
60 ? "text-amber-500" : "")}> + {s.latency == null ? "—" : `${s.latency} мс`} + e.stopPropagation()}> + + + + + } /> + + + {s.name} + + + window.open(`https://${s.host}`, "_blank")}> + Открыть WebFig + + openEdit(s)}> + Редактировать + + {isLive && ( + handlePoll(s.id)} disabled={pollingIds.has(s.id)}> + + {pollingIds.has(s.id) ? "Опрос…" : "Опросить"} + + )} + handleToggleStatus(s.id)}> + + {s.status === "offline" ? "Включить" : "Отключить"} + + + handleDelete(s.id)}> + Удалить + + + +
+
+ + {/* Snapshot / live data */} +
+
+ {s.model && s.model !== "—" && ( + Модель: {s.model} + )} + {s.uptime && ( + Uptime: {s.uptime} + )} + {s.cpuLoad != null && ( + CPU: 80 ? "text-red-400" : s.cpuLoad > 50 ? "text-amber-400" : "text-emerald-400")}>{s.cpuLoad}% + )} + {s.asn && ( + ASN: {s.asn} + )} + {s.ipv6Address && ( + IPv6: {s.ipv6Address} + )} + {s.vrfNames?.map(v => ( + VRF: {v} + ))} + {s.comment && ( + {s.comment} + )} + {s.polledAt && ( + + Опрошен: {new Date(s.polledAt).toLocaleString("ru")} + + )} + {!s.polledAt && ( + ⚠ Ещё не опрашивался + )} +
+ + {isLive && ( + + )} +
+ + {/* Feature matrix */} +
+
+

+ Возможности RouterOS +

+ + + {ver >= 715 + ? "✓ Актуальная версия — все ключевые фичи доступны" + : ver >= 710 + ? "⚠ Рекомендуется обновление до 7.15+" + : s.os !== "—" + ? "✗ Устаревшая версия — требуется обновление" + : "Нет данных — нажмите «Опросить сейчас»"} + +
+ +
+ {ROS_FEATURES.map(f => { + const ok = ver >= f.minVer + return ( +
+ {ok + ? + : } +
+

+ {f.name} +

+

+ {f.label} · {f.desc} +

+
+
+ ) + })} +
+
+
+
+
+
+
+
+ + {/* ══ Sheet: Add / Edit Server ══════════════════════════════════════════ */} + + + + {sheetMode === "edit" ? "Редактировать сервер" : "Новый сервер"} + MikroTik RouterOS · Web API (REST) + + +
+ + {/* 1. Основные */} +
+ Основные + + + set("name", e.target.value)} /> + + + + set("type", v)} + options={[ + { value: "home-router", label: "Home Router" }, + { value: "jump-host", label: "JumpHost" }, + { value: "exit-node", label: "Exit Node" }, + ]} + /> + + +
+ + set("site", e.target.value.toUpperCase())} /> + + {!isHomeRouter && ( + + set("asn", e.target.value)} /> + + )} + {isHomeRouter && ( + + set("lanSubnet", e.target.value)} /> + + )} +
+ + set("country", v)} /> + +
+ Включён + set("enabled", v)} /> +
+
+ + {/* 2. WAN-аплинки (только для home-router) */} + {isHomeRouter && ( +
+ WAN-аплинки + set("wanUplinks", wans)} + /> +
+ )} + + {/* 3. Подключение (API) */} +
+ Подключение (RouterOS REST API) + + + set("host", e.target.value)} /> + + +
+ + { set("proto", v); set("port", v === "https" ? "443" : "80") }} + options={[{ value: "https", label: "HTTPS" }, { value: "http", label: "HTTP" }]} + /> + + + set("port", e.target.value)} /> + +
+ +
+
+

+ + Проверять SSL-сертификат +

+

Отключить для self-signed сертификатов

+
+ set("verifySsl", v)} /> +
+ + + set("apiPath", e.target.value)} /> + + +
+

RouterOS 7.1+ REST API

+

+ {form.proto}://{form.host || "host"}:{form.port}{form.apiPath} +

+ {isHomeRouter && ( +

+ ⚠ Home Router доступен только из LAN / через VPN-туннель +

+ )} +
+ + + set("username", e.target.value)} /> + + + +
+ set("password", e.target.value)} /> + +
+
+ +
+ + {testState === "ok" && ( +
+ {testMsg} +
+ )} + {testState === "error" && ( +
+ {testMsg} +
+ )} +
+
+ + {/* 4. Дополнительно */} +
+ + {form.showAdvanced && ( +
+
+ + set("sshPort", Number(e.target.value))} /> + + + set("winboxPort", Number(e.target.value))} /> + +
+ + set("timeout", Number(e.target.value))} /> + + + set("comment", e.target.value)} /> + +
+ )} +
+ +
+ + + }>Отмена + + +
+
+
+ ) +} diff --git a/app/(main)/settings/page.tsx b/app/(main)/settings/page.tsx new file mode 100644 index 0000000..d87f2b8 --- /dev/null +++ b/app/(main)/settings/page.tsx @@ -0,0 +1,1356 @@ +"use client" + +import { useState } 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 { useDataSource } from "@/lib/data-source" +import { Separator } from "@/components/ui/separator" +import { + Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter, +} from "@/components/ui/sheet" +import { Flag } from "@/components/flag" +import { StatusDot } from "@/components/status-dot" +import { servers } from "@/lib/data" +import { + SaveIcon, PencilIcon, TrashIcon, PlusIcon, CheckIcon, CopyIcon, + XIcon, AlertCircleIcon, ShieldIcon, EyeIcon, EyeOffIcon, WrenchIcon, UserIcon, + ServerIcon, LayoutDashboardIcon, RefreshCwIcon, CableIcon, +} from "lucide-react" +import { cn } from "@/lib/utils" + +// ─── types ──────────────────────────────────────────────────────────────────── + +type Role = "admin" | "operator" | "viewer" +type PermLevel = "none" | "read" | "write" + +interface SectionPerm { section: string; level: PermLevel } +interface ServerPerm { serverId: string; level: PermLevel } + +interface User { + id: string; name: string; login: string; email: string + role: Role; last: string; avatar: string; active: boolean + sections: SectionPerm[]; servers: ServerPerm[] + subUsers: SubUser[] +} + +interface ApiKey { id: string; name: string; prefix: string; created: string; last: string; scopes: string[] } + +// Подчинённый пользователь — учётка для подключения устройства к JH через GRE +interface SubUser { + id: string + login: string + password: string + description: string // "офисный роутер", "склад", и т.п. + jhServerIds: string[] // на каких JH зарегистрирована учётка + clientIp: string // назначенный IP внутри туннеля + active: boolean + lastSeen: string | null +} + +// ─── section definitions ────────────────────────────────────────────────────── + +const SECTION_GROUPS: { group: string; icon: React.ReactNode; items: string[] }[] = [ + { group: "Обзор", icon: , items: ["Дашборд", "Трафик", "Карта сети", "Мониторинг"] }, + { group: "Данные", icon: , items: ["Домены", "IP-диапазоны", "ASN", "Communities"] }, + { group: "Управление", icon: , items: ["Серверы", "Фильтры", "Firewall", "GRE-туннели", "Бэкапы"] }, + { group: "Инструменты", icon: , items: ["Оптимизатор маршрутов", "OSPF", "Диагностика GRE", "Терминал"] }, + { group: "Система", icon: , items: ["Настройки"] }, +] + +const ALL_SECTIONS = SECTION_GROUPS.flatMap(g => g.items) + +// ─── default permissions by role ────────────────────────────────────────────── + +function defaultSections(role: Role): SectionPerm[] { + return ALL_SECTIONS.map(section => { + let level: PermLevel = "none" + if (role === "admin") level = "write" + else if (role === "operator") level = ["Серверы","Фильтры","Firewall","GRE-туннели","Бэкапы","Диагностика GRE","Оптимизатор маршрутов","OSPF"].includes(section) ? "write" : "read" + else if (role === "viewer") level = ["Настройки","Терминал"].includes(section) ? "none" : "read" + return { section, level } + }) +} + +function defaultServers(role: Role): ServerPerm[] { + return servers.map(s => ({ + serverId: s.id, + level: role === "admin" ? "write" : role === "operator" ? "read" : "read" as PermLevel, + })) +} + +// ─── initial data ────────────────────────────────────────────────────────────── + +const INIT_USERS: User[] = [ + { + id: "u1", name: "Александр Коротаев", login: "a.korotaev", email: "a.korotaev@company.io", + role: "admin", last: "сейчас", avatar: "АК", active: true, + sections: defaultSections("admin"), servers: defaultServers("admin"), + subUsers: [ + { id: "su1", login: "gre-office-msk", password: "xK9#mQ2$vLp8", description: "Офис MSK", jhServerIds: ["srv1","srv7"], clientIp: "10.210.0.2", active: true, lastSeen: "5м назад" }, + { id: "su2", login: "gre-datacenter", password: "pW3@jT7!hD5k", description: "ЦОД Tier-2", jhServerIds: ["srv1"], clientIp: "10.210.0.6", active: true, lastSeen: "1ч назад" }, + { id: "su3", login: "gre-warehouse", password: "rN6%bF2*cM8s", description: "Склад Химки", jhServerIds: ["srv7"], clientIp: "10.210.1.2", active: false, lastSeen: "3 дн назад" }, + ], + }, + { + id: "u2", name: "Дмитрий Фёдоров", login: "d.fedorov", email: "d.fedorov@company.io", + role: "operator", last: "2ч назад", avatar: "ДФ", active: true, + sections: defaultSections("operator"), servers: defaultServers("operator"), + subUsers: [ + { id: "su4", login: "gre-spb-branch", password: "qA4^eU9#wG1o", description: "Филиал SPB", jhServerIds: ["srv1","srv7"], clientIp: "10.210.0.10", active: true, lastSeen: "20м назад" }, + ], + }, + { + id: "u3", name: "Мария Соколова", login: "m.sokolova", email: "m.sokolova@company.io", + role: "viewer", last: "вчера", avatar: "МС", active: true, + sections: defaultSections("viewer"), servers: defaultServers("viewer"), + subUsers: [], + }, + { + id: "u4", name: "Игорь Петров", login: "i.petrov", email: "i.petrov@company.io", + role: "operator", last: "3 дн назад", avatar: "ИП", active: false, + sections: defaultSections("operator"), servers: defaultServers("operator"), + subUsers: [ + { id: "su5", login: "gre-retail-01", password: "bF7!nK3@mZ9v", description: "Магазин #1", jhServerIds: ["srv1"], clientIp: "10.210.0.14", active: false, lastSeen: null }, + ], + }, +] + +const INIT_API_KEYS: ApiKey[] = [ + { id: "k1", name: "Monitoring Script", prefix: "rl_live_mU9k2…", created: "12 дн назад", last: "2м назад", scopes: ["servers:read", "probes:read"] }, + { id: "k2", name: "CI/CD deploy hook", prefix: "rl_live_vB3p8…", created: "1 мес назад", last: "вчера", scopes: ["filters:write", "domains:write"] }, + { id: "k3", name: "Grafana datasource", prefix: "rl_live_xK7r1…", created: "2 мес назад", last: "сейчас", scopes: ["traffic:read", "servers:read"] }, +] + +const ROLE_LABEL: Record = { admin: "Администратор", operator: "Оператор", viewer: "Наблюдатель" } +const ROLE_COLOR: Record = { + admin: "bg-violet-500/10 text-violet-600 dark:text-violet-400 border border-violet-500/20", + operator: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border border-sky-500/20", + viewer: "bg-muted text-muted-foreground border border-border", +} + +const PERM_OPTS: { v: PermLevel; label: string }[] = [ + { v: "none", label: "Нет" }, + { v: "read", label: "Просмотр" }, + { v: "write", label: "Управление"}, +] + +const PERM_COLOR: Record = { + none: "bg-muted text-muted-foreground", + read: "bg-sky-500/10 text-sky-600 dark:text-sky-400", + write: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400", +} + +const SECTIONS_NAV = ["Общие", "Уведомления", "Пользователи", "API-ключи", "Безопасность"] as const +type NavSection = typeof SECTIONS_NAV[number] + +// ─── small components ───────────────────────────────────────────────────────── + +function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { + return ( + + ) +} + +function SettingRow({ label, description, children }: { label: string; description?: string; children: React.ReactNode }) { + return ( +
+
+

{label}

+ {description &&

{description}

} +
+
{children}
+
+ ) +} + +function PermPills({ value, onChange, disabled }: { value: PermLevel; onChange: (v: PermLevel) => void; disabled?: boolean }) { + return ( +
+ {PERM_OPTS.map((o, i) => ( + + ))} +
+ ) +} + +function AvatarCircle({ avatar, active }: { avatar: string; active: boolean }) { + return ( +
+
{avatar}
+ {!active && ( + + )} +
+ ) +} + +// ─── user sheet ─────────────────────────────────────────────────────────────── + +interface UserForm { + name: string; login: string; email: string; role: Role; active: boolean + sections: SectionPerm[]; servers: ServerPerm[] + subUsers: SubUser[] +} + +function emptyForm(): UserForm { + return { name: "", login: "", email: "", role: "viewer", active: true, + sections: defaultSections("viewer"), servers: defaultServers("viewer"), subUsers: [] } +} + +function genPassword(): string { + const chars = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789!@#$%" + return Array.from({ length: 16 }, () => chars[Math.floor(Math.random() * chars.length)]).join("") +} + +function genSubLogin(userLogin: string, existing: SubUser[]): string { + const base = `gre-${userLogin.split(".").pop() ?? "client"}` + const n = existing.filter(s => s.login.startsWith(base)).length + return n === 0 ? base : `${base}-${String(n + 1).padStart(2, "0")}` +} + +function UserSheet({ open, user, onSave, onClose }: { + open: boolean + user: User | null // null = create + onSave: (f: UserForm) => void + onClose: () => void +}) { + const isCreate = user === null + const [tab, setTab] = useState<"profile" | "sections" | "servers" | "subusers">("profile") + const [form, setForm] = useState(() => + user ? { + name: user.name, login: user.login, email: user.email, role: user.role, + active: user.active, + sections: user.sections.map(s => ({ ...s })), + servers: user.servers.map(s => ({ ...s })), + subUsers: user.subUsers.map(s => ({ ...s })), + } : emptyForm() + ) + // sub-user add form state + const [addSubOpen, setAddSubOpen] = useState(false) + const [newSubLogin, setNewSubLogin] = useState("") + const [newSubPwd, setNewSubPwd] = useState("") + const [newSubDesc, setNewSubDesc] = useState("") + const [newSubJhs, setNewSubJhs] = useState([]) + const [newSubIp, setNewSubIp] = useState("") + const [revealedIds, setRevealedIds] = useState>(new Set()) + const [errors, setErrors] = useState>>({}) + + const setField = (k: K, v: UserForm[K]) => { + setForm(f => ({ ...f, [k]: v })) + if (errors[k]) setErrors(e => ({ ...e, [k]: undefined })) + } + + // When role changes → reset permissions to defaults + const setRole = (role: Role) => { + setForm(f => ({ ...f, role, sections: defaultSections(role), servers: defaultServers(role) })) + } + + const setSectionPerm = (section: string, level: PermLevel) => { + setForm(f => ({ + ...f, + sections: f.sections.map(s => s.section === section ? { ...s, level } : s), + })) + } + + const setServerPerm = (serverId: string, level: PermLevel) => { + setForm(f => ({ + ...f, + servers: f.servers.map(s => s.serverId === serverId ? { ...s, level } : s), + })) + } + + const setAllSections = (level: PermLevel) => { + setForm(f => ({ ...f, sections: f.sections.map(s => ({ ...s, level })) })) + } + + const setAllServers = (level: PermLevel) => { + setForm(f => ({ ...f, servers: f.servers.map(s => ({ ...s, level })) })) + } + + const validate = () => { + const e: Partial> = {} + if (!form.name.trim()) e.name = "Обязательное поле" + if (!form.login.trim()) e.login = "Обязательное поле" + if (!form.email.trim()) e.email = "Обязательное поле" + setErrors(e) + return !Object.keys(e).length + } + + const handleSave = () => { if (validate()) onSave(form) } + + const isAdmin = form.role === "admin" + + const jhServers = servers.filter(s => s.type === "jump-host" && s.enabled) + + const addSubUser = () => { + if (!newSubLogin.trim() || !newSubPwd.trim() || newSubJhs.length === 0) return + const sub: SubUser = { + id: `su${Date.now()}`, + login: newSubLogin.trim(), + password: newSubPwd, + description: newSubDesc.trim(), + jhServerIds: [...newSubJhs], + clientIp: newSubIp.trim(), + active: true, + lastSeen: null, + } + setForm(f => ({ ...f, subUsers: [...f.subUsers, sub] })) + setAddSubOpen(false) + setNewSubLogin(""); setNewSubPwd(""); setNewSubDesc(""); setNewSubIp(""); setNewSubJhs([]) + } + + const toggleNewSubJh = (id: string) => + setNewSubJhs(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]) + + const removeSubUser = (id: string) => + setForm(f => ({ ...f, subUsers: f.subUsers.filter(s => s.id !== id) })) + + const toggleSubUser = (id: string) => + setForm(f => ({ ...f, subUsers: f.subUsers.map(s => s.id === id ? { ...s, active: !s.active } : s) })) + + const toggleReveal = (id: string) => + setRevealedIds(prev => { + const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next + }) + + const TABS = [ + { id: "profile", label: "Профиль" }, + { id: "sections", label: "Разделы" }, + { id: "servers", label: "Серверы" }, + { id: "subusers", label: "GRE-клиенты", badge: form.subUsers.length || undefined }, + ] as const + + return ( + { if (!v) onClose() }}> + + + {/* header */} + +
+
+ {!isCreate && user && ( + + )} +
+ + {isCreate ? "Новый пользователь" : form.name || "—"} + + + {isCreate ? "Заполните данные и настройте доступ" : `@${form.login}`} + +
+
+ +
+ + {/* tab bar */} +
+ {TABS.map(t => ( + + ))} +
+
+ + {/* body */} +
+ + {/* ── Profile tab ── */} + {tab === "profile" && ( +
+ + + setField("name", e.target.value)} + placeholder="Иван Иванов" className="h-9" /> + + + + setField("login", e.target.value)} + placeholder="i.ivanov" className="h-9 font-mono" /> + + + + setField("email", e.target.value)} + placeholder="i.ivanov@company.io" type="email" className="h-9" /> + + + + + +
+ {(["viewer", "operator", "admin"] as Role[]).map(r => ( + + ))} +
+

+ {form.role === "admin" + ? "Полный доступ ко всем разделам и серверам, не ограничивается матрицей прав" + : form.role === "operator" + ? "Управление инфраструктурой согласно выданным правам" + : "Только просмотр согласно выданным правам"} +

+
+ + + + +
+ setField("active", v)} /> + + {form.active ? "Активна" : "Заблокирована"} + +
+
+
+ )} + + {/* ── Sections tab ── */} + {tab === "sections" && ( +
+ {/* bulk actions */} +
+ Выбрать всё: + {PERM_OPTS.map(o => ( + + ))} + {isAdmin && ( + + Администратор имеет полный доступ + + )} +
+ + {SECTION_GROUPS.map(group => ( +
+ {/* group header */} +
+ {group.icon} + + {group.group} + +
+ {/* section rows */} +
+ {group.items.map(section => { + const perm = form.sections.find(s => s.section === section) + const level = perm?.level ?? "none" + return ( +
+ {section} + setSectionPerm(section, v)} disabled={isAdmin} /> +
+ ) + })} +
+
+ ))} +
+ )} + + {/* ── Servers tab ── */} + {tab === "servers" && ( +
+ {/* bulk actions */} +
+ Выбрать всё: + {PERM_OPTS.map(o => ( + + ))} + {isAdmin && ( + + Администратор имеет полный доступ + + )} +
+ +
+ {servers.map(srv => { + const perm = form.servers.find(s => s.serverId === srv.id) + const level = perm?.level ?? "none" + return ( +
+
+ + {srv.country && } +
+

{srv.name}

+

{srv.host} · {srv.site}

+
+
+ setServerPerm(srv.id, v)} disabled={isAdmin} /> +
+ ) + })} +
+
+ )} + + {/* ── GRE-клиенты tab ── */} + {tab === "subusers" && ( +
+ + {/* hint */} +
+ +

+ Учётные записи для подключения устройств к JH через GRE +

+
+ + {/* table header */} + {form.subUsers.length > 0 && ( +
+ Логин / описание + Пароль + JH-серверы + IP-клиента + + +
+ )} + + {/* rows */} +
+ {form.subUsers.length === 0 && !addSubOpen && ( +
+ +

Нет GRE-клиентов

+

Добавьте учётки для подключения устройств

+
+ )} + + {form.subUsers.map(su => { + const jhs = servers.filter(s => su.jhServerIds.includes(s.id)) + const revealed = revealedIds.has(su.id) + return ( +
+ + {/* login + description */} +
+

{su.login}

+ {su.description && ( +

{su.description}

+ )} + {su.lastSeen && ( +

{su.lastSeen}

+ )} +
+ + {/* password */} +
+ + {revealed ? su.password : "••••••••••••"} + + + +
+ + {/* JH servers */} +
+ {jhs.length === 0 + ? + : jhs.map(jh => ( + + + {jh.name.split("-").slice(-1)[0]} + + )) + } +
+ + {/* client IP */} + + {su.clientIp || "—"} + + + {/* active toggle */} + toggleSubUser(su.id)} /> + + {/* delete */} + +
+ ) + })} +
+ + {/* inline add form */} + {addSubOpen ? ( +
+

Новый GRE-клиент

+ +
+
+ + setNewSubLogin(e.target.value)} /> +
+
+ + setNewSubDesc(e.target.value)} /> +
+
+ +
+ +
+ setNewSubPwd(e.target.value)} /> + +
+
+ +
+ +
+ {jhServers.map(s => { + const checked = newSubJhs.includes(s.id) + return ( + + ) + })} +
+ {newSubJhs.length === 0 && ( +

Выберите хотя бы один JH-сервер

+ )} +
+ +
+ + setNewSubIp(e.target.value)} /> +
+ +
+ + +
+
+ ) : ( + + )} +
+ )} +
+ + {/* footer */} + + + + +
+
+ ) +} + +// ─── helper ─────────────────────────────────────────────────────────────────── + +function Field({ label, error, children }: { label: string; error?: string; children: React.ReactNode }) { + return ( +
+ + {children} + {error && ( +

+ {error} +

+ )} +
+ ) +} + +// ─── delete confirm ─────────────────────────────────────────────────────────── + +function DeleteConfirm({ user, onConfirm, onCancel }: { user: User; onConfirm: () => void; onCancel: () => void }) { + return ( +
+
+
+
+
+ +
+
+

Удалить пользователя?

+

{user.name} · @{user.login}

+
+
+

+ Это действие нельзя отменить. Пользователь потеряет доступ немедленно. +

+
+ + +
+
+
+ ) +} + +// ─── page ───────────────────────────────────────────────────────────────────── + +export default function SettingsPage() { + const [section, setSection] = useState("Общие") + const [saved, setSaved] = useState(false) + const [copied, setCopied] = useState(null) + + // data source + const { mode, setMode, backendUrl, setBackendUrl, backendStatus, checkBackend } = useDataSource() + const [urlDraft, setUrlDraft] = useState(backendUrl) + + // general + const [lang, setLang] = useState("ru") + const [theme, setTheme] = useState("system") + const [timezone, setTimezone] = useState("Europe/Moscow") + const [refreshSec, setRefreshSec] = useState("30") + const [probeTimeout, setProbeTimeout] = useState("5") + + // notifications + const [notifEmail, setNotifEmail] = useState(true) + const [notifSlack, setNotifSlack] = useState(false) + const [notifWh, setNotifWh] = useState(true) + const [notifDegr, setNotifDegr] = useState(true) + const [notifOffline, setNotifOffline] = useState(true) + const [notifBgp, setNotifBgp] = useState(true) + const [notifBackup, setNotifBackup] = useState(false) + + // users + const [users, setUsers] = useState(INIT_USERS) + const [sheetOpen, setSheetOpen] = useState(false) + const [editUser, setEditUser] = useState(null) // null = create mode + const [deleteTarget, setDeleteTarget]= useState(null) + + // api keys + const [apiKeys, setApiKeys] = useState(INIT_API_KEYS) + + // security + const [mfa, setMfa] = useState(true) + const [sessMin, setSessMin] = useState("480") + const [ipAllow, setIpAllow] = useState("10.0.0.0/8\n192.168.0.0/16") + const [auditLog, setAuditLog] = useState(true) + + const handleSave = () => { setSaved(true); setTimeout(() => setSaved(false), 2000) } + + const handleCopy = (text: string) => { + navigator.clipboard.writeText(text).catch(() => {}) + setCopied(text); setTimeout(() => setCopied(null), 1500) + } + + const handleUserSave = (form: UserForm) => { + if (!editUser) { + const initials = form.name.split(" ").map(p => p[0] ?? "").slice(0, 2).join("").toUpperCase() + setUsers(prev => [...prev, { + id: "u" + Date.now(), ...form, + last: "только что", avatar: initials || "??", + }]) + } else { + setUsers(prev => prev.map(u => u.id === editUser.id ? { ...u, ...form } : u)) + } + setSheetOpen(false) + } + + // total sub-users count for summary + const totalSubUsers = users.reduce((s, u) => s + u.subUsers.length, 0) + + const renderContent = () => { + + // ── Общие ── + if (section === "Общие") return ( +
+ + {/* ── Источник данных ── */} + + + Источник данных + + Переключите между живыми данными от бекенда и суррогатными моками из lib/data.ts + + + + + {/* mode toggle */} + +
+ {([["mock", "Моковые"], ["live", "Живые"]] as const).map(([v, l], i) => ( + + ))} +
+
+ + {/* backend URL — visible in both modes so user can configure before switching */} + +
+ setUrlDraft(e.target.value)} + onBlur={() => { setBackendUrl(urlDraft.replace(/\/$/, "")) }} + placeholder="http://localhost:8000" + /> + +
+
+ + {/* connection status */} + +
+ {backendStatus === undefined && ( + + + Не проверялось + + )} + {backendStatus === true && ( + + Доступен + + )} + {backendStatus === false && ( + + Недоступен + + )} + +
+
+ + {/* hint when live but backend unreachable */} + {mode === "live" && backendStatus === false && ( +
+ + Бекенд недоступен — данные будут отображаться из кеша или моков. Запустите бекенд: cd backend && npm run dev +
+ )} +
+
+ + {/* ── Основные настройки ── */} + + Основные настройки + + + + + +
+ {[["light","Светлая"],["dark","Тёмная"],["system","Авто"]].map(([v, l]) => ( + + ))} +
+
+ + + + + setRefreshSec(e.target.value)} /> + + + setProbeTimeout(e.target.value)} /> + +
+
+ +
+ ) + + // ── Уведомления ── + if (section === "Уведомления") return ( +
+ + Каналы уведомлений + + + + + {notifEmail &&
} + + + + {notifSlack &&
} + + + + {notifWh &&
} +
+
+ + Триггеры + + + + + + + + + + + + + + + +
+ ) + + // ── Пользователи ── + if (section === "Пользователи") return ( +
+
+

+ {users.length} пользователей · {users.filter(u => u.active).length} активных + {totalSubUsers > 0 && ` · ${totalSubUsers} GRE-клиентов`} +

+ +
+ + + {/* table header */} +
+ Пользователь + Роль + Последний вход + Статус + +
+ +
+ {users.map(u => { + return ( +
+ {/* user */} +
+ +
+

{u.name}

+
+

@{u.login}

+ {u.subUsers.length > 0 && ( + + {u.subUsers.length} + + )} +
+
+
+ {/* role */} + + {ROLE_LABEL[u.role]} + + {/* last */} + {u.last} + {/* status */} + + {u.active ? "Активен" : "Заблокирован"} + + {/* actions */} +
+ + {u.id !== "u1" && ( + + )} +
+
+ ) + })} +
+
+ + {/* access summary */} + +
+ + Сводка прав доступа +
+
+ + + + + + + + + + + {users.map(u => { + const writeSections = u.role === "admin" ? ALL_SECTIONS : u.sections.filter(s => s.level === "write").map(s => s.section) + const readSections = u.role === "admin" ? [] : u.sections.filter(s => s.level === "read").map(s => s.section) + const accessServers = u.role === "admin" ? servers : servers.filter(s => u.servers.find(p => p.serverId === s.id && p.level !== "none")) + return ( + + + + + + + ) + })} + +
ПользовательРазделыСерверыПрава записи
+
+ + {u.name} +
+
+ {u.role === "admin" + ? Все ({ALL_SECTIONS.length}) + : {(readSections.length + writeSections.length)} из {ALL_SECTIONS.length}} + + {u.role === "admin" + ? Все ({servers.length}) + : {accessServers.length} из {servers.length}} + +
+ {u.role === "admin" + ? Полный доступ + : writeSections.length === 0 + ? Только просмотр + : writeSections.slice(0, 3).map(s => ( + {s} + )) + } + {u.role !== "admin" && writeSections.length > 3 && ( + +{writeSections.length - 3} + )} +
+
+
+
+
+ ) + + // ── API-ключи ── + if (section === "API-ключи") return ( +
+
+ +
+
+ {apiKeys.map(k => ( + + +
+
+

{k.name}

+
+ {k.prefix} + +
+
+ {k.scopes.map(s => ( + {s} + ))} +
+
+
+

Создан: {k.created}

+

Использован: {k.last}

+
+ + +
+
+
+
+
+ ))} +
+ + + Документация API + Base URL: https://api.routerlists.io/v1 + + +
+ {[ + ["GET", "/servers", "Список серверов"], + ["GET", "/traffic", "Данные трафика"], + ["GET", "/filters", "Фильтры маршрутов"], + ["POST", "/filters", "Создать фильтр"], + ["GET", "/probes", "Диагностические пробы"], + ["POST", "/probes/run", "Запустить тест"], + ].map(([method, path, desc], i) => ( +
+ {method} + {path} + {desc} +
+ ))} +
+
+
+
+ ) + + // ── Безопасность ── + if (section === "Безопасность") return ( +
+ + Аутентификация + + + + + + setSessMin(e.target.value)} /> + + + + + + Allowlist IP-адресов + Доступ разрешён только с этих сетей. Одна запись на строку. + + +