fix(ui): показывать достоверное состояние и экспорт VXLAN
This commit is contained in:
1 parent
6fc342cd2c
commit
d200e87e3d
8 files changed
+164
-43
No files matched your search
+37
-22
@@ -12,10 +12,11 @@ import { OpsPanel } from "@/components/ops-panel"
|
|||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
import { VxlanDataGrid } from "@/components/data-grids/vxlan-data-grid"
|
import { VxlanDataGrid } from "@/components/data-grids/vxlan-data-grid"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert"
|
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||||
import {
|
import {
|
||||||
NetworkIcon, PlusIcon, LayersIcon, RefreshCwIcon, AlertCircleIcon,
|
NetworkIcon, LayersIcon, RefreshCwIcon, AlertCircleIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
@@ -96,12 +97,15 @@ function generateVxlanRsc(t: VxlanTunnel, serverById: Record<string, Server>): s
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ExportSheet({
|
function ExportSheet({
|
||||||
open, tunnel, onClose, serverById,
|
open, tunnel, onClose, serverById, receivedAt, stale, mock,
|
||||||
}: {
|
}: {
|
||||||
open: boolean
|
open: boolean
|
||||||
tunnel: VxlanTunnel | null
|
tunnel: VxlanTunnel | null
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
serverById: Record<string, Server>
|
serverById: Record<string, Server>
|
||||||
|
receivedAt: string | null
|
||||||
|
stale: boolean
|
||||||
|
mock: boolean
|
||||||
}) {
|
}) {
|
||||||
const code = useMemo(
|
const code = useMemo(
|
||||||
() => (tunnel ? generateVxlanRsc(tunnel, serverById) : ""),
|
() => (tunnel ? generateVxlanRsc(tunnel, serverById) : ""),
|
||||||
@@ -113,7 +117,7 @@ function ExportSheet({
|
|||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title="Экспорт VXLAN"
|
title="Экспорт VXLAN"
|
||||||
description="RouterOS 7.x · /interface/vxlan + vteps"
|
description={mock ? "Моковый снимок · данные не получены с RouterOS" : `Снимок на момент открытия · ${receivedAt ? new Date(receivedAt).toLocaleString("ru-RU") : "время получения неизвестно"} · ${stale ? "актуальность не подтверждена" : "последний успешный опрос"}`}
|
||||||
formats={[
|
formats={[
|
||||||
{
|
{
|
||||||
id: "rsc",
|
id: "rsc",
|
||||||
@@ -136,7 +140,9 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref,
|
|||||||
const isLive = mode === "live"
|
const isLive = mode === "live"
|
||||||
|
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [exportTunnel, setExportTunnel] = useState<VxlanTunnel | null>(null)
|
const [exportSnapshot, setExportSnapshot] = useState<{ tunnel: VxlanTunnel; servers: Record<string, Server>; at: string | null; stale: boolean; mock: boolean } | null>(null)
|
||||||
|
const [readReceipt, setReadReceipt] = useState<Record<string, string>>({})
|
||||||
|
const [responseReceipt, setResponseReceipt] = useState<string | null>(null)
|
||||||
|
|
||||||
const [liveTunnels, setLiveTunnels] = useState<VxlanTunnel[]>([])
|
const [liveTunnels, setLiveTunnels] = useState<VxlanTunnel[]>([])
|
||||||
const [liveServers, setLiveServers] = useState<Server[]>([])
|
const [liveServers, setLiveServers] = useState<Server[]>([])
|
||||||
@@ -151,7 +157,6 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref,
|
|||||||
if (!isLive) return
|
if (!isLive) return
|
||||||
const epoch = ++loadEpoch.current
|
const epoch = ++loadEpoch.current
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setLiveError(null)
|
|
||||||
try {
|
try {
|
||||||
const [tunnelsRes, serversRes] = await Promise.all([
|
const [tunnelsRes, serversRes] = await Promise.all([
|
||||||
requestJson<unknown>(backendUrl, "/api/vxlan"),
|
requestJson<unknown>(backendUrl, "/api/vxlan"),
|
||||||
@@ -168,7 +173,11 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref,
|
|||||||
const enabledIds = new Set(nextServers.filter(s => s.enabled).map(s => String(s.id)))
|
const enabledIds = new Set(nextServers.filter(s => s.enabled).map(s => String(s.id)))
|
||||||
setLiveTunnels(previous => [...nextTunnels.filter(t => !failedIds.has(t.serverId)), ...previous.filter(t => failedIds.has(t.serverId) && enabledIds.has(t.serverId))])
|
setLiveTunnels(previous => [...nextTunnels.filter(t => !failedIds.has(t.serverId)), ...previous.filter(t => failedIds.has(t.serverId) && enabledIds.has(t.serverId))])
|
||||||
setFailures(nextFailures)
|
setFailures(nextFailures)
|
||||||
|
setLiveError(null)
|
||||||
setHasLoaded(true)
|
setHasLoaded(true)
|
||||||
|
const at = new Date().toISOString()
|
||||||
|
setResponseReceipt(at)
|
||||||
|
setReadReceipt(previous => Object.fromEntries([...enabledIds].map(id => [id, failedIds.has(id) ? previous[id] : at]).filter((entry): entry is [string, string] => !!entry[1])))
|
||||||
setLiveServers(nextServers.filter((s) => s.enabled).map(mapBackendServer))
|
setLiveServers(nextServers.filter((s) => s.enabled).map(mapBackendServer))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (epoch !== loadEpoch.current) return
|
if (epoch !== loadEpoch.current) return
|
||||||
@@ -235,7 +244,7 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref,
|
|||||||
)
|
)
|
||||||
}, [search, scopedTunnels, serverById])
|
}, [search, scopedTunnels, serverById])
|
||||||
|
|
||||||
const failedServerIds = useMemo(() => isLive ? failures.map(f => f.serverId) : [], [isLive, failures])
|
const failedServerIds = useMemo(() => !isLive ? [] : liveError ? [...new Set([...liveTunnels.map(t => t.serverId), ...liveServers.map(s => s.id)])] : failures.map(f => f.serverId), [isLive, liveError, liveTunnels, liveServers, failures])
|
||||||
const scopedFailures = failures.filter(f => effectiveServerId === ALL_SERVERS_ID || f.serverId === effectiveServerId)
|
const scopedFailures = failures.filter(f => effectiveServerId === ALL_SERVERS_ID || f.serverId === effectiveServerId)
|
||||||
const upCount = scopedTunnels.filter((t) => t.status === "up").length
|
const upCount = scopedTunnels.filter((t) => t.status === "up").length
|
||||||
const vnis = new Set(scopedTunnels.map((t) => t.vni)).size
|
const vnis = new Set(scopedTunnels.map((t) => t.vni)).size
|
||||||
@@ -265,9 +274,7 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref,
|
|||||||
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||||||
Обновить
|
Обновить
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm">
|
<Badge variant="outline">Только просмотр</Badge>
|
||||||
<PlusIcon className="size-4" />Новый VXLAN
|
|
||||||
</Button>
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -301,6 +308,10 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref,
|
|||||||
<AlertDescription className="text-xs">Ошибка загрузки: {liveError}</AlertDescription>
|
<AlertDescription className="text-xs">Ошибка загрузки: {liveError}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
{isLive && <div role="group" aria-label="Свежесть VXLAN" className="text-xs text-muted-foreground">
|
||||||
|
{effectiveServerId === ALL_SERVERS_ID ? "Последний ответ получен в приложении: " : "Ответ устройства получен в приложении: "}
|
||||||
|
{(effectiveServerId === ALL_SERVERS_ID ? responseReceipt : readReceipt[effectiveServerId]) ? <time dateTime={(effectiveServerId === ALL_SERVERS_ID ? responseReceipt : readReceipt[effectiveServerId])!} title="Клиентское время ответа, не время измерения RouterOS">{new Date((effectiveServerId === ALL_SERVERS_ID ? responseReceipt : readReceipt[effectiveServerId])!).toLocaleString("ru-RU")}</time> : "неизвестно"}
|
||||||
|
</div>}
|
||||||
{isLive && loading && <p role="status" className="text-sm text-muted-foreground">{hasLoaded ? "Обновление VXLAN…" : "Загрузка VXLAN…"}</p>}
|
{isLive && loading && <p role="status" className="text-sm text-muted-foreground">{hasLoaded ? "Обновление VXLAN…" : "Загрузка VXLAN…"}</p>}
|
||||||
{isLive && liveError && hasLoaded && <p className="text-sm text-muted-foreground">Показаны последние полученные данные; они могут быть устаревшими.</p>}
|
{isLive && liveError && hasLoaded && <p className="text-sm text-muted-foreground">Показаны последние полученные данные; они могут быть устаревшими.</p>}
|
||||||
{selectedTunnelId === null && isLive && !loading && displayTunnels.length === 0 && !liveError && scopedFailures.length === 0 && (
|
{selectedTunnelId === null && isLive && !loading && displayTunnels.length === 0 && !liveError && scopedFailures.length === 0 && (
|
||||||
@@ -320,28 +331,28 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref,
|
|||||||
{
|
{
|
||||||
id: "tunnels",
|
id: "tunnels",
|
||||||
label: "Туннелей",
|
label: "Туннелей",
|
||||||
value: isLive && (!hasLoaded || scopedFailures.length > 0) ? "—" : scopedTunnels.length,
|
value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : scopedTunnels.length,
|
||||||
icon: <NetworkIcon className="size-4" />,
|
icon: <NetworkIcon className="size-4" />,
|
||||||
iconClassName: "text-muted-foreground",
|
iconClassName: "text-muted-foreground",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "up",
|
id: "up",
|
||||||
label: "Активных",
|
label: "Активных",
|
||||||
value: isLive && (!hasLoaded || scopedFailures.length > 0) ? "—" : upCount,
|
value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : upCount,
|
||||||
icon: <LayersIcon className="size-4" />,
|
icon: <LayersIcon className="size-4" />,
|
||||||
iconClassName: "text-success",
|
iconClassName: "text-success",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "vni",
|
id: "vni",
|
||||||
label: "Уникальных VNI",
|
label: "Уникальных VNI",
|
||||||
value: isLive && (!hasLoaded || scopedFailures.length > 0) ? "—" : vnis,
|
value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : vnis,
|
||||||
icon: <LayersIcon className="size-4" />,
|
icon: <LayersIcon className="size-4" />,
|
||||||
iconClassName: "text-info",
|
iconClassName: "text-info",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "servers",
|
id: "servers",
|
||||||
label: "Серверов",
|
label: "Серверов",
|
||||||
value: isLive && (!hasLoaded || scopedFailures.length > 0) ? "—" : new Set(scopedTunnels.map((t) => t.serverId)).size,
|
value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : new Set(scopedTunnels.map((t) => t.serverId)).size,
|
||||||
icon: <NetworkIcon className="size-4" />,
|
icon: <NetworkIcon className="size-4" />,
|
||||||
iconClassName: "text-primary",
|
iconClassName: "text-primary",
|
||||||
},
|
},
|
||||||
@@ -364,14 +375,14 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref,
|
|||||||
search={search}
|
search={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
searchPlaceholder="Поиск по имени, VNI, серверу…"
|
searchPlaceholder="Поиск по имени, VNI, серверу…"
|
||||||
countLabel={`${filtered.length} туннелей`}
|
countLabel={isLive && !hasLoaded ? "Туннели пока неизвестны" : `${filtered.length} туннелей`}
|
||||||
/>
|
/>
|
||||||
<VxlanDataGrid
|
{(!isLive || hasLoaded) && <VxlanDataGrid
|
||||||
tunnels={filtered}
|
tunnels={filtered}
|
||||||
failedServerIds={failedServerIds}
|
failedServerIds={failedServerIds}
|
||||||
servers={displayServers}
|
servers={displayServers}
|
||||||
onExport={setExportTunnel}
|
onExport={tunnel => setExportSnapshot({ tunnel, servers: serverById, at: isLive ? readReceipt[tunnel.serverId] ?? null : null, stale: isLive && (!!liveError || failedServerIds.includes(tunnel.serverId)), mock: !isLive })}
|
||||||
/>
|
/>}
|
||||||
</DataPageCard>
|
</DataPageCard>
|
||||||
|
|
||||||
<OpsPanel title="RouterOS 7 · /interface/vxlan — быстрые команды" contentClassName="px-5 py-4">
|
<OpsPanel title="RouterOS 7 · /interface/vxlan — быстрые команды" contentClassName="px-5 py-4">
|
||||||
@@ -430,16 +441,20 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref,
|
|||||||
</ServerRailLayout>
|
</ServerRailLayout>
|
||||||
|
|
||||||
<ExportSheet
|
<ExportSheet
|
||||||
open={!!exportTunnel}
|
open={!!exportSnapshot}
|
||||||
tunnel={exportTunnel}
|
tunnel={exportSnapshot?.tunnel ?? null}
|
||||||
onClose={() => setExportTunnel(null)}
|
onClose={() => setExportSnapshot(null)}
|
||||||
serverById={serverById}
|
serverById={exportSnapshot?.servers ?? {}}
|
||||||
|
receivedAt={exportSnapshot?.at ?? null}
|
||||||
|
stale={exportSnapshot?.stale ?? false}
|
||||||
|
mock={exportSnapshot?.mock ?? !isLive}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function VxlanRoute() {
|
function VxlanRoute() {
|
||||||
|
const { mode, backendUrl } = useDataSource()
|
||||||
const params = useSearchParams()
|
const params = useSearchParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const serverId = params.get("server") ?? ALL_SERVERS_ID
|
const serverId = params.get("server") ?? ALL_SERVERS_ID
|
||||||
@@ -447,7 +462,7 @@ function VxlanRoute() {
|
|||||||
const rest = new URLSearchParams(params.toString())
|
const rest = new URLSearchParams(params.toString())
|
||||||
rest.delete("tunnel")
|
rest.delete("tunnel")
|
||||||
const allTunnelsHref = "/vxlan" + (rest.size ? "?" + rest.toString() : "")
|
const allTunnelsHref = "/vxlan" + (rest.size ? "?" + rest.toString() : "")
|
||||||
return <VxlanPageContent key={JSON.stringify([serverId, tunnelId])} selectedServerId={serverId} selectedTunnelId={tunnelId} allTunnelsHref={allTunnelsHref} onSelectServer={id => {
|
return <VxlanPageContent key={JSON.stringify([serverId, tunnelId, mode, backendUrl])} selectedServerId={serverId} selectedTunnelId={tunnelId} allTunnelsHref={allTunnelsHref} onSelectServer={id => {
|
||||||
const next = new URLSearchParams(rest)
|
const next = new URLSearchParams(rest)
|
||||||
if (id === ALL_SERVERS_ID) next.delete("server")
|
if (id === ALL_SERVERS_ID) next.delete("server")
|
||||||
else next.set("server", id)
|
else next.set("server", id)
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||||
@@ -29,9 +28,6 @@ import {
|
|||||||
CodeXmlIcon,
|
CodeXmlIcon,
|
||||||
MoreHorizontalIcon,
|
MoreHorizontalIcon,
|
||||||
NetworkIcon,
|
NetworkIcon,
|
||||||
PencilIcon,
|
|
||||||
PowerIcon,
|
|
||||||
Trash2Icon,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
interface VxlanDataGridProps {
|
interface VxlanDataGridProps {
|
||||||
@@ -227,20 +223,6 @@ function VxlanDataGrid({ tunnels, servers, onExport, failedServerIds = [] }: Vxl
|
|||||||
<CodeXmlIcon className="size-4" />
|
<CodeXmlIcon className="size-4" />
|
||||||
Экспорт .rsc
|
Экспорт .rsc
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem>
|
|
||||||
<PencilIcon className="size-4" />
|
|
||||||
Редактировать
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<PowerIcon className="size-4" />
|
|
||||||
{tunnel.enabled ? "Отключить" : "Включить"}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem variant="destructive">
|
|
||||||
<Trash2Icon className="size-4" />
|
|
||||||
Удалить
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
@@ -270,7 +252,7 @@ function VxlanDataGrid({ tunnels, servers, onExport, failedServerIds = [] }: Vxl
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<NetworkIcon className="size-4" />}
|
icon={<NetworkIcon className="size-4" />}
|
||||||
title="Нет VXLAN-туннелей"
|
title="Нет VXLAN-туннелей"
|
||||||
description="Добавьте туннель или измените поиск"
|
description="Измените поиск или проверьте конфигурацию VXLAN в RouterOS"
|
||||||
className="border-0 py-16"
|
className="border-0 py-16"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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.52 — IPsec/GRE/VXLAN, Firewall и операции Filters |
|
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.53 — IPsec/GRE/VXLAN, Firewall и операции Filters |
|
||||||
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
|
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
|
||||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
||||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||||
|
|
||||||
|
## Блок 9.53 — достоверность VXLAN и режим просмотра (2026-09-30)
|
||||||
|
|
||||||
|
- Backend VXLAN предоставляет GET; удалены активные кнопки без обработчиков (создание/редактирование/включение/удаление). Экран явно отмечен «Только просмотр»; доступны опрос и экспорт .rsc. Новые сетевые write-операции в этом блоке не вводились.
|
||||||
|
- При полном отказе сохранённые строки получают неизвестный статус, KPI не выдают старый UP за актуальное значение. Первоначальная ошибка не показывает ложную пустую таблицу. Ошибка сохраняется до успешного повтора, время успешного чтения — по устройствам; частичный отказ не обновляет дату неответившего устройства. Даты клиентские, общий ответ не гарантирует успешный опрос каждого устройства.
|
||||||
|
- Экспорт фиксирует строку, metadata сервера, дату и признак неизвестной актуальности на момент открытия; обновление таблицы не меняет открытый код. Mock-экспорт подписан отдельно. Смена backend/mode отделяет состояние, панели и даты; URL-контекст сохранён.
|
||||||
|
- После последних изменений прошли 17 обычных E2E VXLAN и 4 отдельных E2E смены источника. TypeScript и lint чистые. Backend не менялся; последний frontend build — 9.52, backend — 9.44.
|
||||||
|
- Далее — точность/экранирование .rsc VXLAN и валидация согласованности ответа, затем ошибки общего GRE-каталога и общая регрессия этапа 9. Реальная RouterOS-приёмка/полные права/DB/JWT не выполнены. Локальный коммит без push.
|
||||||
|
|
||||||
## Блок 9.52 — клавиатура и экраны IPsec (2026-09-30)
|
## Блок 9.52 — клавиатура и экраны IPsec (2026-09-30)
|
||||||
|
|
||||||
- Два E2E проверили ПК 1440×1000 и телефон 390×844: выбор устройства на телефоне, открытие клиента клавиатурой, сохранение через Enter с единственным PATCH в правильный serverId, отсутствие горизонтального переполнения документа, границы формы и серверные действия. Шесть PNG сохранены и просмотрены. Имена клиентов получают title для обрезанного текста.
|
- Два E2E проверили ПК 1440×1000 и телефон 390×844: выбор устройства на телефоне, открытие клиента клавиатурой, сохранение через Enter с единственным PATCH в правильный serverId, отсутствие горизонтального переполнения документа, границы формы и серверные действия. Шесть PNG сохранены и просмотрены. Имена клиентов получают title для обрезанного текста.
|
||||||
|
|||||||
@@ -284,3 +284,11 @@ Live-копирование читает актуальный целевой rul
|
|||||||
| [Data Grid Filtering 2](https://reui.io/preview/base/data-grid-filtering-2?ref=mcp) | [Клиенты](ux-ui/screens/ipsec-desktop.png) | [Клиенты](ux-ui/screens/ipsec-mobile.png) |
|
| [Data Grid Filtering 2](https://reui.io/preview/base/data-grid-filtering-2?ref=mcp) | [Клиенты](ux-ui/screens/ipsec-desktop.png) | [Клиенты](ux-ui/screens/ipsec-mobile.png) |
|
||||||
| [Sheet 8](https://reui.io/preview/base/sheet-8?ref=mcp) | [Форма клиента](ux-ui/screens/ipsec-client-desktop.png) | [Форма клиента](ux-ui/screens/ipsec-client-mobile.png) |
|
| [Sheet 8](https://reui.io/preview/base/sheet-8?ref=mcp) | [Форма клиента](ux-ui/screens/ipsec-client-desktop.png) | [Форма клиента](ux-ui/screens/ipsec-client-mobile.png) |
|
||||||
| [List 9](https://reui.io/preview/base/list-9?ref=mcp) | [Серверы](ux-ui/screens/ipsec-servers-desktop.png) | [Сервер](ux-ui/screens/ipsec-servers-mobile.png) |
|
| [List 9](https://reui.io/preview/base/list-9?ref=mcp) | [Серверы](ux-ui/screens/ipsec-servers-desktop.png) | [Сервер](ux-ui/screens/ipsec-servers-mobile.png) |
|
||||||
|
|
||||||
|
## Блок 9.53 — достоверность VXLAN и режим просмотра (2026-09-30)
|
||||||
|
|
||||||
|
- Backend VXLAN предоставляет GET; удалены активные кнопки без обработчиков (создание/редактирование/включение/удаление). Экран явно отмечен «Только просмотр»; доступны опрос и экспорт .rsc. Новые сетевые write-операции в этом блоке не вводились.
|
||||||
|
- При полном отказе сохранённые строки получают неизвестный статус, KPI не выдают старый UP за актуальное значение. Первоначальная ошибка не показывает ложную пустую таблицу. Ошибка сохраняется до успешного повтора, время успешного чтения — по устройствам; частичный отказ не обновляет дату неответившего устройства. Даты клиентские, общий ответ не гарантирует успешный опрос каждого устройства.
|
||||||
|
- Экспорт фиксирует строку, metadata сервера, дату и признак неизвестной актуальности на момент открытия; обновление таблицы не меняет открытый код. Mock-экспорт подписан отдельно. Смена backend/mode отделяет состояние, панели и даты; URL-контекст сохранён.
|
||||||
|
- После последних изменений прошли 17 обычных E2E VXLAN и 4 отдельных E2E смены источника. TypeScript и lint чистые. Backend не менялся; последний frontend build — 9.52, backend — 9.44.
|
||||||
|
- Далее — точность/экранирование .rsc VXLAN и валидация согласованности ответа, затем ошибки общего GRE-каталога и общая регрессия этапа 9. Реальная RouterOS-приёмка/полные права/DB/JWT не выполнены. Локальный коммит без push.
|
||||||
@@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test"
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./tests/e2e",
|
testDir: "./tests/e2e",
|
||||||
testIgnore: ["**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts"],
|
testIgnore: ["**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-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: "vxlan-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,42 @@
|
|||||||
|
import { test, expect } from "@playwright/test"
|
||||||
|
const headers = { "access-control-allow-origin": "*", "access-control-allow-methods": "GET,OPTIONS", "access-control-allow-headers": "Content-Type,Authorization" }
|
||||||
|
const tunnel = (port: string) => ({ id: "*1", name: port === "8101" ? "Новый VXLAN" : "Старый VXLAN", serverId: "1", vni: port === "8101" ? 200 : 100, port: 0, dstPort: 8472, vtepIp: "192.0.2.1", remoteVteps: [], l2mtu: 1500, arpProxy: false, macLearning: true, comment: "", enabled: true, status: "up" })
|
||||||
|
|
||||||
|
for (const change of ["backend", "mode"]) for (const fails of [false, true]) {
|
||||||
|
test(`VXLAN: позднее чтение после смены ${change}/${fails}`, async ({ page }) => {
|
||||||
|
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
|
||||||
|
return r.fulfill({ headers, json: path === "/api/vxlan" ? { tunnels: [tunnel(url.port)] } : path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", enabled: true, country: "RU" }] : path === "/api/auth/config" ? { required: false } : {} })
|
||||||
|
})
|
||||||
|
await page.goto("/settings")
|
||||||
|
await page.getByRole("link", { name: /^VXLAN/ }).click()
|
||||||
|
await expect(page.locator("tr[data-row-id]")).toContainText("Старый VXLAN")
|
||||||
|
let release: (() => void) | undefined, started = false
|
||||||
|
const gate = new Promise<void>(resolve => { release = resolve })
|
||||||
|
await page.route("**/api/vxlan", 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: "Старая ошибка VXLAN" } } : { headers, json: { tunnels: [] } })
|
||||||
|
})
|
||||||
|
await page.getByRole("button", { name: "Обновить", 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: /^VXLAN/ }).click()
|
||||||
|
if (change === "backend") await expect(page.locator("tr[data-row-id]")).toContainText("Новый VXLAN")
|
||||||
|
else await expect(page.getByText("Моковые данные", { exact: true })).toBeVisible()
|
||||||
|
const response = page.waitForResponse(r => r.url().includes(":8000/api/vxlan"))
|
||||||
|
release!()
|
||||||
|
await (await response).finished()
|
||||||
|
await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))))
|
||||||
|
if (change === "backend") await expect(page.locator("tr[data-row-id]")).toContainText("Новый VXLAN")
|
||||||
|
await expect(page.getByText("Ошибка загрузки:", { exact: false })).toHaveCount(0)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { test, expect } from "@playwright/test"
|
||||||
|
|
||||||
|
const devices = [1, 2].map(id => ({ id, name: "Роутер " + id, host: "192.0.2." + id, enabled: true, country: "RU" }))
|
||||||
|
const tunnel = (id: string, vni: number) => ({ id: "*" + id, serverId: id, name: "vxlan-" + id, vni, port: 0, dstPort: 8472, vtepIp: "192.0.2." + id, remoteVteps: ["198.51.100.1"], l2mtu: 1500, arpProxy: false, macLearning: true, comment: "", enabled: true, status: "up" })
|
||||||
|
|
||||||
|
test("VXLAN: свежесть, неизвестный статус и честный режим просмотра", async ({ page }) => {
|
||||||
|
await page.clock.install({ time: new Date("2026-09-30T05:00:00Z") })
|
||||||
|
let phase = 0, writes = 0
|
||||||
|
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
|
||||||
|
await page.route("**/api/**", r => {
|
||||||
|
const path = new URL(r.request().url()).pathname
|
||||||
|
if (r.request().method() !== "GET") writes++
|
||||||
|
if (path === "/api/vxlan" && phase === 2) return r.fulfill({ status: 503, json: { error: "Опрос VXLAN недоступен" } })
|
||||||
|
return r.fulfill({ json: path === "/api/vxlan" ? { tunnels: phase === 1 ? [tunnel("1", 100)] : [tunnel("1", 100), tunnel("2", phase === 3 ? 300 : 200)], failures: phase === 1 ? [{ serverId: "2", error: "Таймаут тест" }] : [] }
|
||||||
|
: path === "/api/servers" ? devices : path === "/api/auth/config" ? { required: false } : {} })
|
||||||
|
})
|
||||||
|
await page.goto("/vxlan?server=2")
|
||||||
|
const rows = page.locator("tr[data-row-id]")
|
||||||
|
await expect(rows).toHaveCount(1)
|
||||||
|
await expect(page.getByText("Только просмотр", { exact: true })).toBeVisible()
|
||||||
|
await expect(page.getByRole("button", { name: "Новый VXLAN" })).toHaveCount(0)
|
||||||
|
const time = page.getByRole("group", { name: "Свежесть VXLAN" }).locator("time")
|
||||||
|
const first = await time.getAttribute("datetime")
|
||||||
|
await page.clock.fastForward(60_000)
|
||||||
|
phase = 1
|
||||||
|
await page.getByRole("button", { name: "Обновить", exact: true }).click()
|
||||||
|
await expect(page.getByRole("alert").filter({ hasText: "Не все устройства ответили" })).toBeVisible()
|
||||||
|
await expect(rows).toContainText("Неизвестно")
|
||||||
|
await expect(time).toHaveAttribute("datetime", first!)
|
||||||
|
await page.getByRole("button", { name: "Действия: vxlan-2" }).click()
|
||||||
|
for (const name of ["Редактировать", "Отключить", "Удалить"]) await expect(page.getByRole("menuitem", { name, exact: true })).toHaveCount(0)
|
||||||
|
await page.getByRole("menuitem", { name: "Экспорт .rsc", exact: true }).click()
|
||||||
|
const dialog = page.getByRole("dialog")
|
||||||
|
await expect(dialog).toContainText("актуальность не подтверждена")
|
||||||
|
await expect(dialog).toContainText("vni=200")
|
||||||
|
phase = 3
|
||||||
|
const refresh = page.getByRole("button", { name: "Обновить", exact: true, includeHidden: true })
|
||||||
|
await refresh.evaluate(el => (el as HTMLButtonElement).click())
|
||||||
|
await expect(rows).toContainText("300")
|
||||||
|
await expect(dialog).toContainText("vni=200")
|
||||||
|
await expect(dialog).toContainText("актуальность не подтверждена")
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await expect(time).not.toHaveAttribute("datetime", first!)
|
||||||
|
const after = await time.getAttribute("datetime")
|
||||||
|
phase = 2
|
||||||
|
await page.clock.fastForward(60_000)
|
||||||
|
await refresh.click()
|
||||||
|
await expect(page.getByText("Ошибка загрузки: Опрос VXLAN недоступен", { exact: true })).toBeVisible()
|
||||||
|
await expect(rows).toHaveCount(1)
|
||||||
|
await expect(rows).toContainText("Неизвестно")
|
||||||
|
await expect(page.getByLabel("Сводка VXLAN")).toContainText("—")
|
||||||
|
await expect(time).toHaveAttribute("datetime", after!)
|
||||||
|
expect(writes).toBe(0)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user