fix(servers): исправить сохранение и опрос серверов

This commit is contained in:
Denozordec committed 2026-09-13 09:32:37 +07:00
1 parent 88326d9851
commit c30f817926
10 files changed
+635 -62

No files matched your search

+4
View File
@@ -31,6 +31,10 @@ pnpm-debug.log*
# coverage
coverage/
test-results/
playwright-report/
test-results/
playwright-report/
# typescript
*.tsbuildinfo
+193 -54
View File
@@ -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>
)
}
+9 -3
View File
@@ -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
View File
@@ -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.
+49
View File
@@ -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
}
+46
View File
@@ -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
View File
@@ -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",
+28
View File
@@ -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",
},
},
})
+8 -1
View File
@@ -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
+265
View File
@@ -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)
})