fix(ui): сохранять данные IPsec при сбоях опроса
This commit is contained in:
1 parent
8fab2f7fd2
commit
b8923d075d
7 files changed
+191
-48
No files matched your search
+68
-42
@@ -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])} />
|
||||
}
|
||||
Reference in new issue
Block a user