fix(ui): проверять данные загрузки Filters

This commit is contained in:
Denozordec committed 2026-09-30 11:50:36 +07:00
1 parent 1fba16e1f2
commit 1c7ffa07a8
6 files changed
+143 -44

No files matched your search

+46 -42
View File
@@ -14,6 +14,8 @@ 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 { firewallServersSchema } from "@/lib/firewall-response"
import { requestJson } from "@/shared/api/http-client"
import {
servers, greTunnels, serverFilterRulesets, filters as mockFiltersCatalog,
@@ -1248,27 +1250,6 @@ function CopyRulesSheet({
// ── page ───────────────────────────────────────────────────────────────────────
interface BackendServer {
id: number
name: string
host: string
type: "jump-host" | "exit-node" | "home-router"
site: string
country: string
asn: string
enabled: boolean
status: "online" | "offline" | null
latency: number | null
}
interface LiveFiltersResponse {
rulesets: ServerFilterRuleset[]
greTunnels: GreTunnel[]
live?: boolean
stale?: boolean
error?: string
}
function buildRulesets(serverList: Server[], sourceRulesets: ServerFilterRuleset[]): ServerFilterRuleset[] {
return serverList.map(s => ({
serverId: s.id,
@@ -1318,12 +1299,15 @@ export default function FiltersPage() {
const [greByServer, setGreByServer] = useState<Record<string, GreTunnel[]>>({})
const [recRoutesByServer, setRecRoutesByServer] = useState<Record<string, RecursiveRouteLite[]>>({})
const [rulesets, setRulesets] = useState<ServerFilterRuleset[]>(
() => buildRulesets(servers, serverFilterRulesets),
() => isLive ? [] : buildRulesets(servers, serverFilterRulesets),
)
const [liveLoadState, setLiveLoadState] = useState<"idle" | "loading" | "error">(
() => (mode === "live" ? "loading" : "idle"),
)
const [liveLoadError, setLiveLoadError] = useState<string | null>(null)
const [loadAttempt, setLoadAttempt] = useState(0)
const ensureGreTunnels = useCallback(async (serverId: string) => {
if (!isLive) return
try {
@@ -1386,12 +1370,21 @@ export default function FiltersPage() {
return
}
let cancelled = false
setLiveLoadState("loading")
setLiveLoadError(null)
Promise.all([
apiFetch<BackendServer[]>("/api/servers"),
apiFetch<LiveFiltersResponse>("/api/filters/rules"),
apiFetch<unknown>("/api/servers"),
apiFetch<unknown>("/api/filters/rules"),
])
.then(([backendServers, liveFilters]) => {
.then(([serverPayload, filtersPayload]) => {
if (cancelled) return
const serverResult = firewallServersSchema.safeParse(serverPayload)
const filtersResult = filtersResponseSchema.safeParse(filtersPayload)
if (!serverResult.success || !filtersResult.success) throw new Error("Сервер вернул некорректные данные Filters")
const backendServers = serverResult.data, liveFilters = filtersResult.data
if (liveFilters.stale) throw new Error("Актуальные правила недоступны")
if (backendServers.some(server => server.enabled && !liveFilters.rulesets.some(rs => rs.serverId === String(server.id)))) throw new Error("Сервер не вернул наборы правил всех устройств")
const mappedServers: Server[] = backendServers.map(s => ({
id: String(s.id),
name: s.name || s.host,
@@ -1416,21 +1409,23 @@ export default function FiltersPage() {
))
setLiveLoadState("idle")
})
.catch(() => {
setLiveServers([])
setGreByServer({})
setRulesets(buildRulesets(servers, serverFilterRulesets))
setSelectedServerId(servers[0]?.id ?? "")
.catch(error => {
if (cancelled) return
setLiveLoadError(error instanceof Error ? error.message : "Не удалось загрузить Filters")
setLiveLoadState("error")
})
}, [isLive, apiFetch])
return () => { cancelled = true }
}, [isLive, apiFetch, loadAttempt])
const loadLiveRules = useCallback(async (serverId: string) => {
if (!isLive || !serverId) return false
try {
const fresh = await apiFetch<LiveFiltersResponse>(
const payload = await apiFetch<unknown>(
`/api/filters/rules?serverId=${encodeURIComponent(serverId)}`,
)
const parsed = filtersResponseSchema.safeParse(payload)
if (!parsed.success) throw new Error("Сервер вернул некорректные правила Filters")
const fresh = parsed.data
const ruleset = fresh.rulesets.find(r => r.serverId === serverId)
if (!ruleset || fresh.stale) throw new Error("Не удалось получить актуальные правила выбранного устройства")
const liveRules = ruleset.rules
@@ -1440,9 +1435,6 @@ export default function FiltersPage() {
return prev.map((rs) => rs.serverId === serverId ? { ...rs, rules: liveRules } : rs)
})
setLiveStale(Boolean(fresh.stale))
if (fresh.greTunnels?.length) {
setGreByServer((prev) => ({ ...prev, [serverId]: fresh.greTunnels }))
}
return true
} catch (err) {
setLiveStale(true)
@@ -1461,8 +1453,8 @@ export default function FiltersPage() {
])
}, [isLive, selectedServerId, liveLoadState, liveServers, ensureGreTunnels, ensureRecursiveRoutes, loadLiveRules])
const allServers = !isLive || liveLoadState === "error" ? servers : liveServers
const allTunnels = !isLive || liveLoadState === "error" ? greTunnels : (greByServer[selectedServerId] ?? [])
const allServers = !isLive ? servers : liveServers
const allTunnels = !isLive ? greTunnels : (greByServer[selectedServerId] ?? [])
const selectedServer = allServers.find(s => s.id === selectedServerId) ?? allServers[0]
const totalRules = rulesets.reduce((s, r) => s + r.rules.length, 0)
const mutationsLocked = movePending || (isLive && (applyBusy || historyRestoring || liveStale || liveLoadState !== "idle"))
@@ -1525,11 +1517,13 @@ export default function FiltersPage() {
applyLock.current = true
setApplyBusy(true)
try {
const res = await apiFetch<{ ok: boolean; rules?: FilterRule[] }>("/api/filters/rules", {
const payload = await apiFetch<unknown>("/api/filters/rules", {
method: "PUT",
body: JSON.stringify({ serverId, rules: nextRules, source }),
})
if (res?.ok !== true) throw new Error("Сервер не подтвердил применение правил")
const parsed = filtersMutationSchema.safeParse(payload)
if (!parsed.success) throw new Error("Сервер не подтвердил применение правил")
const res = parsed.data
setRulesets(p => p.map(rs => rs.serverId === serverId ? { ...rs, rules: res.rules ?? nextRules } : rs))
toast.success("Правила применены на роутер")
return { ok: true }
@@ -1587,11 +1581,13 @@ export default function FiltersPage() {
setApplyBusy(true)
setHistoryRestoring(true)
try {
const res = await apiFetch<{ ok: boolean; rules?: FilterRule[] }>(
const payload = await apiFetch<unknown>(
`/api/filters/revisions/${encodeURIComponent(id)}/restore`,
{ method: "POST", body: JSON.stringify({ serverId: selectedServerId }) },
)
if (res?.ok !== true) throw new Error("Сервер не подтвердил восстановление")
const parsed = filtersMutationSchema.safeParse(payload)
if (!parsed.success) throw new Error("Сервер не подтвердил восстановление")
const res = parsed.data
if (res.rules) {
setRulesets((p) => p.map((rs) => rs.serverId === selectedServerId ? { ...rs, rules: res.rules ?? [] } : rs))
} else {
@@ -1711,6 +1707,13 @@ export default function FiltersPage() {
}
}
if (isLive && liveLoadState === "error" && liveServers.length === 0) {
return <div className="flex h-full flex-col items-center justify-center gap-3 p-6">
<p role="alert" className="text-sm text-destructive">Не удалось загрузить Filters: {liveLoadError}</p>
<Button variant="outline" onClick={() => setLoadAttempt(n => n + 1)}>Повторить загрузку</Button>
</div>
}
if (isLive && liveLoadState === "loading") {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
@@ -1792,7 +1795,8 @@ export default function FiltersPage() {
{isLive && liveLoadState === "error" && (
<div className="shrink-0 border-b border-destructive/30 bg-destructive/10 px-6 py-2.5 text-xs text-destructive flex items-center gap-2">
<AlertCircleIcon className="size-3.5 shrink-0" />
Бекенд недоступен — показаны демо-данные из lib/data. Проверьте URL бекенда в настройках.
<span role="alert">Не удалось загрузить Filters: {liveLoadError}. Сохранены последние данные.</span>
<Button variant="outline" size="sm" onClick={() => setLoadAttempt(n => n + 1)}>Повторить загрузку</Button>
</div>
)}
{isLive && liveStale && liveLoadState !== "error" && (