diff --git a/app/(main)/ipsec/page.tsx b/app/(main)/ipsec/page.tsx index 51acca05..22926956 100644 --- a/app/(main)/ipsec/page.tsx +++ b/app/(main)/ipsec/page.tsx @@ -140,12 +140,14 @@ interface BackendServer { country: string type?: Server["type"] enabled: boolean - status?: Server["status"] + status?: Server["status"] | null latency?: number | null asn?: string } -function mapBackendServer(s: BackendServer): Server { +type InventoryServer = Server & { monitoringUnknown?: boolean } + +function mapBackendServer(s: BackendServer): InventoryServer { return { id: String(s.id), name: s.name || s.host, @@ -157,7 +159,8 @@ function mapBackendServer(s: BackendServer): Server { asn: s.asn ?? "", type: s.type ?? "exit-node", enabled: s.enabled, - status: s.status ?? "online", + status: s.status ?? "offline", + monitoringUnknown: s.status == null, latency: s.latency ?? null, sessions: 0, } @@ -167,7 +170,7 @@ const ipsecInventorySchema = z.array(z.object({ id: z.number().int().positive(), name: z.string(), host: z.string(), enabled: z.boolean(), country: z.string().optional().default("UN"), type: z.enum(["jump-host", "exit-node", "home-router"]).optional(), - status: z.enum(["online", "offline", "degraded"]).optional(), + status: z.enum(["online", "offline", "degraded"]).nullable().optional(), latency: z.number().nullable().optional(), asn: z.string().optional(), })).refine(rows => new Set(rows.map(row => row.id)).size === rows.length) @@ -177,7 +180,7 @@ function IpsecPage() { const [summaries, setSummaries] = useState([]) const [clients, setClients] = useState([]) - const [liveServers, setLiveServers] = useState([]) + const [liveServers, setLiveServers] = useState([]) const [failures, setFailures] = useState([]) const [loading, setLoading] = useState(false) const [dataError, setDataError] = useState(null) @@ -397,13 +400,14 @@ function IpsecPage() { host: s.host, site: s.site, country: s.country, - status: s.status, + status: isLive && (s as InventoryServer).monitoringUnknown ? undefined : s.status, type: s.type, count: counts.get(s.id) ?? 0, + meta: isLive && (s as InventoryServer).monitoringUnknown ? `${counts.get(s.id) ?? 0} · Статус неизвестен` : undefined, enabled: s.enabled, title: [s.name, s.host, s.asn].filter(Boolean).join(" · "), })) - }, [displayServers, displayClients]) + }, [displayServers, displayClients, isLive]) const handleInit = async (form: IpsecInitFormState) => { beginOperation(form.serverId) diff --git a/app/(main)/vxlan/page.tsx b/app/(main)/vxlan/page.tsx index 60467b34..d152da1e 100644 --- a/app/(main)/vxlan/page.tsx +++ b/app/(main)/vxlan/page.tsx @@ -36,7 +36,7 @@ interface BackendServer { country: string asn?: string enabled: boolean - status?: Server["status"] + status?: Server["status"] | null latency?: number | null } @@ -45,7 +45,9 @@ interface VxlanApiResponse { tunnels: VxlanTunnel[] } -function mapBackendServer(s: BackendServer): Server { +type InventoryServer = Server & { monitoringUnknown?: boolean } + +function mapBackendServer(s: BackendServer): InventoryServer { return { id: String(s.id), name: s.name || s.host, @@ -57,7 +59,8 @@ function mapBackendServer(s: BackendServer): Server { asn: s.asn ?? "", type: s.type ?? "exit-node", enabled: s.enabled, - status: s.status ?? "online", + status: s.status ?? "offline", + monitoringUnknown: s.status == null, latency: s.latency ?? null, sessions: 0, } @@ -112,7 +115,7 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, const [responseReceipt, setResponseReceipt] = useState(null) const [liveTunnels, setLiveTunnels] = useState([]) - const [liveServers, setLiveServers] = useState([]) + const [liveServers, setLiveServers] = useState([]) const [loading, setLoading] = useState(true) const [failures, setFailures] = useState>([]) const [hasLoaded, setHasLoaded] = useState(false) @@ -193,12 +196,12 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, 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 filtered = useMemo(() => { if (!search) return scopedTunnels diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index fe27ffeb..07dab7a0 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -25,12 +25,18 @@ | 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже | | 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже | | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | -| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.56 — IPsec/GRE/VXLAN, Firewall и операции Filters | +| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.57 — IPsec/GRE/VXLAN, Firewall и операции Filters | | 10 | Маршрутизация, диагностика, карта и терминал | Не начат | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 9.57 — устройства без снимка мониторинга (2026-09-30) + +- IPsec/VXLAN принимают законный status: null из /servers до первого опроса, а также прежний ответ без status. В rail такой статус обозначен «Статус неизвестен»; он не выдаётся за online/offline и не делает успешный VPN-опрос ошибкой. Внутренний совместимый Server DTO сохраняет fallback, но отображение использует отдельный признак monitoringUnknown. Filters/Firewall уже принимают null. +- 14 E2E прошли, включая четыре новых HTTP-сценария null/missing и регрессию ошибок/частичных отказов IPsec/VXLAN. TypeScript и lint чистые. Реальные устройства не опрашивались. +- Далее — состояния данных GRE, затем readiness, изоляция операций и свежесть. Локальный коммит без push. + ## Блок 9.56 — ошибки общего GRE-каталога (2026-09-30) - Общий GET /filters/gre-tunnels больше не превращает ошибку устройства в пустой успешный каталог: возвращает tunnels успешных устройств и failures с serverId/name/error. Опрос вынесен в тестируемый pollGreCatalog; чужой контекст строки считается отказом исходного устройства. Выборный успешный/503 путь сохранён. diff --git a/docs/ux-ui-stage-9.md b/docs/ux-ui-stage-9.md index 2c0536bd..57e87c8f 100644 --- a/docs/ux-ui-stage-9.md +++ b/docs/ux-ui-stage-9.md @@ -314,3 +314,10 @@ Live-копирование читает актуальный целевой rul - Явный невалидный serverId получает HTTP 400 до БД и не переключает запрос на все устройства. Нет изменений сетевого применения. Дополнительное поле failures совместимо с прежними потребителями, но карта сети/оповещения пока его игнорируют — их состояния входят в этапы 10/11 и не приняты этим блоком. - Unit опроса проверил mixed/empty/all-failed/foreign/нет устройств; Fastify inject с production Zod compiler проверил четыре невалидных ID без запросов DB/RouterOS. TypeScript/backend build и lint новых файлов прошли. Тесты добавлены в test:live-maps. HTTP успех/503 с настоящей БД остаются внешней приёмкой. - Далее — состояния/схемы/частичные отказы экрана GRE (он использует /gre/tunnels, уже имеющий failures), затем его readiness и контекст. Этап 9 открыт; локальный коммит без push. + +## Блок 9.57 — устройства без снимка мониторинга (2026-09-30) + +- IPsec/VXLAN принимают законный status: null из /servers до первого опроса, а также прежний ответ без status. В rail такой статус обозначен «Статус неизвестен»; он не выдаётся за online/offline и не делает успешный VPN-опрос ошибкой. Внутренний совместимый Server DTO сохраняет fallback, но отображение использует отдельный признак monitoringUnknown. Filters/Firewall уже принимают null. +- 14 E2E прошли, включая четыре новых HTTP-сценария null/missing и регрессию ошибок/частичных отказов IPsec/VXLAN. TypeScript и lint чистые. Реальные устройства не опрашивались. +- Далее — состояния данных GRE, затем readiness, изоляция операций и свежесть. Локальный коммит без push. + diff --git a/lib/vxlan-response.ts b/lib/vxlan-response.ts index dde1bd06..42c72a2c 100644 --- a/lib/vxlan-response.ts +++ b/lib/vxlan-response.ts @@ -15,6 +15,6 @@ export const vxlanResponseSchema = z.object({ export const vxlanServersSchema = z.array(z.object({ id: z.number().int().positive(), name: z.string(), host: z.string(), country: z.string(), enabled: z.boolean(), type: z.enum(["jump-host", "exit-node", "home-router"]).optional(), site: z.string().optional(), - asn: z.string().optional(), status: z.enum(["online", "offline", "degraded"]).optional(), + asn: z.string().optional(), status: z.enum(["online", "offline", "degraded"]).nullable().optional(), latency: z.number().finite().nullable().optional(), })).refine(rows => new Set(rows.map(row => row.id)).size === rows.length) diff --git a/tests/e2e/vpn-inventory.spec.ts b/tests/e2e/vpn-inventory.spec.ts new file mode 100644 index 00000000..1d4b569a --- /dev/null +++ b/tests/e2e/vpn-inventory.spec.ts @@ -0,0 +1,20 @@ +import { test, expect } from "@playwright/test" + +for (const path of ["ipsec", "vxlan"]) { + for (const monitoring of ["null", "missing"]) { + test(`${path}: inventory без снимка мониторинга (${monitoring})`, async ({ page }) => { + await page.route("**/health", r => r.fulfill({ json: { ok: true } })) + await page.route("**/api/**", r => { + const url = new URL(r.request().url()).pathname + const server = { id: 1, name: "Без первого опроса", host: "192.0.2.1", enabled: true, country: "RU", type: "exit-node", ...(monitoring === "null" ? { status: null, latency: null } : {}) } + return r.fulfill({ json: url === "/api/servers" ? [server] : url === "/api/ipsec" ? { servers: [], clients: [] } : url === "/api/vxlan" ? { tunnels: [] } : url === "/api/auth/config" ? { required: false } : {} }) + }) + await page.goto("/" + path) + const device = page.getByRole("option").filter({ hasText: "Без первого опроса" }) + await expect(device).toBeVisible() + await expect(device).toContainText("Статус неизвестен") + await expect(page.getByRole("alert").filter({ hasText: /Некорректный ответ|Не удалось загрузить/ })).toHaveCount(0) + await expect(page.getByRole("button", { name: "Обновить", exact: true })).toBeEnabled() + }) + } +}