fix(ui): изолировать контекст и операции IPsec

This commit is contained in:
Denozordec committed 2026-09-30 14:55:20 +07:00
1 parent f0071a495c
commit 921aba3054
7 files changed
+205 -36

No files matched your search

+55 -34
View File
@@ -183,6 +183,9 @@ function IpsecPage() {
const [dataError, setDataError] = useState<string | null>(null)
const [inventoryError, setInventoryError] = useState<string | null>(null)
const [hasLoaded, setHasLoaded] = useState(false)
const mounted = useRef(true)
const operationLock = useRef(false)
useEffect(() => { mounted.current = true; return () => { mounted.current = false } }, [])
const readEpoch = useRef(0)
useEffect(() => () => { readEpoch.current += 1 }, [])
const [busy, setBusy] = useState(false)
@@ -215,8 +218,8 @@ function IpsecPage() {
const historyEpoch = useRef(0)
const [historyRestoring, setHistoryRestoring] = useState(false)
const loadLive = useCallback(async () => {
if (!isLive) return
const loadLive = useCallback(async (afterOperation = false) => {
if (!isLive || !mounted.current || (operationLock.current && !afterOperation)) return
const epoch = ++readEpoch.current
setLoading(true)
const [ipsecResult, inventoryResult] = await Promise.allSettled([
@@ -262,7 +265,7 @@ function IpsecPage() {
useEffect(() => () => { historyEpoch.current += 1 }, [historyServerId, backendUrl, isLive])
const loadRevisions = useCallback(async () => {
if (!isLive || !historyServerId) return
if (!isLive || !historyServerId || !mounted.current) return
const epoch = ++historyEpoch.current
setHistoryLoading(true)
setHistoryError(null)
@@ -281,21 +284,24 @@ function IpsecPage() {
}
}, [isLive, historyServerId, backendUrl])
const restoreRevision = useCallback(async (id: string) => {
const restoreRevision = async (id: string) => {
if (!isLive || !historyServerId) return
beginOperation(historyServerId)
setHistoryRestoring(true)
try {
const res = await restoreIpsecRevision(backendUrl, id, historyServerId)
if (!mounted.current) return
if (res?.ok !== true) throw new Error("Сервер не подтвердил восстановление")
toast.success("Версия применена на роутер")
await loadLive()
await loadLive(true)
await loadRevisions()
} catch (err) {
throw err
} finally {
setHistoryRestoring(false)
endOperation()
if (mounted.current) setHistoryRestoring(false)
}
}, [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)))
@@ -303,7 +309,18 @@ function IpsecPage() {
const canManage = (serverId: string) => isLive && hasLoaded && !dataError && !inventoryError &&
!loading && !busy && !historyRestoring && !failedServerIds.has(serverId) && liveServers.some(s => s.id === serverId)
const assertDeviceReady = (serverId: string) => {
if (!canManage(serverId)) throw new Error("Сначала подтвердите актуальное состояние устройства: повторите опрос IPsec")
if (operationLock.current || !canManage(serverId)) throw new Error("Сначала подтвердите актуальное состояние устройства: повторите опрос IPsec")
}
const beginOperation = (serverId: string) => {
assertDeviceReady(serverId)
operationLock.current = true
readEpoch.current += 1
setLoading(false)
setBusy(true)
}
const endOperation = () => {
operationLock.current = false
if (mounted.current) setBusy(false)
}
const hasReadyDevice = displayServers.some(s => canManage(s.id))
@@ -377,8 +394,7 @@ function IpsecPage() {
}, [displayServers, displayClients])
const handleInit = async (form: IpsecInitFormState) => {
assertDeviceReady(form.serverId)
setBusy(true)
beginOperation(form.serverId)
try {
await initIpsecServer(backendUrl, {
serverId: form.serverId,
@@ -390,17 +406,17 @@ function IpsecPage() {
clientDaysValid: 1825,
createNatRule: form.createNatRule,
})
if (!mounted.current) return
toast.success("IKEv2-сервер инициализирован")
setInitOpen(false)
await loadLive()
await loadLive(true)
} finally {
setBusy(false)
endOperation()
}
}
const handleCreateUser = async (form: IpsecUserFormState) => {
assertDeviceReady(form.serverId)
setBusy(true)
beginOperation(form.serverId)
try {
const res = await createIpsecUser(backendUrl, {
serverId: form.serverId,
@@ -412,10 +428,12 @@ function IpsecPage() {
staticIp: form.useStaticIp && form.staticIp.trim() ? form.staticIp.trim() : undefined,
passphrase: form.passphrase.trim() || undefined,
})
if (!mounted.current) return
toast.success(`Клиент «${form.name.trim()}» создан`)
setUserOpen(false)
setEditing(null)
await loadLive()
await loadLive(true)
if (!mounted.current) return
if (res.bundle) {
certEpoch.current += 1
certPending.current = false
@@ -427,36 +445,37 @@ function IpsecPage() {
setCertOpen(true)
}
} finally {
setBusy(false)
endOperation()
}
}
const handleEditUser = async (form: IpsecUserFormState) => {
if (!editing) return
assertDeviceReady(editing.serverId)
setBusy(true)
beginOperation(editing.serverId)
try {
await patchIpsecUser(backendUrl, editing.serverId, editing.rosId, {
name: form.name.trim() !== editing.name ? form.name.trim() : undefined,
staticIp: form.useStaticIp && form.staticIp.trim() ? form.staticIp.trim() : null,
psk: form.authMethod === "pre-shared-key" && form.psk.trim() ? form.psk : undefined,
})
if (!mounted.current) return
toast.success("Клиент обновлён")
setUserOpen(false)
setEditing(null)
await loadLive()
await loadLive(true)
} finally {
setBusy(false)
endOperation()
}
}
const loadCert = async (request: () => Promise<IpsecCertBundle>) => {
const loadCert = async (serverId: string, request: () => Promise<IpsecCertBundle>) => {
if (certPending.current) return
certPending.current = true
const epoch = ++certEpoch.current
setCertBusy(true)
setCertError(null)
try {
assertDeviceReady(serverId)
const bundle = await request()
if (epoch === certEpoch.current) setCertBundle(bundle)
} catch (error) {
@@ -475,7 +494,7 @@ function IpsecPage() {
setCertBundle(null)
setCertOpen(true)
const passphrase = crypto.randomUUID()
await loadCert(() => byName
await loadCert(client.serverId, () => byName
? exportIpsecCertByName(backendUrl, client.serverId, client.certName!, passphrase)
: exportIpsecUserCert(backendUrl, client.serverId, client.rosId, passphrase))
}
@@ -487,12 +506,12 @@ function IpsecPage() {
setCertClient(null)
setCertBundle(null)
setCertOpen(true)
await loadCert(() => exportIpsecCertByName(backendUrl, serverId, cert.name, crypto.randomUUID()))
await loadCert(serverId, () => exportIpsecCertByName(backendUrl, serverId, cert.name, crypto.randomUUID()))
}
const reexportCert = async (passphrase: string) => {
if (!certClient && !certByName) return
await loadCert(() => certByName
await loadCert(certByName?.serverId ?? certClient!.serverId, () => certByName
? exportIpsecCertByName(backendUrl, certByName.serverId, certByName.name, passphrase)
: exportIpsecUserCert(backendUrl, certClient!.serverId, certClient!.rosId, passphrase))
}
@@ -501,9 +520,9 @@ function IpsecPage() {
if (!pendingDelete || deleteLock.current || (force && !forceAllowed)) return
const target = pendingDelete
const serverId = target.kind === "user" ? target.client.serverId : target.kind === "server" ? target.summary.serverId : target.serverId
if (!canManage(serverId)) { setDeleteError("Повторите опрос IPsec перед удалением"); return }
if (operationLock.current || !canManage(serverId)) { setDeleteError("Повторите опрос IPsec перед удалением"); return }
beginOperation(serverId)
deleteLock.current = true
setBusy(true)
setDeleteError(null)
try {
if (target.kind === "user") {
@@ -515,16 +534,18 @@ function IpsecPage() {
} else {
await deleteIpsecCert(backendUrl, target.serverId, target.cert.name, { force })
}
if (!mounted.current) return
toast.success("Удалено")
setPendingDelete(null)
setForceAllowed(false)
await loadLive()
await loadLive(true)
} catch (error) {
if (!mounted.current) return
setDeleteError(error instanceof Error ? error.message : "Не удалось удалить")
setForceAllowed(error instanceof ApiClientError && error.status === 409 && (target.kind === "peer" || target.kind === "cert"))
} finally {
deleteLock.current = false
setBusy(false)
endOperation()
}
}
@@ -535,8 +556,7 @@ function IpsecPage() {
const handleEditPeer = async (form: IpsecPeerFormState) => {
if (!editingPeer) return
assertDeviceReady(editingPeer.serverId)
setBusy(true)
beginOperation(editingPeer.serverId)
try {
await patchIpsecPeer(backendUrl, editingPeer.serverId, editingPeer.peer.rosId, {
name: form.name.trim() !== editingPeer.peer.name ? form.name.trim() : undefined,
@@ -547,12 +567,13 @@ function IpsecPage() {
profile: form.profile.trim() || undefined,
disabled: form.disabled,
})
if (!mounted.current) return
toast.success("Peer обновлён")
setPeerOpen(false)
setEditingPeer(null)
await loadLive()
await loadLive(true)
} finally {
setBusy(false)
endOperation()
}
}
@@ -673,7 +694,7 @@ function IpsecPage() {
<AlertTitle>{dataError ? "Не удалось загрузить IPsec" : "Не удалось загрузить устройства"}</AlertTitle>
<AlertDescription>
{dataError || inventoryError}. {hasLoaded ? "Показаны последние полученные данные; их актуальность не подтверждена." : "Данные пока неизвестны."}
<Button size="sm" variant="outline" disabled={loading} onClick={() => void loadLive()}>Повторить загрузку IPsec</Button>
<Button size="sm" variant="outline" disabled={loading || busy || historyRestoring} onClick={() => void loadLive()}>Повторить загрузку IPsec</Button>
</AlertDescription>
</Alert>
) : null}
@@ -850,7 +871,7 @@ function IpsecPage() {
<IpsecInitSheet
open={initOpen}
onOpenChange={setInitOpen}
servers={serverOptions}
servers={serverOptions.filter(s => canManage(s.id) || busy)}
busy={busy}
defaultServerId={sheetServerId}
onSubmit={handleInit}