fix(ui): защищать оптимизацию OSPF и подтверждение результата
This commit is contained in:
1 parent
4254904c3a
commit
baa43b7ad4
8 files changed
+193
-42
No files matched your search
+40
-39
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo, useState, useEffect } from "react"
|
import { useMemo, useState, useEffect, useRef } from "react"
|
||||||
import { ospfCatalogSchema, ospfServersSchema, type OspfCatalog } from "@/lib/ospf-response"
|
import { ospfCatalogSchema, ospfServersSchema, ospfOptimizeSchema, type OspfCatalog } from "@/lib/ospf-response"
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
@@ -12,6 +12,7 @@ import { Frame, FramePanel } from "@/components/reui/frame"
|
|||||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import { can } from "@/lib/auth"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import {
|
import {
|
||||||
RefreshCwIcon, WandSparklesIcon, GripVerticalIcon,
|
RefreshCwIcon, WandSparklesIcon, GripVerticalIcon,
|
||||||
@@ -116,10 +117,6 @@ interface BackendInterface {
|
|||||||
helloInterval: number; deadInterval: number; useBfd: boolean
|
helloInterval: number; deadInterval: number; useBfd: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BackendInstance {
|
|
||||||
id: string; serverId: number; serverName: string; serverSite: string; serverCountry: string
|
|
||||||
name: string; routerId: string; version: number; disabled: boolean; inactive: boolean; redistribute: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BackendBfdSession {
|
interface BackendBfdSession {
|
||||||
id: string; serverId: number; serverName: string; serverSite: string; serverCountry: string
|
id: string; serverId: number; serverName: string; serverSite: string; serverCountry: string
|
||||||
@@ -138,23 +135,6 @@ function isRefInterfaceName(name: string): boolean {
|
|||||||
return /^\(ref\s+\*.+\)$/.test(name.trim())
|
return /^\(ref\s+\*.+\)$/.test(name.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BackendOspfOptimizeResponse {
|
|
||||||
serverId: number
|
|
||||||
serverName: string
|
|
||||||
pingWeight: number
|
|
||||||
optimizedCount: number
|
|
||||||
applied: Array<{ interface: string; from: number; to: number }>
|
|
||||||
interfaces: Array<{
|
|
||||||
id: string
|
|
||||||
interface: string
|
|
||||||
currentCost: number
|
|
||||||
optimalCost: number
|
|
||||||
score: number
|
|
||||||
pingMs: number
|
|
||||||
dlMbps: number
|
|
||||||
ulMbps: number
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── backend → frontend mappers ───────────────────────────────────────────────
|
// ─── backend → frontend mappers ───────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -679,6 +659,7 @@ function InterfacesTab({
|
|||||||
onLiveDataRefresh,
|
onLiveDataRefresh,
|
||||||
dataReady,
|
dataReady,
|
||||||
dataIncomplete,
|
dataIncomplete,
|
||||||
|
onOperationBusy,
|
||||||
}: {
|
}: {
|
||||||
items: OspfItem[]
|
items: OspfItem[]
|
||||||
isLive: boolean
|
isLive: boolean
|
||||||
@@ -687,12 +668,17 @@ function InterfacesTab({
|
|||||||
onLiveDataRefresh: () => void
|
onLiveDataRefresh: () => void
|
||||||
dataReady: boolean
|
dataReady: boolean
|
||||||
dataIncomplete: boolean
|
dataIncomplete: boolean
|
||||||
|
onOperationBusy: (busy: boolean) => void
|
||||||
}) {
|
}) {
|
||||||
const [items, setItems] = useState<OspfItem[]>(initialItems)
|
const [items, setItems] = useState<OspfItem[]>(initialItems)
|
||||||
const [dragging, setDragging] = useState<string | null>(null)
|
const [dragging, setDragging] = useState<string | null>(null)
|
||||||
const [dragOver, setDragOver] = useState<string | null>(null)
|
const [dragOver, setDragOver] = useState<string | null>(null)
|
||||||
const [optimizing, setOptimizing] = useState(false)
|
const [optimizing, setOptimizing] = useState(false)
|
||||||
const [liveOptimalCost, setLiveOptimalCost] = useState<Record<string, number>>({})
|
const [liveOptimalCost, setLiveOptimalCost] = useState<Record<string, number>>({})
|
||||||
|
const [operationError, setOperationError] = useState<string | null>(null)
|
||||||
|
const operationLock = useRef(false)
|
||||||
|
const mounted = useRef(true)
|
||||||
|
useEffect(() => { mounted.current = true; return () => { mounted.current = false } }, [])
|
||||||
|
|
||||||
// Sync with live data when it changes
|
// Sync with live data when it changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -738,14 +724,17 @@ function InterfacesTab({
|
|||||||
const canOptimizeLive = isLive && dataReady && filterServerId !== ALL_SERVERS_ID
|
const canOptimizeLive = isLive && dataReady && filterServerId !== ALL_SERVERS_ID
|
||||||
|
|
||||||
async function handleLiveOptimize() {
|
async function handleLiveOptimize() {
|
||||||
if (!canOptimizeLive) {
|
if (!mounted.current || operationLock.current || !canOptimizeLive || !can("mm:servers:write")) {
|
||||||
toast.info("Выберите конкретный сервер для оптимизации OSPF")
|
toast.info("Выберите конкретный сервер для оптимизации OSPF")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const ra = readStoredRouteOptimizerSettings()
|
const ra = readStoredRouteOptimizerSettings()
|
||||||
|
operationLock.current = true
|
||||||
|
onOperationBusy(true)
|
||||||
|
setOperationError(null)
|
||||||
setOptimizing(true)
|
setOptimizing(true)
|
||||||
try {
|
try {
|
||||||
const data = await requestJson<BackendOspfOptimizeResponse>(
|
const response = await requestJson<unknown>(
|
||||||
backendUrl,
|
backendUrl,
|
||||||
`/api/servers/${filterServerId}/ospf/optimize`,
|
`/api/servers/${filterServerId}/ospf/optimize`,
|
||||||
{
|
{
|
||||||
@@ -753,17 +742,23 @@ function InterfacesTab({
|
|||||||
body: JSON.stringify({ pingWeight: ra.pingWeight }),
|
body: JSON.stringify({ pingWeight: ra.pingWeight }),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
if (!mounted.current) return
|
||||||
|
const data = ospfOptimizeSchema.parse(response)
|
||||||
|
if (String(data.serverId) !== filterServerId) throw new Error("API вернул другой контекст OSPF")
|
||||||
const byKey: Record<string, number> = {}
|
const byKey: Record<string, number> = {}
|
||||||
data.interfaces.forEach((row) => {
|
data.interfaces.forEach((row) => {
|
||||||
byKey[`${data.serverId}-${row.id}`] = row.optimalCost
|
byKey[`${data.serverId}-${row.id}`] = row.optimalCost
|
||||||
})
|
})
|
||||||
setLiveOptimalCost(byKey)
|
setLiveOptimalCost(byKey)
|
||||||
toast.success(`OSPF оптимизация применена: ${data.optimizedCount} интерфейсов`)
|
toast.success(`API подтвердил оптимизацию OSPF: ${data.optimizedCount} интерфейсов`)
|
||||||
onLiveDataRefresh()
|
onLiveDataRefresh()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(`Ошибка оптимизации OSPF: ${err instanceof Error ? err.message : String(err)}`)
|
if (!mounted.current) return
|
||||||
|
setOperationError(`API не подтвердил завершение оптимизации: ${err instanceof Error ? err.message : String(err)}. Часть costs могла измениться; выполнен повторный опрос перед новой попыткой.`)
|
||||||
|
onLiveDataRefresh()
|
||||||
} finally {
|
} finally {
|
||||||
setOptimizing(false)
|
operationLock.current = false
|
||||||
|
if (mounted.current) { setOptimizing(false); onOperationBusy(false) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -790,8 +785,9 @@ function InterfacesTab({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
{operationError && <Alert variant="warning"><AlertCircleIcon /><AlertTitle>Оптимизация OSPF не подтверждена</AlertTitle><AlertDescription>{operationError}</AlertDescription></Alert>}
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<Button variant="outline" size="sm" onClick={() => setItems(initialItems)}>
|
<Button variant="outline" size="sm" disabled={optimizing} onClick={() => setItems(initialItems)}>
|
||||||
<RefreshCwIcon className="size-4" />{isLive ? "Последний снимок" : "Сбросить предпросмотр"}
|
<RefreshCwIcon className="size-4" />{isLive ? "Последний снимок" : "Сбросить предпросмотр"}
|
||||||
</Button>
|
</Button>
|
||||||
{needsOptimize && (
|
{needsOptimize && (
|
||||||
@@ -799,8 +795,8 @@ function InterfacesTab({
|
|||||||
<WandSparklesIcon className="size-4" />Оптимизировать
|
<WandSparklesIcon className="size-4" />Оптимизировать
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{canOptimizeLive && (
|
{isLive && filterServerId !== ALL_SERVERS_ID && (
|
||||||
<Button variant="outline" size="sm" onClick={handleLiveOptimize} disabled={optimizing}>
|
<Button variant="outline" size="sm" onClick={handleLiveOptimize} disabled={optimizing || !dataReady || !can("mm:servers:write")}>
|
||||||
<WandSparklesIcon className={cn("size-4", optimizing && "animate-spin")} />
|
<WandSparklesIcon className={cn("size-4", optimizing && "animate-spin")} />
|
||||||
{optimizing ? "Оптимизация OSPF…" : "Оптимизировать OSPF"}
|
{optimizing ? "Оптимизация OSPF…" : "Оптимизировать OSPF"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1155,6 +1151,9 @@ function OspfPageContent() {
|
|||||||
const [fetchedAt, setFetchedAt] = useState<Date | null>(null)
|
const [fetchedAt, setFetchedAt] = useState<Date | null>(null)
|
||||||
const [liveError, setLiveError] = useState<string | null>(null)
|
const [liveError, setLiveError] = useState<string | null>(null)
|
||||||
const [fetchTick, setFetchTick] = useState(0)
|
const [fetchTick, setFetchTick] = useState(0)
|
||||||
|
const operationBusy = useRef(false)
|
||||||
|
const [writeBusy, setWriteBusy] = useState(false)
|
||||||
|
function refresh() { if (operationBusy.current) return; setLoading(true); setFetchTick(t => t + 1) }
|
||||||
|
|
||||||
// Reset server filter when switching live ↔ mock
|
// Reset server filter when switching live ↔ mock
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1193,7 +1192,7 @@ function OspfPageContent() {
|
|||||||
|
|
||||||
const unknownIds = useMemo(() => new Set(liveError ? liveServers.map(s => s.id) : (liveData?.failures ?? []).map(f => f.serverId)), [liveError, liveServers, liveData])
|
const unknownIds = useMemo(() => new Set(liveError ? liveServers.map(s => s.id) : (liveData?.failures ?? []).map(f => f.serverId)), [liveError, liveServers, liveData])
|
||||||
const incomplete = isLive && (!liveData || Boolean(liveError) || (filterServerId === ALL_SERVERS_ID ? unknownIds.size > 0 : unknownIds.has(Number(filterServerId))))
|
const incomplete = isLive && (!liveData || Boolean(liveError) || (filterServerId === ALL_SERVERS_ID ? unknownIds.size > 0 : unknownIds.has(Number(filterServerId))))
|
||||||
const dataReady = !isLive || Boolean(liveData && !liveError && !loading && !liveData.failures.length)
|
const dataReady = !isLive || Boolean(liveData && !liveError && !loading && !writeBusy && !liveData.failures.length && liveServers.some(s => String(s.id) === filterServerId) && can("mm:servers:write"))
|
||||||
|
|
||||||
// Derive frontend types from backend data or use mocks
|
// Derive frontend types from backend data or use mocks
|
||||||
const { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions, routes } = useMemo(() => {
|
const { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions, routes } = useMemo(() => {
|
||||||
@@ -1280,6 +1279,7 @@ function OspfPageContent() {
|
|||||||
const host = s.label.replace(/^mt-/, "")
|
const host = s.label.replace(/^mt-/, "")
|
||||||
return {
|
return {
|
||||||
id: s.id,
|
id: s.id,
|
||||||
|
selectable: !writeBusy,
|
||||||
name: host,
|
name: host,
|
||||||
host,
|
host,
|
||||||
site: s.site,
|
site: s.site,
|
||||||
@@ -1289,7 +1289,7 @@ function OspfPageContent() {
|
|||||||
: undefined,
|
: undefined,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
), [ospfServers, serverCounts, isLive, unknownIds])
|
), [ospfServers, serverCounts, isLive, unknownIds, writeBusy])
|
||||||
|
|
||||||
// Graph always shows full topology (highlight is handled by node click inside tab)
|
// Graph always shows full topology (highlight is handled by node click inside tab)
|
||||||
// KPIs reflect the current filter
|
// KPIs reflect the current filter
|
||||||
@@ -1302,7 +1302,7 @@ function OspfPageContent() {
|
|||||||
preserveSelection
|
preserveSelection
|
||||||
items={ospfRailItems}
|
items={ospfRailItems}
|
||||||
selectedId={filterServerId}
|
selectedId={filterServerId}
|
||||||
onSelect={setFilterServerId}
|
onSelect={id => { if (!operationBusy.current) setFilterServerId(id) }}
|
||||||
showAll
|
showAll
|
||||||
allCount={ospfServers.length}
|
allCount={ospfServers.length}
|
||||||
loading={isLive && loading && ospfServers.length === 0}
|
loading={isLive && loading && ospfServers.length === 0}
|
||||||
@@ -1312,7 +1312,7 @@ function OspfPageContent() {
|
|||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<ServerRailMobileButton />
|
<ServerRailMobileButton />
|
||||||
<Button variant="outline" size="sm" disabled={!isLive || loading} onClick={() => setFetchTick(t => t + 1)}>
|
<Button variant="outline" size="sm" disabled={!isLive || loading || writeBusy} onClick={refresh}>
|
||||||
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||||||
Обновить
|
Обновить
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1324,7 +1324,7 @@ function OspfPageContent() {
|
|||||||
<div className="border-b bg-background shrink-0">
|
<div className="border-b bg-background shrink-0">
|
||||||
<div className="flex items-center px-6">
|
<div className="flex items-center px-6">
|
||||||
{TABS.map(t => (
|
{TABS.map(t => (
|
||||||
<button key={t.id} aria-pressed={activeTab === t.id} onClick={() => setActiveTab(t.id)}
|
<button key={t.id} aria-pressed={activeTab === t.id} disabled={writeBusy} onClick={() => { if (!operationBusy.current) setActiveTab(t.id) }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
|
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
|
||||||
activeTab === t.id
|
activeTab === t.id
|
||||||
@@ -1347,7 +1347,7 @@ function OspfPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isLive && fetchedAt && <p role="group" aria-label="Свежесть OSPF" className="text-xs text-muted-foreground">Ответ каталога получен <time dateTime={fetchedAt.toISOString()}>{fetchedAt.toLocaleTimeString("ru")}</time>. Время получения браузером, не измерения RouterOS.</p>}
|
{isLive && fetchedAt && <p role="group" aria-label="Свежесть OSPF" className="text-xs text-muted-foreground">Ответ каталога получен <time dateTime={fetchedAt.toISOString()}>{fetchedAt.toLocaleTimeString("ru")}</time>. Время получения браузером, не измерения RouterOS.</p>}
|
||||||
{isLive && (liveError || Boolean(liveData?.failures.length)) && <Alert variant="warning"><AlertCircleIcon /><AlertTitle>{liveError ? "Не удалось загрузить OSPF" : "Не удалось опросить часть роутеров OSPF"}</AlertTitle><AlertDescription>{liveError ?? liveData?.failures.map(f => f.serverName + ": " + f.error).join("; ")}. {liveData ? "Показаны последние полученные данные; состояния отказавших устройств неизвестны." : "Данные пока неизвестны."}<Button variant="outline" size="sm" disabled={loading} onClick={() => setFetchTick(t => t + 1)} aria-label="Повторить загрузку OSPF">Повторить</Button></AlertDescription></Alert>}
|
{isLive && (liveError || Boolean(liveData?.failures.length)) && <Alert variant="warning"><AlertCircleIcon /><AlertTitle>{liveError ? "Не удалось загрузить OSPF" : "Не удалось опросить часть роутеров OSPF"}</AlertTitle><AlertDescription>{liveError ?? liveData?.failures.map(f => f.serverName + ": " + f.error).join("; ")}. {liveData ? "Показаны последние полученные данные; состояния отказавших устройств неизвестны." : "Данные пока неизвестны."}<Button variant="outline" size="sm" disabled={loading || writeBusy} onClick={refresh} aria-label="Повторить загрузку OSPF">Повторить</Button></AlertDescription></Alert>}
|
||||||
{incomplete && (activeTab === "interfaces" ? displayItems.length === 0 : activeTab === "neighbors" ? displayNeighbors.length === 0 : activeTab === "bfd" ? displayBfdSessions.length === 0 : displayRoutes.length === 0) && <p role="status">Данные выбранного раздела OSPF пока неизвестны.</p>}
|
{incomplete && (activeTab === "interfaces" ? displayItems.length === 0 : activeTab === "neighbors" ? displayNeighbors.length === 0 : activeTab === "bfd" ? displayBfdSessions.length === 0 : displayRoutes.length === 0) && <p role="status">Данные выбранного раздела OSPF пока неизвестны.</p>}
|
||||||
{mode === "mock" && (
|
{mode === "mock" && (
|
||||||
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
|
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||||
@@ -1384,14 +1384,15 @@ function OspfPageContent() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{activeTab === "interfaces" && (!incomplete || displayItems.length > 0) && (
|
{activeTab === "interfaces" && (!incomplete || displayItems.length > 0) && (
|
||||||
<InterfacesTab
|
<InterfacesTab key={filterServerId}
|
||||||
items={displayItems}
|
items={displayItems}
|
||||||
isLive={isLive}
|
isLive={isLive}
|
||||||
filterServerId={filterServerId}
|
filterServerId={filterServerId}
|
||||||
backendUrl={backendUrl}
|
backendUrl={backendUrl}
|
||||||
onLiveDataRefresh={() => setFetchTick(t => t + 1)}
|
onLiveDataRefresh={() => { setLoading(true); setFetchTick(t => t + 1) }}
|
||||||
dataReady={dataReady}
|
dataReady={dataReady}
|
||||||
dataIncomplete={incomplete}
|
dataIncomplete={incomplete}
|
||||||
|
onOperationBusy={busy => { operationBusy.current = busy; setWriteBusy(busy) }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeTab === "neighbors" && (!incomplete || displayNeighbors.length > 0) && (
|
{activeTab === "neighbors" && (!incomplete || displayNeighbors.length > 0) && (
|
||||||
|
|||||||
@@ -26,18 +26,25 @@
|
|||||||
| 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 | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
||||||
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.10, данные OSPF; далее операции |
|
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.11, операции OSPF; далее URL/свежесть |
|
||||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
||||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||||
|
|
||||||
|
## Блок 10.11 — защита оптимизации OSPF (2026-09-30)
|
||||||
|
|
||||||
|
- Синхронный lock исключает повторный POST. Во время операции заблокированы Refresh, смена устройства и вкладки; после API-ответа повторное чтение блокирует новую запись до подтверждённого каталога. Право UI — mm:servers:write, соответствующее существующему backend-маршруту /servers/:id/ospf/optimize; политика backend не изменена.
|
||||||
|
- Подтверждение проверяет serverId, параметры/диапазоны, уникальные интерфейсы и согласованность applied/optimizedCount. Ошибка/чужой/невалидный ответ не выдаёт success; сохраняет постоянный alert и запускает чтение, поскольку составная операция могла применить часть costs. Транзакционность не добавлена и не обещана.
|
||||||
|
- Mounted guards отклоняют поздний read/write после смены backend/live/mock: нет toast/ошибки/опроса в новом контексте. Уже отправленный RouterOS-запрос сменой страницы не отменяется.
|
||||||
|
- 21 OSPF E2E прошёл: семь данных, шесть operations/read-права и восемь source read/write. TypeScript чистый, lint — только два исходных warning stateClass/fmtMs; BackendInstance удалён. Далее — URL и временные отметки каждого устройства/подтверждения API, поля и layout. Квота: 88% окна использовано; локальный коммит без push.
|
||||||
|
|
||||||
## Блок 10.10 — достоверность данных OSPF (2026-09-30)
|
## Блок 10.10 — достоверность данных OSPF (2026-09-30)
|
||||||
|
|
||||||
- Экран использует /ospf/catalog и inventory, проверяет все пять списков/поля/ID/failures и включённый контекст. Partial сохраняет набор отказавшего устройства, successful empty очищает; общий/ошибочный ответ сохраняет кэш. Mode/backendUrl изолированы key/cleanup.
|
- Экран использует /ospf/catalog и inventory, проверяет все пять списков/поля/ID/failures и включённый контекст. Partial сохраняет набор отказавшего устройства, successful empty очищает; общий/ошибочный ответ сохраняет кэш. Mode/backendUrl изолированы key/cleanup.
|
||||||
- Начальный отказ не выглядит как пустая настройка/нули. KPI неизвестны, строки neighbors/BFD/interfaces/routes явно отмечены при сбое. Топология не считается текущей при неполном опросе; сводка исправного выбранного устройства остаётся доступной. Rail включает включённые устройства без OSPF-строк.
|
- Начальный отказ не выглядит как пустая настройка/нули. KPI неизвестны, строки neighbors/BFD/interfaces/routes явно отмечены при сбое. Топология не считается текущей при неполном опросе; сводка исправного выбранного устройства остаётся доступной. Rail включает включённые устройства без OSPF-строк.
|
||||||
- Постоянный alert/повтор и отметка браузерного получения; повтор Refresh блокируется во время чтения. Оптимизация пока доступна только после полного успешного каталога. Времена успешного опроса каждого устройства, права/lock/поздний write ещё требуют следующего блока.
|
- Постоянный alert/повтор и отметка браузерного получения; повтор Refresh блокируется во время чтения. Оптимизация пока доступна только после полного успешного каталога. Времена успешного опроса каждого устройства, права/lock/поздний write ещё требуют следующего блока.
|
||||||
- Удалён придуманный live optimal cost из порядка интерфейсов; opt показан только после ответа оптимизации. Убрано ложное mock «применены»: локальное изменение обозначено предпросмотром; кнопка сброса не выдаётся за серверный опрос.
|
- Удалён придуманный live optimal cost из порядка интерфейсов; opt показан только после ответа оптимизации. Убрано ложное mock «применены»: локальное изменение обозначено предпросмотром; кнопка сброса не выдаётся за серверный опрос.
|
||||||
- 17 E2E прошли (семь OSPF data + routing/BGP), TypeScript чистый, lint без ошибок с двумя исходными warning stateClass/fmtMs. Далее — операции OSPF, URL/свежесть, unknown state/counters и layout. Квота: 84% окна использовано; локальный коммит без push.
|
- 17 E2E прошли (семь OSPF data + routing/BGP), TypeScript чистый, lint без ошибок с двумя исходными warning stateClass/fmtMs и неиспользуемым BackendInstance (удалён в 10.11). Далее — операции OSPF, URL/свежесть, unknown state/counters и layout. Квота: 84% окна использовано; локальный коммит без push.
|
||||||
|
|
||||||
## Блок 10.9 — строгий каталог OSPF (2026-09-30)
|
## Блок 10.9 — строгий каталог OSPF (2026-09-30)
|
||||||
|
|
||||||
|
|||||||
@@ -80,3 +80,7 @@ server/session/tab и возврат/reload сохраняют контекст;
|
|||||||
## Блок 10.10 — OSPF данные
|
## Блок 10.10 — OSPF данные
|
||||||
|
|
||||||
Каталог/inventory валидируются, кэш отказавшего устройства сохраняется, состояния/KPI неизвестны, топология не подменяет полный опрос. Убраны фиктивные live costs/mock применение. 17 E2E/TypeScript/lint прошли (два старых warning). Далее — операции/URL/свежесть/поля/layout.
|
Каталог/inventory валидируются, кэш отказавшего устройства сохраняется, состояния/KPI неизвестны, топология не подменяет полный опрос. Убраны фиктивные live costs/mock применение. 17 E2E/TypeScript/lint прошли (два старых warning). Далее — операции/URL/свежесть/поля/layout.
|
||||||
|
|
||||||
|
## Блок 10.11 — OSPF операции
|
||||||
|
|
||||||
|
Lock/readiness, существующее mm:servers:write, валидация подтверждения и постоянная ошибка с повторным чтением. Поздний read/write не действует в другом источнике. 21 OSPF E2E/TypeScript/lint прошли (два старых warning). Далее — URL/времена/поля/layout.
|
||||||
@@ -15,3 +15,10 @@ export const ospfCatalogSchema = z.object({
|
|||||||
})
|
})
|
||||||
export const ospfServersSchema = vxlanServersSchema
|
export const ospfServersSchema = vxlanServersSchema
|
||||||
export type OspfCatalog = z.infer<typeof ospfCatalogSchema>
|
export type OspfCatalog = z.infer<typeof ospfCatalogSchema>
|
||||||
|
|
||||||
|
export const ospfOptimizeSchema = z.object({
|
||||||
|
serverId: z.number().int().positive(), serverName: z.string(), pingWeight: z.number().int().min(0).max(100),
|
||||||
|
optimizedCount: z.number().int().nonnegative(),
|
||||||
|
applied: z.array(z.object({ interface: z.string().min(1), from: z.number().int().min(1).max(65535), to: z.number().int().min(1).max(65535) })),
|
||||||
|
interfaces: z.array(z.object({ id: z.string().trim().min(1), interface: z.string(), currentCost: z.number().int().min(1).max(65535), optimalCost: z.number().int().min(1).max(65535), score: z.number().finite(), pingMs: z.number().finite().nonnegative(), dlMbps: z.number().finite().nonnegative(), ulMbps: z.number().finite().nonnegative() })),
|
||||||
|
}).refine(data => data.optimizedCount === data.applied.length && new Set(data.interfaces.map(row => row.id)).size === data.interfaces.length && new Set(data.applied.map(row => row.interface)).size === data.applied.length && data.applied.every(row => data.interfaces.some(iface => iface.interface === row.interface && iface.currentCost === row.from && iface.optimalCost === row.to)))
|
||||||
@@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test"
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./tests/e2e",
|
testDir: "./tests/e2e",
|
||||||
testIgnore: ["**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"],
|
testIgnore: ["**/ospf-context.spec.ts", "**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"],
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
retries: 0,
|
retries: 0,
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig } from "@playwright/test"
|
||||||
|
import base from "./playwright.config"
|
||||||
|
|
||||||
|
const server = base.webServer
|
||||||
|
if (!server || Array.isArray(server)) throw new Error("Ожидается один локальный сервер")
|
||||||
|
export default defineConfig({
|
||||||
|
...base,
|
||||||
|
use: { ...base.use, baseURL: "http://127.0.0.1:3101" },
|
||||||
|
testIgnore: [],
|
||||||
|
testMatch: "ospf-context.spec.ts",
|
||||||
|
webServer: { ...server, command: "node node_modules/next/dist/bin/next dev --hostname 127.0.0.1 --port 3101", url: "http://127.0.0.1:3101", env: { ...server.env, NEXT_PUBLIC_BACKEND_URL: "local" } },
|
||||||
|
})
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { test, expect } from "@playwright/test"
|
||||||
|
const headers = { "access-control-allow-origin": "*", "access-control-allow-methods": "GET,POST,OPTIONS", "access-control-allow-headers": "Content-Type,Authorization" }
|
||||||
|
const device = { id: 1, name: "Роутер 1", host: "192.0.2.1", enabled: true, country: "RU", status: null }
|
||||||
|
const iface = { id: "*1", serverId: 1, serverName: "Роутер 1", serverSite: "", serverCountry: "RU", instance: "default", area: "backbone", areaId: "0.0.0.0", interface: "ether1", cost: 20, type: "broadcast", disabled: false, inactive: false, priority: 1, helloInterval: 10, deadInterval: 40, useBfd: false }
|
||||||
|
const catalog = { neighbors: [], interfaces: [iface], instances: [], bfdSessions: [], routes: [], failures: [] }
|
||||||
|
const result = { serverId: 1, serverName: "Роутер 1", pingWeight: 60, optimizedCount: 1, applied: [{ interface: "ether1", from: 20, to: 10 }], interfaces: [{ id: "*1", interface: "ether1", currentCost: 20, optimalCost: 10, score: 1, pingMs: 10, dlMbps: 100, ulMbps: 100 }] }
|
||||||
|
|
||||||
|
for (const change of ["backend", "mode"]) for (const operation of ["read", "write"]) for (const fails of [false, true]) {
|
||||||
|
test('OSPF: поздний ' + operation + ' после смены ' + change + '/' + fails, async ({ page }) => {
|
||||||
|
let newReads = 0
|
||||||
|
await page.route("**/health", r => r.fulfill({ headers, json: { ok: true } }))
|
||||||
|
await page.route("**/api/**", r => {
|
||||||
|
const url = new URL(r.request().url()), path = url.pathname
|
||||||
|
if (path === "/api/ospf/catalog" && url.port === "8101") newReads++
|
||||||
|
return r.fulfill({ headers, json: path === "/api/ospf/catalog" ? { ...catalog, interfaces: [{ ...iface, interface: url.port === "8101" ? "Новый интерфейс" : "Старый интерфейс" }] } : path === "/api/servers" ? [device] : path === "/api/auth/config" ? { required: false } : {} })
|
||||||
|
})
|
||||||
|
await page.goto("/settings")
|
||||||
|
await page.getByRole("link", { name: /^OSPF/ }).click()
|
||||||
|
await expect(page.getByText("Старый интерфейс", { exact: true })).toBeVisible()
|
||||||
|
await page.getByRole("option").filter({ hasText: "Роутер 1" }).click()
|
||||||
|
let release: (() => void) | undefined, started = false
|
||||||
|
const gate = new Promise<void>(resolve => { release = resolve })
|
||||||
|
const endpoint = operation === "read" ? "/api/ospf/catalog" : "/api/servers/1/ospf/optimize"
|
||||||
|
await page.route("**" + endpoint, async r => {
|
||||||
|
if (new URL(r.request().url()).port !== "8000") return r.fallback()
|
||||||
|
started = true
|
||||||
|
await gate
|
||||||
|
return r.fulfill(fails ? { headers, status: 503, json: { error: "Старая ошибка OSPF" } } : { headers, json: operation === "read" ? { ...catalog, interfaces: [] } : result })
|
||||||
|
})
|
||||||
|
await page.getByRole("button", { name: operation === "read" ? "Обновить" : "Оптимизировать OSPF", exact: true }).click()
|
||||||
|
await expect.poll(() => started).toBe(true)
|
||||||
|
await page.goBack()
|
||||||
|
await expect(page).toHaveURL(/settings/)
|
||||||
|
if (change === "backend") {
|
||||||
|
const input = page.getByPlaceholder("http://localhost:8000")
|
||||||
|
await input.fill("http://localhost:8101")
|
||||||
|
await input.press("Enter")
|
||||||
|
} else await page.getByRole("button", { name: "Моковые", exact: true }).click()
|
||||||
|
await page.getByRole("link", { name: /^OSPF/ }).click()
|
||||||
|
if (change === "backend") await expect(page.getByText("Новый интерфейс", { exact: true })).toBeVisible()
|
||||||
|
else await expect(page.getByText("Моковые данные", { exact: true })).toBeVisible()
|
||||||
|
const before = newReads
|
||||||
|
const response = page.waitForResponse(r => r.url().includes(":8000" + endpoint))
|
||||||
|
release!()
|
||||||
|
await (await response).finished()
|
||||||
|
await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))))
|
||||||
|
if (change === "backend") await expect(page.getByText("Новый интерфейс", { exact: true })).toBeVisible()
|
||||||
|
await expect(page.getByText("Старая ошибка OSPF", { exact: false })).toHaveCount(0)
|
||||||
|
await expect(page.getByText("API подтвердил оптимизацию OSPF", { exact: false })).toHaveCount(0)
|
||||||
|
expect(newReads).toBe(before)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { test, expect } from "@playwright/test"
|
||||||
|
const device = { id: 1, name: "Роутер 1", host: "192.0.2.1", enabled: true, country: "RU", status: null }
|
||||||
|
const iface = { id: "*1", serverId: 1, serverName: "Роутер 1", serverSite: "", serverCountry: "RU", instance: "default", area: "backbone", areaId: "0.0.0.0", interface: "ether1", cost: 20, type: "broadcast", disabled: false, inactive: false, priority: 1, helloInterval: 10, deadInterval: 40, useBfd: false }
|
||||||
|
const catalog = { neighbors: [], interfaces: [iface], instances: [], bfdSessions: [], routes: [], failures: [] }
|
||||||
|
const result = { serverId: 1, serverName: "Роутер 1", pingWeight: 60, optimizedCount: 1, applied: [{ interface: "ether1", from: 20, to: 10 }], interfaces: [{ id: "*1", interface: "ether1", currentCost: 20, optimalCost: 10, score: 1, pingMs: 10, dlMbps: 100, ulMbps: 100 }] }
|
||||||
|
|
||||||
|
for (const outcome of ["success", "http", "foreign", "malformed", "poll-failure"]) {
|
||||||
|
test("OSPF: оптимизация, общий lock и результат " + outcome, async ({ page }) => {
|
||||||
|
let writes = 0, polled = false, reads = 0
|
||||||
|
let release: (() => void) | undefined
|
||||||
|
const gate = new Promise<void>(resolve => { release = resolve })
|
||||||
|
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
|
||||||
|
await page.route("**/api/**", async r => {
|
||||||
|
const path = new URL(r.request().url()).pathname
|
||||||
|
if (path === "/api/servers/1/ospf/optimize") {
|
||||||
|
writes++
|
||||||
|
expect(r.request().method()).toBe("POST")
|
||||||
|
expect(r.request().postDataJSON().pingWeight).toBeGreaterThanOrEqual(0)
|
||||||
|
await gate
|
||||||
|
polled = true
|
||||||
|
return r.fulfill(outcome === "http" ? { status: 503, json: { error: "Применение не завершено" } } : { json: outcome === "foreign" ? { ...result, serverId: 2 } : outcome === "malformed" ? { ...result, optimizedCount: 99 } : result })
|
||||||
|
}
|
||||||
|
if (path === "/api/ospf/catalog") {
|
||||||
|
reads++
|
||||||
|
if (polled && outcome === "poll-failure") return r.fulfill({ status: 503, json: { error: "Опрос после записи недоступен" } })
|
||||||
|
return r.fulfill({ json: catalog })
|
||||||
|
}
|
||||||
|
return r.fulfill({ json: path === "/api/servers" ? [device] : path === "/api/auth/config" ? { required: false } : {} })
|
||||||
|
})
|
||||||
|
await page.goto("/ospf")
|
||||||
|
await page.getByRole("option").filter({ hasText: "Роутер 1" }).click()
|
||||||
|
const optimize = page.getByRole("button", { name: "Оптимизировать OSPF", exact: true })
|
||||||
|
await expect(optimize).toBeEnabled()
|
||||||
|
await expect(page.getByText("opt 10", { exact: true })).toHaveCount(0)
|
||||||
|
await optimize.evaluate(el => { (el as HTMLButtonElement).click(); (el as HTMLButtonElement).click() })
|
||||||
|
await expect.poll(() => writes).toBe(1)
|
||||||
|
await expect(page.getByRole("button", { name: "Оптимизация OSPF…", exact: true })).toBeDisabled()
|
||||||
|
await expect(page.getByRole("button", { name: "Обновить", exact: true })).toBeDisabled()
|
||||||
|
await page.getByRole("button", { name: "Обновить", exact: true }).evaluate(el => (el as HTMLButtonElement).click())
|
||||||
|
expect(reads).toBe(1)
|
||||||
|
release!()
|
||||||
|
await expect.poll(() => reads).toBe(2)
|
||||||
|
if (["http", "foreign", "malformed"].includes(outcome)) {
|
||||||
|
await expect(page.getByRole("alert").filter({ hasText: "Оптимизация OSPF не подтверждена" })).toBeVisible()
|
||||||
|
await expect(page.getByText("API подтвердил оптимизацию OSPF", { exact: false })).toHaveCount(0)
|
||||||
|
} else await expect(page.getByText("API подтвердил оптимизацию OSPF: 1 интерфейсов", { exact: true })).toBeVisible()
|
||||||
|
if (outcome === "poll-failure") {
|
||||||
|
await expect(page.getByRole("alert").filter({ hasText: "Опрос после записи недоступен" })).toBeVisible()
|
||||||
|
await expect(optimize).toBeDisabled()
|
||||||
|
} else await expect(optimize).toBeEnabled()
|
||||||
|
expect(writes).toBe(1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
test("OSPF: read-права не разрешают оптимизацию устройства", async ({ page }) => {
|
||||||
|
const payload = { sub: "test", email: "[email protected]", name: "Reader", apps: ["mm"], permissions: ["mm:network:read", "mm:servers:read"], exp: 4102444800 }
|
||||||
|
await page.addInitScript(value => localStorage.setItem("mmapp_token", "test." + btoa(JSON.stringify(value)) + ".test"), payload)
|
||||||
|
let 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() === "POST") writes++
|
||||||
|
return r.fulfill({ json: path === "/api/auth/config" ? { required: true } : path === "/api/servers" ? [device] : path === "/api/ospf/catalog" ? catalog : {} })
|
||||||
|
})
|
||||||
|
await page.goto("/ospf")
|
||||||
|
await page.getByRole("option").filter({ hasText: "Роутер 1" }).click()
|
||||||
|
await expect(page.getByRole("button", { name: "Оптимизировать OSPF", exact: true })).toBeDisabled()
|
||||||
|
expect(writes).toBe(0)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user