fix(ui): сохранять подтверждённые данные GRE при отказах
This commit is contained in:
1 parent
6ea3cdad0e
commit
fb009344e1
7 files changed
+183
-104
No files matched your search
+74
-97
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user