fix(ui): изолировать операции GRE и готовность устройств

This commit is contained in:
Denozordec committed 2026-09-30 15:56:51 +07:00
1 parent fb009344e1
commit ceb2113209
8 files changed
+244 -38

No files matched your search

+58 -31
View File
@@ -232,7 +232,12 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
const failedServerIds = useMemo(() => failures.map(f => f.serverId), [failures])
const [hasLoaded, setHasLoaded] = useState(false)
const loadEpoch = useRef(0)
useEffect(() => () => { loadEpoch.current += 1 }, [])
const mounted = useRef(true)
const operationLock = useRef(false)
useEffect(() => {
mounted.current = true
return () => { mounted.current = false; loadEpoch.current += 1 }
}, [])
const [pageTab, setPageTab] = useState<PageTab>("tunnels")
const [tabFilter, setTabFilter] = useState<TabFilter>("all")
@@ -266,8 +271,8 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
const setT = <K extends keyof typeof defaultTunnelForm>(k: K, v: (typeof defaultTunnelForm)[K]) =>
setTForm((f) => ({ ...f, [k]: v }))
const loadLive = useCallback(async () => {
if (!isLive) return
const loadLive = useCallback(async (afterOperation = false) => {
if (!isLive || !mounted.current || (operationLock.current && !afterOperation)) return
const epoch = ++loadEpoch.current
setDataLoading(true)
try {
@@ -347,7 +352,22 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
const historyServerId = selectedServerId === ALL_SERVERS_ID ? null : selectedServerId
const mutationsLocked = isLive && (mutateBusy || dataLoading || !hasLoaded || liveStale || failures.length > 0 || selectedTunnelError || historyRestoring)
const mutationsLocked = !isLive || mutateBusy || dataLoading || !hasLoaded || liveStale || historyRestoring
function canManage(serverId: string) {
return !mutationsLocked && !failedServerIds.includes(serverId) && liveServers.some(s => s.id === serverId && s.enabled)
}
function beginOperation(serverId: string) {
if (!mounted.current || operationLock.current || !canManage(serverId)) return false
operationLock.current = true
loadEpoch.current += 1
setMutateBusy(true)
return true
}
function endOperation() {
operationLock.current = false
if (mounted.current) setMutateBusy(false)
}
const canCreate = selectedServerId === ALL_SERVERS_ID ? liveServers.some(s => canManage(s.id)) : canManage(selectedServerId)
useEffect(() => () => { historyEpoch.current += 1 }, [historyServerId, backendUrl, isLive])
@@ -370,24 +390,26 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
}
}, [isLive, historyServerId, apiFetch])
const restoreRevision = useCallback(async (id: string) => {
if (!isLive || !historyServerId) return
const restoreRevision = async (id: string) => {
if (!historyServerId || !beginOperation(historyServerId)) throw new Error("Устройство не готово к восстановлению GRE")
setHistoryRestoring(true)
try {
const res = await apiFetch<{ ok: boolean }>(
`/api/gre/revisions/${encodeURIComponent(id)}/restore`,
{ method: "POST", body: JSON.stringify({ serverId: historyServerId }) },
)
if (!mounted.current) return
if (res?.ok !== true) throw new Error("Сервер не подтвердил восстановление")
toast.success("Версия применена на роутер")
await loadLive()
await loadRevisions()
await loadLive(true)
if (mounted.current) await loadRevisions()
} catch (err) {
throw err
if (mounted.current) throw err
} finally {
setHistoryRestoring(false)
if (mounted.current) setHistoryRestoring(false)
endOperation()
}
}, [isLive, historyServerId, apiFetch, loadLive, loadRevisions])
}
function tunnelWriteBody(form: typeof defaultTunnelForm) {
return {
@@ -428,8 +450,8 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
setFormError("Для IPsec нужен PSK не короче 8 символов")
return
}
if (!beginOperation(tForm.serverId)) { setFormError("Устройство не готово к изменению GRE; повторите опрос"); return }
formLock.current = true
setMutateBusy(true)
try {
if (tunnelMode === "edit" && editingTunnel) {
const result = await apiFetch<{ ok: boolean }>("/api/gre/tunnels", {
@@ -440,6 +462,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
name: editingTunnel.name,
}),
})
if (!mounted.current) return
if (result?.ok !== true) throw new Error("Сервер не подтвердил сохранение туннеля")
toast.success(`Туннель ${tForm.name} обновлён`)
} else {
@@ -447,6 +470,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
method: "POST",
body: JSON.stringify(tunnelWriteBody(tForm)),
})
if (!mounted.current) return
const confirmed = typeof result === "object" && result !== null && "name" in result && result.name === tForm.name.trim() && (
("ok" in result && result.ok === true) ||
("id" in result && typeof result.id === "string" && result.id.length > 0 &&
@@ -459,21 +483,20 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
}
setTunnelOpen(false)
setEditingTunnel(null)
await loadLive()
await loadLive(true)
} catch (err) {
setFormError(err instanceof Error ? err.message : "Не удалось сохранить туннель")
if (mounted.current) setFormError(err instanceof Error ? err.message : "Не удалось сохранить туннель")
} finally {
formLock.current = false
setMutateBusy(false)
endOperation()
}
}
async function toggleTunnel(t: GreTunnel) {
if (!isLive || mutationsLocked || toggleLock.current) return
if (toggleLock.current || !beginOperation(t.serverId)) return
toggleLock.current = true
setToggleError(null)
setToggleStatus(`Ожидание изменения ${t.name}…`)
setMutateBusy(true)
try {
const result = await apiFetch<{ ok: boolean }>("/api/gre/tunnels", {
method: "PATCH",
@@ -485,42 +508,44 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
remoteAddress: t.remoteAddress,
}),
})
if (!mounted.current) return
if (result?.ok !== true) throw new Error("Сервер не подтвердил изменение туннеля")
toast.success(t.enabled ? `Выключен ${t.name}` : `Включён ${t.name}`)
await loadLive()
await loadLive(true)
} catch (err) {
setToggleError(`${t.name}: ${err instanceof Error ? err.message : "Не удалось изменить туннель"}`)
if (mounted.current) setToggleError(`${t.name}: ${err instanceof Error ? err.message : "Не удалось изменить туннель"}`)
} finally {
toggleLock.current = false
setToggleStatus(null)
setMutateBusy(false)
if (mounted.current) setToggleStatus(null)
endOperation()
}
}
async function confirmDeleteTunnel() {
const t = pendingDelete
if (!t || !isLive || mutationsLocked || deleteLock.current) return
if (!t || deleteLock.current || !beginOperation(t.serverId)) return
deleteLock.current = true
setDeleteError(null)
setMutateBusy(true)
try {
const result = await apiFetch<{ ok: boolean }>("/api/gre/tunnels", {
method: "DELETE",
body: JSON.stringify({ serverId: t.serverId, rosId: t.id, name: t.name }),
})
if (!mounted.current) return
if (result?.ok !== true) throw new Error("Сервер не подтвердил удаление туннеля")
toast.success(`Удалён ${t.name}`)
setPendingDelete(null)
await loadLive()
await loadLive(true)
} catch (err) {
setDeleteError(err instanceof Error ? err.message : "Не удалось удалить туннель")
if (mounted.current) setDeleteError(err instanceof Error ? err.message : "Не удалось удалить туннель")
} finally {
deleteLock.current = false
setMutateBusy(false)
endOperation()
}
}
function openCreateTunnel() {
if (isLive && !canCreate) return
setFormError(null)
setTunnelMode("create")
setEditingTunnel(null)
@@ -532,6 +557,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
}
function openEditTunnel(t: GreTunnel) {
if (isLive && !canManage(t.serverId)) return
setFormError(null)
setTunnelMode("edit")
setEditingTunnel(t)
@@ -627,13 +653,13 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
setHistoryOpen(true)
void loadRevisions()
}}
disabled={!isLive || !historyServerId || dataLoading}
disabled={!isLive || !historyServerId || dataLoading || mutateBusy || historyRestoring}
title={!historyServerId ? "Выберите сервер, чтобы смотреть историю" : "История версий и откат на CHR"}
>
<HistoryIcon className="size-4" />
История
</Button>
<Button size="sm" onClick={openCreateTunnel} disabled={mutateBusy || (isLive && mutationsLocked)}>
<Button size="sm" onClick={openCreateTunnel} disabled={mutateBusy || (isLive && !canCreate)}>
<PlusIcon className="size-4" />Добавить туннель
</Button>
</>
@@ -759,6 +785,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
onToggle={(t) => { void toggleTunnel(t) }}
onDelete={setPendingDelete}
mutationsLocked={mutationsLocked}
canManage={canManage}
unknownServerIds={unknownServerIds}
emptyTitle={isLive && !hasLoaded ? dataLoading ? "Загрузка GRE…" : "Данные GRE неизвестны" : incomplete ? "Нет подтверждённых строк" : undefined}
emptyDescription={incomplete ? "Повторите опрос; отсутствие строк не подтверждено" : undefined}
@@ -894,7 +921,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
<select aria-label="Сервер (MikroTik)" value={tForm.serverId} onChange={(e) => setT("serverId", e.target.value)} disabled={tunnelMode === "edit"}
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
<option value="" disabled>Выбрать сервер…</option>
{displayServers.map((s) => <option key={s.id} value={s.id}>{s.name} ({s.site})</option>)}
{displayServers.map((s) => <option key={s.id} value={s.id} disabled={isLive && !canManage(s.id)}>{s.name} ({s.site}){isLive && !canManage(s.id) ? " — недоступен для изменений" : ""}</option>)}
</select>
</FormField>
<FormField label="Комментарий">
@@ -1043,7 +1070,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
{formError && <p role="alert" className="px-6 text-sm text-destructive">{formError}</p>}
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" disabled={mutateBusy} />}>Отмена</SheetClose>
<Button className="flex-1" onClick={() => void submitTunnel()} disabled={mutateBusy}>
<Button className="flex-1" onClick={() => void submitTunnel()} disabled={mutateBusy || (isLive && !canManage(tForm.serverId))}>
{tunnelMode === "edit" ? "Сохранить" : "Создать туннель"}
</Button>
</SheetFooter>
@@ -1123,7 +1150,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={mutateBusy}>Отмена</AlertDialogCancel>
<AlertDialogAction variant="destructive" disabled={mutationsLocked} onClick={(e) => { e.preventDefault(); void confirmDeleteTunnel() }}>
<AlertDialogAction variant="destructive" disabled={!pendingDelete || !canManage(pendingDelete.serverId)} onClick={(e) => { e.preventDefault(); void confirmDeleteTunnel() }}>
Удалить
</AlertDialogAction>
</AlertDialogFooter>