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)
|
||||
|
||||
@@ -105,6 +105,9 @@ interface GreTunnelsDataGridProps {
|
||||
onToggle?: (tunnel: GreTunnel) => void
|
||||
onDelete?: (tunnel: GreTunnel) => void
|
||||
mutationsLocked?: boolean
|
||||
unknownServerIds?: Set<string>
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
}
|
||||
|
||||
function GreTunnelsDataGrid({
|
||||
@@ -116,6 +119,7 @@ function GreTunnelsDataGrid({
|
||||
onToggle,
|
||||
onDelete,
|
||||
mutationsLocked = false,
|
||||
unknownServerIds, emptyTitle, emptyDescription,
|
||||
}: GreTunnelsDataGridProps) {
|
||||
const serverMap = useMemo(() => new Map(servers.map((s) => [s.id, s])), [servers])
|
||||
const poolMap = useMemo(() => new Map(pools.map((p) => [p.id, p])), [pools])
|
||||
@@ -260,7 +264,7 @@ function GreTunnelsDataGrid({
|
||||
id: "status",
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => <TunnelStatus status={row.original.status} />,
|
||||
cell: ({ row }) => unknownServerIds?.has(row.original.serverId) ? <span className="text-sm text-muted-foreground">Неизвестно</span> : <TunnelStatus status={row.original.status} />,
|
||||
meta: { headerTitle: "Статус", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
@@ -337,7 +341,7 @@ function GreTunnelsDataGrid({
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||
},
|
||||
],
|
||||
[onCodePreview, onEdit, onToggle, onDelete, mutationsLocked, poolMap, serverMap],
|
||||
[onCodePreview, onEdit, onToggle, onDelete, mutationsLocked, unknownServerIds, poolMap, serverMap],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
@@ -345,15 +349,15 @@ function GreTunnelsDataGrid({
|
||||
manualPagination: true,
|
||||
data: tunnels,
|
||||
columns,
|
||||
getRowId: (row, index) => `${row.id}:${row.serverId}:${row.name}:${index}`,
|
||||
getRowId: row => JSON.stringify([row.serverId, row.id]),
|
||||
})
|
||||
|
||||
if (tunnels.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<NetworkIcon className="size-4" />}
|
||||
title="Нет GRE-туннелей"
|
||||
description="Измените фильтр или добавьте туннель"
|
||||
title={emptyTitle ?? "Нет GRE-туннелей"}
|
||||
description={emptyDescription ?? "Измените фильтр или добавьте туннель"}
|
||||
className="border-0 py-16"
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -25,12 +25,19 @@
|
||||
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
|
||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.57 — IPsec/GRE/VXLAN, Firewall и операции Filters |
|
||||
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.58 — IPsec/GRE/VXLAN, Firewall и операции Filters |
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||
|
||||
## Блок 9.58 — достоверность чтения GRE (2026-09-30)
|
||||
|
||||
- Добавлена схема GRE с IPsec/PSK, уникальными составными serverId/id, уникальными failures и запретом одновременного успеха/отказа устройства. Inventory валидируется и принимает null мониторинга; rail показывает неизвестный статус. Ошибочный ответ не заменяет подтверждённые данные.
|
||||
- Частичный отказ сохраняет строки отказавшего включённого устройства, успешный пустой опрос удаляет их. Постоянный alert содержит причину и повтор; начальный отказ не выглядит как отсутствие туннелей. Неполная сводка — неизвестная, отказавшие строки — «Неизвестно», фильтр активных исключает неподтверждённые статусы. Source/URL key и epoch исключают поздний результат чтения другого контекста.
|
||||
- 22 E2E прошли: восемь сценариев данных плюс ссылки/формы/удаление/переключение. TypeScript и lint чистые. Удалены остатки неиспользуемых UI-хелперов; таблица использует стабильный составной ID.
|
||||
- На время частичного отказа изменения пока блокируются для всего GRE. Следующий блок — readiness каждого устройства, общий lock записи и поздние результаты операций. Live-пулы/экспорт требуют дальнейшей проверки. Локальный коммит без push.
|
||||
|
||||
## Блок 9.57 — устройства без снимка мониторинга (2026-09-30)
|
||||
|
||||
- IPsec/VXLAN принимают законный status: null из /servers до первого опроса, а также прежний ответ без status. В rail такой статус обозначен «Статус неизвестен»; он не выдаётся за online/offline и не делает успешный VPN-опрос ошибкой. Внутренний совместимый Server DTO сохраняет fallback, но отображение использует отдельный признак monitoringUnknown. Filters/Firewall уже принимают null.
|
||||
|
||||
@@ -321,3 +321,11 @@ Live-копирование читает актуальный целевой rul
|
||||
- 14 E2E прошли, включая четыре новых HTTP-сценария null/missing и регрессию ошибок/частичных отказов IPsec/VXLAN. TypeScript и lint чистые. Реальные устройства не опрашивались.
|
||||
- Далее — состояния данных GRE, затем readiness, изоляция операций и свежесть. Локальный коммит без push.
|
||||
|
||||
|
||||
## Блок 9.58 — достоверность чтения GRE (2026-09-30)
|
||||
|
||||
- Добавлена схема GRE с IPsec/PSK, уникальными составными serverId/id, уникальными failures и запретом одновременного успеха/отказа устройства. Inventory валидируется и принимает null мониторинга; rail показывает неизвестный статус. Ошибочный ответ не заменяет подтверждённые данные.
|
||||
- Частичный отказ сохраняет строки отказавшего включённого устройства, успешный пустой опрос удаляет их. Постоянный alert содержит причину и повтор; начальный отказ не выглядит как отсутствие туннелей. Неполная сводка — неизвестная, отказавшие строки — «Неизвестно», фильтр активных исключает неподтверждённые статусы. Source/URL key и epoch исключают поздний результат чтения другого контекста.
|
||||
- 22 E2E прошли: восемь сценариев данных плюс ссылки/формы/удаление/переключение. TypeScript и lint чистые. Удалены остатки неиспользуемых UI-хелперов; таблица использует стабильный составной ID.
|
||||
- На время частичного отказа изменения пока блокируются для всего GRE. Следующий блок — readiness каждого устройства, общий lock записи и поздние результаты операций. Live-пулы/экспорт требуют дальнейшей проверки. Локальный коммит без push.
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { z } from "zod"
|
||||
import { filtersGreResponseSchema } from "./filters-response"
|
||||
|
||||
export const greResponseSchema = z.object({
|
||||
tunnels: z.array(filtersGreResponseSchema.shape.tunnels.element.extend({
|
||||
ipsec: z.object({
|
||||
secret: z.string(), encAlg: z.enum(["aes-128", "aes-192", "aes-256"]).optional(),
|
||||
authAlg: z.enum(["sha1", "sha256", "sha512"]).optional(),
|
||||
dhGroup: z.enum(["modp1024", "modp2048", "modp4096", "ecp256", "ecp384", "ecp521"]).optional(),
|
||||
ikeVersion: z.enum(["ikev1", "ikev2"]).optional(), lifetime: z.string().optional(), pfs: z.boolean().optional(),
|
||||
}).nullable(),
|
||||
})).refine(rows => new Set(rows.map(t => JSON.stringify([t.serverId, t.id]))).size === rows.length),
|
||||
failures: z.array(z.object({ serverId: z.string().trim().min(1), serverName: z.string().optional(), error: z.string() })).default([]),
|
||||
}).refine(data => new Set(data.failures.map(f => f.serverId)).size === data.failures.length && !data.tunnels.some(t => data.failures.some(f => f.serverId === t.serverId)))
|
||||
@@ -0,0 +1,68 @@
|
||||
import { test, expect } from "@playwright/test"
|
||||
|
||||
const tunnel = (id: string, name = "Туннель " + id) => ({ id: "*1", serverId: id, name, localAddress: "192.0.2.1", remoteAddress: "192.0.2.2", localInnerIp: "10.0.0.1/30", remoteInnerIp: "10.0.0.2/30", poolId: "live", ipsec: null, mtu: 1476, keepaliveInterval: 10, keepaliveRetries: 3, dscp: "inherit", clampTcpMss: true, allowFastPath: true, comment: "", enabled: true, status: "up" })
|
||||
const servers = [1, 2].map(id => ({ id, name: "Роутер " + id, host: "192.0.2." + id, enabled: true, country: "RU", type: "exit-node", status: null, latency: null }))
|
||||
const full = { tunnels: [tunnel("1"), tunnel("2")] }
|
||||
|
||||
for (const broken of ["http", "payload", "duplicate", "conflict", "duplicate-failure", "blank-failure", "inventory"]) {
|
||||
test(`GRE: неизвестное начало, сохранение кэша и восстановление ${broken}`, async ({ page }) => {
|
||||
let phase = 0
|
||||
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
|
||||
await page.route("**/api/**", r => {
|
||||
const path = new URL(r.request().url()).pathname
|
||||
if (path === "/api/servers") return r.fulfill({ json: phase === 2 && broken === "inventory" ? [servers[0], servers[0]] : servers })
|
||||
if (path === "/api/gre/tunnels") {
|
||||
if (phase === 0 || phase === 2 && broken === "http") return r.fulfill({ status: 503, json: { error: "GRE недоступен тест" } })
|
||||
const failure = { serverId: "2", error: "Сбой роутера тест" }
|
||||
return r.fulfill({ json: phase !== 2 ? full : broken === "payload" ? { tunnels: [{}] }
|
||||
: broken === "duplicate" ? { tunnels: [tunnel("1"), tunnel("1")] }
|
||||
: broken === "conflict" ? { ...full, failures: [failure] }
|
||||
: broken === "duplicate-failure" ? { tunnels: [], failures: [failure, failure] }
|
||||
: broken === "blank-failure" ? { tunnels: [], failures: [{ ...failure, serverId: " " }] } : full })
|
||||
}
|
||||
return r.fulfill({ json: path === "/api/auth/config" ? { required: false } : {} })
|
||||
})
|
||||
await page.goto("/gre")
|
||||
const alert = page.getByRole("alert").filter({ hasText: "Не удалось загрузить GRE" })
|
||||
await expect(alert).toContainText("Данные пока неизвестны")
|
||||
await expect(page.getByText("Нет GRE-туннелей", { exact: true })).toHaveCount(0)
|
||||
await expect(page.getByLabel("Сводка GRE")).toContainText("неизвестно")
|
||||
phase = 1
|
||||
await alert.getByRole("button", { name: "Повторить загрузку GRE" }).click()
|
||||
const rows = page.locator("tr[data-row-id]")
|
||||
await expect(rows).toHaveCount(2)
|
||||
await expect(page.getByRole("option").filter({ hasText: "Роутер 1" })).toContainText("Статус неизвестен")
|
||||
phase = 2
|
||||
await page.getByRole("button", { name: "Обновить", exact: true }).click()
|
||||
await expect(alert).toContainText("Показаны последние полученные данные")
|
||||
await expect(rows).toHaveCount(2)
|
||||
await expect(rows.filter({ hasText: "Неизвестно" })).toHaveCount(2)
|
||||
await expect(page.getByLabel("Сводка GRE")).toContainText("неизвестно")
|
||||
phase = 3
|
||||
await alert.getByRole("button", { name: "Повторить загрузку GRE" }).click()
|
||||
await expect(alert).toHaveCount(0)
|
||||
await expect(rows.filter({ hasText: "Неизвестно" })).toHaveCount(0)
|
||||
})
|
||||
}
|
||||
|
||||
test("GRE: частичный отказ сохраняет строки устройства, успешный пустой опрос очищает", async ({ page }) => {
|
||||
let phase = 0
|
||||
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
|
||||
await page.route("**/api/**", r => {
|
||||
const path = new URL(r.request().url()).pathname
|
||||
return r.fulfill({ json: path === "/api/servers" ? servers : path === "/api/gre/tunnels" ? phase === 0 ? full : phase === 1 ? { tunnels: [tunnel("1", "Обновлённый GRE")], failures: [{ serverId: "2", serverName: "Роутер 2", error: "Таймаут тест" }] } : { tunnels: [tunnel("1", "Обновлённый GRE")] } : path === "/api/auth/config" ? { required: false } : {} })
|
||||
})
|
||||
await page.goto("/gre")
|
||||
const rows = page.locator("tr[data-row-id]")
|
||||
await expect(rows).toHaveCount(2)
|
||||
phase = 1
|
||||
await page.getByRole("button", { name: "Обновить", exact: true }).click()
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Не удалось опросить часть роутеров" })).toContainText("Роутер 2: Таймаут тест")
|
||||
await expect(rows).toHaveCount(2)
|
||||
await expect(rows.filter({ hasText: "Туннель 2" })).toContainText("Неизвестно")
|
||||
await expect(rows.filter({ hasText: "Обновлённый GRE" })).toContainText("Up")
|
||||
phase = 2
|
||||
await page.getByRole("button", { name: "Повторить загрузку GRE" }).click()
|
||||
await expect(rows).toHaveCount(1)
|
||||
await expect(rows.filter({ hasText: "Туннель 2" })).toHaveCount(0)
|
||||
})
|
||||
@@ -69,7 +69,8 @@ test("GRE: прямая ссылка, сброс, назад, reload и ошиб
|
||||
control.partial = true
|
||||
await page.getByRole("button", { name: "Обновить", exact: true }).click()
|
||||
await expect(context).toContainText("Не удалось получить данные выбранного сервера")
|
||||
await expect(rows).toHaveCount(0)
|
||||
await expect(rows).toHaveCount(1)
|
||||
await expect(rows).toContainText("Неизвестно")
|
||||
expect(control.posts).toEqual([])
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await context.scrollIntoViewIfNeeded()
|
||||
|
||||
Reference in new issue
Block a user