fix(ui): показывать достоверное состояние и экспорт VXLAN

This commit is contained in:
Denozordec committed 2026-09-30 15:15:13 +07:00
1 parent 6fc342cd2c
commit d200e87e3d
8 files changed
+164 -43

No files matched your search

+37 -22
View File
@@ -12,10 +12,11 @@ import { OpsPanel } from "@/components/ops-panel"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { VxlanDataGrid } from "@/components/data-grids/vxlan-data-grid"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Alert, AlertDescription } from "@/components/ui/alert"
import {
NetworkIcon, PlusIcon, LayersIcon, RefreshCwIcon, AlertCircleIcon,
NetworkIcon, LayersIcon, RefreshCwIcon, AlertCircleIcon,
} from "lucide-react"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
import { useDataSource } from "@/lib/data-source"
@@ -96,12 +97,15 @@ function generateVxlanRsc(t: VxlanTunnel, serverById: Record<string, Server>): s
}
function ExportSheet({
open, tunnel, onClose, serverById,
open, tunnel, onClose, serverById, receivedAt, stale, mock,
}: {
open: boolean
tunnel: VxlanTunnel | null
onClose: () => void
serverById: Record<string, Server>
receivedAt: string | null
stale: boolean
mock: boolean
}) {
const code = useMemo(
() => (tunnel ? generateVxlanRsc(tunnel, serverById) : ""),
@@ -113,7 +117,7 @@ function ExportSheet({
open={open}
onClose={onClose}
title="Экспорт VXLAN"
description="RouterOS 7.x · /interface/vxlan + vteps"
description={mock ? "Моковый снимок · данные не получены с RouterOS" : `Снимок на момент открытия · ${receivedAt ? new Date(receivedAt).toLocaleString("ru-RU") : "время получения неизвестно"} · ${stale ? "актуальность не подтверждена" : "последний успешный опрос"}`}
formats={[
{
id: "rsc",
@@ -136,7 +140,9 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref,
const isLive = mode === "live"
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 [liveServers, setLiveServers] = useState<Server[]>([])
@@ -151,7 +157,6 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref,
if (!isLive) return
const epoch = ++loadEpoch.current
setLoading(true)
setLiveError(null)
try {
const [tunnelsRes, serversRes] = await Promise.all([
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)))
setLiveTunnels(previous => [...nextTunnels.filter(t => !failedIds.has(t.serverId)), ...previous.filter(t => failedIds.has(t.serverId) && enabledIds.has(t.serverId))])
setFailures(nextFailures)
setLiveError(null)
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))
} catch (e) {
if (epoch !== loadEpoch.current) return
@@ -235,7 +244,7 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref,
)
}, [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 upCount = scopedTunnels.filter((t) => t.status === "up").length
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")} />
Обновить
</Button>
<Button size="sm">
<PlusIcon className="size-4" />Новый VXLAN
</Button>
<Badge variant="outline">Только просмотр</Badge>
</>
}
/>
@@ -301,6 +308,10 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref,
<AlertDescription className="text-xs">Ошибка загрузки: {liveError}</AlertDescription>
</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 && liveError && hasLoaded && <p className="text-sm text-muted-foreground">Показаны последние полученные данные; они могут быть устаревшими.</p>}
{selectedTunnelId === null && isLive && !loading && displayTunnels.length === 0 && !liveError && scopedFailures.length === 0 && (
@@ -320,28 +331,28 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref,
{
id: "tunnels",
label: "Туннелей",
value: isLive && (!hasLoaded || scopedFailures.length > 0) ? "—" : scopedTunnels.length,
value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : scopedTunnels.length,
icon: <NetworkIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "up",
label: "Активных",
value: isLive && (!hasLoaded || scopedFailures.length > 0) ? "—" : upCount,
value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : upCount,
icon: <LayersIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "vni",
label: "Уникальных VNI",
value: isLive && (!hasLoaded || scopedFailures.length > 0) ? "—" : vnis,
value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : vnis,
icon: <LayersIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "servers",
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" />,
iconClassName: "text-primary",
},
@@ -364,14 +375,14 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref,
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по имени, VNI, серверу…"
countLabel={`${filtered.length} туннелей`}
countLabel={isLive && !hasLoaded ? "Туннели пока неизвестны" : `${filtered.length} туннелей`}
/>
<VxlanDataGrid
{(!isLive || hasLoaded) && <VxlanDataGrid
tunnels={filtered}
failedServerIds={failedServerIds}
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>
<OpsPanel title="RouterOS 7 · /interface/vxlan — быстрые команды" contentClassName="px-5 py-4">
@@ -430,16 +441,20 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref,
</ServerRailLayout>
<ExportSheet
open={!!exportTunnel}
tunnel={exportTunnel}
onClose={() => setExportTunnel(null)}
serverById={serverById}
open={!!exportSnapshot}
tunnel={exportSnapshot?.tunnel ?? null}
onClose={() => setExportSnapshot(null)}
serverById={exportSnapshot?.servers ?? {}}
receivedAt={exportSnapshot?.at ?? null}
stale={exportSnapshot?.stale ?? false}
mock={exportSnapshot?.mock ?? !isLive}
/>
</>
)
}
function VxlanRoute() {
const { mode, backendUrl } = useDataSource()
const params = useSearchParams()
const router = useRouter()
const serverId = params.get("server") ?? ALL_SERVERS_ID
@@ -447,7 +462,7 @@ function VxlanRoute() {
const rest = new URLSearchParams(params.toString())
rest.delete("tunnel")
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)
if (id === ALL_SERVERS_ID) next.delete("server")
else next.set("server", id)