fix(ui): показывать свежесть опроса и изменений IPsec

This commit is contained in:
Denozordec committed 2026-09-30 14:58:55 +07:00
1 parent 921aba3054
commit ce43b2d762
5 files changed
+75 -1

No files matched your search

+24
View File
@@ -183,6 +183,9 @@ function IpsecPage() {
const [dataError, setDataError] = useState<string | null>(null)
const [inventoryError, setInventoryError] = useState<string | null>(null)
const [hasLoaded, setHasLoaded] = useState(false)
const [readReceipt, setReadReceipt] = useState<Record<string, string>>({})
const [responseReceipt, setResponseReceipt] = useState<string | null>(null)
const [writeReceipt, setWriteReceipt] = useState<{ serverId: string; at: string } | null>(null)
const mounted = useRef(true)
const operationLock = useRef(false)
useEffect(() => { mounted.current = true; return () => { mounted.current = false } }, [])
@@ -239,6 +242,10 @@ function IpsecPage() {
setSummaries(previous => [...ipsec.servers, ...previous.filter(s => failedIds.has(s.serverId))])
setClients(previous => [...ipsec.clients, ...previous.filter(c => failedIds.has(c.serverId))])
setFailures(ipsec.failures ?? [])
const at = new Date().toISOString()
setResponseReceipt(at)
const successfulIds = new Set([...ipsec.servers.map(s => s.serverId), ...ipsec.clients.map(c => c.serverId)])
setReadReceipt(previous => ({ ...previous, ...Object.fromEntries([...successfulIds].map(id => [id, at])) }))
setHasLoaded(true)
setDataError(null)
} else {
@@ -292,6 +299,7 @@ function IpsecPage() {
const res = await restoreIpsecRevision(backendUrl, id, historyServerId)
if (!mounted.current) return
if (res?.ok !== true) throw new Error("Сервер не подтвердил восстановление")
confirmOperation(historyServerId)
toast.success("Версия применена на роутер")
await loadLive(true)
await loadRevisions()
@@ -311,6 +319,10 @@ function IpsecPage() {
const assertDeviceReady = (serverId: string) => {
if (operationLock.current || !canManage(serverId)) throw new Error("Сначала подтвердите актуальное состояние устройства: повторите опрос IPsec")
}
const confirmOperation = (serverId: string) => setWriteReceipt({ serverId, at: new Date().toISOString() })
const formatReceipt = (at: string) => new Date(at).toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" })
const scopeReceipt = effectiveServerId === ALL_SERVERS_ID ? responseReceipt : readReceipt[effectiveServerId]
const scopeWriteReceipt = writeReceipt && (effectiveServerId === ALL_SERVERS_ID || writeReceipt.serverId === effectiveServerId) ? writeReceipt : null
const beginOperation = (serverId: string) => {
assertDeviceReady(serverId)
operationLock.current = true
@@ -407,6 +419,7 @@ function IpsecPage() {
createNatRule: form.createNatRule,
})
if (!mounted.current) return
confirmOperation(form.serverId)
toast.success("IKEv2-сервер инициализирован")
setInitOpen(false)
await loadLive(true)
@@ -429,6 +442,7 @@ function IpsecPage() {
passphrase: form.passphrase.trim() || undefined,
})
if (!mounted.current) return
confirmOperation(form.serverId)
toast.success(`Клиент «${form.name.trim()}» создан`)
setUserOpen(false)
setEditing(null)
@@ -459,6 +473,7 @@ function IpsecPage() {
psk: form.authMethod === "pre-shared-key" && form.psk.trim() ? form.psk : undefined,
})
if (!mounted.current) return
confirmOperation(editing.serverId)
toast.success("Клиент обновлён")
setUserOpen(false)
setEditing(null)
@@ -535,6 +550,7 @@ function IpsecPage() {
await deleteIpsecCert(backendUrl, target.serverId, target.cert.name, { force })
}
if (!mounted.current) return
confirmOperation(serverId)
toast.success("Удалено")
setPendingDelete(null)
setForceAllowed(false)
@@ -568,6 +584,7 @@ function IpsecPage() {
disabled: form.disabled,
})
if (!mounted.current) return
confirmOperation(editingPeer.serverId)
toast.success("Peer обновлён")
setPeerOpen(false)
setEditingPeer(null)
@@ -688,6 +705,13 @@ function IpsecPage() {
]}
/>
{isLive && <div role="group" aria-label="Свежесть IPsec" className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span data-source-time="read">{effectiveServerId === ALL_SERVERS_ID ? "Последний ответ IPsec получен в приложении: " : "Устройство опрошено, ответ получен в приложении: "}
{scopeReceipt ? <time dateTime={scopeReceipt} title="Клиентское время получения ответа; не время измерения RouterOS">{formatReceipt(scopeReceipt)}</time> : "неизвестно"}
</span>
{scopeWriteReceipt && <span data-source-time="write">Изменение подтверждено API: <time dateTime={scopeWriteReceipt.at} title={`Устройство ${scopeWriteReceipt.serverId}; подтверждение API не заменяет опрос`}>{formatReceipt(scopeWriteReceipt.at)}</time></span>}
</div>}
{isLive && (dataError || inventoryError) ? (
<Alert variant="destructive">
<AlertCircleIcon />
+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.50 — IPsec/GRE/VXLAN, Firewall и операции Filters |
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.51 — IPsec/GRE/VXLAN, Firewall и операции Filters |
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
## Блок 9.51 — свежесть IPsec (2026-09-30)
- По выбранному устройству показано клиентское время получения успешного ответа; по всем устройствам — время последнего валидного ответа IPsec, которое не означает успешный опрос каждого роутера. Частичный отказ сохраняет прошлое время неответившего устройства, полный отказ не меняет ни одну дату. До первого успешного результата время неизвестно; время отмечается только для устройств, присутствующих в успешных сводках/клиентах.
- Подтверждённые изменения/восстановление показывают отдельный timestamp API с serverId. Он не заменяет последующий опрос. ISO доступен через time.dateTime, title поясняет клиентские часы; даты не сохраняются между перезагрузками, возраст не обновляется таймером.
- 8 E2E прошли: данные/схемы, новый тест управляемых часов с выбором устройства и расширенный замок удаления/ошибка опроса с подтверждением записи. TypeScript и lint чистые. Backend не менялся, build пока не повторялся.
- Далее — профильная клавиатурная и адаптивная приёмка IPsec, проверенные экраны в плане; затем оставшиеся VXLAN/GRE. Реальные RouterOS/JWT/БД не проверены. Локальный коммит без push.
## Блок 9.50 — замок и контекст операций IPsec (2026-09-30)
- Общий синхронный operationLock охватывает инициализацию, создание/редактирование клиентов/peer, удаление и восстановление до последующего чтения. Начало записи инвалидирует предыдущий опрос; внешний refresh при записи не отправляется. Восстановление и экспорт проверяют readiness устройства, ошибки остаются в своих панелях.
+7
View File
@@ -264,3 +264,10 @@ Live-копирование читает актуальный целевой rul
- Успешное удаление закрывает подтверждение даже при отказе последующего опроса; таблица сохраняет последние строки с явной ошибкой чтения и отключёнными действиями до повтора. Проверки не выдают это за неуспешное удаление.
- 30 E2E прошли: 6 отдельных тестов backend/mode контекста, 23 регрессии IPsec/истории (включая GRE smoke) и новый сценарий замка/ошибки чтения. TypeScript и lint чистые. Отдельная конфигурация ipsec-context исключена из обычной и использует порт 3101 последовательно.
- Далее — клиентская свежесть IPsec и профильные ПК/телефон экраны; затем оставшиеся VXLAN/GRE. Последние build frontend — 9.47, backend — 9.44. Реальные RouterOS/JWT/БД не проверены. Локальный коммит без push.
## Блок 9.51 — свежесть IPsec (2026-09-30)
- По выбранному устройству показано клиентское время получения успешного ответа; по всем устройствам — время последнего валидного ответа IPsec, которое не означает успешный опрос каждого роутера. Частичный отказ сохраняет прошлое время неответившего устройства, полный отказ не меняет ни одну дату. До первого успешного результата время неизвестно; время отмечается только для устройств, присутствующих в успешных сводках/клиентах.
- Подтверждённые изменения/восстановление показывают отдельный timestamp API с serverId. Он не заменяет последующий опрос. ISO доступен через time.dateTime, title поясняет клиентские часы; даты не сохраняются между перезагрузками, возраст не обновляется таймером.
- 8 E2E прошли: данные/схемы, новый тест управляемых часов с выбором устройства и расширенный замок удаления/ошибка опроса с подтверждением записи. TypeScript и lint чистые. Backend не менялся, build пока не повторялся.
- Далее — профильная клавиатурная и адаптивная приёмка IPsec, проверенные экраны в плане; затем оставшиеся VXLAN/GRE. Реальные RouterOS/JWT/БД не проверены. Локальный коммит без push.
+35
View File
@@ -0,0 +1,35 @@
import { test, expect } from "@playwright/test"
test("IPsec: время успешного опроса сохраняется при частичном/полном отказе", async ({ page }) => {
await page.clock.install({ time: new Date("2026-09-30T05:00:00Z") })
const summary = (id: string) => ({ serverId: id, serverName: "Роутер " + id, initialized: false, ike2Ready: false, peers: [], natRuleManaged: false, clientsTotal: 0, clientsOnline: 0 })
let phase = 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 (path === "/api/ipsec" && phase === 2) return r.fulfill({ status: 503, json: { error: "Опрос недоступен тест" } })
return r.fulfill({ json: path === "/api/ipsec" ? { servers: phase === 1 ? [summary("1")] : [summary("1"), summary("2")], clients: [], failures: phase === 1 ? [{ serverId: "2", error: "Таймаут тест" }] : [] }
: path === "/api/servers" ? [1, 2].map(id => ({ id, name: "Роутер " + id, host: "192.0.2." + id, enabled: true, country: "RU" }))
: path === "/api/auth/config" ? { required: false } : {} })
})
await page.goto("/ipsec")
const time = page.getByRole("group", { name: "Свежесть IPsec" }).locator('[data-source-time="read"] time')
await expect(time).toHaveCount(1)
await page.getByRole("option").filter({ hasText: "Роутер 2" }).click()
const first = await time.getAttribute("datetime")
phase = 1
await page.clock.fastForward(60_000)
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(page.getByRole("alert").filter({ hasText: "Не удалось опросить часть роутеров" })).toBeVisible()
await expect(time).toHaveAttribute("datetime", first!)
phase = 2
await page.clock.fastForward(60_000)
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(page.getByRole("alert").filter({ hasText: "Не удалось загрузить IPsec" })).toBeVisible()
await expect(time).toHaveAttribute("datetime", first!)
phase = 3
await page.clock.fastForward(60_000)
await page.getByRole("button", { name: "Повторить загрузку IPsec" }).click()
await expect(time).not.toHaveAttribute("datetime", first!)
await expect(page.getByRole("button", { name: "Новый клиент", exact: true })).toBeEnabled()
})
+1
View File
@@ -30,6 +30,7 @@ test("IPsec: удаление держит замок до обновления
await expect(submit).toBeDisabled()
release!()
await expect(dialog).toHaveCount(0)
await expect(page.getByRole("group", { name: "Свежесть IPsec" }).locator('[data-source-time="write"] time')).toHaveCount(1)
await expect(page.getByRole("alert").filter({ hasText: "Не удалось загрузить IPsec" })).toContainText("Опрос после удаления недоступен")
await expect(rows).toHaveCount(1)
await expect(rows.getByRole("button", { name: "Удалить клиента" })).toBeDisabled()