fix(ui): защитить списки трафика от устаревших ответов
This commit is contained in:
1 parent
480aa82f78
commit
94dd453b33
3 files changed
+157
-91
No files matched your search
+87
-91
@@ -784,6 +784,44 @@ const SORT_FIELDS: Array<{ field: SortField; label: string; modesOnly?: GroupMod
|
||||
{ field: "sessions", label: "Сессий", modesOnly: ["servers", "users", "flows"] },
|
||||
]
|
||||
|
||||
const toLiveServer = (s: LiveTrafficServer): ServerTraffic => {
|
||||
return {
|
||||
id: String(s.id),
|
||||
name: s.name,
|
||||
site: s.site || "—",
|
||||
country: s.country || "UN",
|
||||
status: s.status,
|
||||
rxNow: s.rxNow,
|
||||
txNow: s.txNow,
|
||||
rxPeak: s.rxPeak,
|
||||
txPeak: s.txPeak,
|
||||
rxTotal: s.rxTotal,
|
||||
txTotal: s.txTotal,
|
||||
sessions: s.sessions,
|
||||
rxSeries: s.rxSeries.length ? s.rxSeries : Array(60).fill(0),
|
||||
txSeries: s.txSeries.length ? s.txSeries : Array(60).fill(0),
|
||||
boundIfaces: [],
|
||||
}
|
||||
}
|
||||
|
||||
const toLiveBound = (c: BoundIfaceTraffic): BoundIfaceTraffic => ({
|
||||
...c,
|
||||
interfaceType: (["ether", "gre", "wg", "other"].includes(c.interfaceType) ? c.interfaceType : "other") as InterfaceType,
|
||||
rxSeries: c.rxSeries.length ? c.rxSeries : Array(60).fill(0),
|
||||
txSeries: c.txSeries.length ? c.txSeries : Array(60).fill(0),
|
||||
})
|
||||
|
||||
const toLiveUser = (u: UserTraffic & { interfaces?: BoundIfaceTraffic[] }): UserTraffic => {
|
||||
const ifaces = (u.interfaces ?? []).map(toLiveBound)
|
||||
return {
|
||||
...u,
|
||||
role: ROLE_LABEL[u.role as keyof typeof ROLE_LABEL] ?? u.role,
|
||||
interfaces: ifaces,
|
||||
rxSeries: u.rxSeries.length ? u.rxSeries : Array(60).fill(0),
|
||||
txSeries: u.txSeries.length ? u.txSeries : Array(60).fill(0),
|
||||
}
|
||||
}
|
||||
|
||||
export default function TrafficPage() {
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const isLive = mode === "live"
|
||||
@@ -795,6 +833,12 @@ export default function TrafficPage() {
|
||||
const [selectedId, setSelectedId] = useState("srv1")
|
||||
const [range, setRange] = useState<Range>("1h")
|
||||
const [search, setSearch] = useState("")
|
||||
const [trafficRetry, setTrafficRetry] = useState(0)
|
||||
const trafficKey = JSON.stringify([isLive, backendUrl, range, groupMode, trafficRetry])
|
||||
const [trafficState, setTrafficState] = useState<{ key: string; status: "ready" | "error"; error?: string } | null>(null)
|
||||
const trafficReady = trafficState?.key === trafficKey && trafficState.status === "ready"
|
||||
const trafficLoading = trafficState?.key !== trafficKey
|
||||
const trafficError = trafficState?.key === trafficKey ? trafficState.error : undefined
|
||||
const [liveServers, setLiveServers] = useState<ServerTraffic[]>([])
|
||||
const [liveBusy, setLiveBusy] = useState(false)
|
||||
const [liveError, setLiveError] = useState<string | null>(null)
|
||||
@@ -839,87 +883,41 @@ export default function TrafficPage() {
|
||||
excludeOverlay: flowExcludeOverlay,
|
||||
})
|
||||
|
||||
const toLiveServer = (s: LiveTrafficServer): ServerTraffic => {
|
||||
return {
|
||||
id: String(s.id),
|
||||
name: s.name,
|
||||
site: s.site || "—",
|
||||
country: s.country || "UN",
|
||||
status: s.status,
|
||||
rxNow: s.rxNow,
|
||||
txNow: s.txNow,
|
||||
rxPeak: s.rxPeak,
|
||||
txPeak: s.txPeak,
|
||||
rxTotal: s.rxTotal,
|
||||
txTotal: s.txTotal,
|
||||
sessions: s.sessions,
|
||||
rxSeries: s.rxSeries.length ? s.rxSeries : Array(60).fill(0),
|
||||
txSeries: s.txSeries.length ? s.txSeries : Array(60).fill(0),
|
||||
boundIfaces: [],
|
||||
}
|
||||
}
|
||||
|
||||
const toLiveBound = (c: BoundIfaceTraffic): BoundIfaceTraffic => ({
|
||||
...c,
|
||||
interfaceType: (["ether", "gre", "wg", "other"].includes(c.interfaceType) ? c.interfaceType : "other") as InterfaceType,
|
||||
rxSeries: c.rxSeries.length ? c.rxSeries : Array(60).fill(0),
|
||||
txSeries: c.txSeries.length ? c.txSeries : Array(60).fill(0),
|
||||
})
|
||||
|
||||
const toLiveUser = (u: UserTraffic & { interfaces?: BoundIfaceTraffic[] }): UserTraffic => {
|
||||
const ifaces = (u.interfaces ?? []).map(toLiveBound)
|
||||
return {
|
||||
...u,
|
||||
role: ROLE_LABEL[u.role as keyof typeof ROLE_LABEL] ?? u.role,
|
||||
interfaces: ifaces,
|
||||
rxSeries: u.rxSeries.length ? u.rxSeries : Array(60).fill(0),
|
||||
txSeries: u.txSeries.length ? u.txSeries : Array(60).fill(0),
|
||||
}
|
||||
}
|
||||
|
||||
const loadLiveTraffic = useCallback(async (targetRange: Range) => {
|
||||
if (!isLive) return
|
||||
setLiveBusy(true)
|
||||
setLiveError(null)
|
||||
try {
|
||||
const q = encodeURIComponent(targetRange === "30d" ? "24h" : targetRange)
|
||||
const [srvRes, usersRes, ifacesRes] = await Promise.all([
|
||||
apiFetch<{ servers: LiveTrafficServer[] }>(`/api/traffic/servers?range=${q}`),
|
||||
apiFetch<{ users: UserTraffic[] }>(`/api/traffic/users?range=${q}`),
|
||||
apiFetch<{ interfaces: BoundIfaceTraffic[] }>(`/api/traffic/bound-interfaces?range=${q}`),
|
||||
])
|
||||
const liveIfaces = ifacesRes.interfaces.map(toLiveBound)
|
||||
const mapped: ServerTraffic[] = srvRes.servers.map((s) => {
|
||||
const base = toLiveServer(s)
|
||||
return { ...base, boundIfaces: liveIfaces.filter((i) => i.serverId === String(s.id)) }
|
||||
})
|
||||
const mappedUsers = usersRes.users.map(toLiveUser)
|
||||
setLiveServers(mapped)
|
||||
setLiveUsers(mappedUsers)
|
||||
setLiveBoundIfaces(liveIfaces)
|
||||
setSelectedId((prev) => {
|
||||
if (groupMode === "users") return mappedUsers.some((x) => x.id === prev) ? prev : (mappedUsers[0]?.id ?? "")
|
||||
if (groupMode === "ifaces") return liveIfaces.some((x) => x.id === prev) ? prev : (liveIfaces[0]?.id ?? "")
|
||||
return mapped.some((x) => x.id === prev) ? prev : (mapped[0]?.id ?? "")
|
||||
})
|
||||
} catch (e) {
|
||||
setLiveError(e instanceof Error ? e.message : "Не удалось загрузить live трафик")
|
||||
} finally {
|
||||
setLiveBusy(false)
|
||||
}
|
||||
}, [apiFetch, isLive, groupMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setLiveServers([])
|
||||
setLiveUsers([])
|
||||
setLiveBoundIfaces([])
|
||||
setLiveError(null)
|
||||
return
|
||||
if (!isLive || groupMode === "flows") return
|
||||
let cancelled = false
|
||||
const loadLiveTraffic = async (targetRange: Range) => {
|
||||
const key = JSON.stringify([isLive, backendUrl, targetRange, groupMode, trafficRetry])
|
||||
try {
|
||||
const q = encodeURIComponent(targetRange === "30d" ? "24h" : targetRange)
|
||||
const [srvRes, usersRes, ifacesRes] = await Promise.all([
|
||||
apiFetch<{ servers: LiveTrafficServer[] }>(`/api/traffic/servers?range=${q}`),
|
||||
apiFetch<{ users: UserTraffic[] }>(`/api/traffic/users?range=${q}`),
|
||||
apiFetch<{ interfaces: BoundIfaceTraffic[] }>(`/api/traffic/bound-interfaces?range=${q}`),
|
||||
])
|
||||
if (cancelled) return
|
||||
const liveIfaces = ifacesRes.interfaces.map(toLiveBound)
|
||||
const mapped: ServerTraffic[] = srvRes.servers.map((s) => {
|
||||
const base = toLiveServer(s)
|
||||
return { ...base, boundIfaces: liveIfaces.filter((i) => i.serverId === String(s.id)) }
|
||||
})
|
||||
const mappedUsers = usersRes.users.map(toLiveUser)
|
||||
setLiveServers(mapped)
|
||||
setLiveUsers(mappedUsers)
|
||||
setLiveBoundIfaces(liveIfaces)
|
||||
setTrafficState({ key, status: "ready" })
|
||||
setSelectedId((prev) => {
|
||||
if (groupMode === "users") return mappedUsers.some((x) => x.id === prev) ? prev : (mappedUsers[0]?.id ?? "")
|
||||
if (groupMode === "ifaces") return liveIfaces.some((x) => x.id === prev) ? prev : (liveIfaces[0]?.id ?? "")
|
||||
return mapped.some((x) => x.id === prev) ? prev : (mapped[0]?.id ?? "")
|
||||
})
|
||||
} catch (e) {
|
||||
if (!cancelled) setTrafficState({ key, status: "error", error: e instanceof Error ? e.message : "Не удалось загрузить трафик" })
|
||||
}
|
||||
}
|
||||
void loadLiveTraffic(range)
|
||||
}, [isLive, range, loadLiveTraffic])
|
||||
return () => { cancelled = true }
|
||||
}, [apiFetch, isLive, groupMode, backendUrl, trafficRetry, range])
|
||||
|
||||
const loadFlows = useCallback(async () => {
|
||||
if (!isLive) return
|
||||
@@ -984,9 +982,9 @@ export default function TrafficPage() {
|
||||
void listServers(backendUrl).then(setCatalogServers).catch(() => setCatalogServers([]))
|
||||
}, [isLive, backendUrl])
|
||||
|
||||
const activeServerTraffic = isLive ? liveServers : serverTraffic
|
||||
const activeUserTraffic = isLive ? liveUsers : userTraffic
|
||||
const activeBoundIfaces = isLive ? liveBoundIfaces : boundIfaces
|
||||
const activeServerTraffic = useMemo(() => isLive ? (trafficReady ? liveServers : []) : serverTraffic, [isLive, trafficReady, liveServers])
|
||||
const activeUserTraffic = useMemo(() => isLive ? (trafficReady ? liveUsers : []) : userTraffic, [isLive, trafficReady, liveUsers])
|
||||
const activeBoundIfaces = useMemo(() => isLive ? (trafficReady ? liveBoundIfaces : []) : boundIfaces, [isLive, trafficReady, liveBoundIfaces])
|
||||
const visibleIfaces = useMemo(
|
||||
() => hideDisabledIfaces ? serverIfaces.filter((i) => !i.disabled && i.running) : serverIfaces,
|
||||
[serverIfaces, hideDisabledIfaces],
|
||||
@@ -1210,11 +1208,11 @@ export default function TrafficPage() {
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (effectiveMode === "flows") void loadFlows()
|
||||
else void loadLiveTraffic(range)
|
||||
else setTrafficRetry(value => value + 1)
|
||||
}}
|
||||
disabled={isLive && liveBusy}
|
||||
disabled={isLive && (effectiveMode === "flows" ? liveBusy : trafficLoading)}
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-4", isLive && liveBusy && "animate-spin")} />Обновить
|
||||
<RefreshCwIcon className={cn("size-4", isLive && (effectiveMode === "flows" ? liveBusy : trafficLoading) && "animate-spin")} />Обновить
|
||||
</Button>
|
||||
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||||
</>
|
||||
@@ -1240,10 +1238,11 @@ export default function TrafficPage() {
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
<KpiStatGrid
|
||||
{(!isLive || effectiveMode === "flows" || !trafficError) && <KpiStatGrid
|
||||
isLoading={isLive && effectiveMode !== "flows" && trafficLoading}
|
||||
aria-label="Сводка трафика"
|
||||
items={effectiveMode === "flows" ? flowKpiItems : counterKpiItems}
|
||||
/>
|
||||
/>}
|
||||
|
||||
{effectiveMode === "flows" ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
@@ -1394,11 +1393,8 @@ export default function TrafficPage() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{liveError && (
|
||||
<div className="text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
|
||||
{liveError}
|
||||
</div>
|
||||
)}
|
||||
{isLive && trafficLoading && <p role="status" className="text-sm text-muted-foreground">Загрузка списка трафика…</p>}
|
||||
{trafficError && <Alert variant="destructive" role="alert"><AlertTitle>Список трафика недоступен</AlertTitle><AlertDescription>{trafficError}<Button type="button" size="sm" variant="outline" onClick={() => setTrafficRetry(value => value + 1)}>Повторить загрузку списка</Button></AlertDescription></Alert>}
|
||||
|
||||
{/* entity list */}
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
Reference in new issue
Block a user