984 lines
48 KiB
TypeScript
984 lines
48 KiB
TypeScript
"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>
|
||
}
|