fix(ui): сохранять данные IPsec при сбоях опроса

This commit is contained in:
Denozordec committed 2026-09-30 14:43:22 +07:00
1 parent 8fab2f7fd2
commit b8923d075d
7 files changed
+191 -48

No files matched your search

+68 -42
View File
@@ -1,5 +1,6 @@
"use client"
import { z } from "zod"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { servers as mockServers } from "@/lib/data"
@@ -162,7 +163,15 @@ function mapBackendServer(s: BackendServer): Server {
}
}
export default function IpsecPage() {
const ipsecInventorySchema = z.array(z.object({
id: z.number().int().positive(), name: z.string(), host: z.string(), enabled: z.boolean(),
country: z.string().optional().default("UN"),
type: z.enum(["jump-host", "exit-node", "home-router"]).optional(),
status: z.enum(["online", "offline", "degraded"]).optional(),
latency: z.number().nullable().optional(), asn: z.string().optional(),
})).refine(rows => new Set(rows.map(row => row.id)).size === rows.length)
function IpsecPage() {
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
@@ -171,6 +180,11 @@ export default function IpsecPage() {
const [liveServers, setLiveServers] = useState<Server[]>([])
const [failures, setFailures] = useState<IpsecFailure[]>([])
const [loading, setLoading] = useState(false)
const [dataError, setDataError] = useState<string | null>(null)
const [inventoryError, setInventoryError] = useState<string | null>(null)
const [hasLoaded, setHasLoaded] = useState(false)
const readEpoch = useRef(0)
useEffect(() => () => { readEpoch.current += 1 }, [])
const [busy, setBusy] = useState(false)
const [certBusy, setCertBusy] = useState(false)
const [certError, setCertError] = useState<string | null>(null)
@@ -203,44 +217,35 @@ export default function IpsecPage() {
const loadLive = useCallback(async () => {
if (!isLive) return
const epoch = ++readEpoch.current
setLoading(true)
try {
const [ipsec, servers] = await Promise.all([
listIpsec(backendUrl),
requestJson<BackendServer[]>(backendUrl, "/api/servers"),
])
setSummaries(ipsec.servers)
setClients(ipsec.clients)
setLiveServers(servers.filter((s) => s.enabled).map(mapBackendServer))
const [ipsecResult, inventoryResult] = await Promise.allSettled([
listIpsec(backendUrl), requestJson<unknown>(backendUrl, "/api/servers"),
])
if (epoch !== readEpoch.current) return
if (inventoryResult.status === "fulfilled") {
const parsed = ipsecInventorySchema.safeParse(inventoryResult.value)
if (parsed.success) {
setLiveServers(parsed.data.filter(s => s.enabled).map(mapBackendServer))
setInventoryError(null)
} else setInventoryError("Некорректный список устройств")
} else setInventoryError(inventoryResult.reason instanceof Error ? inventoryResult.reason.message : "Не удалось загрузить устройства")
if (ipsecResult.status === "fulfilled") {
const ipsec = ipsecResult.value
const failedIds = new Set((ipsec.failures ?? []).map(f => f.serverId))
setSummaries(previous => [...ipsec.servers, ...previous.filter(s => failedIds.has(s.serverId))])
setClients(previous => [...ipsec.clients, ...previous.filter(c => failedIds.has(c.serverId))])
setFailures(ipsec.failures ?? [])
if (ipsec.failures?.length) {
toast.warning(
`Не удалось опросить: ${ipsec.failures.map((f) => f.serverName ?? f.serverId).join(", ")}`,
)
}
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка загрузки IPsec")
setSummaries([])
setClients([])
setFailures([])
} finally {
setLoading(false)
setHasLoaded(true)
setDataError(null)
} else {
setDataError(ipsecResult.reason instanceof Error ? ipsecResult.reason.message : "Ошибка загрузки IPsec")
}
setLoading(false)
}, [isLive, backendUrl])
useEffect(() => {
if (!isLive) {
queueMicrotask(() => {
setSummaries([])
setClients([])
setLiveServers([])
setFailures([])
})
return
}
queueMicrotask(() => {
void loadLive()
})
if (isLive) queueMicrotask(() => { void loadLive() })
}, [isLive, loadLive])
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
@@ -292,6 +297,9 @@ export default function IpsecPage() {
}
}, [isLive, historyServerId, backendUrl, loadLive, loadRevisions])
const failedServerIds = new Set(failures.map(f => f.serverId))
const scopeUnknown = isLive && (!hasLoaded || !!dataError || (effectiveServerId === ALL_SERVERS_ID ? failures.length > 0 : failedServerIds.has(effectiveServerId)))
const scopedClients = useMemo(() => {
if (effectiveServerId === ALL_SERVERS_ID) return displayClients
return displayClients.filter((c) => c.serverId === effectiveServerId)
@@ -618,40 +626,51 @@ export default function IpsecPage() {
{
id: "servers",
label: "Серверов IKEv2",
value: serversWithIpsec,
value: scopeUnknown ? "—" : serversWithIpsec,
icon: <ShieldCheckIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "clients",
label: "Клиентов",
value: scopedClients.length,
value: scopeUnknown ? "—" : scopedClients.length,
icon: <UsersIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "online",
label: "Онлайн",
value: clientsOnline,
value: scopeUnknown ? "—" : clientsOnline,
icon: <ActivityIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "ca",
label: "CA",
value: caOk ? "ок" : "—",
value: scopeUnknown ? "неизвестно" : caOk ? "ок" : "нет",
icon: <KeyRoundIcon className="size-4" />,
iconClassName: "text-primary",
},
]}
/>
{isLive && (dataError || inventoryError) ? (
<Alert variant="destructive">
<AlertCircleIcon />
<AlertTitle>{dataError ? "Не удалось загрузить IPsec" : "Не удалось загрузить устройства"}</AlertTitle>
<AlertDescription>
{dataError || inventoryError}. {hasLoaded ? "Показаны последние полученные данные; их актуальность не подтверждена." : "Данные пока неизвестны."}
<Button size="sm" variant="outline" disabled={loading} onClick={() => void loadLive()}>Повторить загрузку IPsec</Button>
</AlertDescription>
</Alert>
) : null}
{failures.length > 0 ? (
<Alert variant="destructive">
<AlertCircleIcon />
<AlertTitle>Не удалось опросить часть роутеров</AlertTitle>
<AlertDescription>
{failures.map((f) => f.serverName ?? f.serverId).join(", ")}
{failures.map((f) => `${f.serverName ?? f.serverId}: ${f.error}`).join("; ")}. Показаны последние данные недоступных устройств; актуальность не подтверждена.
</AlertDescription>
</Alert>
) : null}
@@ -701,18 +720,20 @@ export default function IpsecPage() {
) : undefined
}
/>
<IpsecUsersGrid
{isLive && !hasLoaded ? <p role="status" className="p-5 text-sm text-muted-foreground">{loading ? "Загрузка клиентов IPsec…" : "Клиенты IPsec пока неизвестны"}</p> : <IpsecUsersGrid
clients={filteredClients}
emptyTitle={scopeUnknown ? "Клиенты IPsec пока неизвестны" : undefined}
emptyDescription={scopeUnknown ? "Повторите опрос устройств, чтобы подтвердить состояние" : undefined}
compactServer={compactServer}
onDownloadCert={isLive ? (row) => void openCert(row) : undefined}
onEdit={isLive ? (row) => { setEditing(row); setUserOpen(true) } : undefined}
onDelete={isLive ? (row) => setPendingDelete({ kind: "user", client: row }) : undefined}
/>
/>}
</DataPageCard>
</TabsContent>
<TabsContent value="server" className="mt-0 outline-none">
<IpsecServerGrid
{isLive && !hasLoaded ? <p role="status" className="p-5 text-sm text-muted-foreground">Состояние серверов IPsec пока неизвестно</p> : <IpsecServerGrid
servers={scopedSummaries}
onInit={isLive ? (s) => { setSelectedServerId(s.serverId); setInitOpen(true) } : undefined}
onRemove={isLive ? (s) => setPendingDelete({ kind: "server", summary: s }) : undefined}
@@ -720,7 +741,7 @@ export default function IpsecPage() {
onDeletePeer={isLive ? (serverId, peer) => setPendingDelete({ kind: "peer", serverId, peer }) : undefined}
onDeleteCert={isLive ? (serverId, cert) => setPendingDelete({ kind: "cert", serverId, cert }) : undefined}
onExportCert={isLive ? (serverId, cert) => void openCertByName(serverId, cert) : undefined}
/>
/>}
</TabsContent>
<TabsContent value="cli" className="mt-0 outline-none">
@@ -873,3 +894,8 @@ export default function IpsecPage() {
</>
)
}
export default function IpsecSource() {
const { mode, backendUrl } = useDataSource()
return <IpsecPage key={JSON.stringify([mode, backendUrl])} />
}