fix(ui): защитить списки трафика от устаревших ответов

This commit is contained in:
Denozordec committed 2026-09-24 01:25:20 +07:00
1 parent 480aa82f78
commit 94dd453b33
3 files changed
+157 -91

No files matched your search

+87 -91
View File
@@ -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">