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 { 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)
|
||||
|
||||
Reference in new issue
Block a user