"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 lanSubnet: string wanUplinks: WanUplink[] 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, lanSubnet: s.lanSubnet || undefined, wanUplinks: Array.isArray(s.wanUplinks) && s.wanUplinks.length ? s.wanUplinks : undefined, // carry extra fields needed for expanded view uptime: s.uptime ?? undefined, cpuLoad: s.cpuLoad ?? undefined, freeMemory: s.freeMemory ?? undefined, totalMemory: s.totalMemory ?? 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-[var(--status-online-bg)] text-[var(--status-online-fg)] border-current/20" : v >= 710 ? "bg-[var(--status-degraded-bg)] text-[var(--status-degraded-fg)] border-current/20" : "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)] border-current/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") { queueMicrotask(() => { 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, lanSubnet: full.lanSubnet ?? prev.lanSubnet, wanUplinks: Array.isArray(full.wanUplinks) && full.wanUplinks.length ? JSON.parse(JSON.stringify(full.wanUplinks)) : prev.wanUplinks, })) } 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, lanSubnet: form.lanSubnet.trim(), wanUplinks: form.type === "home-router" ? form.wanUplinks : [], } 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, lanSubnet: form.lanSubnet || undefined, wanUplinks: form.type === "home-router" ? form.wanUplinks : undefined, 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, lanSubnet: form.lanSubnet || undefined, wanUplinks: form.type === "home-router" ? form.wanUplinks : 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-[var(--status-degraded-fg)]" : "")}> {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)} />
)}
}>Отмена
) }