fix(ui): сохранять недоступные шлюзы Filters

This commit is contained in:
Denozordec committed 2026-09-30 12:21:00 +07:00
1 parent 860e88ed3e
commit 0ab5a23eb3
7 files changed
+149 -30

No files matched your search

+55 -26
View File
@@ -14,7 +14,7 @@ import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { cn } from "@/lib/utils"
import { filtersResponseSchema, filtersMutationSchema } from "@/lib/filters-response"
import { filtersResponseSchema, filtersMutationSchema, filtersGreResponseSchema, filtersRecursiveResponseSchema } from "@/lib/filters-response"
import { firewallServersSchema } from "@/lib/firewall-response"
import { requestJson } from "@/shared/api/http-client"
import {
@@ -818,6 +818,7 @@ function PreviewModal({ open, serverId, rulesets, onClose, serversList, tunnelsL
// ── Copy Rules Sheet ──────────────────────────────────────────────────────────
type CopyMode = "append" | "replace"
type GatewayReadiness = Record<string, Partial<Record<"gre" | "recursive", boolean>>>
/** Try to find an equivalent tunnel on the target server (same remoteAddress). */
function mapTunnel(
@@ -876,6 +877,7 @@ function CopyRulesSheet({
ensureGreFor,
recRoutesByServer,
ensureRecursiveFor,
catalogReady,
}: {
open: boolean; onClose: () => void
sourceServerId: string; sourceRules: FilterRule[]
@@ -886,6 +888,7 @@ function CopyRulesSheet({
ensureGreFor?: (serverId: string) => void
recRoutesByServer: Record<string, RecursiveRouteLite[]>
ensureRecursiveFor?: (serverId: string) => void
catalogReady?: GatewayReadiness
}) {
const [selected, setSelected] = useState<Set<string>>(() => new Set(sourceRules.map(r => r.id)))
const [targetId, setTargetId] = useState<string | null>(null)
@@ -972,7 +975,7 @@ function CopyRulesSheet({
}
async function handleCopy() {
if (copyLock.current || !targetId || selectedRules.length === 0) return
if (copyLock.current || !targetId || selectedRules.length === 0 || !mappingReady) return
copyLock.current = true
setCopying(true)
setCopyError(null)
@@ -1010,7 +1013,18 @@ function CopyRulesSheet({
}
const close = () => { if (!copyLock.current) onClose() }
const canCopy = targetId !== null && selectedRules.length > 0 && !copying
const requiredKinds = selectedRules.filter(r => (r.action ?? "route") === "route").map(r => isRecursiveGatewayRef(r.gatewayTunnelId) ? "recursive" as const : "gre" as const)
const catalogsReady = !catalogReady || (targetId !== null && requiredKinds.every(kind => catalogReady[sourceServerId]?.[kind] && catalogReady[targetId]?.[kind]))
const mappingReady = catalogsReady && selectedRules.every(rule => {
if ((rule.action ?? "route") === "blackhole") return true
if (!targetId) return false
if (isRecursiveGatewayRef(rule.gatewayTunnelId)) {
const source = sourceRecursiveRoutes.find(r => r.id === rule.gatewayTunnelId.slice(4))
return !!source && !!pickRecursiveRouteForCopy(source, targetRecursiveRoutes)
}
return !!mapTunnel(rule.gatewayTunnelId, targetId, tunnelsList)
})
const canCopy = targetId !== null && selectedRules.length > 0 && !copying && mappingReady
return (
<Sheet open={open} onOpenChange={v => { if (!v) close() }}>
@@ -1218,7 +1232,7 @@ function CopyRulesSheet({
))}
{mapping.some(m => !m.mapped) && (
<p className="text-[10px] text-muted-foreground/60">
Правила с пустым шлюзом нужно отредактировать после копирования
Копирование недоступно, пока для каждого маршрута не найден шлюз целевого устройства
</p>
)}
</div>
@@ -1226,6 +1240,10 @@ function CopyRulesSheet({
)}
</fieldset>
{targetId && !mappingReady && <div className="px-5 py-3 flex flex-col gap-2">
<p role="alert" className="text-sm text-destructive">Каталоги или сопоставление шлюзов не подтверждены. Копирование маршрутов заблокировано.</p>
<Button variant="outline" disabled={copying} onClick={() => { for (const id of [sourceServerId, targetId]) { ensureGreFor?.(id); ensureRecursiveFor?.(id) } }}>Обновить шлюзы</Button>
</div>}
{copyError && <p role="alert" className="px-5 py-3 text-sm text-destructive">{copyError}</p>}
{copying && <p role="status" className="px-5 py-2 text-sm text-muted-foreground">Ожидание подтверждения копирования…</p>}
@@ -1313,33 +1331,38 @@ function FiltersPage() {
const [liveLoadError, setLiveLoadError] = useState<string | null>(null)
const [loadAttempt, setLoadAttempt] = useState(0)
const ensureGreTunnels = useCallback(async (serverId: string) => {
if (!isLive || !mounted.current) return
try {
const d = await apiFetch<{ tunnels: GreTunnel[] }>(
`/api/filters/gre-tunnels?serverId=${encodeURIComponent(serverId)}`,
)
if (!mounted.current) return
setGreByServer(prev => ({ ...prev, [serverId]: d.tunnels }))
} catch {
if (!mounted.current) return
setGreByServer(prev => ({ ...prev, [serverId]: [] }))
}
}, [isLive, apiFetch])
const [gatewayReady, setGatewayReady] = useState<GatewayReadiness>({})
const [gatewayErrors, setGatewayErrors] = useState<Record<string, Partial<Record<"gre" | "recursive", string>>>>({})
const gatewayEpoch = useRef<Record<string, number>>({})
const ensureRecursiveRoutes = useCallback(async (serverId: string) => {
const loadGatewayCatalog = useCallback(async (serverId: string, kind: "gre" | "recursive") => {
if (!isLive || !mounted.current) return
const key = serverId + ":" + kind
const epoch = (gatewayEpoch.current[key] ?? 0) + 1
gatewayEpoch.current[key] = epoch
setGatewayReady(prev => ({ ...prev, [serverId]: { ...prev[serverId], [kind]: false } }))
try {
const d = await apiFetch<{ routes: RecursiveRouteLite[] }>(
`/api/recursive-routes?serverId=${encodeURIComponent(serverId)}`,
)
if (!mounted.current) return
setRecRoutesByServer(prev => ({ ...prev, [serverId]: d.routes }))
} catch {
if (!mounted.current) return
setRecRoutesByServer(prev => ({ ...prev, [serverId]: [] }))
const path = kind === "gre" ? "/api/filters/gre-tunnels" : "/api/recursive-routes"
const payload = await apiFetch<unknown>(`${path}?serverId=${encodeURIComponent(serverId)}`)
if (!mounted.current || gatewayEpoch.current[key] !== epoch) return
if (kind === "gre") {
const parsed = filtersGreResponseSchema.safeParse(payload)
if (!parsed.success || parsed.data.tunnels.some(t => t.serverId !== serverId)) throw new Error("Некорректный каталог GRE")
setGreByServer(prev => ({ ...prev, [serverId]: parsed.data.tunnels }))
} else {
const parsed = filtersRecursiveResponseSchema.safeParse(payload)
if (!parsed.success || parsed.data.stale) throw new Error("Рекурсивные шлюзы недоступны или устарели")
setRecRoutesByServer(prev => ({ ...prev, [serverId]: parsed.data.routes }))
}
setGatewayReady(prev => ({ ...prev, [serverId]: { ...prev[serverId], [kind]: true } }))
setGatewayErrors(prev => ({ ...prev, [serverId]: { ...prev[serverId], [kind]: undefined } }))
} catch (error) {
if (!mounted.current || gatewayEpoch.current[key] !== epoch) return
setGatewayErrors(prev => ({ ...prev, [serverId]: { ...prev[serverId], [kind]: error instanceof Error ? error.message : "Каталог шлюзов недоступен" } }))
}
}, [isLive, apiFetch])
const ensureGreTunnels = useCallback((serverId: string) => loadGatewayCatalog(serverId, "gre"), [loadGatewayCatalog])
const ensureRecursiveRoutes = useCallback((serverId: string) => loadGatewayCatalog(serverId, "recursive"), [loadGatewayCatalog])
const [selectedServerId, setSelectedServerId] = useState("srv1")
const [search, setSearch] = useState("")
const [sheetOpen, setSheetOpen] = useState(false)
@@ -1667,6 +1690,7 @@ function FiltersPage() {
const handleSave = async (form: RuleForm): Promise<string | null> => {
if (mutationsLocked) return "Изменения заблокированы — обновите данные или дождитесь завершения операции"
if (isLive && form.action === "route" && !gatewayReady[selectedServerId]?.[form.gatewayKind]) return "Шлюзы устройства не подтверждены — обновите шлюзы"
const { gatewayKind: _gk, ...payload } = form
let result
if (sheetMode === "create") {
@@ -1848,6 +1872,10 @@ function FiltersPage() {
<Button variant="outline" size="sm" onClick={() => setLoadAttempt(n => n + 1)}>Повторить загрузку</Button>
</div>
)}
{isLive && Object.values(gatewayErrors[selectedServerId] ?? {}).some(Boolean) && <div className="border-b px-6 py-3 flex items-center gap-3">
<p role="alert" className="text-xs text-destructive">{Object.values(gatewayErrors[selectedServerId] ?? {}).filter(Boolean).join(" · ")}. Сохранены последние каталоги шлюзов.</p>
<Button variant="outline" size="sm" disabled={applyBusy} onClick={() => { void ensureGreTunnels(selectedServerId); void ensureRecursiveRoutes(selectedServerId) }}>Обновить шлюзы</Button>
</div>}
{isLive && rulesReading && <p role="status" className="border-b px-6 py-2 text-xs text-muted-foreground">Чтение актуальных правил устройства… Изменения временно заблокированы.</p>}
{isLive && liveStale && liveLoadState !== "error" && (
<div className="shrink-0 border-b border-amber-500/30 bg-amber-500/10 px-6 py-2.5 text-xs text-amber-700 dark:text-amber-400 flex items-center gap-2">
@@ -2058,6 +2086,7 @@ function FiltersPage() {
ensureGreFor={ensureGreTunnels}
recRoutesByServer={recRoutesByServer}
ensureRecursiveFor={ensureRecursiveRoutes}
catalogReady={isLive ? gatewayReady : undefined}
/>
<ConfigHistorySheet