fix(servers): исправить сохранение и опрос серверов
This commit is contained in:
1 parent
88326d9851
commit
c30f817926
10 files changed
+635
-62
No files matched your search
+193
-54
@@ -1,6 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
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"
|
||||
@@ -231,35 +235,83 @@ export default function ServersPage() {
|
||||
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 controlsBusy = saving || rowBusy || pollAllBusy || pollingIds.size > 0
|
||||
|
||||
const set = <K extends keyof FormState>(k: K, v: FormState[K]) =>
|
||||
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([])
|
||||
})
|
||||
if (mode !== "live") {
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return
|
||||
setServerList(initialServers)
|
||||
setBackendOk(false)
|
||||
})
|
||||
return
|
||||
return invalidate
|
||||
}
|
||||
listServers(backendUrl)
|
||||
.then(data => {
|
||||
if (cancelled || version !== contextVersion.current) return
|
||||
setBackendOk(true)
|
||||
setServerList(data.map(toFrontendServer))
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((error) => {
|
||||
if (cancelled || version !== contextVersion.current) return
|
||||
setOperationError(serverOperationError(error))
|
||||
setBackendOk(false)
|
||||
setServerList([])
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
return invalidate
|
||||
}, [mode, backendUrl])
|
||||
|
||||
// ── actions ──────────────────────────────────────────────────────────────
|
||||
|
||||
function openAdd() {
|
||||
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)
|
||||
@@ -267,11 +319,15 @@ export default function ServersPage() {
|
||||
}
|
||||
|
||||
async function openEdit(s: Server) {
|
||||
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.status !== "offline", host: s.host,
|
||||
asn: s.asn ?? "", enabled: s.enabled, host: s.host,
|
||||
comment: s.comment ?? "",
|
||||
lanSubnet: s.lanSubnet ?? "",
|
||||
wanUplinks: s.wanUplinks ? JSON.parse(JSON.stringify(s.wanUplinks)) : [],
|
||||
@@ -282,9 +338,11 @@ export default function ServersPage() {
|
||||
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",
|
||||
@@ -295,16 +353,23 @@ export default function ServersPage() {
|
||||
? JSON.parse(JSON.stringify(full.wanUplinks))
|
||||
: prev.wanUplinks,
|
||||
}))
|
||||
} catch {
|
||||
// ignore — user can fill in manually
|
||||
setDetailsReady(true)
|
||||
} catch (error) {
|
||||
if (version === formVersion.current) setFormError(serverOperationError(error))
|
||||
} finally {
|
||||
if (version === formVersion.current) setDetailsLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
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) || (form.proto === "https" ? 443 : 80),
|
||||
port: Number(form.port),
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
useSsl: form.proto === "https",
|
||||
@@ -320,18 +385,32 @@ export default function ServersPage() {
|
||||
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) {
|
||||
console.error("Ошибка сохранения:", e)
|
||||
if (context === contextVersion.current) setFormError(serverOperationError(e))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// offline mode — update local state only
|
||||
@@ -349,7 +428,7 @@ export default function ServersPage() {
|
||||
comment: form.comment,
|
||||
lanSubnet: form.lanSubnet || undefined,
|
||||
wanUplinks: form.type === "home-router" ? form.wanUplinks : undefined,
|
||||
status: form.enabled ? (s.status === "offline" ? "online" : s.status) : "offline",
|
||||
enabled: form.enabled,
|
||||
}
|
||||
: s
|
||||
))
|
||||
@@ -368,57 +447,80 @@ export default function ServersPage() {
|
||||
}
|
||||
|
||||
setOpen(false)
|
||||
formVersion.current++; testVersion.current++
|
||||
setOperationMessage(isLive ? "Сервер сохранён." : "Сервер сохранён в демо-режиме.")
|
||||
} finally {
|
||||
gate.current.release("save"); setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggleStatus(id: string) {
|
||||
setServerList(list => list.map(s =>
|
||||
s.id === id ? { ...s, status: s.status === "offline" ? "online" : "offline" } : s
|
||||
))
|
||||
async function handleToggleStatus(id: string) {
|
||||
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 (isLive) {
|
||||
try {
|
||||
await deleteServer(backendUrl, id)
|
||||
} catch (e) {
|
||||
console.error("Ошибка удаления:", e)
|
||||
}
|
||||
}
|
||||
setServerList(list => list.filter(s => s.id !== id))
|
||||
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) {
|
||||
if (!isLive) return
|
||||
setPollingIds(p => new Set(p).add(id))
|
||||
try {
|
||||
await pollServer(backendUrl, id)
|
||||
// Reload full list to get updated status/version
|
||||
const data = await listServers(backendUrl)
|
||||
setServerList(data.map(toFrontendServer))
|
||||
} catch (e) {
|
||||
console.error("Poll error:", e)
|
||||
} finally {
|
||||
setPollingIds(p => { const n = new Set(p); n.delete(id); return n })
|
||||
}
|
||||
await runPoll([id])
|
||||
}
|
||||
|
||||
async function handlePollAll() {
|
||||
if (!isLive) return
|
||||
setPollAllBusy(true)
|
||||
await runPoll(serverList.map(s => s.id))
|
||||
}
|
||||
|
||||
async function runPoll(ids: string[]) {
|
||||
if (!isLive || !gate.current.acquire("rows")) return
|
||||
const context = contextVersion.current
|
||||
setPollAllBusy(true); setPollingIds(new Set(ids)); setOperationError(""); setOperationMessage(""); setPollResults([])
|
||||
try {
|
||||
const ids = serverList.map(s => s.id)
|
||||
await Promise.all(ids.map(id => pollServer(backendUrl, id).catch(() => {})))
|
||||
const data = await listServers(backendUrl)
|
||||
setServerList(data.map(toFrontendServer))
|
||||
const results = await pollServers(ids, async id => {
|
||||
if (context !== contextVersion.current) throw new Error("Контекст изменился; опрос отменён.")
|
||||
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 {
|
||||
setPollAllBusy(false)
|
||||
gate.current.release("rows"); setPollAllBusy(false); setPollingIds(new Set())
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
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, {
|
||||
@@ -430,14 +532,18 @@ export default function ServersPage() {
|
||||
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 {
|
||||
} catch (error) {
|
||||
if (version !== testVersion.current) return
|
||||
setTestState("error")
|
||||
setTestMsg(`Не удалось связаться с бекендом — убедитесь, что он запущен на ${backendUrl}`)
|
||||
setTestMsg(serverOperationError(error))
|
||||
} finally {
|
||||
gate.current.release("test")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,11 +584,11 @@ export default function ServersPage() {
|
||||
crumbs={[{ label: "Управление" }, { label: "Серверы" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={handlePollAll} disabled={!isLive || pollAllBusy}>
|
||||
<Button variant="outline" size="sm" onClick={handlePollAll} disabled={!isLive || controlsBusy || serverList.length === 0}>
|
||||
<RefreshCwIcon className={cn("size-4", pollAllBusy && "animate-spin")} />Проверить все
|
||||
</Button>
|
||||
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||||
<Button size="sm" onClick={openAdd}><PlusIcon className="size-4" />Добавить сервер</Button>
|
||||
<Button size="sm" onClick={openAdd} disabled={controlsBusy}><PlusIcon className="size-4" />Добавить сервер</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -490,6 +596,18 @@ export default function ServersPage() {
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
{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={controlsBusy} onClick={() => runPoll(pollResults.filter(r => r.error).map(r => r.id))}>Повторить неуспешные</Button>}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка серверов"
|
||||
items={[
|
||||
@@ -548,9 +666,10 @@ export default function ServersPage() {
|
||||
servers={filtered}
|
||||
isLive={isLive}
|
||||
pollingIds={pollingIds}
|
||||
busy={controlsBusy}
|
||||
onPoll={handlePoll}
|
||||
onEdit={openEdit}
|
||||
onDelete={handleDelete}
|
||||
onDelete={id => { setDeleteError(""); setDeleteTarget(serverList.find(s => s.id === id) ?? null) }}
|
||||
onToggleStatus={handleToggleStatus}
|
||||
/>
|
||||
</DataPageCard>
|
||||
@@ -558,13 +677,19 @@ export default function ServersPage() {
|
||||
</div>
|
||||
|
||||
{/* ══ Sheet: Add / Edit Server ══════════════════════════════════════════ */}
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
|
||||
<Sheet open={open} onOpenChange={closeForm}>
|
||||
<SheetContent side="right" showCloseButton={!saving} className="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={saving || !detailsReady} className="flex min-h-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}>
|
||||
@@ -776,22 +901,36 @@ export default function ServersPage() {
|
||||
</StepperContent>
|
||||
</StepperPanel>
|
||||
</Stepper>
|
||||
</fieldset>
|
||||
|
||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||
<SheetClose render={<Button variant="outline" />}>Отмена</SheetClose>
|
||||
<SheetClose render={<Button variant="outline" disabled={saving} />}>Отмена</SheetClose>
|
||||
{sheetStep > 1 && (
|
||||
<Button variant="outline" onClick={() => setSheetStep((s) => s - 1)}>Назад</Button>
|
||||
<Button variant="outline" disabled={saving || !detailsReady} onClick={() => setSheetStep((s) => s - 1)}>Назад</Button>
|
||||
)}
|
||||
{sheetStep < 4 ? (
|
||||
<Button className="ml-auto" onClick={() => setSheetStep((s) => s + 1)}>Далее</Button>
|
||||
<Button className="ml-auto" disabled={saving || !detailsReady} onClick={() => setSheetStep((s) => s + 1)}>Далее</Button>
|
||||
) : (
|
||||
<Button className="ml-auto" onClick={handleSave}>
|
||||
{sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
|
||||
<Button className="ml-auto" disabled={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={deleting} onClick={() => { if (deleteTarget) void handleDelete(deleteTarget.id) }}>{deleting ? "Удаление…" : "Удалить"}</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user