"use client" import { Suspense, useEffect, useMemo, useRef, useState } from "react" import Link from "next/link" import { can } from "@/lib/auth" import { useSearchParams } from "next/navigation" import { serverCreateSchema } from "@mmapp/contracts/servers" import { OperationGate, pollServers, serverOperationError, type PollResult } from "@/lib/server-operations" import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert" import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter } from "@/components/ui/alert-dialog" 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 { useServerUrlState } from "@/hooks/use-server-url-state" import { ServerFilters } from "@/components/data-grids/server-filters" import { applyFilterQuery } from "@/lib/data-filters/apply-filter-query" import { SERVER_FILTER_ACCESSORS } 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 { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" 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-kit/accessible-stepper" import { RefreshCwIcon, 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" // ════════════════════════════════════════════════════════════════════════════ function ServersPageContent() { const searchParams = useSearchParams() const selectedServerId = searchParams.get("server") const clearParams = new URLSearchParams(searchParams.toString()) clearParams.delete("server") const allServersHref = "/servers" + (clearParams.size ? "?" + clearParams : "") const { mode, backendUrl } = useDataSource() const isLive = mode === "live" const [serverList, setServerList] = useState([]) const [listState, setListState] = useState<"loading" | "ready" | "error">("loading") const { search, setSearch, typeFilter, setTypeFilter, filters: advancedFilters, setFilters: setAdvancedFilters, error: urlError, hasFilters, reset: resetFilters } = useServerUrlState() 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 [saving, setSaving] = useState(false) const [detailsLoading, setDetailsLoading] = useState(false) const [detailsReady, setDetailsReady] = useState(true) const [formError, setFormError] = useState("") const [operationMessage, setOperationMessage] = useState("") const [operationError, setOperationError] = useState("") const [deleteTarget, setDeleteTarget] = useState(null) const [deleting, setDeleting] = useState(false) const [deleteError, setDeleteError] = useState("") const [pollResults, setPollResults] = useState([]) const [rowBusy, setRowBusy] = useState(false) const gate = useRef(new OperationGate()) const formVersion = useRef(0) const testVersion = useRef(0) const contextVersion = useRef(0) const canWrite = can("mm:servers:write") const accessDenied = "Недостаточно прав: изменение и опрос серверов требуют разрешения на запись." function requireWrite(report: (message: string) => void = setOperationError) { if (can("mm:servers:write")) return true report(accessDenied) return false } const controlsBusy = saving || rowBusy || pollAllBusy || pollingIds.size > 0 const set = (k: K, v: FormState[K]) => { if (gate.current.has("save") || detailsLoading) return if (["host", "port", "proto", "verifySsl", "apiPath", "username", "password"].includes(k)) { testVersion.current++ setTestState("idle"); setTestMsg("") } setForm((f) => ({ ...f, [k]: v })) } function closeForm(next: boolean) { if (gate.current.has("save")) return if (!next) { formVersion.current++; testVersion.current++ } setOpen(next) } // ── load from backend when mode/url changes ─────────────────────────────── useEffect(() => { const version = ++contextVersion.current let cancelled = false const invalidate = () => { cancelled = true contextVersion.current++ formVersion.current++ testVersion.current++ } queueMicrotask(() => { if (cancelled) return formVersion.current++; testVersion.current++ setOpen(false); setDeleteTarget(null); setOperationError(""); setOperationMessage(""); setPollResults([]) setListState("loading") }) if (mode !== "live") { queueMicrotask(() => { if (cancelled) return setServerList(initialServers) setListState("ready") }) return invalidate } listServers(backendUrl) .then(data => { if (cancelled || version !== contextVersion.current) return setListState("ready") setServerList(data.map(toFrontendServer)) }) .catch((error) => { if (cancelled || version !== contextVersion.current) return setOperationError(serverOperationError(error)) setListState("error") setServerList([]) }) return invalidate }, [mode, backendUrl]) // ── actions ────────────────────────────────────────────────────────────── function openAdd() { if (!requireWrite()) return if (gate.current.has("save")) return formVersion.current++; testVersion.current++ setFormError(""); setDetailsLoading(false); setDetailsReady(true) setSheetMode("add"); setEditingId(null) setForm(defaultForm); setTestState("idle"); setTestMsg("") setSheetStep(1) setOpen(true) } async function openEdit(s: Server) { if (!requireWrite()) return if (gate.current.has("save")) return const version = ++formVersion.current testVersion.current++ setFormError(""); setDetailsLoading(isLive); setDetailsReady(!isLive) 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.enabled, 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) if (version !== formVersion.current) return setForm(prev => ({ ...prev, username: full.username ?? "", enabled: full.enabled, 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, })) setDetailsReady(true) } catch (error) { if (version === formVersion.current) setFormError(serverOperationError(error)) } finally { if (version === formVersion.current) setDetailsLoading(false) } } } async function handleSave() { if (!requireWrite(setFormError)) return if (!detailsReady || detailsLoading || !gate.current.acquire("save")) return const context = contextVersion.current setSaving(true); setFormError("") try { const payload: ServerCreate = { host: form.host, port: Number(form.port), 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 (!form.host.trim() || !form.username.trim() || !Number.isInteger(payload.port) || payload.port! > 65535 || payload.port! < 1) { setFormError("Укажите хост, имя пользователя и порт от 1 до 65535.") setSheetStep(3) return } const validation = serverCreateSchema.safeParse(payload) if (!validation.success) { setFormError("Проверьте параметры сервера: " + validation.error.issues.map(i => i.path.join(".")).join(", ")) return } if (isLive) { try { if (sheetMode === "edit" && editingId) { const updatedPayload: ServerUpdate = payload const updated = await updateServer(backendUrl, editingId, updatedPayload) if (context !== contextVersion.current) return setServerList(list => list.map(s => s.id === editingId ? toFrontendServer(updated) : s)) } else { const created = await createServer(backendUrl, payload) if (context !== contextVersion.current) return setServerList(list => [...list, toFrontendServer(created)]) } } catch (e) { if (context === contextVersion.current) setFormError(serverOperationError(e)) return } } 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, enabled: form.enabled, } : 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) formVersion.current++; testVersion.current++ setOperationMessage(isLive ? "Сервер сохранён." : "Сервер сохранён в демо-режиме.") } finally { gate.current.release("save"); setSaving(false) } } async function handleToggleStatus(id: string) { if (!requireWrite()) return const server = serverList.find(s => s.id === id) if (!server || !gate.current.acquire("rows")) return const context = contextVersion.current setRowBusy(true); setOperationError(""); setOperationMessage("") try { let enabled = !server.enabled if (isLive) enabled = (await updateServer(backendUrl, id, { enabled })).enabled if (context !== contextVersion.current) return setServerList(list => list.map(s => s.id === id ? { ...s, enabled } : s)) setOperationMessage(`${server.name}: ${enabled ? "включён" : "отключён"}${isLive ? "." : " (демо)."}`) } catch (error) { if (context === contextVersion.current) setOperationError(serverOperationError(error)) } finally { gate.current.release("rows"); setRowBusy(false) } } async function handleDelete(id: string) { if (!requireWrite(setDeleteError)) return if (!gate.current.acquire("rows")) return const context = contextVersion.current setDeleting(true); setRowBusy(true); setDeleteError("") try { if (isLive) await deleteServer(backendUrl, id) if (context !== contextVersion.current) return setServerList(list => list.filter(s => s.id !== id)) setDeleteTarget(null); setOperationError(""); setOperationMessage("Сервер удалён из списка.") } catch (error) { if (context === contextVersion.current) setDeleteError(serverOperationError(error)) } finally { gate.current.release("rows"); setDeleting(false); setRowBusy(false) } } async function handlePoll(id: string) { await runPoll([id]) } async function handlePollAll() { await runPoll(serverList.map(s => s.id)) } async function runPoll(ids: string[]) { if (!requireWrite()) return if (!isLive || !gate.current.acquire("rows")) return const context = contextVersion.current setPollAllBusy(true); setPollingIds(new Set(ids)); setOperationError(""); setOperationMessage(""); setPollResults([]) try { const results = await pollServers(ids, async id => { if (context !== contextVersion.current) throw new Error("Контекст изменился; опрос отменён.") if (!can("mm:servers:write")) throw new Error(accessDenied) await pollServer(backendUrl, id) }) if (context !== contextVersion.current) return setPollResults(results) try { const data = await listServers(backendUrl) if (context === contextVersion.current) setServerList(data.map(toFrontendServer)) } catch (error) { if (context === contextVersion.current) setOperationError("Опрос завершён, но список не обновлён. " + serverOperationError(error)) } } finally { gate.current.release("rows"); setPollAllBusy(false); setPollingIds(new Set()) } } async function handleTest() { if (!requireWrite(setFormError)) return if (!detailsReady || gate.current.has("save") || gate.current.has("test")) return const version = ++testVersion.current if (!form.host || !form.username) { setTestState("error"); setTestMsg("Заполните Хост и Имя пользователя"); return } gate.current.acquire("test") 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 (version !== testVersion.current) return if (data.success) { setTestState("ok"); setTestMsg(data.message) } else { setTestState("error"); setTestMsg(data.message) } } catch (error) { if (version !== testVersion.current) return setTestState("error") setTestMsg(serverOperationError(error)) } finally { gate.current.release("test") } } // ── derived ────────────────────────────────────────────────────────────── const selectedServer = serverList.find(server => server.id === selectedServerId) const filtered = useMemo(() => { if (urlError) return [] const base = serverList.filter(sv => { if (selectedServerId !== null && sv.id !== selectedServerId) return false 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 applyFilterQuery(base, advancedFilters, SERVER_FILTER_ACCESSORS) }, [serverList, search, typeFilter, advancedFilters, selectedServerId, urlError]) 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 (
} />
{!canWrite && Только просмотрИзменение, удаление, опрос серверов и тест соединения требуют разрешения на запись. Просмотр деталей и фильтры доступны.} {operationError && Действие не завершено{operationError}} {operationMessage && {operationMessage}} {pollResults.length > 0 && ( r.error) ? "warning" : "success"} role="status"> Опрос: успешно {pollResults.filter(r => !r.error).length} из {pollResults.length}
    {pollResults.map(r =>
  • {serverList.find(s => s.id === r.id)?.name ?? r.id}: {r.error ?? "опрос выполнен"}
  • )}
{pollResults.some(r => r.error) && }
)} {selectedServerId !== null && ( {selectedServer ? "Выбран сервер: " + selectedServer.name : "Сервер по ссылке"}

{listState === "loading" ? "Загрузка сервера…" : listState === "error" ? "Не удалось загрузить сервер. Проверьте сообщение об ошибке выше." : selectedServer ? selectedServer.host + " · Список ограничен выбранным сервером. Сводка относится ко всем серверам." : "Сервер не найден или недоступен. Возможно, он удалён или у вас нет доступа."}

Все серверы К дашборду
)} , iconClassName: "text-muted-foreground", }, { id: "online", label: "Онлайн", value: counts.online, icon: , iconClassName: "text-success", }, { id: "jh-en", label: "JH + Exit Node", value: counts["jump-host"] + counts["exit-node"], icon: , iconClassName: "text-muted-foreground", }, { id: "home", label: "Home Router", value: counts["home-router"], icon: , iconClassName: "text-muted-foreground", }, ]} /> {/* Table */} {urlError && Фильтры требуют внимания{urlError}} {hasFilters &&
} ({ value: tab.value, label: tab.label, count: tab.value === "all" ? counts.all : counts[tab.value as ServerType], })), }} filterControl={} search={search} onSearchChange={setSearch} searchPlaceholder="Поиск по имени, хосту…" countLabel={`${filtered.length} серверов`} /> 0} isLive={isLive} pollingIds={pollingIds} busy={controlsBusy} canWrite={canWrite} onPoll={handlePoll} onEdit={openEdit} onDelete={id => { if (!requireWrite()) return; setDeleteError(""); setDeleteTarget(serverList.find(s => s.id === id) ?? null) }} onToggleStatus={handleToggleStatus} />
{/* ══ Sheet: Add / Edit Server ══════════════════════════════════════════ */} {sheetMode === "edit" ? "Редактировать сервер" : "Новый сервер"} MikroTik RouterOS · Web API (REST) {detailsLoading &&

Загрузка параметров сервера…

} {formError &&
{formError} {!detailsReady && !detailsLoading && editingId && }
}
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 ? ( ) : ( )}
{ if (!next && !gate.current.has("rows")) setDeleteTarget(null) }}> Удалить сервер? Сервер «{deleteTarget?.name}» ({deleteTarget?.host}) будет удалён из списка управления. {deleteError &&

{deleteError}

}
) } export default function ServersPage() { return Загрузка серверов…

}>
}