fix(ui): учитывать готовность устройства в действиях IPsec

This commit is contained in:
Denozordec committed 2026-09-30 14:48:21 +07:00
1 parent b8923d075d
commit f0071a495c
7 files changed
+97 -12

No files matched your search

+20 -5
View File
@@ -300,6 +300,13 @@ function IpsecPage() {
const failedServerIds = new Set(failures.map(f => f.serverId))
const scopeUnknown = isLive && (!hasLoaded || !!dataError || (effectiveServerId === ALL_SERVERS_ID ? failures.length > 0 : failedServerIds.has(effectiveServerId)))
const canManage = (serverId: string) => isLive && hasLoaded && !dataError && !inventoryError &&
!loading && !busy && !historyRestoring && !failedServerIds.has(serverId) && liveServers.some(s => s.id === serverId)
const assertDeviceReady = (serverId: string) => {
if (!canManage(serverId)) throw new Error("Сначала подтвердите актуальное состояние устройства: повторите опрос IPsec")
}
const hasReadyDevice = displayServers.some(s => canManage(s.id))
const scopedClients = useMemo(() => {
if (effectiveServerId === ALL_SERVERS_ID) return displayClients
return displayClients.filter((c) => c.serverId === effectiveServerId)
@@ -370,6 +377,7 @@ function IpsecPage() {
}, [displayServers, displayClients])
const handleInit = async (form: IpsecInitFormState) => {
assertDeviceReady(form.serverId)
setBusy(true)
try {
await initIpsecServer(backendUrl, {
@@ -391,6 +399,7 @@ function IpsecPage() {
}
const handleCreateUser = async (form: IpsecUserFormState) => {
assertDeviceReady(form.serverId)
setBusy(true)
try {
const res = await createIpsecUser(backendUrl, {
@@ -424,6 +433,7 @@ function IpsecPage() {
const handleEditUser = async (form: IpsecUserFormState) => {
if (!editing) return
assertDeviceReady(editing.serverId)
setBusy(true)
try {
await patchIpsecUser(backendUrl, editing.serverId, editing.rosId, {
@@ -490,6 +500,8 @@ function IpsecPage() {
const confirmDelete = async (force = false) => {
if (!pendingDelete || deleteLock.current || (force && !forceAllowed)) return
const target = pendingDelete
const serverId = target.kind === "user" ? target.client.serverId : target.kind === "server" ? target.summary.serverId : target.serverId
if (!canManage(serverId)) { setDeleteError("Повторите опрос IPsec перед удалением"); return }
deleteLock.current = true
setBusy(true)
setDeleteError(null)
@@ -523,6 +535,7 @@ function IpsecPage() {
const handleEditPeer = async (form: IpsecPeerFormState) => {
if (!editingPeer) return
assertDeviceReady(editingPeer.serverId)
setBusy(true)
try {
await patchIpsecPeer(backendUrl, editingPeer.serverId, editingPeer.peer.rosId, {
@@ -586,7 +599,7 @@ function IpsecPage() {
<Button
size="sm"
variant="outline"
disabled={loading}
disabled={loading || busy || historyRestoring}
onClick={() => void loadLive()}
>
<RefreshCwIcon className={`size-4 ${loading ? "animate-spin" : ""}`} />
@@ -597,7 +610,7 @@ function IpsecPage() {
<Button
size="sm"
variant="outline"
disabled={loading || !historyServerId}
disabled={loading || busy || historyRestoring || !historyServerId}
title={!historyServerId ? "Выберите сервер, чтобы смотреть историю" : "История версий и откат"}
onClick={() => {
setHistoryOpen(true)
@@ -609,7 +622,7 @@ function IpsecPage() {
</Button>
)}
{isLive ? (
<Button size="sm" onClick={() => setUserOpen(true)}>
<Button size="sm" disabled={!hasReadyDevice} onClick={() => setUserOpen(true)}>
<PlusIcon className="size-4" />
Новый клиент
</Button>
@@ -713,7 +726,7 @@ function IpsecPage() {
searchPlaceholder="Поиск по имени / CN / IP…"
actions={
isLive ? (
<Button size="sm" onClick={() => { setEditing(null); setUserOpen(true) }}>
<Button size="sm" disabled={!hasReadyDevice} onClick={() => { setEditing(null); setUserOpen(true) }}>
<PlusIcon className="size-4" />
Клиент
</Button>
@@ -722,6 +735,7 @@ function IpsecPage() {
/>
{isLive && !hasLoaded ? <p role="status" className="p-5 text-sm text-muted-foreground">{loading ? "Загрузка клиентов IPsec…" : "Клиенты IPsec пока неизвестны"}</p> : <IpsecUsersGrid
clients={filteredClients}
canManage={canManage}
emptyTitle={scopeUnknown ? "Клиенты IPsec пока неизвестны" : undefined}
emptyDescription={scopeUnknown ? "Повторите опрос устройств, чтобы подтвердить состояние" : undefined}
compactServer={compactServer}
@@ -735,6 +749,7 @@ function IpsecPage() {
<TabsContent value="server" className="mt-0 outline-none">
{isLive && !hasLoaded ? <p role="status" className="p-5 text-sm text-muted-foreground">Состояние серверов IPsec пока неизвестно</p> : <IpsecServerGrid
servers={scopedSummaries}
canManage={canManage}
onInit={isLive ? (s) => { setSelectedServerId(s.serverId); setInitOpen(true) } : undefined}
onRemove={isLive ? (s) => setPendingDelete({ kind: "server", summary: s }) : undefined}
onEditPeer={isLive ? openEditPeer : undefined}
@@ -814,7 +829,7 @@ function IpsecPage() {
<IpsecUserSheet
open={userOpen}
onOpenChange={(v) => { setUserOpen(v); if (!v) setEditing(null) }}
servers={serverOptions}
servers={serverOptions.filter(s => canManage(s.id) || busy)}
peers={peerOptions}
busy={busy}
defaultServerId={sheetServerId}
+8 -4
View File
@@ -150,6 +150,7 @@ function CertRow({
}
export interface IpsecServerGridProps {
canManage?: (serverId: string) => boolean
servers: IpsecServerSummaryDto[]
onInit?: (row: IpsecServerSummaryDto) => void
onRemove?: (row: IpsecServerSummaryDto) => void
@@ -161,6 +162,7 @@ export interface IpsecServerGridProps {
function IpsecServerGrid({
servers,
canManage,
onInit,
onRemove,
onEditPeer,
@@ -208,8 +210,8 @@ function IpsecServerGrid({
<PeerRow
key={p.rosId}
peer={p}
onEdit={onEditPeer ? (peer) => onEditPeer(s.serverId, peer) : undefined}
onDelete={onDeletePeer ? (peer) => onDeletePeer(s.serverId, peer) : undefined}
onEdit={onEditPeer && (!canManage || canManage(s.serverId)) ? (peer) => onEditPeer(s.serverId, peer) : undefined}
onDelete={onDeletePeer && (!canManage || canManage(s.serverId)) ? (peer) => onDeletePeer(s.serverId, peer) : undefined}
/>
))}
</div>
@@ -228,8 +230,8 @@ function IpsecServerGrid({
<CertRow
key={c.name}
cert={c}
onDelete={onDeleteCert ? (cert) => onDeleteCert(s.serverId, cert) : undefined}
onExport={onExportCert ? (cert) => onExportCert(s.serverId, cert) : undefined}
onDelete={onDeleteCert && (!canManage || canManage(s.serverId)) ? (cert) => onDeleteCert(s.serverId, cert) : undefined}
onExport={onExportCert && (!canManage || canManage(s.serverId)) ? (cert) => onExportCert(s.serverId, cert) : undefined}
/>
))}
</div>
@@ -250,6 +252,7 @@ function IpsecServerGrid({
type="button"
size="sm"
variant="outline"
disabled={!onInit || (canManage && !canManage(s.serverId))}
onClick={() => onInit?.(s)}
title={s.initialized ? "Обновить managed-конфиг сервера" : "Инициализировать IKEv2-сервер"}
>
@@ -264,6 +267,7 @@ function IpsecServerGrid({
className="size-8 text-destructive"
aria-label="Удалить IKEv2-сервер"
title="Удалить managed-объекты IKEv2 (identity/mode-config/peer/pool/NAT + сертификаты)"
disabled={canManage && !canManage(s.serverId)}
onClick={() => onRemove(s)}
>
<Trash2Icon className="size-3.5" />
+6 -1
View File
@@ -28,6 +28,7 @@ import {
} from "lucide-react"
export interface IpsecUsersGridProps {
canManage?: (serverId: string) => boolean
clients: IpsecClientDto[]
compactServer?: boolean
emptyAction?: ReactNode
@@ -40,6 +41,7 @@ export interface IpsecUsersGridProps {
function IpsecUsersGrid({
clients,
canManage,
compactServer = false,
emptyAction,
emptyTitle = "Нет клиентов IKEv2",
@@ -63,6 +65,7 @@ function IpsecUsersGrid({
<UsersIcon className="size-3.5 shrink-0 text-muted-foreground" />
<button
type="button"
disabled={!onEdit || (canManage && !canManage(c.serverId))}
className="truncate text-left font-medium hover:underline"
onClick={() => onEdit?.(c)}
>
@@ -198,6 +201,7 @@ function IpsecUsersGrid({
className="size-7"
aria-label="Скачать сертификат"
title="Скачать .p12"
disabled={canManage && !canManage(c.serverId)}
onClick={() => onDownloadCert(c)}
>
<WifiIcon className="size-3.5" />
@@ -210,6 +214,7 @@ function IpsecUsersGrid({
size="icon"
className="size-7 text-destructive"
aria-label="Удалить клиента"
disabled={canManage && !canManage(c.serverId)}
onClick={() => onDelete(c)}
>
<Trash2Icon className="size-3.5" />
@@ -226,7 +231,7 @@ function IpsecUsersGrid({
)
return cols
}, [compactServer, onDownloadCert, onEdit, onDelete])
}, [compactServer, onDownloadCert, onEdit, onDelete, canManage])
const table = useTable({
features: dataGridFeatures,
+8 -1
View File
@@ -25,12 +25,19 @@
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.48 — IPsec/GRE/VXLAN, Firewall и операции Filters |
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.49 — IPsec/GRE/VXLAN, Firewall и операции Filters |
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
## Блок 9.49 — readiness действий IPsec (2026-09-30)
- Кнопки клиентов и серверов используют readiness конкретного serverId: требуется успешное чтение, доступный inventory, включённое устройство, отсутствие его failure и текущей загрузки/операции. Сохранённые строки не превращаются в разрешение редактировать/удалять. Успешное устройство остаётся доступным при частичном отказе другого.
- Формы создания получают только доступные устройства, уже выполняемый запрос сохраняет список. Submit и удаление дополнительно проверяют readiness перед API. Первоначальный/полный отказ блокирует создание. Refresh во время изменения/восстановления отключён. Серверные операции с неответившим устройством недоступны; просмотр истории не является разрешением восстановления.
- Новый E2E проверяет начальный отказ, частичный отказ двух устройств, общий отказ и восстановление с нулём write-запросов; вместе с прежними операциями/таблицами прошло 15 E2E. TypeScript и lint чистые. Табличная IPsec-фикстура теперь содержит реальный inventory устройства вместо пустого списка.
- Следующий блок: общий замок/контекст поздних операций, restore/export readiness и свежесть. Backend не менялся; последние build frontend — 9.47, backend — 9.44. Реальные RouterOS/JWT/БД не проверены. Локальный коммит без push.
## Блок 9.48 — состояния чтения IPsec (2026-09-30)
- listIpsec проверяет контракт, уникальность серверов/клиентов/отказов, конфликт успешного и неответившего устройства и контекст выборного ответа. Повреждённый ответ не меняет данные. IPsec и inventory загружаются независимо через allSettled; список устройств тоже проверяется схемой.
+7
View File
@@ -249,3 +249,10 @@ Live-копирование читает актуальный целевой rul
- Поколение чтения игнорирует устаревшие ответы; смена mode/backend монтирует отдельное состояние. Поздние записи и доступность действий на сохранённых данных будут проверены следующим блоком, ещё не приняты.
- 17 E2E прошли: шесть новых сценариев чтения и 11 прежних операций. Ранее прошли три теста таблиц IPsec/GRE (два новых теста сперва требовали уточнения alert-локатора из-за Next route-announcer, исправлены). TypeScript и lint четырёх файлов чистые. Backend не менялся; последние build frontend — 9.47, backend — 9.44.
- Далее — readiness действий, свежесть и гонки IPsec; затем VXLAN/общий GRE. Реальные RouterOS/JWT/БД не проверены. Квота: 0% использования в начале, 4% пятичасовой/16% недельной после проверок. Локальный коммит без push.
## Блок 9.49 — readiness действий IPsec (2026-09-30)
- Кнопки клиентов и серверов используют readiness конкретного serverId: требуется успешное чтение, доступный inventory, включённое устройство, отсутствие его failure и текущей загрузки/операции. Сохранённые строки не превращаются в разрешение редактировать/удалять. Успешное устройство остаётся доступным при частичном отказе другого.
- Формы создания получают только доступные устройства, уже выполняемый запрос сохраняет список. Submit и удаление дополнительно проверяют readiness перед API. Первоначальный/полный отказ блокирует создание. Refresh во время изменения/восстановления отключён. Серверные операции с неответившим устройством недоступны; просмотр истории не является разрешением восстановления.
- Новый E2E проверяет начальный отказ, частичный отказ двух устройств, общий отказ и восстановление с нулём write-запросов; вместе с прежними операциями/таблицами прошло 15 E2E. TypeScript и lint чистые. Табличная IPsec-фикстура теперь содержит реальный inventory устройства вместо пустого списка.
- Следующий блок: общий замок/контекст поздних операций, restore/export readiness и свежесть. Backend не менялся; последние build frontend — 9.47, backend — 9.44. Реальные RouterOS/JWT/БД не проверены. Локальный коммит без push.
+1 -1
View File
@@ -20,7 +20,7 @@ async function setup(page: Page, path: string, empty = false) {
const path = new URL(route.request().url()).pathname
return route.fulfill({ json: path === "/api/ipsec" ? (empty ? { servers: [], clients: [] } : ipsec)
: path === "/api/gre/tunnels" ? { tunnels: empty ? [] : tunnels }
: path === "/api/auth/config" ? { required: false } : path === "/api/servers" ? [] : {} })
: path === "/api/auth/config" ? { required: false } : path === "/api/servers" ? [{ id: 1, name: "Тестовый роутер", host: "192.0.2.1", enabled: true, country: "RU" }] : {} })
})
await page.goto(path)
}
+47
View File
@@ -0,0 +1,47 @@
import { test, expect } from "@playwright/test"
test("IPsec: действия требуют актуальный опрос конкретного устройства", async ({ page }) => {
const devices = [1, 2].map(id => ({ id, name: "Устройство " + id, host: "192.0.2." + id, enabled: true, country: "RU" }))
const clients = ["1", "2"].map(id => ({ id: id + "-*1", rosId: "*1", serverId: id, serverName: "Устройство " + id, name: "Клиент " + id, authMethod: "certificate", kind: "cert", online: false, disabled: false, managed: true }))
const summaries = ["1", "2"].map(id => ({ serverId: id, serverName: "Устройство " + id, initialized: false, ike2Ready: false, peers: [], natRuleManaged: false, clientsTotal: 1, clientsOnline: 0 }))
let phase = 0, writes = 0
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
if (r.request().method() !== "GET") { writes++; return r.fulfill({ json: { ok: true } }) }
if (path === "/api/ipsec") {
if (phase === 0 || phase === 3) return r.fulfill({ status: 503, json: { error: "Опрос недоступен тест" } })
return r.fulfill({ json: phase === 2 ? { servers: [summaries[0]], clients: [clients[0]], failures: [{ serverId: "2", error: "Таймаут тест" }] } : { servers: summaries, clients } })
}
return r.fulfill({ json: path === "/api/servers" ? devices : path === "/api/auth/config" ? { required: false } : {} })
})
await page.goto("/ipsec")
const add = page.getByRole("button", { name: "Новый клиент", exact: true })
await expect(page.getByRole("alert").filter({ hasText: "Не удалось загрузить IPsec" })).toBeVisible()
await expect(add).toBeDisabled()
phase = 1
await page.getByRole("button", { name: "Обновить", exact: true }).click()
const rows = page.locator("tr[data-row-id]")
await expect(rows).toHaveCount(2)
await expect(add).toBeEnabled()
const one = rows.filter({ hasText: "Клиент 1" }), two = rows.filter({ hasText: "Клиент 2" })
await expect(two.getByRole("button", { name: "Удалить клиента" })).toBeEnabled()
phase = 2
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(page.getByRole("alert").filter({ hasText: "Не удалось опросить часть роутеров" })).toBeVisible()
await expect(rows).toHaveCount(2)
await expect(two.getByRole("button", { name: "Удалить клиента" })).toBeDisabled()
await expect(two.getByRole("button", { name: "Клиент 2", exact: true })).toBeDisabled()
await expect(one.getByRole("button", { name: "Удалить клиента" })).toBeEnabled()
await expect(add).toBeEnabled()
phase = 3
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(page.getByRole("alert").filter({ hasText: "Не удалось загрузить IPsec" })).toBeVisible()
await expect(one.getByRole("button", { name: "Удалить клиента" })).toBeDisabled()
await expect(add).toBeDisabled()
phase = 4
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(add).toBeEnabled()
await expect(two.getByRole("button", { name: "Удалить клиента" })).toBeEnabled()
expect(writes).toBe(0)
})