fix(ui): различать ошибки и пустую историю WireGuard
This commit is contained in:
1 parent
1d1de12c58
commit
d0f5649725
7 files changed
+116
-14
No files matched your search
@@ -46,6 +46,7 @@ import {
|
||||
exportWireGuard,
|
||||
importWireGuard,
|
||||
listWireGuard,
|
||||
listWireGuardRevisions,
|
||||
patchWireGuardInterface,
|
||||
} from "@/shared/api/wireguard"
|
||||
import type { WgIfaceDto } from "@mmapp/contracts/wireguard"
|
||||
@@ -211,6 +212,9 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
|
||||
const [historyOpen, setHistoryOpen] = useState(false)
|
||||
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
|
||||
const [historyLoading, setHistoryLoading] = useState(false)
|
||||
const [historyError, setHistoryError] = useState<string | null>(null)
|
||||
const historyEpoch = useRef(0)
|
||||
useEffect(() => () => { historyEpoch.current += 1 }, [])
|
||||
const restoreLock = useRef(false)
|
||||
const [historyRestoring, setHistoryRestoring] = useState(false)
|
||||
const [liveExport, setLiveExport] = useState<{
|
||||
@@ -272,18 +276,18 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
|
||||
|
||||
const loadRevisions = useCallback(async () => {
|
||||
if (!isLive || !historyServerId) return
|
||||
const epoch = ++historyEpoch.current
|
||||
setHistoryLoading(true)
|
||||
setHistoryError(null)
|
||||
try {
|
||||
const res = await requestJson<{ revisions: ConfigRevisionDto[] }>(
|
||||
backendUrl,
|
||||
`/api/wireguard/revisions?serverId=${encodeURIComponent(historyServerId)}`,
|
||||
)
|
||||
setRevisions(res.revisions)
|
||||
} catch (err) {
|
||||
toast.error("Не удалось загрузить историю", { description: String(err) })
|
||||
setRevisions([])
|
||||
const revisions = await listWireGuardRevisions(backendUrl, historyServerId)
|
||||
if (epoch !== historyEpoch.current) return
|
||||
setRevisions(revisions)
|
||||
} catch (error) {
|
||||
if (epoch !== historyEpoch.current) return
|
||||
setHistoryError(error instanceof Error ? error.message : "Не удалось загрузить историю")
|
||||
} finally {
|
||||
setHistoryLoading(false)
|
||||
if (epoch === historyEpoch.current) setHistoryLoading(false)
|
||||
}
|
||||
}, [isLive, historyServerId, backendUrl])
|
||||
|
||||
@@ -990,7 +994,9 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
|
||||
|
||||
<ConfigHistorySheet
|
||||
open={historyOpen}
|
||||
onOpenChange={setHistoryOpen}
|
||||
onOpenChange={v => { if (!v) historyEpoch.current += 1; setHistoryOpen(v) }}
|
||||
loadError={historyError}
|
||||
onRetry={() => void loadRevisions()}
|
||||
title="История WireGuard"
|
||||
itemLabel="интерфейсов"
|
||||
revisions={revisions}
|
||||
|
||||
@@ -65,6 +65,8 @@ export function ConfigHistorySheet({
|
||||
itemLabel,
|
||||
revisions,
|
||||
loading,
|
||||
loadError,
|
||||
onRetry,
|
||||
restoring,
|
||||
onRestore,
|
||||
}: {
|
||||
@@ -74,6 +76,8 @@ export function ConfigHistorySheet({
|
||||
itemLabel: string
|
||||
revisions: ConfigRevisionDto[]
|
||||
loading: boolean
|
||||
loadError?: string | null
|
||||
onRetry?: () => void
|
||||
restoring: boolean
|
||||
onRestore: (id: string) => Promise<void> | void
|
||||
}) {
|
||||
@@ -122,6 +126,11 @@ export function ConfigHistorySheet({
|
||||
<LoaderCircleIcon className="size-4 animate-spin" />
|
||||
Загрузка истории…
|
||||
</div>
|
||||
) : loadError ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p role="alert" className="text-sm text-destructive">Не удалось загрузить историю: {loadError}</p>
|
||||
{onRetry && <Button type="button" variant="outline" disabled={busy} onClick={onRetry}>Повторить загрузку</Button>}
|
||||
</div>
|
||||
) : newestFirst.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<HistoryIcon className="size-4" />}
|
||||
|
||||
+2
-2
@@ -80,7 +80,7 @@ flowchart LR
|
||||
|
||||
## Текущая точка продолжения
|
||||
|
||||
По журналу этапы 0–8 завершены в границах локальной приёмки; внешние ограничения сохраняются. Этап 9 начат (блоки 9.1–9.9 — операции WireGuard и общий диалог восстановления); этапы 10–12 не начаты. Этап 11 разделён: 11А — пользователи, контейнеры, сертификаты, резервирование; 11Б — оповещения, сбор данных, интеграции, релизы, настройки.
|
||||
По журналу этапы 0–8 завершены в границах локальной приёмки; внешние ограничения сохраняются. Этап 9 начат (блоки 9.1–9.10 — операции и история WireGuard); этапы 10–12 не начаты. Этап 11 разделён: 11А — пользователи, контейнеры, сертификаты, резервирование; 11Б — оповещения, сбор данных, интеграции, релизы, настройки.
|
||||
|
||||
Подробные планы: [этап 6](ux-ui-stage-6.md), [этап 7](ux-ui-stage-7.md), [этап 8 и итоговая матрица](ux-ui-stage-8.md), [этап 9](ux-ui-stage-9.md).
|
||||
|
||||
@@ -88,7 +88,7 @@ flowchart LR
|
||||
|
||||
Ближайший оставшийся объём:
|
||||
|
||||
1. Этап 9: карта разделов и схема сохранены; удаление, импорт, создание, переключение, контекст экспорта и выбор пира WireGuard исправлены в 9.1–9.7; в 9.8 проверено подтверждение восстановления. В 9.9 исправлены разметка и сохранение подтверждения при ошибке у всех шести потребителей. Далее — состояния загрузки истории и защита от поздних ответов; затем IPsec/GRE/VXLAN/Firewall/Filters.
|
||||
1. Этап 9: карта разделов и схема сохранены; удаление, импорт, создание, переключение, контекст экспорта и выбор пира WireGuard исправлены в 9.1–9.7; в 9.8 проверено подтверждение восстановления. В 9.9 исправлены разметка и сохранение подтверждения при ошибке у всех шести потребителей. В 9.10 исправлены ошибки и поздние ответы загрузки истории WireGuard. Далее — подтверждение восстановления и состояния истории остальных разделов; затем IPsec/GRE/VXLAN/Firewall/Filters.
|
||||
2. Сохранять контекст устройства, введённые значения при ошибке и подтверждать результат операций по API; локальные моки проверяют клиентские сценарии, реальные сетевые операции требуют тестового окружения.
|
||||
3. В этапе 12 выполнить полный аудит доступности (скринридер, контраст), производительности и сквозную регрессию. Ограничения этапа 8 — удалённые серверы месячного каталога, отсутствие экспорта и автоматического переподключения SSE — остаются явно зафиксированными, без заявления о реализации.
|
||||
|
||||
|
||||
@@ -25,12 +25,20 @@
|
||||
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
|
||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.9 — операции WireGuard и общий диалог восстановления |
|
||||
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.10 — операции и загрузка истории WireGuard |
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||
|
||||
## Блок 9.10 — состояния загрузки истории WireGuard (2026-09-29)
|
||||
|
||||
- Ошибка истории больше не подменяется пустым списком. ConfigHistorySheet получил необязательные loadError/onRetry; WireGuard показывает ошибку через role=alert с повтором. Пустое состояние доступно только после успешного пустого ответа. Во время ошибки прежние ревизии не предлагаются для отката; данные остаются в состоянии, но скрыты до успешной загрузки.
|
||||
- listWireGuardRevisions проверяет поля ревизий, раздел wireguard и совпадение serverId. Повреждённый ответ и история другого сервера отклоняются. Новое поколение каждого запроса и инвалидирование при закрытии/размонтировании защищают результат, ошибку и finally от поздних ответов.
|
||||
- Три новых E2E прошли: 403 → повреждённый ответ → чужой serverId → корректный пустой список; поздний успех и поздняя ошибка после переоткрытия не снимают новую загрузку. Итоговый набор из 33 E2E WireGuard и всех шести потребителей диалога истории прошёл. Lint изменённых файлов, TypeScript и diff-check прошли.
|
||||
- Получен ReUI checklist; общий компонент сохраняет прежнее поведение для потребителей без новых props. Реальные RouterOS/JWT не проверены. Запросы после закрытия физически не отменяются, их ответы игнорируются. Production build не повторялся; последний успешный — 9.9. Исходные lint-ограничения остальных страниц из 9.9 сохраняются.
|
||||
- Далее — подтвердить ok=true восстановления IPsec/GRE/Firewall/Filters/рекурсивных маршрутов; затем распространить обработку ошибок загрузки истории и продолжить профильный UX сетевых разделов. Этап 9 открыт; локальный коммит без push.
|
||||
|
||||
## Блок 9.9 — общий диалог восстановления (2026-09-29)
|
||||
|
||||
- ConfigHistorySheet закрывает подтверждение только после успешного завершения onRestore. Ошибка остаётся внутри через role=alert, выбранная ревизия сохраняется для повтора. Синхронный замок предотвращает двойной вызов; ожидание объявляется через role=status. Во время операции нельзя закрыть подтверждение или панель истории.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Этап 9: VPN и сетевые операции
|
||||
|
||||
Статус: начат 2026-09-28, выполнены блоки 9.1–9.9. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем.
|
||||
Статус: начат 2026-09-28, выполнены блоки 9.1–9.10. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем.
|
||||
|
||||
## Схема и референсы принятого плана
|
||||
|
||||
@@ -77,3 +77,7 @@
|
||||
## Блок 9.9 — общий диалог восстановления (2026-09-29)
|
||||
|
||||
Исправлена HTML-разметка предупреждения; ошибка сохраняет выбранную ревизию и подтверждение для повтора. Общий замок блокирует повтор и закрытие до завершения Promise. Ошибки всех шести потребителей передаются в диалог; IPsec возвращает Promise. Все 40 профильных E2E прошли, включая 6 новых сценариев истории. Общий компонент и тесты lint-чистые; 7 исходных ошибок и 31 предупреждение в страницах подтверждены сравнением с HEAD и не изменились. Сборка — в журнале. Далее — ошибки/гонки загрузки истории, затем подтверждение восстановления остальных разделов. Реальные устройства не проверены.
|
||||
|
||||
## Блок 9.10 — загрузка истории WireGuard (2026-09-29)
|
||||
|
||||
Ошибка отделена от пустого списка и имеет повтор; ревизии проверяются по схеме и выбранному серверу. Закрытие/размонтирование и новое поколение запроса исключают поздние результат/ошибку/finally. Три новых E2E, lint и TypeScript прошли; итог регрессии в журнале. Остальные страницы пока не подключены к новым loadError/onRetry. Далее — подтверждение восстановления остальных разделов и их состояния истории.
|
||||
@@ -1,3 +1,4 @@
|
||||
import { z } from "zod"
|
||||
import { wgIfaceDtoSchema, wgImportResponseSchema, wgExportResponseSchema } from "@mmapp/contracts/wireguard"
|
||||
import type {
|
||||
WgCreateInterface,
|
||||
@@ -126,3 +127,18 @@ export async function exportWireGuard(
|
||||
}
|
||||
return parsed.data
|
||||
}
|
||||
|
||||
const wireguardRevisionsSchema = z.object({ revisions: z.array(z.object({
|
||||
id: z.string().min(1), serverId: z.string().min(1), section: z.literal("wireguard"),
|
||||
source: z.enum(["apply", "rollback", "observed", "copy"]), fingerprint: z.string(),
|
||||
createdAt: z.string().refine(value => Number.isFinite(Date.parse(value))),
|
||||
note: z.string().nullable(), itemCount: z.number().int().nonnegative(),
|
||||
})) })
|
||||
|
||||
export async function listWireGuardRevisions(baseUrl: string, serverId: string) {
|
||||
const result = wireguardRevisionsSchema.safeParse(await requestJson<unknown>(baseUrl, "/api/wireguard/revisions?serverId=" + encodeURIComponent(serverId)))
|
||||
if (!result.success || result.data.revisions.some(revision => revision.serverId !== serverId)) {
|
||||
throw new Error("Некорректный ответ истории WireGuard")
|
||||
}
|
||||
return result.data.revisions
|
||||
}
|
||||
@@ -468,3 +468,62 @@ test("WireGuard: восстановление требует подтвержд
|
||||
await expect(page.getByText("Версия применена на роутер", { exact: true })).toBeVisible()
|
||||
expect(attempts).toBe(2)
|
||||
})
|
||||
|
||||
test("WireGuard: ошибка истории не становится пустым списком", async ({ page }) => {
|
||||
await setup(page, "/wireguard?server=1")
|
||||
let attempts = 0
|
||||
await page.route("**/api/wireguard/revisions?**", route => {
|
||||
attempts++
|
||||
if (attempts === 1) return route.fulfill({ status: 403, json: { error: "История запрещена тест" } })
|
||||
if (attempts === 2) return route.fulfill({ json: {} })
|
||||
if (attempts === 3) return route.fulfill({ json: { revisions: [{ id: "wrong", serverId: "2", section: "wireguard", source: "apply", fingerprint: "fixture", createdAt: "2026-09-29T00:00:00Z", note: null, itemCount: 1 }] } })
|
||||
return route.fulfill({ json: { revisions: [] } })
|
||||
})
|
||||
await page.getByRole("button", { name: "История", exact: true }).click()
|
||||
const sheet = page.getByRole("dialog", { name: "История WireGuard" })
|
||||
for (const message of ["История запрещена тест", "Некорректный ответ истории WireGuard", "Некорректный ответ истории WireGuard"]) {
|
||||
await expect(sheet.getByRole("alert")).toContainText(message)
|
||||
await expect(sheet.getByText("Истории пока нет", { exact: true })).toHaveCount(0)
|
||||
await expect(sheet.getByRole("button", { name: "Откатить", exact: true })).toHaveCount(0)
|
||||
await sheet.getByRole("button", { name: "Повторить загрузку" }).click()
|
||||
}
|
||||
await expect(sheet.getByText("Истории пока нет", { exact: true })).toBeVisible()
|
||||
await expect(sheet.getByRole("alert")).toHaveCount(0)
|
||||
expect(attempts).toBe(4)
|
||||
})
|
||||
|
||||
for (const oldFails of [false, true]) {
|
||||
test("WireGuard: закрытая история не меняет новый запрос " + oldFails, async ({ page }) => {
|
||||
await setup(page, "/wireguard?server=1")
|
||||
let releaseOld: (() => void) | undefined
|
||||
let releaseNew: (() => void) | undefined
|
||||
const oldGate = new Promise<void>(resolve => { releaseOld = resolve })
|
||||
const newGate = new Promise<void>(resolve => { releaseNew = resolve })
|
||||
let attempts = 0
|
||||
await page.route("**/api/wireguard/revisions?**", async route => {
|
||||
const current = ++attempts
|
||||
await (current === 1 ? oldGate : newGate)
|
||||
if (current === 1 && oldFails) return route.fulfill({ status: 500, json: { error: "Старая ошибка истории" } })
|
||||
return route.fulfill({ json: { revisions: current === 1 ? [] : [{ id: "new", serverId: "1", section: "wireguard", source: "apply", fingerprint: "fixture", createdAt: "2026-09-29T00:00:00Z", note: null, itemCount: 9 }] } })
|
||||
})
|
||||
const open = page.getByRole("button", { name: "История", exact: true })
|
||||
const sheet = page.getByRole("dialog", { name: "История WireGuard" })
|
||||
await open.click()
|
||||
await expect.poll(() => attempts).toBe(1)
|
||||
await expect(sheet.getByRole("status")).toContainText("Загрузка истории")
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(sheet).toHaveCount(0)
|
||||
await open.click()
|
||||
await expect.poll(() => attempts).toBe(2)
|
||||
const response = page.waitForResponse(r => r.url().includes("/api/wireguard/revisions?"))
|
||||
releaseOld!()
|
||||
await (await response).finished()
|
||||
await page.waitForTimeout(150)
|
||||
await expect(sheet.getByRole("status")).toContainText("Загрузка истории")
|
||||
await expect(sheet.getByRole("alert")).toHaveCount(0)
|
||||
releaseNew!()
|
||||
await expect(sheet).toContainText("9 интерфейсов")
|
||||
await expect(sheet.getByRole("status")).toHaveCount(0)
|
||||
await expect(sheet.getByRole("button", { name: "Откатить", exact: true })).toBeVisible()
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user