fix(ui): сохранять контекст и свежесть опроса BGP

This commit is contained in:
Denozordec committed 2026-09-30 17:05:37 +07:00
1 parent 8fdc4b917b
commit 2e17cbe926
8 files changed
+182 -19

No files matched your search

+43 -15
View File
@@ -1,6 +1,7 @@
"use client"
import { useState, useMemo, useEffect } from "react"
import { Suspense, useState, useMemo, useEffect } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { PageHeader } from "@/components/page-header"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { BgpSessionsDataGrid } from "@/components/data-grids/bgp-sessions-data-grid"
@@ -258,7 +259,7 @@ const BGP_FILTER_ACCESSORS = {
afi: (s: BgpSession) => s.afi,
}
function SessionsTab({ sessions, emptyTitle }: { sessions: BgpSession[]; emptyTitle?: string }) {
function SessionsTab({ sessions, emptyTitle, selectedSessionId, onSelectSession }: { sessions: BgpSession[]; emptyTitle?: string; selectedSessionId: string | null; onSelectSession: (id: string | null) => void }) {
const [search, setSearch] = useState("")
const [stateFilter, setStateFilter] = useState<StateFilter>("all")
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
@@ -324,7 +325,7 @@ function SessionsTab({ sessions, emptyTitle }: { sessions: BgpSession[]; emptyTi
</div>
}
/>
<BgpSessionsDataGrid sessions={filtered} emptyTitle={emptyTitle} />
<BgpSessionsDataGrid sessions={filtered} emptyTitle={emptyTitle} selectedSessionId={selectedSessionId} onSelectSession={onSelectSession} />
</DataPageCard>
</div>
)
@@ -611,14 +612,33 @@ const TABS: Array<{ id: BgpTab; label: string; icon: React.ReactNode }> = [
]
export default function BgpPage() {
const { mode, backendUrl } = useDataSource()
return <BgpPageContent key={JSON.stringify([mode, backendUrl])} />
return <Suspense fallback={<div role="status">Загрузка BGP…</div>}><BgpRoute /></Suspense>
}
function BgpPageContent() {
const [activeTab, setActiveTab] = useState<BgpTab>("sessions")
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
function BgpRoute() {
const { mode, backendUrl } = useDataSource()
const params = useSearchParams()
const router = useRouter()
const selectedServerId = params.get("server") ?? ALL_SERVERS_ID
const selectedSessionId = params.get("session")
const tab = params.get("tab")
const activeTab: BgpTab = tab === "routers" || tab === "analytics" ? tab : "sessions"
function navigate(values: Record<string, string | null>) {
const next = new URLSearchParams(params.toString())
for (const [key, value] of Object.entries(values)) {
if (value === null) next.delete(key)
else next.set(key, value)
}
router.push("/bgp" + (next.size ? "?" + next.toString() : ""))
}
return <BgpPageContent key={JSON.stringify([mode, backendUrl])} selectedServerId={selectedServerId} selectedSessionId={selectedSessionId} activeTab={activeTab}
onSelectServer={id => navigate({ server: id === ALL_SERVERS_ID ? null : id, session: null })}
onSelectSession={id => navigate({ session: id })}
onSelectTab={tab => navigate({ tab: tab === "sessions" ? null : tab })} />
}
function BgpPageContent({ selectedServerId, selectedSessionId, activeTab, onSelectServer, onSelectSession, onSelectTab }: {
selectedServerId: string; selectedSessionId: string | null; activeTab: BgpTab;
onSelectServer: (id: string) => void; onSelectSession: (id: string | null) => void; onSelectTab: (tab: BgpTab) => void;
}) {
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
@@ -629,6 +649,7 @@ function BgpPageContent() {
const [liveError, setLiveError] = useState<string | null>(null)
const [failures, setFailures] = useState<BgpFailure[]>([])
const [hasLoaded, setHasLoaded] = useState(false)
const [readReceipts, setReadReceipts] = useState<Record<string, string>>({})
const [fetchTick, setFetchTick] = useState(0)
useEffect(() => {
@@ -660,7 +681,9 @@ function BgpPageContent() {
setFailures(catalog.failures)
setLiveError(null)
setHasLoaded(true)
setFetchedAt(new Date())
const receivedAt = new Date()
setFetchedAt(receivedAt)
setReadReceipts(previous => ({ ...Object.fromEntries(Object.entries(previous).filter(([id]) => enabled.has(Number(id)))), ...Object.fromEntries([...enabled].filter(id => !failed.has(String(id))).map(id => [String(id), receivedAt.toISOString()])) }))
setLoading(false)
})
.catch((err: unknown) => {
@@ -674,7 +697,8 @@ function BgpPageContent() {
const unknownIds = useMemo(() => new Set(isLive ? (liveError ? liveServers.map(s => s.id) : failures.map(f => String(f.serverId))) : []), [isLive, liveError, liveServers, failures])
const allSessions: BgpSession[] = isLive ? liveSessions.map(s => unknownIds.has(s.serverId) ? { ...s, state: "Unknown" } : s) : SESSIONS
const incomplete = isLive && (!hasLoaded || Boolean(liveError) || (selectedServerId === ALL_SERVERS_ID ? failures.length > 0 : unknownIds.has(selectedServerId)))
const unavailableContext = isLive && hasLoaded && selectedServerId !== ALL_SERVERS_ID && !liveServers.some(s => s.id === selectedServerId)
const incomplete = isLive && (unavailableContext || !hasLoaded || Boolean(liveError) || (selectedServerId === ALL_SERVERS_ID ? failures.length > 0 : unknownIds.has(selectedServerId)))
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
const effectiveServerId = selectedServerId
@@ -711,9 +735,10 @@ function BgpPageContent() {
return (
<ServerRailLayout
preserveSelection
items={railItems}
selectedId={effectiveServerId}
onSelect={setSelectedServerId}
onSelect={onSelectServer}
showAll
allCount={displayServers.length}
loading={isLive && loading && displayServers.length === 0}
@@ -735,7 +760,7 @@ function BgpPageContent() {
<div className="border-b bg-background shrink-0">
<div className="flex items-center px-6">
{TABS.map(t => (
<button key={t.id} aria-pressed={activeTab === t.id} onClick={() => setActiveTab(t.id)}
<button key={t.id} aria-pressed={activeTab === t.id} onClick={() => onSelectTab(t.id)}
className={cn(
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === t.id
@@ -751,6 +776,8 @@ function BgpPageContent() {
>
<div className="flex flex-col gap-5">
{unavailableContext && <Alert variant="warning"><AlertCircleIcon /><AlertTitle>Устройство BGP недоступно в этом каталоге</AlertTitle><AlertDescription>Контекст {selectedServerId} сохранён. Выберите доступное устройство.<Button variant="outline" size="sm" onClick={() => onSelectServer(ALL_SERVERS_ID)}>Все устройства BGP</Button></AlertDescription></Alert>}
{selectedSessionId && hasLoaded && !sessions.some(s => s.id === selectedSessionId) && <Alert variant="warning"><AlertCircleIcon /><AlertDescription>Сессия из ссылки отсутствует в выбранном снимке. Контекст ссылки сохранён.<Button variant="outline" size="sm" onClick={() => onSelectSession(null)}>Все сессии BGP</Button></AlertDescription></Alert>}
{/* data source banner */}
{isLive && loading && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
@@ -762,6 +789,7 @@ function BgpPageContent() {
<div role="group" aria-label="Свежесть BGP" className="text-xs text-muted-foreground">
Ответ каталога получен <time dateTime={fetchedAt.toISOString()}>{fetchedAt.toLocaleTimeString("ru")}</time>.
Это время получения браузером, не время измерения RouterOS.
{displayServers.filter(s => effectiveServerId === ALL_SERVERS_ID || s.id === effectiveServerId).map(s => <p key={s.id}>{s.name}: {readReceipts[s.id] ? <time data-bgp-server={s.id} dateTime={readReceipts[s.id]}>{new Date(readReceipts[s.id]).toLocaleTimeString("ru")}</time> : "успешный опрос не получен"}{unknownIds.has(s.id) ? " · данные устарели" : ""}</p>)}
</div>
)}
{isLive && (liveError || failures.length > 0) && (
@@ -830,7 +858,7 @@ function BgpPageContent() {
)}
{/* tab content */}
{activeTab === "sessions" && <SessionsTab sessions={sessions} emptyTitle={incomplete && sessions.length === 0 ? "Данные BGP неизвестны" : undefined} />}
{activeTab === "sessions" && <SessionsTab sessions={sessions} emptyTitle={incomplete && sessions.length === 0 ? "Данные BGP неизвестны" : undefined} selectedSessionId={selectedSessionId} onSelectSession={onSelectSession} />}
{activeTab === "routers" && (incomplete ? <p role="status">Сводка по роутерам неизвестна до успешного опроса. Последние сессии доступны в таблице.</p> : <RoutersTab sessions={sessions} />)}
{activeTab === "analytics" && (incomplete ? <p role="status">Аналитика неизвестна до успешного опроса. Последние сессии доступны в таблице.</p> : <AnalyticsTab sessions={sessions} />)}