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)
@@ -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"
/>
)
+8 -1
View File
@@ -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.
+8
View File
@@ -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.
+14
View File
@@ -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)))
+68
View File
@@ -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)
})
+2 -1
View File
@@ -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()