"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 => (
{ 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",
)}>
{c.code}
))}
)
}
// ─── 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-аплинк
)
}
// ─── 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 (
selectedServerId !== null ? selectedServer && void runPoll([selectedServer.id]) : void handlePollAll()} disabled={!canWrite || !isLive || controlsBusy || (selectedServerId !== null ? !selectedServer : serverList.length === 0)}>
{selectedServerId !== null ? "Проверить сервер" : "Проверить все"}
Добавить сервер
>
}
/>
{!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) && runPoll(pollResults.filter(r => r.error).map(r => r.id))}>Повторить неуспешные }
)}
{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 &&
{ const server = serverList.find(s => s.id === editingId); if (server) void openEdit(server) }}>Повторить загрузку }
}
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)} />
set("showPassword", !form.showPassword)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
{form.showPassword ? : }
{testState === "testing"
? <> Проверка…>
: <> Проверить подключение>}
{testState === "ok" && (
{testMsg}
)}
{testState === "error" && (
{testMsg}
)}
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 ? : }
Дополнительно
{form.showAdvanced && (
)}
}>Отмена
{sheetStep > 1 && (
setSheetStep((s) => s - 1)}>Назад
)}
{sheetStep < 4 ? (
setSheetStep((s) => s + 1)}>Далее
) : (
{saving ? "Сохранение…" : sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
)}
{ if (!next && !gate.current.has("rows")) setDeleteTarget(null) }}>
Удалить сервер?
Сервер «{deleteTarget?.name}» ({deleteTarget?.host}) будет удалён из списка управления.
{deleteError && {deleteError}
}
setDeleteTarget(null)}>Отмена
{ if (deleteTarget) void handleDelete(deleteTarget.id) }}>{deleting ? "Удаление…" : "Удалить"}
)
}
export default function ServersPage() {
return Загрузка серверов…}>
}