fix(ui): изолировать операции GRE и готовность устройств
This commit is contained in:
1 parent
fb009344e1
commit
ceb2113209
8 files changed
+244
-38
No files matched your search
+58
-31
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user