fix(ui): сохранять подтверждённые данные GRE при отказах

This commit is contained in:
Denozordec committed 2026-09-30 15:49:26 +07:00
1 parent 6ea3cdad0e
commit fb009344e1
7 files changed
+183 -104

No files matched your search

+74 -97
View File
@@ -13,6 +13,9 @@ import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/compone
import { greTunnels as mockGreTunnels, grePools as mockGrePools, servers as mockServers } from "@/lib/data"
import type { GrePool, GreTunnel, GreStatus, IpsecEncAlg, IpsecAuthAlg, IpsecDhGroup, IkeVersion, Server } from "@/lib/data"
import { useDataSource } from "@/lib/data-source"
import { greResponseSchema } from "@/lib/gre-response"
import { vxlanServersSchema } from "@/lib/vxlan-response"
import type { z } from "zod"
import { requestJson } from "@/shared/api/http-client"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
@@ -38,15 +41,11 @@ import {
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, DropdownMenuGroup,
} from "@/components/ui/dropdown-menu"
import {
PlusIcon, RefreshCwIcon, MoreHorizontalIcon,
LockIcon, LockOpenIcon, ShieldCheckIcon, NetworkIcon,
PlusIcon, RefreshCwIcon,
LockIcon, ShieldCheckIcon, NetworkIcon,
EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon,
CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon,
DatabaseIcon, HistoryIcon, TriangleAlertIcon,
Trash2Icon,
DatabaseIcon, HistoryIcon,
} from "lucide-react"
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
@@ -147,47 +146,10 @@ function generateRosCommands(t: GreTunnel, serverById: Record<string, Server>):
// ─── small ui helpers ────────────────────────────────────────────────────────
function TunnelStatus({ status }: { status: GreStatus }) {
const s = greStatusMeta(status)
return (
<span className="inline-flex items-center gap-1.5 text-sm">
<span className={`size-1.5 rounded-full ${s.dot}`} />
{s.label}
</span>
)
}
function IpsecBadge({ secured }: { secured: boolean }) {
return secured ? (
<span className="inline-flex items-center gap-1 text-xs font-medium border rounded px-2 py-0.5 bg-emerald-500/10 text-emerald-400 border-emerald-500/20">
<LockIcon className="size-3" /> IPsec
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs font-medium border rounded px-2 py-0.5 bg-muted text-muted-foreground border-border">
<LockOpenIcon className="size-3" /> Открытый
</span>
)
}
// ─── Live API (как на /filters) ─────────────────────────────────────────────
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 GreTunnelsApiResponse {
tunnels: GreTunnel[]
failures?: Array<{ serverId: string; serverName?: string; error: string }>
}
type BackendServer = z.infer<typeof vxlanServersSchema>[number]
type InventoryServer = Server & { monitoringUnknown?: boolean }
function makeApiFetch(backendUrl: string) {
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
@@ -195,19 +157,20 @@ function makeApiFetch(backendUrl: string) {
}
}
function mapBackendToServer(s: BackendServer): Server {
function mapBackendToServer(s: BackendServer): InventoryServer {
return {
id: String(s.id),
name: s.name || s.host,
host: s.host,
model: "—",
os: "—",
site: s.site,
site: s.site ?? "",
country: s.country || "UN",
asn: s.asn,
type: s.type,
asn: s.asn ?? "",
type: s.type ?? "exit-node",
enabled: s.enabled,
status: (s.status ?? "offline") as Server["status"],
status: s.status ?? "offline",
monitoringUnknown: s.status == null,
latency: s.latency != null ? Math.round(s.latency) : null,
sessions: 0,
}
@@ -261,11 +224,15 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
const isLive = mode === "live"
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const [liveServers, setLiveServers] = useState<Server[]>([])
const [liveServers, setLiveServers] = useState<InventoryServer[]>([])
const [liveTunnels, setLiveTunnels] = useState<GreTunnel[]>([])
const [dataLoading, setDataLoading] = useState(true)
const [dataError, setDataError] = useState<string | null>(null)
const [failedServerIds, setFailedServerIds] = useState<string[]>([])
const [failures, setFailures] = useState<z.infer<typeof greResponseSchema>["failures"]>([])
const failedServerIds = useMemo(() => failures.map(f => f.serverId), [failures])
const [hasLoaded, setHasLoaded] = useState(false)
const loadEpoch = useRef(0)
useEffect(() => () => { loadEpoch.current += 1 }, [])
const [pageTab, setPageTab] = useState<PageTab>("tunnels")
const [tabFilter, setTabFilter] = useState<TabFilter>("all")
@@ -301,27 +268,30 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
const loadLive = useCallback(async () => {
if (!isLive) return
const epoch = ++loadEpoch.current
setDataLoading(true)
setDataError(null)
try {
const [backendServers, greRes] = await Promise.all([
apiFetch<BackendServer[]>("/api/servers"),
apiFetch<GreTunnelsApiResponse>("/api/gre/tunnels"),
const [serversPayload, tunnelsPayload] = await Promise.all([
apiFetch<unknown>("/api/servers"), apiFetch<unknown>("/api/gre/tunnels"),
])
setLiveServers(backendServers.map(mapBackendToServer))
setLiveTunnels(greRes.tunnels)
setFailedServerIds((greRes.failures ?? []).map(f => String(f.serverId)))
if (epoch !== loadEpoch.current) return
const inventory = vxlanServersSchema.safeParse(serversPayload)
const response = greResponseSchema.safeParse(tunnelsPayload)
if (!inventory.success || !response.success) throw new Error("Некорректный ответ загрузки GRE")
const failedIds = new Set(response.data.failures.map(f => f.serverId))
const enabledIds = new Set(inventory.data.filter(s => s.enabled).map(s => String(s.id)))
setLiveServers(inventory.data.map(mapBackendToServer))
setLiveTunnels(previous => [...response.data.tunnels, ...previous.filter(t => failedIds.has(t.serverId) && enabledIds.has(t.serverId))])
setFailures(response.data.failures)
setHasLoaded(true)
setDataError(null)
setLiveStale(false)
if (greRes.failures?.length) {
toast.warning(
`Не удалось опросить: ${greRes.failures.map((f) => f.serverName ?? f.serverId).join(", ")}`,
)
}
} catch (e) {
if (epoch !== loadEpoch.current) return
setDataError(e instanceof Error ? e.message : "Ошибка загрузки")
setLiveStale(true)
} finally {
setDataLoading(false)
if (epoch === loadEpoch.current) setDataLoading(false)
}
}, [isLive, apiFetch])
@@ -354,12 +324,12 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
host: s.host,
site: s.site,
country: s.country,
status: s.status,
status: isLive && (s as InventoryServer).monitoringUnknown ? undefined : s.status,
type: s.type,
enabled: s.enabled,
meta: String(displayTunnels.filter((t) => t.serverId === s.id).length),
meta: `${displayTunnels.filter((t) => t.serverId === s.id).length}${isLive && (s as InventoryServer).monitoringUnknown ? " · Статус неизвестен" : ""}`,
}))
), [displayServers, displayTunnels])
), [displayServers, displayTunnels, isLive])
const scopedTunnels = useMemo(() => {
if (selectedTunnelId !== null) return displayTunnels.filter(t => t.serverId === selectedServerId && t.id === selectedTunnelId)
@@ -374,13 +344,10 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
() => Object.fromEntries(displayServers.map((s) => [s.id, s])),
[displayServers],
)
const poolById = useMemo(
() => Object.fromEntries(displayPools.map((p) => [p.id, p])),
[displayPools],
)
const historyServerId = selectedServerId === ALL_SERVERS_ID ? null : selectedServerId
const mutationsLocked = isLive && (mutateBusy || liveStale || selectedTunnelError || historyRestoring)
const mutationsLocked = isLive && (mutateBusy || dataLoading || !hasLoaded || liveStale || failures.length > 0 || selectedTunnelError || historyRestoring)
useEffect(() => () => { historyEpoch.current += 1 }, [historyServerId, backendUrl, isLive])
@@ -591,13 +558,12 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
setTunnelOpen(true)
}
useEffect(() => {
if (dataError) toast.error(dataError)
}, [dataError])
const incomplete = isLive && (!hasLoaded || !!dataError || failures.length > 0)
const unknownServerIds = useMemo(() => new Set(isLive ? dataError ? displayTunnels.map(t => t.serverId) : failedServerIds : []), [isLive, dataError, displayTunnels, failedServerIds])
const filtered = useMemo(() => {
return scopedTunnels.filter((t) => {
if (tabFilter === "up" && t.status !== "up") return false
if (tabFilter === "up" && (t.status !== "up" || unknownServerIds.has(t.serverId))) return false
if (tabFilter === "ipsec" && !t.ipsec) return false
if (tabFilter === "plain" && t.ipsec) return false
if (!search) return true
@@ -609,7 +575,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
serverById[t.serverId]?.name.toLowerCase().includes(q)
)
})
}, [tabFilter, search, scopedTunnels, serverById])
}, [tabFilter, search, scopedTunnels, serverById, unknownServerIds])
const upCount = displayTunnels.filter((t) => t.status === "up").length
const ipsecCount = displayTunnels.filter((t) => t.ipsec).length
@@ -638,14 +604,6 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
showAll
allCount={displayServers.length}
loading={isLive && dataLoading && displayServers.length === 0}
banner={
isLive && liveStale ? (
<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">
<TriangleAlertIcon className="size-3.5 shrink-0" />
Роутер недоступен — показан кэш. Изменения заблокированы, пока не удастся прочитать CHR.
</div>
) : null
}
header={
<PageHeader
crumbs={[{ label: "Управление" }, { label: "GRE-туннели" }]}
@@ -656,7 +614,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
variant="outline"
size="sm"
onClick={() => { void loadLive() }}
disabled={!isLive || dataLoading}
disabled={!isLive || dataLoading || mutateBusy || historyRestoring}
title={!isLive ? "Включите Live и доступный бэкенд в настройках источника данных" : "Обновить список GRE с устройств"}
>
<RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} />
@@ -675,7 +633,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
<HistoryIcon className="size-4" />
История
</Button>
<Button size="sm" onClick={openCreateTunnel} disabled={mutateBusy}>
<Button size="sm" onClick={openCreateTunnel} disabled={mutateBusy || (isLive && mutationsLocked)}>
<PlusIcon className="size-4" />Добавить туннель
</Button>
</>
@@ -697,6 +655,17 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
</Alert>
)}
{isLive && (dataError || failures.length > 0) && (
<Alert variant="warning" role="alert">
<AlertTitle>{dataError ? "Не удалось загрузить GRE" : "Не удалось опросить часть роутеров"}</AlertTitle>
<AlertDescription>
<p>{hasLoaded ? "Показаны последние полученные данные; актуальность отказавших устройств не подтверждена." : "Данные пока неизвестны. Пустой список не означает отсутствие туннелей."}</p>
<p className="wrap-anywhere">{dataError ?? failures.map(f => (f.serverName ?? f.serverId) + ": " + f.error).join("; ")}</p>
<Button variant="outline" size="sm" disabled={dataLoading || mutateBusy || historyRestoring} onClick={() => { void loadLive() }}>Повторить загрузку GRE</Button>
</AlertDescription>
</Alert>
)}
{/* Legacy banner */}
<div className="flex items-start gap-3 rounded-lg bg-amber-500/5 border border-amber-500/20 px-4 py-3 text-sm">
<ShieldCheckIcon className="size-5 text-amber-500 shrink-0 mt-0.5" />
@@ -717,28 +686,32 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
{
id: "tunnels",
label: "Всего туннелей",
value: displayTunnels.length,
value: incomplete ? "—" : displayTunnels.length,
hint: incomplete ? "неизвестно" : undefined,
icon: <NetworkIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "up",
label: "Активно",
value: upCount,
value: incomplete ? "—" : upCount,
hint: incomplete ? "неизвестно" : undefined,
icon: <ShieldCheckIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "ipsec",
label: "Защищены IPsec",
value: ipsecCount,
value: incomplete ? "—" : ipsecCount,
hint: incomplete ? "неизвестно" : undefined,
icon: <LockIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "pools",
label: "IP-пулов",
value: displayPools.length,
value: incomplete ? "—" : displayPools.length,
hint: incomplete ? "неизвестно" : undefined,
icon: <DatabaseIcon className="size-4" />,
iconClassName: "text-primary",
},
@@ -768,13 +741,13 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
options: tunnelTabs.map((t) => ({
value: t.value,
label: t.label,
count: t.count,
count: incomplete ? undefined : t.count,
})),
}}
search={search}
onSearchChange={setSearch}
searchPlaceholder="Поиск по имени, IP…"
countLabel={`${filtered.length} туннелей`}
countLabel={incomplete ? `${filtered.length} сохранённых строк · сводка неизвестна` : `${filtered.length} туннелей`}
/>
<GreTunnelsDataGrid
@@ -786,6 +759,9 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
onToggle={(t) => { void toggleTunnel(t) }}
onDelete={setPendingDelete}
mutationsLocked={mutationsLocked}
unknownServerIds={unknownServerIds}
emptyTitle={isLive && !hasLoaded ? dataLoading ? "Загрузка GRE…" : "Данные GRE неизвестны" : incomplete ? "Нет подтверждённых строк" : undefined}
emptyDescription={incomplete ? "Повторите опрос; отсутствие строк не подтверждено" : undefined}
/>
</DataPageCard>
)}
@@ -1158,6 +1134,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
}
function GreRoute() {
const { mode, backendUrl } = useDataSource()
const params = useSearchParams()
const router = useRouter()
const serverId = params.get("server") ?? ALL_SERVERS_ID
@@ -1165,7 +1142,7 @@ function GreRoute() {
const rest = new URLSearchParams(params.toString())
rest.delete("tunnel")
const allTunnelsHref = "/gre" + (rest.size ? "?" + rest.toString() : "")
return <GrePageContent key={JSON.stringify([serverId, tunnelId])} selectedServerId={serverId} selectedTunnelId={tunnelId} allTunnelsHref={allTunnelsHref} onSelectServer={id => {
return <GrePageContent key={JSON.stringify([serverId, tunnelId, mode, backendUrl])} selectedServerId={serverId} selectedTunnelId={tunnelId} allTunnelsHref={allTunnelsHref} onSelectServer={id => {
const next = new URLSearchParams(rest)
if (id === ALL_SERVERS_ID) next.delete("server")
else next.set("server", id)