fix(servers): исправить сохранение и опрос серверов
This commit is contained in:
1 parent
88326d9851
commit
c30f817926
10 files changed
+635
-62
No files matched your search
@@ -31,6 +31,10 @@ pnpm-debug.log*
|
||||
|
||||
# coverage
|
||||
coverage/
|
||||
test-results/
|
||||
playwright-report/
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
+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>
|
||||
)
|
||||
}
|
||||
@@ -86,6 +86,7 @@ function RosBadge({ os }: { os: string }) {
|
||||
}
|
||||
|
||||
interface ServerRowActionsProps {
|
||||
busy: boolean
|
||||
server: Server
|
||||
isLive: boolean
|
||||
isPolling: boolean
|
||||
@@ -96,6 +97,7 @@ interface ServerRowActionsProps {
|
||||
}
|
||||
|
||||
function ServerRowActions({
|
||||
busy,
|
||||
server,
|
||||
isLive,
|
||||
isPolling,
|
||||
@@ -114,6 +116,7 @@ function ServerRowActions({
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
disabled={busy}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
@@ -148,7 +151,7 @@ function ServerRowActions({
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => onToggleStatus(server.id)}>
|
||||
<PowerIcon className="size-4" />
|
||||
{server.status === "offline" ? "Включить" : "Отключить"}
|
||||
{server.enabled ? "Отключить" : "Включить"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onClick={() => onDelete(server.id)}>
|
||||
@@ -162,6 +165,7 @@ function ServerRowActions({
|
||||
}
|
||||
|
||||
interface ServersDataGridProps {
|
||||
busy?: boolean
|
||||
servers: Server[]
|
||||
isLive: boolean
|
||||
pollingIds: Set<string>
|
||||
@@ -172,6 +176,7 @@ interface ServersDataGridProps {
|
||||
}
|
||||
|
||||
function ServersDataGrid({
|
||||
busy = false,
|
||||
servers,
|
||||
isLive,
|
||||
pollingIds,
|
||||
@@ -326,7 +331,7 @@ function ServersDataGrid({
|
||||
id: "status",
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
cell: ({ row }) => <div className="flex flex-col gap-1"><StatusBadge status={row.original.status} />{!row.original.enabled && <span className="text-xs text-muted-foreground">Отключён</span>}</div>,
|
||||
meta: { headerTitle: "Статус", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
@@ -336,6 +341,7 @@ function ServersDataGrid({
|
||||
const s = row.original
|
||||
return (
|
||||
<ServerRowActions
|
||||
busy={busy}
|
||||
server={s}
|
||||
isLive={isLive}
|
||||
isPolling={pollingIds.has(s.id)}
|
||||
@@ -354,7 +360,7 @@ function ServersDataGrid({
|
||||
},
|
||||
},
|
||||
],
|
||||
[isLive, onDelete, onEdit, onPoll, onToggleStatus, pollingIds],
|
||||
[busy, isLive, onDelete, onEdit, onPoll, onToggleStatus, pollingIds],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
|
||||
+30
-3
@@ -15,8 +15,8 @@
|
||||
| Этап | Результат | Статус |
|
||||
| --- | --- | --- |
|
||||
| 0 | Исходная база, карта миграции, проверки и обзор экранов | Завершён 2026-09-13 |
|
||||
| 1 | Достоверность сохранения, удаления, включения и опроса серверов | Следующий |
|
||||
| 2 | Общая оболочка Data Grid/TanStack v9, Filters и эталон серверов | Не начат |
|
||||
| 1 | Достоверность сохранения, удаления, включения и опроса серверов | Завершён 2026-09-13 |
|
||||
| 2 | Общая оболочка Data Grid/TanStack v9, Filters и эталон серверов | Следующий, отдельное окно |
|
||||
| 3 | Основные таблицы и справочники | Не начат |
|
||||
| 4 | Остальные таблицы, удаление старых API, общая регрессия | Не начат |
|
||||
| 5 | Состояния данных, свежесть, частичные сбои, гонки polling | Не начат |
|
||||
@@ -65,7 +65,7 @@ Lint hotspots: components/reui/sortable.tsx (8 ошибок), app/(main)/probes/
|
||||
Последние коммиты исправляют общий IKEv2-peer с отдельными сертификатами клиентов, декодирование .p12 из RouterOS /file/get, отсутствие неподдерживаемых proposal-ключей в .sswan и горизонтальное переполнение шторки сертификата. Эти сценарии включить в регрессию этапов 4, 9, 11А. Ограничение passphrase брать из @mmapp/contracts (IPSEC_MIN_PASSPHRASE), не дублировать.
|
||||
Тест ipsec-config проходит отдельно, но отсутствует в общей npm test-цепочке backend; подключить его при следующей правке тестовой инфраструктуры.
|
||||
|
||||
## Следующий шаг — этап 1
|
||||
## Задание этапа 1 (выполнено)
|
||||
|
||||
Начать с app/(main)/servers/page.tsx, components/data-grids/servers-data-grid.tsx, shared/api/servers.ts и моделей серверов. Не начинать миграцию ReUI в этом этапе.
|
||||
|
||||
@@ -79,6 +79,33 @@ Lint hotspots: components/reui/sortable.tsx (8 ошибок), app/(main)/probes/
|
||||
|
||||
Остановка после этапа 0: на последней проверке осталось 15% пятичасовой квоты (было 35% на старте). Этап 1 не начат по согласованному правилу резерва. При продолжении сначала проверить свежие лимиты.
|
||||
|
||||
## Этап 1 — достоверность операций
|
||||
|
||||
Исходный HEAD: 88326d9. Изменения предыдущей настройки ReUI и служебные файлы не входят в коммит этапа.
|
||||
|
||||
- В `app/(main)/servers/page.tsx` сохранение закрывает форму только после подтверждения API. Ошибки остаются рядом с формой, повторные отправки блокируются синхронным замком. Пока загружаются детали, сохранение недоступно; ошибку загрузки можно повторить. Поздний ответ другого объекта игнорируется.
|
||||
- Удаление требует подтверждения имени и адреса, при ошибке строка и диалог остаются. Включение изменяет `enabled` через API и не подменяет измеренный online/offline. Изменение параметров подключения сбрасывает прежний результат проверки.
|
||||
- `lib/server-operations.ts` содержит замок операций, понятные сообщения ошибок и очередь опроса максимум по четыре запроса. Результат показан по каждому серверу, повторяются только неудачные запросы. Ошибка последующего обновления списка отделена от результата опроса, прежние строки сохраняются.
|
||||
- В `shared/api/servers.ts` ожидание ограничено 30 секундами, для опроса — 60. Таймаут явно означает неизвестный результат: клиентское прерывание не отменяет действие на backend. Контракты и маршруты API не изменены.
|
||||
- В `components/data-grids/servers-data-grid.tsx` действия блокируются на время операции; выключенный сервер помечается отдельно от доступности.
|
||||
- Добавлены Playwright, `playwright.config.ts`, `tests/e2e/servers.spec.ts` и команда `npm run test:servers`. Используется установленный Microsoft Edge, свободный порт 3100 и отдельный Next dev процесс. Запросы API и health перехвачены тестами; встроенный mock-режим не используется. Отчёты исключены из Git.
|
||||
|
||||
### Проверки этапа 1
|
||||
|
||||
| Проверка | Результат |
|
||||
| --- | --- |
|
||||
| `npm run test:servers` | 14 браузерных тестов прошли: создание, сохранение 400/403/500 и повтор, двойное нажатие, удаление с повтором, enabled, загрузка деталей и смена объекта, массовый опрос и повтор, таймаут, отдельная ошибка обновления списка, запрет включения, сброс проверки подключения |
|
||||
| TypeScript | Проверка без emit прошла; production build также проверяет типы |
|
||||
| `npm run build` | Успешно: contracts, Next.js, TypeScript, 34 статические страницы |
|
||||
| ESLint изменённых TS/TSX-файлов | 0 ошибок, 1 известное предупреждение React Compiler о TanStack v8 `useReactTable` |
|
||||
| ReUI `get_audit_checklist` | Получен; сохранены установленные компоненты, Frame, семантические токены и типизированный API. Требование TanStack v9 остаётся этапам 2–4; полная доступность и адаптивность — профильным этапам и приёмке |
|
||||
|
||||
Моки достаточны для этих клиентских сценариев. Реальный backend, права реальных пользователей и применение RouterOS не проверены. Общие исходные ошибки lint перечислены в этапе 0; они не устранены этим этапом. Полную регрессию повторять после миграции общей основы.
|
||||
|
||||
### Следующий шаг — этап 2
|
||||
|
||||
В новом окне проверить лимиты, git status и последние коммиты. Создать ветку `codex/reui-v9-migration`, перепроверить актуальный совместимый API ReUI Data Grid/TanStack и Filters. Мигрировать общую оболочку и одну таблицу серверов, закрепить зависимости и описать образец переноса. Сохранить проверки операций из этапа 1. Ветку не выпускать до завершения этапа 4. После этапа — отдельный локальный коммит без push.
|
||||
|
||||
## Карта миграции
|
||||
|
||||
Текущая связка: @tanstack/react-table ^8.21.3, @tanstack/react-virtual ^3.14.4, base-nova. Актуальный ReUI MCP ранее подтвердил useTable/dataGridFeatures для Data Grid и дерево FilterQuery вместо filters/onChange для Filters. Перед установкой перепроверить совместимые версии, сохранить семантику существующих фильтров (простые условия → AND) и закрепить lockfile.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/** A synchronous lock: React state alone cannot prevent two calls in one tick. */
|
||||
export class OperationGate {
|
||||
private keys = new Set<string>()
|
||||
|
||||
acquire(key: string): boolean {
|
||||
if (this.keys.has(key)) return false
|
||||
this.keys.add(key)
|
||||
return true
|
||||
}
|
||||
|
||||
release(key: string) { this.keys.delete(key) }
|
||||
has(key: string) { return this.keys.has(key) }
|
||||
}
|
||||
|
||||
export type PollResult = { id: string; error: string | null }
|
||||
|
||||
export function serverOperationError(error: unknown): string {
|
||||
if (error && typeof error === "object" && "status" in error && error.status === 403) {
|
||||
return "Недостаточно прав для этого действия."
|
||||
}
|
||||
if (error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError")) {
|
||||
return "Время ожидания истекло. Результат операции неизвестен — обновите данные перед повтором."
|
||||
}
|
||||
if (error instanceof TypeError) return "Нет соединения с сервером. Проверьте подключение и обновите данные."
|
||||
return error instanceof Error ? error.message : "Не удалось выполнить действие. Попробуйте ещё раз."
|
||||
}
|
||||
|
||||
/** Bounded polling, preserving one outcome for every requested server. */
|
||||
export async function pollServers(
|
||||
ids: string[],
|
||||
poll: (id: string) => Promise<void>,
|
||||
): Promise<PollResult[]> {
|
||||
const uniqueIds = [...new Set(ids)]
|
||||
const results: PollResult[] = new Array(uniqueIds.length)
|
||||
let next = 0
|
||||
await Promise.all(Array.from({ length: Math.min(4, uniqueIds.length) }, async () => {
|
||||
while (next < uniqueIds.length) {
|
||||
const index = next++
|
||||
const id = uniqueIds[index]
|
||||
try {
|
||||
await poll(id)
|
||||
results[index] = { id, error: null }
|
||||
} catch (error) {
|
||||
results[index] = { id, error: serverOperationError(error) }
|
||||
}
|
||||
}
|
||||
}))
|
||||
return results
|
||||
}
|
||||
Generated
+46
@@ -38,6 +38,7 @@
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
@@ -3686,6 +3687,22 @@
|
||||
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz",
|
||||
@@ -11529,6 +11546,35 @@
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
|
||||
+3
-1
@@ -12,7 +12,8 @@
|
||||
"postinstall": "npm run build -w @mmapp/contracts",
|
||||
"prepare": "node .ci/scripts/ensure-git-hooks.mjs",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"test:servers": "playwright test tests/e2e/servers.spec.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.4.1",
|
||||
@@ -40,6 +41,7 @@
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from "@playwright/test"
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
timeout: 45_000,
|
||||
use: {
|
||||
baseURL: "http://127.0.0.1:3100",
|
||||
browserName: "chromium",
|
||||
channel: "msedge",
|
||||
viewport: { width: 1440, height: 1000 },
|
||||
trace: "retain-on-failure",
|
||||
},
|
||||
webServer: {
|
||||
command: "node node_modules/next/dist/bin/next dev --hostname 127.0.0.1 --port 3100",
|
||||
url: "http://127.0.0.1:3100",
|
||||
reuseExistingServer: false,
|
||||
timeout: 120_000,
|
||||
env: {
|
||||
NEXT_PUBLIC_BACKEND_URL: "same-origin",
|
||||
NEXT_PUBLIC_DEFAULT_DATA_SOURCE: "live",
|
||||
NEXT_PUBLIC_ALLOW_MOCK_DATA: "true",
|
||||
NEXT_PUBLIC_AUTH_ENABLED: "false",
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -7,7 +7,14 @@ import {
|
||||
type ServerUpdate,
|
||||
type TestConnectionRequest,
|
||||
} from "@mmapp/contracts/servers"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import { requestJson as httpRequestJson } from "@/shared/api/http-client"
|
||||
|
||||
function requestJson<T>(baseUrl: string, path: string, init?: RequestInit): Promise<T> {
|
||||
return httpRequestJson<T>(baseUrl, path, {
|
||||
...init,
|
||||
signal: AbortSignal.timeout(path.endsWith("/poll") ? 60_000 : 30_000),
|
||||
})
|
||||
}
|
||||
|
||||
type TestConnectionResponse = {
|
||||
success: boolean
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { test, expect, type Page, type Route } from "@playwright/test"
|
||||
import { serverReadSchema, type ServerRead } from "../../packages/contracts/src/servers"
|
||||
|
||||
function server(id: number): ServerRead {
|
||||
return serverReadSchema.parse({
|
||||
id, name: `Проверка ${id}`, host: `192.0.2.${id}`, port: 443,
|
||||
username: "test-user", password: "fixture-only", useSsl: true, verifySsl: true,
|
||||
type: "exit-node", site: "TEST", country: "RU", asn: "", comment: "",
|
||||
enabled: true, lanSubnet: "", wanUplinks: [],
|
||||
createdAt: "2026-09-13T00:00:00Z", updatedAt: "2026-09-13T00:00:00Z",
|
||||
status: "offline", latency: null, os: "7.20", model: "Test router",
|
||||
uptime: null, cpuLoad: null, freeMemory: null, totalMemory: null,
|
||||
identityName: null, sessions: 0, polledAt: null,
|
||||
})
|
||||
}
|
||||
|
||||
async function setup(page: Page, intercept?: (route: Route, path: string) => Promise<boolean>, count = 2) {
|
||||
const rows = Array.from({ length: count }, (_, i) => server(i + 1))
|
||||
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
|
||||
await page.route("**/api/**", async route => {
|
||||
const path = new URL(route.request().url()).pathname
|
||||
if (await intercept?.(route, path)) return
|
||||
const method = route.request().method()
|
||||
let body: unknown = {}
|
||||
if (path === "/api/auth/config") body = { required: false }
|
||||
else if (path === "/api/servers") {
|
||||
if (method === "POST") {
|
||||
const added = { ...server(rows.length + 1), ...route.request().postDataJSON() }
|
||||
rows.push(added); body = added
|
||||
} else body = rows
|
||||
} else if (/^\/api\/servers\/\d+$/.test(path)) {
|
||||
const index = rows.findIndex(s => s.id === Number(path.split("/").pop()))
|
||||
if (method === "DELETE") { rows.splice(index, 1); await route.fulfill({ status: 204 }); return }
|
||||
if (method === "PUT") rows[index] = { ...rows[index], ...route.request().postDataJSON() }
|
||||
body = rows[index]
|
||||
}
|
||||
await route.fulfill({ json: body })
|
||||
})
|
||||
await page.goto("/servers")
|
||||
await expect(page.getByText("Проверка 1", { exact: true })).toBeVisible()
|
||||
}
|
||||
|
||||
async function edit(page: Page, id = 1) {
|
||||
await page.getByRole("button", { name: `Действия: Проверка ${id}`, exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "Редактировать", exact: true }).click()
|
||||
}
|
||||
|
||||
async function finalStep(page: Page) {
|
||||
for (let i = 0; i < 3; i++) await page.getByRole("button", { name: "Далее", exact: true }).click()
|
||||
}
|
||||
|
||||
for (const status of [400, 403, 500]) {
|
||||
test(`ошибка сохранения ${status} сохраняет форму; повтор завершается успешно`, async ({ page }) => {
|
||||
let fail = true
|
||||
await setup(page, async (route, path) => {
|
||||
if (path === "/api/servers/1" && route.request().method() === "PUT" && fail) {
|
||||
await route.fulfill({ status, json: { error: "Сохранение отклонено" } }); return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
await edit(page)
|
||||
await expect(page.getByRole("button", { name: "Далее", exact: true })).toBeEnabled()
|
||||
await finalStep(page)
|
||||
await page.getByRole("button", { name: "Сохранить", exact: true }).click()
|
||||
await expect(page.getByRole("alert")).toContainText(status === 403 ? "Недостаточно прав" : "Сохранение отклонено")
|
||||
await expect(page.getByRole("dialog")).toBeVisible()
|
||||
fail = false
|
||||
await page.getByRole("button", { name: "Сохранить", exact: true }).click()
|
||||
await expect(page.getByRole("dialog")).toBeHidden()
|
||||
await expect(page.getByText("Сервер сохранён.", { exact: true })).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
test("двойное сохранение отправляет один запрос и блокирует форму", async ({ page }) => {
|
||||
let requests = 0
|
||||
let finish!: () => void
|
||||
const pending = new Promise<void>(resolve => { finish = resolve })
|
||||
await setup(page, async (route, path) => {
|
||||
if (path === "/api/servers/1" && route.request().method() === "PUT") {
|
||||
requests++; await pending
|
||||
await route.fulfill({ json: server(1) }); return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
await edit(page); await finalStep(page)
|
||||
await page.getByRole("button", { name: "Сохранить", exact: true }).evaluate((button: HTMLButtonElement) => { button.click(); button.click() })
|
||||
await expect(page.getByRole("button", { name: "Сохранение…", exact: true })).toBeDisabled()
|
||||
await expect(page.getByRole("button", { name: "Отмена", exact: true })).toBeDisabled()
|
||||
await expect.poll(() => requests).toBe(1)
|
||||
finish()
|
||||
await expect(page.getByRole("dialog")).toBeHidden()
|
||||
expect(requests).toBe(1)
|
||||
})
|
||||
|
||||
test("ошибка удаления оставляет строку и подтверждение; повтор удаляет", async ({ page }) => {
|
||||
let fail = true
|
||||
await setup(page, async (route, path) => {
|
||||
if (path === "/api/servers/1" && route.request().method() === "DELETE" && fail) {
|
||||
await route.fulfill({ status: 500, json: { error: "Удаление отклонено" } }); return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
await page.getByRole("button", { name: "Действия: Проверка 1", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "Удалить", exact: true }).click()
|
||||
await expect(page.getByRole("alertdialog")).toContainText("Проверка 1")
|
||||
await page.getByRole("button", { name: "Удалить", exact: true }).click()
|
||||
await expect(page.getByRole("alertdialog")).toContainText("Удаление отклонено")
|
||||
await expect(page.getByText("Проверка 1", { exact: true })).toBeVisible()
|
||||
fail = false
|
||||
await page.getByRole("button", { name: "Удалить", exact: true }).click()
|
||||
await expect(page.getByRole("alertdialog")).toBeHidden()
|
||||
await expect(page.getByText("Проверка 1", { exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("отключение сохраняет offline-статус и отправляет enabled через API", async ({ page }) => {
|
||||
let payload: unknown
|
||||
await setup(page, async (route, path) => {
|
||||
if (path === "/api/servers/1" && route.request().method() === "PUT") payload = route.request().postDataJSON()
|
||||
return false
|
||||
})
|
||||
await page.getByRole("button", { name: "Действия: Проверка 1", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "Отключить", exact: true }).click()
|
||||
await expect(page.getByText("Проверка 1: отключён.", { exact: true })).toBeVisible()
|
||||
expect(payload).toEqual({ enabled: false })
|
||||
await expect(page.getByText("Оффлайн", { exact: true })).toHaveCount(2)
|
||||
})
|
||||
|
||||
test("ошибка загрузки деталей блокирует сохранение и позволяет повтор", async ({ page }) => {
|
||||
let fail = true
|
||||
await setup(page, async (route, path) => {
|
||||
if (path === "/api/servers/1" && route.request().method() === "GET" && fail) {
|
||||
await route.fulfill({ status: 403, json: { error: "Forbidden" } }); return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
await edit(page)
|
||||
await expect(page.getByRole("alert")).toContainText("Недостаточно прав")
|
||||
await expect(page.getByRole("button", { name: "Далее", exact: true })).toBeDisabled()
|
||||
fail = false
|
||||
await page.getByRole("button", { name: "Повторить загрузку", exact: true }).click()
|
||||
await expect(page.getByRole("button", { name: "Далее", exact: true })).toBeEnabled()
|
||||
})
|
||||
|
||||
test("поздний ответ первого сервера не перезаписывает второй", async ({ page }) => {
|
||||
let finish!: () => void
|
||||
const pending = new Promise<void>(resolve => { finish = resolve })
|
||||
await setup(page, async (route, path) => {
|
||||
if (path === "/api/servers/1" && route.request().method() === "GET") {
|
||||
await pending; await route.fulfill({ json: { ...server(1), username: "wrong-user" } }); return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
await edit(page)
|
||||
await expect(page.getByText("Загрузка параметров сервера…")).toBeVisible()
|
||||
await page.getByRole("button", { name: "Отмена", exact: true }).click()
|
||||
await edit(page, 2)
|
||||
await expect(page.getByRole("button", { name: "Далее", exact: true })).toBeEnabled()
|
||||
finish()
|
||||
await page.getByRole("button", { name: "Далее", exact: true }).click()
|
||||
await page.getByRole("button", { name: "Далее", exact: true }).click()
|
||||
await expect(page.getByPlaceholder("api-user", { exact: true })).toHaveValue("test-user")
|
||||
})
|
||||
|
||||
test("массовый опрос ограничен четырьмя запросами и повторяет только ошибки", async ({ page }) => {
|
||||
let active = 0, maximum = 0
|
||||
const calls: string[] = []
|
||||
let fail = true
|
||||
await setup(page, async (route, path) => {
|
||||
if (path.endsWith("/poll")) {
|
||||
calls.push(path); maximum = Math.max(maximum, ++active)
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
active--
|
||||
await route.fulfill({ status: fail && path === "/api/servers/2/poll" ? 500 : 200, json: { error: "Опрос недоступен" } })
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}, 6)
|
||||
await page.getByRole("button", { name: "Проверить все", exact: true }).click()
|
||||
await expect(page.getByText("Опрос: успешно 5 из 6", { exact: true })).toBeVisible()
|
||||
expect(maximum).toBeLessThanOrEqual(4)
|
||||
expect(calls).toHaveLength(6)
|
||||
fail = false
|
||||
await page.getByRole("button", { name: "Повторить неуспешные", exact: true }).click()
|
||||
await expect(page.getByText("Опрос: успешно 1 из 1", { exact: true })).toBeVisible()
|
||||
expect(calls.slice(6)).toEqual(["/api/servers/2/poll"])
|
||||
})
|
||||
|
||||
test("таймаут сохранения сохраняет форму и показывает неопределённый результат", async ({ page }) => {
|
||||
// Speed up only the browser's server-operation deadline, keeping real fetch/AbortSignal.
|
||||
await page.addInitScript(() => {
|
||||
const timeout = AbortSignal.timeout.bind(AbortSignal)
|
||||
AbortSignal.timeout = (ms: number) => timeout(ms === 30_000 ? 500 : ms)
|
||||
})
|
||||
await setup(page, async (route, path) => {
|
||||
if (path === "/api/servers/1" && route.request().method() === "PUT") {
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
await route.fulfill({ json: server(1) }).catch(() => {})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
await edit(page); await finalStep(page)
|
||||
await page.getByRole("button", { name: "Сохранить", exact: true }).click()
|
||||
await expect(page.getByRole("alert")).toContainText("Результат операции неизвестен")
|
||||
await expect(page.getByRole("button", { name: "Сохранить", exact: true })).toBeEnabled()
|
||||
})
|
||||
|
||||
test("создание сервера проходит через API и добавляет подтверждённую запись", async ({ page }) => {
|
||||
await setup(page)
|
||||
await page.getByRole("button", { name: "Добавить сервер", exact: true }).click()
|
||||
await page.getByRole("button", { name: "Далее", exact: true }).click()
|
||||
await page.getByRole("button", { name: "Далее", exact: true }).click()
|
||||
await page.getByPlaceholder("203.0.113.1", { exact: true }).fill("192.0.2.99")
|
||||
await page.getByPlaceholder("api-user", { exact: true }).fill("fixture-user")
|
||||
await page.getByRole("button", { name: "Далее", exact: true }).click()
|
||||
await page.getByRole("dialog").getByRole("button", { name: "Добавить сервер", exact: true }).click()
|
||||
await expect(page.getByRole("dialog")).toBeHidden()
|
||||
await expect(page.getByRole("button", { name: "Действия: 192.0.2.99", exact: true })).toHaveCount(1)
|
||||
})
|
||||
|
||||
test("успешный опрос и ошибка обновления списка имеют раздельные результаты", async ({ page }) => {
|
||||
let polled = false
|
||||
await setup(page, async (route, path) => {
|
||||
if (path.endsWith("/poll")) { polled = true; await route.fulfill({ json: {} }); return true }
|
||||
if (path === "/api/servers" && polled) {
|
||||
await route.fulfill({ status: 500, json: { error: "Список недоступен" } }); return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
await page.getByRole("button", { name: "Действия: Проверка 1", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "Опросить", exact: true }).click()
|
||||
await expect(page.getByText("Опрос: успешно 1 из 1", { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Действие не завершено" })).toContainText("Опрос завершён, но список не обновлён")
|
||||
await expect(page.getByText("Проверка 1", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("ошибка отключения не меняет enabled", async ({ page }) => {
|
||||
await setup(page, async (route, path) => {
|
||||
if (path === "/api/servers/1" && route.request().method() === "PUT") {
|
||||
await route.fulfill({ status: 403, json: { error: "Forbidden" } }); return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
await page.getByRole("button", { name: "Действия: Проверка 1", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "Отключить", exact: true }).click()
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Действие не завершено" })).toContainText("Недостаточно прав")
|
||||
await page.getByRole("button", { name: "Действия: Проверка 1", exact: true }).click()
|
||||
await expect(page.getByRole("menuitem", { name: "Отключить", exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("изменение подключения сбрасывает результат его проверки", async ({ page }) => {
|
||||
await setup(page, async (route, path) => {
|
||||
if (path.endsWith("/test-connection")) {
|
||||
await route.fulfill({ json: { success: true, message: "Проверено тестовым API" } }); return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
await edit(page)
|
||||
await page.getByRole("button", { name: "Далее", exact: true }).click()
|
||||
await page.getByRole("button", { name: "Далее", exact: true }).click()
|
||||
await page.getByRole("button", { name: "Проверить подключение", exact: true }).click()
|
||||
await expect(page.getByText("Проверено тестовым API", { exact: true })).toBeVisible()
|
||||
await page.getByPlaceholder("api-user", { exact: true }).fill("changed-user")
|
||||
await expect(page.getByText("Проверено тестовым API", { exact: true })).toHaveCount(0)
|
||||
})
|
||||
Reference in new issue
Block a user