Files
MikrotikManager/app/(main)/servers/page.tsx
T

984 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 (
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">Страна <span className="text-destructive">*</span></label>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Input placeholder="Поиск или код (RU, DE…)" value={query}
onChange={e => {
const v = e.target.value.toUpperCase().slice(0, 3)
setQuery(v)
if (v.length === 2) {
const match = COUNTRIES.find(c => c.code === v)
if (match) onChange(match.code)
}
}}
className="font-mono pr-10 h-8 text-sm" />
{value && (
<span className="absolute right-2.5 top-1/2 -translate-y-1/2">
<Flag code={value} size={20} />
</span>
)}
</div>
{value && (
<span className="text-sm font-mono text-muted-foreground shrink-0">
{COUNTRIES.find(c => c.code === value)?.label ?? value}
</span>
)}
</div>
<div className="grid grid-cols-5 gap-1.5 max-h-52 overflow-y-auto pr-0.5">
{visible.map(c => (
<button key={c.code} type="button"
onClick={() => { onChange(c.code); setQuery("") }}
title={`${c.code} · ${c.label}`}
className={cn(
"flex flex-col items-center gap-1 px-1 py-2 rounded-lg border text-[10px] transition-all",
value === c.code
? "border-primary bg-primary/5 ring-1 ring-primary/30 font-semibold text-primary"
: "border-border hover:border-muted-foreground/40 hover:bg-muted/40 text-muted-foreground",
)}>
<Flag code={c.code} size={24} />
<span className="font-mono leading-none">{c.code}</span>
</button>
))}
</div>
</div>
)
}
// ─── 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<WanUplink>) {
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 (
<div className="flex flex-col gap-3">
{wans.map((wan, idx) => (
<div key={wan.id} className="rounded-lg border border-border bg-muted/20 p-3 flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
WAN {idx + 1}
</span>
<button type="button" onClick={() => removeWan(wan.id)}
className="text-muted-foreground hover:text-destructive transition-colors">
<TrashIcon className="size-3.5" />
</button>
</div>
<div className="grid grid-cols-2 gap-2">
<FormField label="Имя" required>
<Input className="h-8 font-mono text-xs" placeholder="WAN1-RT"
value={wan.name} onChange={e => updateWan(wan.id, { name: e.target.value })} />
</FormField>
<FormField label="Интерфейс">
<Input className="h-8 font-mono text-xs" placeholder="ether1"
value={wan.iface} onChange={e => updateWan(wan.id, { iface: e.target.value })} />
</FormField>
<FormField label="Провайдер (ISP)">
<Input className="h-8 text-xs" placeholder="Rostelecom"
value={wan.isp} onChange={e => updateWan(wan.id, { isp: e.target.value })} />
</FormField>
<FormField label="Внешний IP">
<Input className="h-8 font-mono text-xs" placeholder="94.25.168.1"
value={wan.ip} onChange={e => updateWan(wan.id, { ip: e.target.value })} />
</FormField>
<FormField label="↓ Макс. Мбит">
<Input className="h-8 font-mono text-xs" type="number" min={1}
value={wan.maxDl} onChange={e => updateWan(wan.id, { maxDl: Number(e.target.value) })} />
</FormField>
<FormField label="↑ Макс. Мбит">
<Input className="h-8 font-mono text-xs" type="number" min={1}
value={wan.maxUl} onChange={e => updateWan(wan.id, { maxUl: Number(e.target.value) })} />
</FormField>
</div>
</div>
))}
<Button type="button" variant="outline" size="sm" className="w-fit gap-1.5" onClick={addWan}>
<PlusIcon className="size-3.5" />Добавить WAN-аплинк
</Button>
</div>
)
}
// ─── 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<Server[]>([])
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<SheetMode>("add")
const [editingId, setEditingId] = useState<string | null>(null)
const [sheetStep, setSheetStep] = useState(1)
const [form, setForm] = useState<FormState>(defaultForm)
const [testState, setTestState] = useState<TestState>("idle")
const [testMsg, setTestMsg] = useState("")
const [pollingIds, setPollingIds] = useState<Set<string>>(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<Server | null>(null)
const [deleting, setDeleting] = useState(false)
const [deleteError, setDeleteError] = useState("")
const [pollResults, setPollResults] = useState<PollResult[]>([])
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 extends keyof FormState>(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 (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Серверы" }]}
actions={
<>
<Button variant="outline" size="sm" onClick={() => selectedServerId !== null ? selectedServer && void runPoll([selectedServer.id]) : void handlePollAll()} disabled={!canWrite || !isLive || controlsBusy || (selectedServerId !== null ? !selectedServer : serverList.length === 0)}>
<RefreshCwIcon className={cn("size-4", pollAllBusy && "animate-spin")} />{selectedServerId !== null ? "Проверить сервер" : "Проверить все"}
</Button>
<Button size="sm" onClick={openAdd} disabled={!canWrite || controlsBusy}><PlusIcon className="size-4" />Добавить сервер</Button>
</>
}
/>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
{!canWrite && <Alert variant="info" role="status"><AlertTitle>Только просмотр</AlertTitle><AlertDescription>Изменение, удаление, опрос серверов и тест соединения требуют разрешения на запись. Просмотр деталей и фильтры доступны.</AlertDescription></Alert>}
{operationError && <Alert variant="destructive" role="alert"><AlertTitle>Действие не завершено</AlertTitle><AlertDescription>{operationError}</AlertDescription></Alert>}
{operationMessage && <Alert variant="success" role="status"><AlertDescription>{operationMessage}</AlertDescription></Alert>}
{pollResults.length > 0 && (
<Alert variant={pollResults.some(r => r.error) ? "warning" : "success"} role="status">
<AlertTitle>Опрос: успешно {pollResults.filter(r => !r.error).length} из {pollResults.length}</AlertTitle>
<AlertDescription>
<ul>{pollResults.map(r => <li key={r.id}>{serverList.find(s => s.id === r.id)?.name ?? r.id}: {r.error ?? "опрос выполнен"}</li>)}</ul>
{pollResults.some(r => r.error) && <Button type="button" variant="outline" size="sm" disabled={!canWrite || controlsBusy} onClick={() => runPoll(pollResults.filter(r => r.error).map(r => r.id))}>Повторить неуспешные</Button>}
</AlertDescription>
</Alert>
)}
{selectedServerId !== null && (
<Alert variant={listState === "error" || (listState === "ready" && !selectedServer) ? "warning" : "info"} role="status" aria-label="Сервер по ссылке">
<AlertTitle className="line-clamp-none wrap-anywhere">{selectedServer ? "Выбран сервер: " + selectedServer.name : "Сервер по ссылке"}</AlertTitle>
<AlertDescription>
<p>{listState === "loading" ? "Загрузка сервера…" : listState === "error" ? "Не удалось загрузить сервер. Проверьте сообщение об ошибке выше." : selectedServer ? selectedServer.host + " · Список ограничен выбранным сервером. Сводка относится ко всем серверам." : "Сервер не найден или недоступен. Возможно, он удалён или у вас нет доступа."}</p>
<div className="flex flex-wrap gap-4">
<Link href={allServersHref} className="underline underline-offset-2">Все серверы</Link>
<Link href="/dashboard" className="underline underline-offset-2">К дашборду</Link>
</div>
</AlertDescription>
</Alert>
)}
<KpiStatGrid
aria-label="Сводка серверов"
items={[
{
id: "all",
label: "Всего серверов",
value: counts.all,
icon: <ServerIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "online",
label: "Онлайн",
value: counts.online,
icon: <CheckCircleIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "jh-en",
label: "JH + Exit Node",
value: counts["jump-host"] + counts["exit-node"],
icon: <NetworkIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "home",
label: "Home Router",
value: counts["home-router"],
icon: <HomeIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
]}
/>
{/* Table */}
{urlError && <Alert variant="warning" role="alert"><AlertTitle>Фильтры требуют внимания</AlertTitle><AlertDescription>{urlError}</AlertDescription></Alert>}
{hasFilters && <div><Button variant="outline" size="sm" onClick={resetFilters}>Сбросить фильтры списка</Button></div>}
<DataPageCard>
<DataPageToolbar
segmented={{
value: typeFilter,
onChange: setTypeFilter,
options: tabs.map((tab) => ({
value: tab.value,
label: tab.label,
count: tab.value === "all" ? counts.all : counts[tab.value as ServerType],
})),
}}
filterControl={<ServerFilters query={advancedFilters} onQueryChange={setAdvancedFilters} />}
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по имени, хосту…"
countLabel={`${filtered.length} серверов`}
/>
<ServersDataGrid
servers={filtered}
hasUnfilteredRows={serverList.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}
/>
</DataPageCard>
</div>
</div>
{/* ══ Sheet: Add / Edit Server ══════════════════════════════════════════ */}
<Sheet open={open} onOpenChange={closeForm}>
<SheetContent side="right" showCloseButton={!saving} className="data-[side=right]:w-full sm:max-w-lg flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>{sheetMode === "edit" ? "Редактировать сервер" : "Новый сервер"}</SheetTitle>
<SheetDescription>MikroTik RouterOS · Web API (REST)</SheetDescription>
</SheetHeader>
{detailsLoading && <p className="px-6 py-2 text-sm" role="status">Загрузка параметров сервера…</p>}
{formError && <div className="px-6 py-2"><Alert variant="destructive" role="alert"><AlertDescription>{formError}</AlertDescription></Alert>
{!detailsReady && !detailsLoading && editingId && <Button type="button" variant="outline" onClick={() => { const server = serverList.find(s => s.id === editingId); if (server) void openEdit(server) }}>Повторить загрузку</Button>}
</div>}
<fieldset disabled={!canWrite || saving || !detailsReady} className="flex min-h-0 min-w-0 flex-1 flex-col" aria-busy={saving || detailsLoading}>
<Stepper value={sheetStep} onValueChange={setSheetStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
<StepperNav className="mb-5">
<StepperItem step={1}>
<StepperTrigger>
<StepperIndicator>1</StepperIndicator>
<StepperTitle className="sr-only">Основные</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={2}>
<StepperTrigger>
<StepperIndicator>2</StepperIndicator>
<StepperTitle className="sr-only">WAN</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={3}>
<StepperTrigger>
<StepperIndicator>3</StepperIndicator>
<StepperTitle className="sr-only">API</StepperTitle>
</StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={4}>
<StepperTrigger>
<StepperIndicator>4</StepperIndicator>
<StepperTitle className="sr-only">Дополнительно</StepperTitle>
</StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel className="flex-1 overflow-y-auto">
<StepperContent value={1} className="flex flex-col gap-4">
<FormField label="Имя сервера" required hint="Например home-msk-01">
<Input className="font-mono" placeholder="home-msk-01"
value={form.name} onChange={e => set("name", e.target.value)} />
</FormField>
<FormField label="Тип узла" required>
<SegmentedControl
className="max-w-full flex-wrap"
value={form.type}
onChange={v => set("type", v)}
options={[
{ value: "home-router", label: "Home Router" },
{ value: "jump-host", label: "JumpHost" },
{ value: "exit-node", label: "Exit Node" },
]}
/>
</FormField>
<div className="grid grid-cols-2 gap-3">
<FormField label="Площадка" required hint="MSK, SPB, FRA…">
<Input className="font-mono uppercase" placeholder="MSK"
value={form.site} onChange={e => set("site", e.target.value.toUpperCase())} />
</FormField>
{!isHomeRouter && (
<FormField label="ASN" hint="Например AS65001">
<Input className="font-mono" placeholder="AS65001"
value={form.asn} onChange={e => set("asn", e.target.value)} />
</FormField>
)}
{isHomeRouter && (
<FormField label="LAN-подсеть" hint="Например 192.168.10.0/24">
<Input className="font-mono" placeholder="192.168.10.0/24"
value={form.lanSubnet} onChange={e => set("lanSubnet", e.target.value)} />
</FormField>
)}
</div>
<CountryField value={form.country} onChange={v => set("country", v)} />
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Включён</span>
<FormToggle checked={form.enabled} onChange={v => set("enabled", v)} />
</div>
</StepperContent>
<StepperContent value={2} className="flex flex-col gap-4">
<SectionTitle>WAN-аплинки</SectionTitle>
{!isHomeRouter ? (
<p className="text-sm text-muted-foreground">WAN-аплинки доступны только для типа Home Router.</p>
) : (
<WanUplinkEditor
wans={form.wanUplinks}
onChange={wans => set("wanUplinks", wans)}
/>
)}
</StepperContent>
<StepperContent value={3} className="flex flex-col gap-4">
<SectionTitle>Подключение (RouterOS REST API)</SectionTitle>
<FormField label="Хост / IP-адрес" required
hint={isHomeRouter
? "Управляющий LAN-адрес роутера, например 192.168.10.1"
: "Внешний или управляющий IP-адрес роутера"}>
<Input className="font-mono" placeholder={isHomeRouter ? "192.168.10.1" : "203.0.113.1"}
value={form.host} onChange={e => set("host", e.target.value)} />
</FormField>
<div className="grid grid-cols-2 gap-3">
<FormField label="Протокол">
<SegmentedControl
value={form.proto}
onChange={v => { set("proto", v); set("port", v === "https" ? "443" : "80") }}
options={[{ value: "https", label: "HTTPS" }, { value: "http", label: "HTTP" }]}
/>
</FormField>
<FormField label="Порт" hint="443 / 80">
<Input className="font-mono" placeholder="443"
value={form.port} onChange={e => set("port", e.target.value)} />
</FormField>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium flex items-center gap-1.5">
<ShieldIcon className="size-3.5 text-muted-foreground" />
Проверять SSL-сертификат
</p>
<p className="text-xs text-muted-foreground">Отключить для self-signed сертификатов</p>
</div>
<FormToggle checked={form.verifySsl} onChange={v => set("verifySsl", v)} />
</div>
<FormField label="Путь API">
<Input className="font-mono" placeholder="/rest"
value={form.apiPath} onChange={e => set("apiPath", e.target.value)} />
</FormField>
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3 text-xs text-muted-foreground">
<p className="font-medium text-foreground mb-1">RouterOS 7.1+ REST API</p>
<p className="font-mono text-foreground">
{form.proto}://{form.host || "host"}:{form.port}{form.apiPath}
</p>
{isHomeRouter && (
<p className="mt-1 text-amber-600 dark:text-amber-400">
⚠ Home Router доступен только из LAN / через VPN-туннель
</p>
)}
</div>
<FormField label="Имя пользователя" required hint="Пользователь RouterOS с доступом к API">
<Input className="font-mono" placeholder="api-user"
value={form.username} onChange={e => set("username", e.target.value)} />
</FormField>
<FormField label="Пароль" required>
<div className="relative">
<Input type={form.showPassword ? "text" : "password"}
className="font-mono pr-9" placeholder="Пароль пользователя RouterOS"
value={form.password} onChange={e => set("password", e.target.value)} />
<button type="button"
onClick={() => set("showPassword", !form.showPassword)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
{form.showPassword ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
</button>
</div>
</FormField>
<div className="flex flex-col gap-2">
<Button type="button" variant="outline" size="sm" className="w-fit gap-2"
disabled={!canWrite || testState === "testing"} onClick={handleTest}>
{testState === "testing"
? <><LoaderCircleIcon className="size-4 animate-spin" />Проверка…</>
: <><WifiIcon className="size-4" />Проверить подключение</>}
</Button>
{testState === "ok" && (
<div className="flex items-start gap-2 rounded-md border border-current/25 px-3 py-2 text-xs"
style={{ background: "var(--status-online-bg)", color: "var(--status-online-fg)" }}>
<CheckCircleIcon className="size-3.5 shrink-0 mt-0.5" /><span>{testMsg}</span>
</div>
)}
{testState === "error" && (
<div className="flex items-start gap-2 rounded-md border border-current/25 px-3 py-2 text-xs"
style={{ background: "var(--status-offline-bg)", color: "var(--status-offline-fg)" }}>
<XCircleIcon className="size-3.5 shrink-0 mt-0.5" /><span>{testMsg}</span>
</div>
)}
</div>
</StepperContent>
<StepperContent value={4} className="flex flex-col gap-4">
<button type="button" onClick={() => set("showAdvanced", !form.showAdvanced)}
className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors">
{form.showAdvanced ? <ChevronDownIcon className="size-3.5" /> : <ChevronRightIcon className="size-3.5" />}
Дополнительно
<div className="flex-1 h-px bg-border" />
</button>
{form.showAdvanced && (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3">
<FormField label="SSH-порт">
<Input type="number" className="font-mono" value={form.sshPort}
onChange={e => set("sshPort", Number(e.target.value))} />
</FormField>
<FormField label="Winbox-порт">
<Input type="number" className="font-mono" value={form.winboxPort}
onChange={e => set("winboxPort", Number(e.target.value))} />
</FormField>
</div>
<FormField label="Таймаут соединения, с">
<Input type="number" className="font-mono" value={form.timeout}
onChange={e => set("timeout", Number(e.target.value))} />
</FormField>
<FormField label="Комментарий">
<Input placeholder="Описание или заметка" value={form.comment}
onChange={e => set("comment", e.target.value)} />
</FormField>
</div>
)}
</StepperContent>
</StepperPanel>
</Stepper>
</fieldset>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row flex-wrap gap-2">
<SheetClose render={<Button variant="outline" disabled={saving} />}>Отмена</SheetClose>
{sheetStep > 1 && (
<Button variant="outline" disabled={saving || !detailsReady} onClick={() => setSheetStep((s) => s - 1)}>Назад</Button>
)}
{sheetStep < 4 ? (
<Button className="ml-auto" disabled={saving || !detailsReady} onClick={() => setSheetStep((s) => s + 1)}>Далее</Button>
) : (
<Button className="ml-auto" disabled={!canWrite || saving || !detailsReady || testState === "testing"} onClick={handleSave}>
{saving ? "Сохранение…" : sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
</Button>
)}
</SheetFooter>
</SheetContent>
</Sheet>
<AlertDialog open={deleteTarget !== null} onOpenChange={next => { if (!next && !gate.current.has("rows")) setDeleteTarget(null) }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Удалить сервер?</AlertDialogTitle>
<AlertDialogDescription>Сервер «{deleteTarget?.name}» ({deleteTarget?.host}) будет удалён из списка управления.</AlertDialogDescription>
</AlertDialogHeader>
{deleteError && <p role="alert" className="text-sm text-destructive">{deleteError}</p>}
<AlertDialogFooter>
<Button type="button" variant="outline" disabled={deleting} onClick={() => setDeleteTarget(null)}>Отмена</Button>
<Button type="button" variant="destructive" disabled={!canWrite || deleting} onClick={() => { if (deleteTarget) void handleDelete(deleteTarget.id) }}>{deleting ? "Удаление…" : "Удалить"}</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
export default function ServersPage() {
return <Suspense fallback={<p role="status" className="p-6">Загрузка серверов…</p>}><ServersPageContent /></Suspense>
}