"use client" import { useEffect, useMemo, useState } from "react" import { PageHeader } from "@/components/page-header" import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit" import { DataPageToolbar } from "@/components/data-page-toolbar" import { ServersDataGrid } from "@/components/data-grids/servers-data-grid" import type { Filter } from "@/components/reui/filters" import { applyReuiFilters } from "@/lib/data-filters/apply-reui-filters" import { SERVER_FILTER_ACCESSORS, SERVER_FILTER_FIELDS } from "@/lib/data-filters/server-filter-fields" import { servers as initialServers } from "@/lib/data" import type { ServerType, Server, WanUplink } from "@/lib/data" import type { ServerCreate, ServerUpdate } from "@mmapp/contracts/servers" import { toFrontendServer } from "@/entities/server/model/mappers" import { createServer, deleteServer, getServer, listServers, pollServer, testServerConnection, updateServer, } from "@/shared/api/servers" // ─── Backend integration ────────────────────────────────────────────────────── import { useDataSource } from "@/lib/data-source" import { Flag } from "@/components/flag" import { cn } from "@/lib/utils" import { Frame, FramePanel } from "@/components/reui/frame" import { IconTile } from "@/components/reui/icon-tile" import { DataPageCard } from "@/components/data-page-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 { Stepper, StepperContent, StepperIndicator, StepperItem, StepperNav, StepperPanel, StepperSeparator, StepperTitle, StepperTrigger, } from "@/components/reui/stepper" import { RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon, EyeIcon, EyeOffIcon, ChevronRightIcon, ChevronDownIcon, CheckCircleIcon, XCircleIcon, LoaderCircleIcon, ShieldIcon, WifiIcon, HomeIcon, ServerIcon, NetworkIcon, } from "lucide-react" // ─── 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: "Норвегия" }, ] // ─── Shared small components ────────────────────────────────────────────────── // ─── 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 } = useDataSource() const isLive = mode === "live" const [serverList, setServerList] = useState([]) const [_backendOk, setBackendOk] = useState(false) const [search, setSearch] = useState("") const [typeFilter, setTypeFilter] = useState("all") const [advancedFilters, setAdvancedFilters] = useState([]) const [open, setOpen] = useState(false) const [sheetMode, setSheetMode] = useState("add") const [editingId, setEditingId] = useState(null) const [sheetStep, setSheetStep] = useState(1) 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 } listServers(backendUrl) .then(data => { setBackendOk(true) setServerList(data.map(toFrontendServer)) }) .catch(() => { setBackendOk(false) setServerList([]) }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [mode, backendUrl]) // ── actions ────────────────────────────────────────────────────────────── function openAdd() { setSheetMode("add"); setEditingId(null) setForm(defaultForm); setTestState("idle"); setTestMsg("") setSheetStep(1) 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(""); setSheetStep(1); setOpen(true) // Fetch full server details (including credentials) from backend if (isLive) { try { const full = await getServer(backendUrl, 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: ServerCreate = { 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 updatedPayload: ServerUpdate = payload const updated = await updateServer(backendUrl, editingId, updatedPayload) setServerList(list => list.map(s => s.id === editingId ? toFrontendServer(updated) : s)) } else { const created = await createServer(backendUrl, payload) setServerList(list => [...list, toFrontendServer(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 deleteServer(backendUrl, id) } 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 pollServer(backendUrl, id) // Reload full list to get updated status/version const data = await listServers(backendUrl) setServerList(data.map(toFrontendServer)) } 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 => pollServer(backendUrl, id).catch(() => {}))) const data = await listServers(backendUrl) setServerList(data.map(toFrontendServer)) } finally { setPollAllBusy(false) } } async function handleTest() { if (!form.host || !form.username) { setTestState("error"); setTestMsg("Заполните Хост и Имя пользователя"); return } setTestState("testing"); setTestMsg("") try { const data = await testServerConnection(backendUrl, { 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, }) if (data.success) { setTestState("ok"); setTestMsg(data.message) } else { setTestState("error"); setTestMsg(data.message) } } catch { setTestState("error") setTestMsg(`Не удалось связаться с бекендом — убедитесь, что он запущен на ${backendUrl}`) } } // ── derived ────────────────────────────────────────────────────────────── const filtered = useMemo(() => { const base = 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) }) return applyReuiFilters(base, advancedFilters, SERVER_FILTER_ACCESSORS) }, [serverList, search, typeFilter, advancedFilters]) 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: , iconClass: "text-muted-foreground" }, { label: "Онлайн", value: counts.online, icon: , iconClass: "text-[var(--status-online-fg)]" }, { label: "JH + Exit Node", value: counts["jump-host"] + counts["exit-node"], icon: , iconClass: "text-muted-foreground" }, { label: "Home Router", value: counts["home-router"],icon: , iconClass: "text-muted-foreground" }, ].map(s => (

{s.label}

{s.value}

))}
{/* Table */} ({ value: tab.value, label: tab.label, count: tab.value === "all" ? counts.all : counts[tab.value as ServerType], })), }} filters={advancedFilters} onFiltersChange={setAdvancedFilters} filterFields={SERVER_FILTER_FIELDS} search={search} onSearchChange={setSearch} searchPlaceholder="Поиск по имени, хосту…" countLabel={`${filtered.length} серверов`} />
{/* ══ Sheet: Add / Edit Server ══════════════════════════════════════════ */} {sheetMode === "edit" ? "Редактировать сервер" : "Новый сервер"} MikroTik RouterOS · Web API (REST) 1 Основные 2 WAN 3 API 4 Дополнительно 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)} />
WAN-аплинки {!isHomeRouter ? (

WAN-аплинки доступны только для типа Home Router.

) : ( set("wanUplinks", wans)} /> )}
Подключение (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}
)}
{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)} />
)}
}>Отмена {sheetStep > 1 && ( )} {sheetStep < 4 ? ( ) : ( )}
) }