fix(ui): проверять ревизии и сохранять ошибки истории
This commit is contained in:
1 parent
46d62c98b8
commit
4a8d696e4e
11 files changed
+183
-98
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo, useState, useCallback, useEffect } from "react"
|
import { useMemo, useState, useCallback, useEffect, useRef } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { EmptyState } from "@/components/empty-state"
|
import { EmptyState } from "@/components/empty-state"
|
||||||
import { StatusDot } from "@/components/status-dot"
|
import { StatusDot } from "@/components/status-dot"
|
||||||
@@ -37,7 +37,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
|||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
||||||
import { ConfigHistorySheet } from "@/components/config-history-sheet"
|
import { ConfigHistorySheet } from "@/components/config-history-sheet"
|
||||||
import type { ConfigRevisionDto } from "@/lib/config-revisions"
|
import { parseConfigRevisions, type ConfigRevisionDto } from "@/lib/config-revisions"
|
||||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
import { type ServerTileItem } from "@/components/server-tile-rail"
|
import { type ServerTileItem } from "@/components/server-tile-rail"
|
||||||
|
|
||||||
@@ -1334,6 +1334,8 @@ export default function FiltersPage() {
|
|||||||
const [liveStale, setLiveStale] = useState(false)
|
const [liveStale, setLiveStale] = useState(false)
|
||||||
const [historyOpen, setHistoryOpen] = useState(false)
|
const [historyOpen, setHistoryOpen] = useState(false)
|
||||||
const [historyLoading, setHistoryLoading] = useState(false)
|
const [historyLoading, setHistoryLoading] = useState(false)
|
||||||
|
const [historyError, setHistoryError] = useState<string | null>(null)
|
||||||
|
const historyEpoch = useRef(0)
|
||||||
const [historyRestoring, setHistoryRestoring] = useState(false)
|
const [historyRestoring, setHistoryRestoring] = useState(false)
|
||||||
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
|
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
|
||||||
|
|
||||||
@@ -1508,19 +1510,24 @@ export default function FiltersPage() {
|
|||||||
}
|
}
|
||||||
}, [isLive, selectedServerId, applyBusy, loadLiveRules, ensureGreTunnels, ensureRecursiveRoutes])
|
}, [isLive, selectedServerId, applyBusy, loadLiveRules, ensureGreTunnels, ensureRecursiveRoutes])
|
||||||
|
|
||||||
|
useEffect(() => () => { historyEpoch.current += 1 }, [selectedServerId, backendUrl, isLive])
|
||||||
|
|
||||||
const loadRevisions = useCallback(async () => {
|
const loadRevisions = useCallback(async () => {
|
||||||
if (!isLive || !selectedServerId) return
|
if (!isLive || !selectedServerId) return
|
||||||
|
const epoch = ++historyEpoch.current
|
||||||
setHistoryLoading(true)
|
setHistoryLoading(true)
|
||||||
|
setHistoryError(null)
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch<{ revisions: ConfigRevisionDto[] }>(
|
const res = await apiFetch<unknown>(
|
||||||
`/api/filters/revisions?serverId=${encodeURIComponent(selectedServerId)}`,
|
`/api/filters/revisions?serverId=${encodeURIComponent(selectedServerId)}`,
|
||||||
)
|
)
|
||||||
setRevisions(res.revisions)
|
if (epoch !== historyEpoch.current) return
|
||||||
} catch (err) {
|
setRevisions(parseConfigRevisions(res, selectedServerId, "filters"))
|
||||||
toast.error("Не удалось загрузить историю", { description: String(err) })
|
} catch (error) {
|
||||||
setRevisions([])
|
if (epoch !== historyEpoch.current) return
|
||||||
|
setHistoryError(error instanceof Error ? error.message : "Не удалось загрузить историю")
|
||||||
} finally {
|
} finally {
|
||||||
setHistoryLoading(false)
|
if (epoch === historyEpoch.current) setHistoryLoading(false)
|
||||||
}
|
}
|
||||||
}, [isLive, selectedServerId, apiFetch])
|
}, [isLive, selectedServerId, apiFetch])
|
||||||
|
|
||||||
@@ -1881,7 +1888,9 @@ export default function FiltersPage() {
|
|||||||
|
|
||||||
<ConfigHistorySheet
|
<ConfigHistorySheet
|
||||||
open={historyOpen}
|
open={historyOpen}
|
||||||
onOpenChange={setHistoryOpen}
|
onOpenChange={v => { if (!v) historyEpoch.current += 1; setHistoryOpen(v) }}
|
||||||
|
loadError={historyError}
|
||||||
|
onRetry={() => { void loadRevisions() }}
|
||||||
title="История фильтров"
|
title="История фильтров"
|
||||||
itemLabel="правил"
|
itemLabel="правил"
|
||||||
revisions={revisions}
|
revisions={revisions}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-ra
|
|||||||
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
|
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { ConfigHistorySheet } from "@/components/config-history-sheet"
|
import { ConfigHistorySheet } from "@/components/config-history-sheet"
|
||||||
import type { ConfigRevisionDto } from "@/lib/config-revisions"
|
import { parseConfigRevisions, type ConfigRevisionDto } from "@/lib/config-revisions"
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -1952,6 +1952,8 @@ function FirewallPageInner() {
|
|||||||
const [historyOpen, setHistoryOpen] = useState(false)
|
const [historyOpen, setHistoryOpen] = useState(false)
|
||||||
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
|
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
|
||||||
const [historyLoading, setHistoryLoading] = useState(false)
|
const [historyLoading, setHistoryLoading] = useState(false)
|
||||||
|
const [historyError, setHistoryError] = useState<string | null>(null)
|
||||||
|
const historyEpoch = useRef(0)
|
||||||
const [historyRestoring, setHistoryRestoring] = useState(false)
|
const [historyRestoring, setHistoryRestoring] = useState(false)
|
||||||
|
|
||||||
const loadLive = useCallback(async () => {
|
const loadLive = useCallback(async () => {
|
||||||
@@ -1978,19 +1980,24 @@ function FirewallPageInner() {
|
|||||||
|
|
||||||
const historyServerId = selectedServerId === ALL_SERVERS_ID ? null : selectedServerId
|
const historyServerId = selectedServerId === ALL_SERVERS_ID ? null : selectedServerId
|
||||||
|
|
||||||
|
useEffect(() => () => { historyEpoch.current += 1 }, [historyServerId, backendUrl, isLive])
|
||||||
|
|
||||||
const loadRevisions = useCallback(async () => {
|
const loadRevisions = useCallback(async () => {
|
||||||
if (!isLive || !historyServerId) return
|
if (!isLive || !historyServerId) return
|
||||||
|
const epoch = ++historyEpoch.current
|
||||||
setHistoryLoading(true)
|
setHistoryLoading(true)
|
||||||
|
setHistoryError(null)
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch<{ revisions: ConfigRevisionDto[] }>(
|
const res = await apiFetch<unknown>(
|
||||||
`/api/firewall/revisions?serverId=${encodeURIComponent(historyServerId)}`,
|
`/api/firewall/revisions?serverId=${encodeURIComponent(historyServerId)}`,
|
||||||
)
|
)
|
||||||
setRevisions(res.revisions)
|
if (epoch !== historyEpoch.current) return
|
||||||
} catch (err) {
|
setRevisions(parseConfigRevisions(res, historyServerId, "firewall"))
|
||||||
toast.error("Не удалось загрузить историю", { description: String(err) })
|
} catch (error) {
|
||||||
setRevisions([])
|
if (epoch !== historyEpoch.current) return
|
||||||
|
setHistoryError(error instanceof Error ? error.message : "Не удалось загрузить историю")
|
||||||
} finally {
|
} finally {
|
||||||
setHistoryLoading(false)
|
if (epoch === historyEpoch.current) setHistoryLoading(false)
|
||||||
}
|
}
|
||||||
}, [isLive, historyServerId, apiFetch])
|
}, [isLive, historyServerId, apiFetch])
|
||||||
|
|
||||||
@@ -2645,7 +2652,9 @@ function FirewallPageInner() {
|
|||||||
|
|
||||||
<ConfigHistorySheet
|
<ConfigHistorySheet
|
||||||
open={historyOpen}
|
open={historyOpen}
|
||||||
onOpenChange={setHistoryOpen}
|
onOpenChange={v => { if (!v) historyEpoch.current += 1; setHistoryOpen(v) }}
|
||||||
|
loadError={historyError}
|
||||||
|
onRetry={() => { void loadRevisions() }}
|
||||||
title="История Firewall"
|
title="История Firewall"
|
||||||
itemLabel="объектов"
|
itemLabel="объектов"
|
||||||
revisions={revisions}
|
revisions={revisions}
|
||||||
|
|||||||
+18
-9
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Suspense, useCallback, useEffect, useMemo, useState } from "react"
|
import { Suspense, useCallback, useEffect, useRef, useMemo, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
|
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
|
||||||
@@ -21,7 +21,7 @@ import { OpsPanel } from "@/components/ops-panel"
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { ConfigHistorySheet } from "@/components/config-history-sheet"
|
import { ConfigHistorySheet } from "@/components/config-history-sheet"
|
||||||
import type { ConfigRevisionDto } from "@/lib/config-revisions"
|
import { parseConfigRevisions, type ConfigRevisionDto } from "@/lib/config-revisions"
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -280,6 +280,8 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
|
|||||||
const [historyOpen, setHistoryOpen] = useState(false)
|
const [historyOpen, setHistoryOpen] = useState(false)
|
||||||
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
|
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
|
||||||
const [historyLoading, setHistoryLoading] = useState(false)
|
const [historyLoading, setHistoryLoading] = useState(false)
|
||||||
|
const [historyError, setHistoryError] = useState<string | null>(null)
|
||||||
|
const historyEpoch = useRef(0)
|
||||||
const [historyRestoring, setHistoryRestoring] = useState(false)
|
const [historyRestoring, setHistoryRestoring] = useState(false)
|
||||||
const [poolOpen, setPoolOpen] = useState(false)
|
const [poolOpen, setPoolOpen] = useState(false)
|
||||||
const [codePreviewTunnel, setCodePreviewTunnel] = useState<GreTunnel | null>(null)
|
const [codePreviewTunnel, setCodePreviewTunnel] = useState<GreTunnel | null>(null)
|
||||||
@@ -373,19 +375,24 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
|
|||||||
const historyServerId = selectedServerId === ALL_SERVERS_ID ? null : selectedServerId
|
const historyServerId = selectedServerId === ALL_SERVERS_ID ? null : selectedServerId
|
||||||
const mutationsLocked = isLive && (mutateBusy || liveStale || selectedTunnelError || historyRestoring)
|
const mutationsLocked = isLive && (mutateBusy || liveStale || selectedTunnelError || historyRestoring)
|
||||||
|
|
||||||
|
useEffect(() => () => { historyEpoch.current += 1 }, [historyServerId, backendUrl, isLive])
|
||||||
|
|
||||||
const loadRevisions = useCallback(async () => {
|
const loadRevisions = useCallback(async () => {
|
||||||
if (!isLive || !historyServerId) return
|
if (!isLive || !historyServerId) return
|
||||||
|
const epoch = ++historyEpoch.current
|
||||||
setHistoryLoading(true)
|
setHistoryLoading(true)
|
||||||
|
setHistoryError(null)
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch<{ revisions: ConfigRevisionDto[] }>(
|
const res = await apiFetch<unknown>(
|
||||||
`/api/gre/revisions?serverId=${encodeURIComponent(historyServerId)}`,
|
`/api/gre/revisions?serverId=${encodeURIComponent(historyServerId)}`,
|
||||||
)
|
)
|
||||||
setRevisions(res.revisions)
|
if (epoch !== historyEpoch.current) return
|
||||||
} catch (err) {
|
setRevisions(parseConfigRevisions(res, historyServerId, "gre"))
|
||||||
toast.error("Не удалось загрузить историю", { description: String(err) })
|
} catch (error) {
|
||||||
setRevisions([])
|
if (epoch !== historyEpoch.current) return
|
||||||
|
setHistoryError(error instanceof Error ? error.message : "Не удалось загрузить историю")
|
||||||
} finally {
|
} finally {
|
||||||
setHistoryLoading(false)
|
if (epoch === historyEpoch.current) setHistoryLoading(false)
|
||||||
}
|
}
|
||||||
}, [isLive, historyServerId, apiFetch])
|
}, [isLive, historyServerId, apiFetch])
|
||||||
|
|
||||||
@@ -1073,7 +1080,9 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
|
|||||||
|
|
||||||
<ConfigHistorySheet
|
<ConfigHistorySheet
|
||||||
open={historyOpen}
|
open={historyOpen}
|
||||||
onOpenChange={setHistoryOpen}
|
onOpenChange={v => { if (!v) historyEpoch.current += 1; setHistoryOpen(v) }}
|
||||||
|
loadError={historyError}
|
||||||
|
onRetry={() => { void loadRevisions() }}
|
||||||
title="История GRE"
|
title="История GRE"
|
||||||
itemLabel="туннелей"
|
itemLabel="туннелей"
|
||||||
revisions={revisions}
|
revisions={revisions}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ import {
|
|||||||
type ServerTileItem,
|
type ServerTileItem,
|
||||||
} from "@/components/server-tile-rail"
|
} from "@/components/server-tile-rail"
|
||||||
import { ConfigHistorySheet } from "@/components/config-history-sheet"
|
import { ConfigHistorySheet } from "@/components/config-history-sheet"
|
||||||
import type { ConfigRevisionDto } from "@/lib/config-revisions"
|
import { parseConfigRevisions, type ConfigRevisionDto } from "@/lib/config-revisions"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { findFreePoolIp } from "@/lib/ipsec-client"
|
import { findFreePoolIp } from "@/lib/ipsec-client"
|
||||||
import {
|
import {
|
||||||
@@ -255,13 +255,12 @@ export default function IpsecPage() {
|
|||||||
setHistoryLoading(true)
|
setHistoryLoading(true)
|
||||||
setHistoryError(null)
|
setHistoryError(null)
|
||||||
try {
|
try {
|
||||||
const res = await requestJson<{ revisions: ConfigRevisionDto[] }>(
|
const res = await requestJson<unknown>(
|
||||||
backendUrl,
|
backendUrl,
|
||||||
`/api/ipsec/revisions?serverId=${encodeURIComponent(historyServerId)}`,
|
`/api/ipsec/revisions?serverId=${encodeURIComponent(historyServerId)}`,
|
||||||
)
|
)
|
||||||
if (epoch !== historyEpoch.current) return
|
if (epoch !== historyEpoch.current) return
|
||||||
if (!Array.isArray(res?.revisions)) throw new Error("Некорректный ответ истории IPsec")
|
setRevisions(parseConfigRevisions(res, historyServerId, "ipsec"))
|
||||||
setRevisions(res.revisions)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (epoch !== historyEpoch.current) return
|
if (epoch !== historyEpoch.current) return
|
||||||
setHistoryError(err instanceof Error ? err.message : "Не удалось загрузить историю")
|
setHistoryError(err instanceof Error ? err.message : "Не удалось загрузить историю")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useCallback, useEffect, useRef, useMemo, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import {
|
import {
|
||||||
RecursiveRoutesDataGrid,
|
RecursiveRoutesDataGrid,
|
||||||
@@ -23,7 +23,7 @@ import { toast } from "sonner"
|
|||||||
import { requestJson } from "@/shared/api/http-client"
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
import { ConfigHistorySheet } from "@/components/config-history-sheet"
|
import { ConfigHistorySheet } from "@/components/config-history-sheet"
|
||||||
import type { ConfigRevisionDto } from "@/lib/config-revisions"
|
import { parseConfigRevisions, type ConfigRevisionDto } from "@/lib/config-revisions"
|
||||||
import { type ServerTileItem } from "@/components/server-tile-rail"
|
import { type ServerTileItem } from "@/components/server-tile-rail"
|
||||||
|
|
||||||
interface BackendServer {
|
interface BackendServer {
|
||||||
@@ -379,6 +379,8 @@ export default function RecursiveRoutesPage() {
|
|||||||
const [liveStale, setLiveStale] = useState(false)
|
const [liveStale, setLiveStale] = useState(false)
|
||||||
const [historyOpen, setHistoryOpen] = useState(false)
|
const [historyOpen, setHistoryOpen] = useState(false)
|
||||||
const [historyLoading, setHistoryLoading] = useState(false)
|
const [historyLoading, setHistoryLoading] = useState(false)
|
||||||
|
const [historyError, setHistoryError] = useState<string | null>(null)
|
||||||
|
const historyEpoch = useRef(0)
|
||||||
const [historyRestoring, setHistoryRestoring] = useState(false)
|
const [historyRestoring, setHistoryRestoring] = useState(false)
|
||||||
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
|
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
|
||||||
/** В live не дергаем API с id мока (srv1…) пока не подтянули /api/servers */
|
/** В live не дергаем API с id мока (srv1…) пока не подтянули /api/servers */
|
||||||
@@ -472,19 +474,24 @@ export default function RecursiveRoutesPage() {
|
|||||||
}
|
}
|
||||||
}, [isLive, selectedServerId, liveStale, rows, apiFetch])
|
}, [isLive, selectedServerId, liveStale, rows, apiFetch])
|
||||||
|
|
||||||
|
useEffect(() => () => { historyEpoch.current += 1 }, [selectedServerId, backendUrl, isLive])
|
||||||
|
|
||||||
const loadRevisions = useCallback(async () => {
|
const loadRevisions = useCallback(async () => {
|
||||||
if (!isLive || !selectedServerId) return
|
if (!isLive || !selectedServerId) return
|
||||||
|
const epoch = ++historyEpoch.current
|
||||||
setHistoryLoading(true)
|
setHistoryLoading(true)
|
||||||
|
setHistoryError(null)
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch<{ revisions: ConfigRevisionDto[] }>(
|
const res = await apiFetch<unknown>(
|
||||||
`/api/recursive-routes/revisions?serverId=${encodeURIComponent(selectedServerId)}`,
|
`/api/recursive-routes/revisions?serverId=${encodeURIComponent(selectedServerId)}`,
|
||||||
)
|
)
|
||||||
setRevisions(res.revisions)
|
if (epoch !== historyEpoch.current) return
|
||||||
} catch (e) {
|
setRevisions(parseConfigRevisions(res, selectedServerId, "recursive-routes"))
|
||||||
toast.error(e instanceof Error ? e.message : "Не удалось загрузить историю")
|
} catch (error) {
|
||||||
setRevisions([])
|
if (epoch !== historyEpoch.current) return
|
||||||
|
setHistoryError(error instanceof Error ? error.message : "Не удалось загрузить историю")
|
||||||
} finally {
|
} finally {
|
||||||
setHistoryLoading(false)
|
if (epoch === historyEpoch.current) setHistoryLoading(false)
|
||||||
}
|
}
|
||||||
}, [isLive, selectedServerId, apiFetch])
|
}, [isLive, selectedServerId, apiFetch])
|
||||||
|
|
||||||
@@ -752,7 +759,9 @@ export default function RecursiveRoutesPage() {
|
|||||||
|
|
||||||
<ConfigHistorySheet
|
<ConfigHistorySheet
|
||||||
open={historyOpen}
|
open={historyOpen}
|
||||||
onOpenChange={setHistoryOpen}
|
onOpenChange={v => { if (!v) historyEpoch.current += 1; setHistoryOpen(v) }}
|
||||||
|
loadError={historyError}
|
||||||
|
onRetry={() => { void loadRevisions() }}
|
||||||
title="История маршрутов"
|
title="История маршрутов"
|
||||||
itemLabel="маршрутов"
|
itemLabel="маршрутов"
|
||||||
revisions={revisions}
|
revisions={revisions}
|
||||||
|
|||||||
+2
-2
@@ -80,7 +80,7 @@ flowchart LR
|
|||||||
|
|
||||||
## Текущая точка продолжения
|
## Текущая точка продолжения
|
||||||
|
|
||||||
По журналу этапы 0–8 завершены в границах локальной приёмки; внешние ограничения сохраняются. Этап 9 начат (блоки 9.1–9.12 — история WireGuard/IPsec и восстановление); этапы 10–12 не начаты. Этап 11 разделён: 11А — пользователи, контейнеры, сертификаты, резервирование; 11Б — оповещения, сбор данных, интеграции, релизы, настройки.
|
По журналу этапы 0–8 завершены в границах локальной приёмки; внешние ограничения сохраняются. Этап 9 начат (блоки 9.1–9.13 — история всех шести разделов и восстановление); этапы 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).
|
Подробные планы: [этап 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 исправлены разметка и сохранение подтверждения при ошибке у всех шести потребителей. В 9.10 исправлены ошибки и поздние ответы загрузки истории WireGuard. В 9.11 подтверждение восстановления проверено у остальных пяти потребителей. В 9.12 обработаны ошибка/повтор/поздние ответы истории IPsec. Далее — история GRE/Firewall/Filters/рекурсивных маршрутов и общая валидация ревизий; затем IPsec/GRE/VXLAN/Firewall/Filters.
|
1. Этап 9: карта разделов и схема сохранены; удаление, импорт, создание, переключение, контекст экспорта и выбор пира WireGuard исправлены в 9.1–9.7; в 9.8 проверено подтверждение восстановления. В 9.9 исправлены разметка и сохранение подтверждения при ошибке у всех шести потребителей. В 9.10 исправлены ошибки и поздние ответы загрузки истории WireGuard. В 9.11 подтверждение восстановления проверено у остальных пяти потребителей. В 9.12 обработаны ошибка/повтор/поздние ответы истории IPsec. В 9.13 подключены ошибка/повтор/защита поздних ответов остальных четырёх разделов и общая валидация ревизий всех шести. Далее — инициализация IPsec: подтверждение API, сохранение формы и защита повторной отправки; затем пользователи/сертификаты и GRE/VXLAN/Firewall/Filters.
|
||||||
2. Сохранять контекст устройства, введённые значения при ошибке и подтверждать результат операций по API; локальные моки проверяют клиентские сценарии, реальные сетевые операции требуют тестового окружения.
|
2. Сохранять контекст устройства, введённые значения при ошибке и подтверждать результат операций по API; локальные моки проверяют клиентские сценарии, реальные сетевые операции требуют тестового окружения.
|
||||||
3. В этапе 12 выполнить полный аудит доступности (скринридер, контраст), производительности и сквозную регрессию. Ограничения этапа 8 — удалённые серверы месячного каталога, отсутствие экспорта и автоматического переподключения SSE — остаются явно зафиксированными, без заявления о реализации.
|
3. В этапе 12 выполнить полный аудит доступности (скринридер, контраст), производительности и сквозную регрессию. Ограничения этапа 8 — удалённые серверы месячного каталога, отсутствие экспорта и автоматического переподключения SSE — остаются явно зафиксированными, без заявления о реализации.
|
||||||
|
|
||||||
|
|||||||
@@ -25,12 +25,20 @@
|
|||||||
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
|
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
|
||||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
||||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||||
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.12 — история WireGuard/IPsec и восстановление |
|
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.13 — история всех шести разделов и восстановление |
|
||||||
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
|
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
|
||||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
||||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||||
|
|
||||||
|
## Блок 9.13 — общая проверка и загрузка истории (2026-09-29)
|
||||||
|
|
||||||
|
- GRE, Firewall, Filters и рекурсивные маршруты подключены к ошибке/повтору общей панели. Ошибка не обнуляет сохранённые ревизии и не маскируется пустым списком. Поколение запроса защищает результат, ошибку и finally после закрытия, смены контекста и размонтирования; физическая отмена запросов не добавлялась.
|
||||||
|
- Общая parseConfigRevisions проверяет контейнер, поля записей, дату, количество, источник, раздел и принадлежность выбранному серверу во всех шести потребителях. Тип ConfigRevisionDto дополнен IPsec согласно backend; WireGuard использует ту же схему. Backend и правила применения не изменены.
|
||||||
|
- Все 36 E2E истории/WireGuard прошли. Пять сценариев истории проверяют 403, задержанный ответ закрытой панели, отсутствие контейнера, другой сервер/раздел, повреждённую дату и успешный пустой список. TypeScript прошёл. Общая схема, API и тест lint-чистые; 7 исходных ошибок и 31 предупреждение страниц не изменились (сравнение с HEAD). Production build прошёл: 34/34 страницы.
|
||||||
|
- Следующий шаг: IPsec — handleInit и форма инициализации: подтверждение API, сохранение ввода при ошибке, блокировка повторной отправки и закрытия до ответа; затем пользователи/сертификаты и остальные разделы карты. Полная проверка payload восстановления rules/routes, реальные RouterOS/JWT и весь этап 9 пока не завершены.
|
||||||
|
- После профильных проверок: использовано 21% пятичасовой и 81% недельной квоты. Локальный коммит без push; чужие изменения skill/config/AGENTS сохранены.
|
||||||
|
|
||||||
## Блок 9.12 — ошибка и повтор истории IPsec (2026-09-29)
|
## Блок 9.12 — ошибка и повтор истории IPsec (2026-09-29)
|
||||||
|
|
||||||
- IPsec подключён к loadError/onRetry общего диалога. Ошибка не обнуляет историю и не показывает пустое состояние; прежние ревизии скрыты до успешного повтора. Ответ без массива revisions отклоняется.
|
- IPsec подключён к loadError/onRetry общего диалога. Ошибка не обнуляет историю и не показывает пустое состояние; прежние ревизии скрыты до успешного повтора. Ответ без массива revisions отклоняется.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Этап 9: VPN и сетевые операции
|
# Этап 9: VPN и сетевые операции
|
||||||
|
|
||||||
Статус: начат 2026-09-28, выполнены блоки 9.1–9.12. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем.
|
Статус: начат 2026-09-28, выполнены блоки 9.1–9.13. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем.
|
||||||
|
|
||||||
## Схема и референсы принятого плана
|
## Схема и референсы принятого плана
|
||||||
|
|
||||||
@@ -89,3 +89,7 @@ IPsec/GRE/Firewall/Filters/рекурсивные маршруты требую
|
|||||||
## Блок 9.12 — загрузка истории IPsec (2026-09-29)
|
## Блок 9.12 — загрузка истории IPsec (2026-09-29)
|
||||||
|
|
||||||
Ошибка и повтор подключены к общей панели; поздние ответы закрытого/сменившего контекст запроса игнорируются. Проверяется наличие массива revisions, без полной валидации записей. Все 7 E2E истории прошли. Далее — GRE/Firewall/Filters/рекурсивные маршруты и общая схема ревизий. Реальные устройства не проверены.
|
Ошибка и повтор подключены к общей панели; поздние ответы закрытого/сменившего контекст запроса игнорируются. Проверяется наличие массива revisions, без полной валидации записей. Все 7 E2E истории прошли. Далее — GRE/Firewall/Filters/рекурсивные маршруты и общая схема ревизий. Реальные устройства не проверены.
|
||||||
|
|
||||||
|
## Блок 9.13 — история всех разделов (2026-09-29)
|
||||||
|
|
||||||
|
Ошибка/повтор и защита поздних ответов подключены к GRE/Firewall/Filters/рекурсивным маршрутам. Общая схема проверяет записи, сервер и раздел во всех шести потребителях, включая IPsec. Все 36 E2E истории/WireGuard и TypeScript прошли; исходный lint страниц не изменился, новые общие файлы и тест чистые. Результат build — в журнале. Далее — форма инициализации IPsec (handleInit): подтверждение, сохранение ввода и повторные нажатия. Реальные устройства и payload восстановления rules/routes остаются вне проверки этого блока.
|
||||||
+31
-1
@@ -1,12 +1,42 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
export type ConfigRevisionSource = "apply" | "rollback" | "observed" | "copy"
|
export type ConfigRevisionSource = "apply" | "rollback" | "observed" | "copy"
|
||||||
|
|
||||||
export interface ConfigRevisionDto {
|
export interface ConfigRevisionDto {
|
||||||
id: string
|
id: string
|
||||||
serverId: string
|
serverId: string
|
||||||
section: "filters" | "recursive-routes" | "firewall" | "wireguard" | "gre"
|
section: "filters" | "recursive-routes" | "firewall" | "wireguard" | "gre" | "ipsec"
|
||||||
source: ConfigRevisionSource
|
source: ConfigRevisionSource
|
||||||
fingerprint: string
|
fingerprint: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
note: string | null
|
note: string | null
|
||||||
itemCount: number
|
itemCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const revisionsSchema = z.object({ revisions: z.array(z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
serverId: z.string().min(1),
|
||||||
|
section: z.enum(["filters", "recursive-routes", "firewall", "wireguard", "gre", "ipsec"]),
|
||||||
|
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(),
|
||||||
|
})) })
|
||||||
|
|
||||||
|
const sectionLabels: Record<ConfigRevisionDto["section"], string> = {
|
||||||
|
filters: "Filters", "recursive-routes": "рекурсивных маршрутов", firewall: "Firewall",
|
||||||
|
wireguard: "WireGuard", gre: "GRE", ipsec: "IPsec",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseConfigRevisions(
|
||||||
|
payload: unknown,
|
||||||
|
serverId: string,
|
||||||
|
section: ConfigRevisionDto["section"],
|
||||||
|
): ConfigRevisionDto[] {
|
||||||
|
const result = revisionsSchema.safeParse(payload)
|
||||||
|
if (!result.success || result.data.revisions.some(revision => revision.serverId !== serverId || revision.section !== section)) {
|
||||||
|
throw new Error("Некорректный ответ истории " + sectionLabels[section])
|
||||||
|
}
|
||||||
|
return result.data.revisions
|
||||||
|
}
|
||||||
+3
-13
@@ -1,4 +1,4 @@
|
|||||||
import { z } from "zod"
|
import { parseConfigRevisions } from "@/lib/config-revisions"
|
||||||
import { wgIfaceDtoSchema, wgImportResponseSchema, wgExportResponseSchema } from "@mmapp/contracts/wireguard"
|
import { wgIfaceDtoSchema, wgImportResponseSchema, wgExportResponseSchema } from "@mmapp/contracts/wireguard"
|
||||||
import type {
|
import type {
|
||||||
WgCreateInterface,
|
WgCreateInterface,
|
||||||
@@ -128,17 +128,7 @@ export async function exportWireGuard(
|
|||||||
return parsed.data
|
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) {
|
export async function listWireGuardRevisions(baseUrl: string, serverId: string) {
|
||||||
const result = wireguardRevisionsSchema.safeParse(await requestJson<unknown>(baseUrl, "/api/wireguard/revisions?serverId=" + encodeURIComponent(serverId)))
|
const result = await requestJson<unknown>(baseUrl, "/api/wireguard/revisions?serverId=" + encodeURIComponent(serverId))
|
||||||
if (!result.success || result.data.revisions.some(revision => revision.serverId !== serverId)) {
|
return parseConfigRevisions(result, serverId, "wireguard")
|
||||||
throw new Error("Некорректный ответ истории WireGuard")
|
|
||||||
}
|
|
||||||
return result.data.revisions
|
|
||||||
}
|
}
|
||||||
@@ -68,47 +68,65 @@ for (const section of ["wireguard", "ipsec", "gre", "firewall", "filters", "recu
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
test("История IPsec: ошибка, повтор и поздний ответ закрытой панели", async ({ page }) => {
|
for (const section of ["ipsec", "gre", "firewall", "filters", "recursive-routes"]) {
|
||||||
let attempts = 0
|
test(`История ${section}: ошибка, повтор и поздний ответ закрытой панели`, async ({ page }) => {
|
||||||
let release: (() => void) | undefined
|
let attempts = 0
|
||||||
const gate = new Promise<void>(resolve => { release = resolve })
|
let release: (() => void) | undefined
|
||||||
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
|
const gate = new Promise<void>(resolve => { release = resolve })
|
||||||
await page.route("**/api/**", async route => {
|
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
|
||||||
const path = new URL(route.request().url()).pathname
|
await page.route("**/api/**", async route => {
|
||||||
if (path === "/api/ipsec/revisions") {
|
const path = new URL(route.request().url()).pathname
|
||||||
attempts++
|
if (path === `/api/${section}/revisions`) {
|
||||||
if (attempts === 1) return route.fulfill({ status: 403, json: { error: "История недоступна тест" } })
|
attempts++
|
||||||
if (attempts === 2) {
|
if (attempts === 1) return route.fulfill({ status: 403, json: { error: "История недоступна тест" } })
|
||||||
await gate
|
if (attempts === 2) {
|
||||||
return route.fulfill({ status: 500, json: { error: "Устаревшая ошибка тест" } })
|
await gate
|
||||||
|
return route.fulfill({ status: 500, json: { error: "Устаревшая ошибка тест" } })
|
||||||
|
}
|
||||||
|
if (attempts === 3) return route.fulfill({ json: {} })
|
||||||
|
const revision = { id: "rev/1", serverId: "1", section, source: "apply", fingerprint: "fixture", createdAt: "2026-09-29T00:00:00Z", note: null, itemCount: 1 }
|
||||||
|
if (attempts === 4) return route.fulfill({ json: { revisions: [{ ...revision, serverId: "2" }] } })
|
||||||
|
if (attempts === 5) return route.fulfill({ json: { revisions: [{ ...revision, section: "wireguard" }] } })
|
||||||
|
if (attempts === 6) return route.fulfill({ json: { revisions: [{ ...revision, createdAt: "invalid" }] } })
|
||||||
|
return route.fulfill({ json: { revisions: [] } })
|
||||||
}
|
}
|
||||||
if (attempts === 3) return route.fulfill({ json: {} })
|
return route.fulfill({ json: path === "/api/auth/config" ? { required: false }
|
||||||
return route.fulfill({ json: { revisions: [] } })
|
: path === "/api/ipsec" ? { servers: [], clients: [] }
|
||||||
|
: path === "/api/gre/tunnels" || path === "/api/filters/gre-tunnels" ? { tunnels: [] }
|
||||||
|
: path === "/api/firewall/all" ? { rules: [], addressLists: [] }
|
||||||
|
: path === "/api/filters/rules" ? { rulesets: [{ serverId: "1", rules: [] }], greTunnels: [], live: true }
|
||||||
|
: path === "/api/recursive-routes" ? { routes: [] }
|
||||||
|
: path === "/api/recursive-routes/gateways" ? { gateways: [] }
|
||||||
|
: path === "/api/evobgp/settings" ? { enabled: false, baseUrl: "", secretConfigured: false }
|
||||||
|
: path === "/api/servers" ? [{ id: 1, name: "История роутер", host: "192.0.2.1", enabled: true, type: "exit-node", country: "RU", status: "online" }] : {} })
|
||||||
|
})
|
||||||
|
await page.goto(`/${section}?server=1`)
|
||||||
|
if (section === "ipsec") await page.getByRole("option").filter({ hasText: "История роутер" }).click()
|
||||||
|
const open = page.getByRole("button", { name: "История", exact: true })
|
||||||
|
const sheet = page.getByRole("dialog")
|
||||||
|
await open.click()
|
||||||
|
await expect(sheet.getByRole("alert")).toContainText("История недоступна тест")
|
||||||
|
await expect(sheet.getByText("Истории пока нет", { exact: true })).toHaveCount(0)
|
||||||
|
await sheet.getByRole("button", { name: "Повторить загрузку" }).click()
|
||||||
|
await expect.poll(() => attempts).toBe(2)
|
||||||
|
await expect(sheet.getByRole("status")).toContainText("Загрузка истории")
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await expect(sheet).toHaveCount(0)
|
||||||
|
await open.click()
|
||||||
|
await expect(sheet.getByRole("alert")).toContainText("Некорректный ответ истории")
|
||||||
|
for (let attempt = 4; attempt <= 6; attempt++) {
|
||||||
|
await sheet.getByRole("button", { name: "Повторить загрузку" }).click()
|
||||||
|
await expect.poll(() => attempts).toBe(attempt)
|
||||||
|
await expect(sheet.getByRole("alert")).toContainText("Некорректный ответ истории")
|
||||||
|
await expect(sheet.getByRole("button", { name: "Откатить", exact: true })).toHaveCount(0)
|
||||||
}
|
}
|
||||||
return route.fulfill({ json: path === "/api/auth/config" ? { required: false }
|
await sheet.getByRole("button", { name: "Повторить загрузку" }).click()
|
||||||
: path === "/api/ipsec" ? { servers: [], clients: [] }
|
await expect(sheet.getByText("Истории пока нет", { exact: true })).toBeVisible()
|
||||||
: path === "/api/servers" ? [{ id: 1, name: "История роутер", host: "192.0.2.1", enabled: true, type: "exit-node", country: "RU", status: "online" }] : {} })
|
const response = page.waitForResponse(r => r.url().includes(`/api/${section}/revisions?`))
|
||||||
|
release!()
|
||||||
|
await (await response).finished()
|
||||||
|
await page.waitForTimeout(150)
|
||||||
|
await expect(sheet.getByRole("alert")).toHaveCount(0)
|
||||||
|
await expect(sheet.getByText("Истории пока нет", { exact: true })).toBeVisible()
|
||||||
})
|
})
|
||||||
await page.goto("/ipsec")
|
}
|
||||||
await page.getByRole("option").filter({ hasText: "История роутер" }).click()
|
|
||||||
const open = page.getByRole("button", { name: "История", exact: true })
|
|
||||||
const sheet = page.getByRole("dialog", { name: "История конфигураций IPsec" })
|
|
||||||
await open.click()
|
|
||||||
await expect(sheet.getByRole("alert")).toContainText("История недоступна тест")
|
|
||||||
await expect(sheet.getByText("Истории пока нет", { exact: true })).toHaveCount(0)
|
|
||||||
await sheet.getByRole("button", { name: "Повторить загрузку" }).click()
|
|
||||||
await expect.poll(() => attempts).toBe(2)
|
|
||||||
await expect(sheet.getByRole("status")).toContainText("Загрузка истории")
|
|
||||||
await page.keyboard.press("Escape")
|
|
||||||
await expect(sheet).toHaveCount(0)
|
|
||||||
await open.click()
|
|
||||||
await expect(sheet.getByRole("alert")).toContainText("Некорректный ответ истории IPsec")
|
|
||||||
await sheet.getByRole("button", { name: "Повторить загрузку" }).click()
|
|
||||||
await expect(sheet.getByText("Истории пока нет", { exact: true })).toBeVisible()
|
|
||||||
const response = page.waitForResponse(r => r.url().includes("/api/ipsec/revisions?"))
|
|
||||||
release!()
|
|
||||||
await (await response).finished()
|
|
||||||
await page.waitForTimeout(150)
|
|
||||||
await expect(sheet.getByRole("alert")).toHaveCount(0)
|
|
||||||
await expect(sheet.getByText("Истории пока нет", { exact: true })).toBeVisible()
|
|
||||||
})
|
|
||||||
Reference in new issue
Block a user