fix(ui): учитывать готовность устройства в действиях IPsec
This commit is contained in:
1 parent
b8923d075d
commit
f0071a495c
7 files changed
+97
-12
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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; список устройств тоже проверяется схемой.
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user