From 6c8e66693b893619ea74c0d91b786ba20aea0e17 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sun, 20 Sep 2026 17:00:30 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D0=BE=D1=82=D0=BA=D1=80=D1=8B=D0=B2?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D0=B2=D1=8B=D0=B1=D1=80=D0=B0=D0=BD=D0=BD?= =?UTF-8?q?=D1=83=D1=8E=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D1=83?= =?UTF-8?q?=20=D0=B8=D0=B7=20=D0=BE=D1=87=D0=B5=D1=80=D0=B5=D0=B4=D0=B8=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B1=D0=BB=D0=B5=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/uptime/page.tsx | 50 +++++++++++++++++--- docs/ux-ui-progress.md | 14 +++++- docs/ux-ui-stage-6.md | 4 ++ hooks/use-dashboard-live.ts | 2 +- tests/e2e/uptime-links.spec.ts | 85 ++++++++++++++++++++++++++++++++++ 5 files changed, 147 insertions(+), 8 deletions(-) create mode 100644 tests/e2e/uptime-links.spec.ts diff --git a/app/(main)/uptime/page.tsx b/app/(main)/uptime/page.tsx index 0b95e001..2af42809 100644 --- a/app/(main)/uptime/page.tsx +++ b/app/(main)/uptime/page.tsx @@ -1,6 +1,9 @@ "use client" -import { useState, useMemo, useEffect, useRef, useCallback } from "react" +import { Suspense, useState, useMemo, useEffect, useRef, useCallback } from "react" +import Link from "next/link" +import { useSearchParams } from "next/navigation" +import { Alert as ContextAlert, AlertTitle as ContextTitle, AlertDescription as ContextDescription } from "@/components/reui/alert" import { PageHeader } from "@/components/page-header" import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit" import { Frame, FramePanel } from "@/components/reui/frame" @@ -1047,12 +1050,13 @@ function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerRe // ── page ─────────────────────────────────────────────────────────────────────── -export default function UptimePage() { +function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId: string | null; allProbesHref: string }) { const { mode, backendUrl, backendStatus, checkBackend } = useDataSource() /** При mode=live всегда ходим на backend. Нельзя требовать backendStatus===true: до ответа /health там undefined — иначе обзор/«Обновить» молчат. */ const liveApi = mode === "live" const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl]) + const [overviewState, setOverviewState] = useState<"loading" | "ready" | "error">("loading") const [allServers, setAllServers] = useState([]) const [tab, setTab] = useState<"probes" | "resources" | "speed">("probes") const [probes, setProbes] = useState([]) @@ -1095,6 +1099,7 @@ export default function UptimePage() { setAllServers(mockServers) setProbes(mockProbesWithSavedStars(INIT_PROBES)) setResources(INIT_RESOURCES) + setOverviewState("ready") }, [liveApi]) const isPausedRef = useRef(isPaused) @@ -1116,9 +1121,11 @@ export default function UptimePage() { setAllServers(mapBackendServersToServers(serverRows)) setProbes(data.probes) setResources(data.resources) + setOverviewState("ready") } catch (e) { if (myReq !== overviewReqRef.current) return setOpError(e instanceof Error ? e.message : "Не удалось загрузить uptime") + setOverviewState("error") } }, [apiFetch, liveApi]) @@ -1395,7 +1402,11 @@ export default function UptimePage() { [resources, allServers], ) + const selectedProbe = probes.find(probe => probe.id === selectedProbeId) + const selectedProbeServer = allServers.find(server => server.id === selectedProbe?.srcServerId) + const filtered = useMemo(() => probes.filter(p => { + if (selectedProbeId !== null && p.id !== selectedProbeId) return false if (serverFilter.length > 0 && !serverFilter.includes(p.srcServerId)) return false if (statusFilter !== "all" && p.status !== statusFilter) return false if (search) { @@ -1407,7 +1418,7 @@ export default function UptimePage() { ) } return true - }), [probes, serverFilter, statusFilter, search]) + }), [probes, serverFilter, statusFilter, search, selectedProbeId]) const grouped = useMemo(() => { const byServer = new Map>() @@ -1946,13 +1957,13 @@ export default function UptimePage() { {tab === "probes" && ( @@ -2324,6 +2335,21 @@ export default function UptimePage() { )} + {selectedProbeId !== null && ( +
+ + {selectedProbe ? "Выбрана проверка: " + selectedProbe.name : "Проверка по ссылке"} + +

{overviewState === "loading" ? "Загрузка проверки…" : overviewState === "error" ? "Не удалось обновить данные проверки. Повторите обновление; сохранённые данные могут быть устаревшими." : selectedProbe ? (selectedProbeServer?.name ?? selectedProbe.srcServerId) + " → " + selectedProbe.target + ". Список ограничен выбранной проверкой; сводка относится ко всем проверкам." : "Проверка не найдена или недоступна. Возможно, она удалена или у вас нет доступа."}

+
+ Все проверки + К дашборду +
+
+
+
+ )} + {/* ── summary + server filter ── */}
@@ -2488,7 +2514,7 @@ export default function UptimePage() { {group.probes.map((p) => { const linkedSp = findLinkedSpeedProbe(p, speedProbes, allServers, speedIfaces) return ( -
+
) } + +function UptimeRoute() { + const params = useSearchParams() + const id = params.get("probe") + const rest = new URLSearchParams(params.toString()) + rest.delete("probe") + return +} + +export default function UptimePage() { + return Загрузка мониторинга…

}>
+} diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 71233072..11c6d49b 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -20,7 +20,7 @@ | 3 | Основные таблицы и справочники | Завершён 2026-09-13, границы проверок ниже | | 4 | Остальные таблицы, удаление старых API, общая регрессия | Завершён 2026-09-20; границы приёмки ниже | | 5 | Состояния данных, свежесть, частичные сбои, гонки polling | Завершён 2026-09-20 для дашборда; границы проверок ниже | -| 6 | Навигация и дашборд | В работе: шапка, поиск и иерархия готовы; остаются переходы к объектам | +| 6 | Навигация и дашборд | В работе: готовы переходы к серверам и проверкам; остаются туннели | | 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Не начат | | 8 | Трафик и статистика | Не начат | | 9 | VPN, Firewall и Filters | Не начат | @@ -423,6 +423,18 @@ useDashboardLive больше не обрезает массивы пробле Квота нового окна: 1% использовано на старте, 26% при запуске итоговой сборки. Блок сохранить отдельным локальным коммитом без push. Этап 6 открыт: далее `/uptime?probe=` и обработка выбранной проверки, затем туннели с идентификатором устройства и объекта. +## Этап 6 — блок 5: переход к проверке мониторинга (2026-09-20) + +Очередь проверок формирует `/uptime?probe=`. UptimeRoute читает параметр в Suspense и сбрасывает локальный контекст страницы при выборе другого ID или полного списка. Фильтрация использует точный ID, поэтому одинаковые имена/назначения не смешиваются. Контекст показывает источник, назначение, состояния загрузки/ошибки/отсутствия; «Все проверки» удаляет только probe, остальные query-параметры сохраняются. Прямой URL, перезагрузка, браузерный возврат и ссылка на дашборд работают. + +Ссылка и верхнее «Обновить данные» выполняют чтение, без collect-now. Ручной ping существующей кнопкой группы отправляет только ID выбранной проверки; формат API и механизм мутаций не менялись. При сбое фонового обновления прежние данные остаются, а контекст предупреждает об их возможном устаревании. В режиме полного списка верхнее обновление сохраняет прежнее поведение. Выбор не распространяется на отдельные вкладки ресурсов/speed; общий URL-контракт вкладок и фильтров относится к этапу 7. + +Проверки: пять новых сценариев uptime-links passed, затем **18 связанных сценариев passed** (uptime-links/dashboard-data/collector-probes-tables/navigation). Проверены совпадающие имена, кодирование ID со слешем, отсутствие POST при переходе/чтении, payload ручного ping, сброс/возврат/reload, отсутствующий объект, 403/500, сохранение строки при 503 и восстановление. Мобильный блок контекста 390 просмотрен и не обрезан. У существующей широкой сетки строк Ping-проб на телефоне по-прежнему обрезается правая часть; исправление сетки и доступности её действий включить в этап 7, не считать весь экран мониторинга принятым по этой проверке. + +Production build успешен: контракты, TypeScript, 34 страницы. Lint нового теста и изменённого hook успешен. У uptime/page.tsx исходные одна ошибка react-hooks/set-state-in-effect в эффекте speedDraft и шесть предупреждений unused-vars — тот же набор подтверждён запуском ESLint на содержимом HEAD до изменений. Новых lint-ошибок нет; не заявлять чистый lint проекта. ReUI checklist получен в этом продолжении, используются установленный Alert и существующая композиция. Проверки на HTTP-моках, реальная сеть не проверена. + +Квота перед итоговой сборкой: использовано 55% пятичасовой и 40% недельной. Сохранить отдельный локальный коммит без push. **Следующий шаг этапа 6 — туннели GRE/WireGuard/VXLAN:** добавить сервер и ID объекта в href overlay, обработать оба значения на принимающих страницах, исключить подстановку другого устройства при отсутствующем ID. Проверить прямую ссылку, возврат, одинаковые имена/RouterOS ID на разных устройствах и отсутствие мутаций при открытии. Этап 6 остаётся открытым до этих переходов и приёмки. + ## Карта миграции (исходная инвентаризация этапа 0) Исходная связка на этапе 0: @tanstack/react-table ^8.21.3, @tanstack/react-virtual ^3.14.4, base-nova. По итогам этапа 4 используется @tanstack/react-table 9.2.4 с useTable/dataGridFeatures и дерево FilterQuery вместо старого filters/onChange. Список ниже сохранён как историческая инвентаризация. diff --git a/docs/ux-ui-stage-6.md b/docs/ux-ui-stage-6.md index ccc17114..55d24043 100644 --- a/docs/ux-ui-stage-6.md +++ b/docs/ux-ui-stage-6.md @@ -46,3 +46,7 @@ HTTP-моки проверяют интерфейс и запросы. Реал ## Состояние реализации Шапка и навигация завершены. Иерархия дашборда, подписи и полные счётчики проблем проверены. Остаются ссылки очереди на конкретные объекты и их приёмка на принимающих страницах. Общая регрессия текущего состояния: 65 сценариев passed; production build успешен. Подробнее — ux-ui-progress.md. + +### Переходы к объектам + +Готовы и проверены: /servers?server= и /uptime?probe=. Идентификаторы кодируются encodeURIComponent, целевые страницы выбирают точный ID и сообщают об отсутствии/ошибке загрузки. Снятие выбора сохраняет остальные query-параметры. Ещё не реализованы ссылки на конкретные GRE/WireGuard/VXLAN; для них нужен контекст сервера вместе с ID объекта. diff --git a/hooks/use-dashboard-live.ts b/hooks/use-dashboard-live.ts index f3c43f23..3be4c562 100644 --- a/hooks/use-dashboard-live.ts +++ b/hooks/use-dashboard-live.ts @@ -658,7 +658,7 @@ export function useDashboardLive() { id: p.id, title: p.name, hint: p.status === "warn" ? "Предупреждение" : "Ошибка", - href: "/uptime", + href: "/uptime?probe=" + encodeURIComponent(p.id), tone: p.status === "warn" ? ("warning" as const) : ("destructive" as const), })) }, [enabledProbes]) diff --git a/tests/e2e/uptime-links.spec.ts b/tests/e2e/uptime-links.spec.ts new file mode 100644 index 00000000..6c6bddfc --- /dev/null +++ b/tests/e2e/uptime-links.spec.ts @@ -0,0 +1,85 @@ +import { test, expect, type Page } from "@playwright/test" + +async function setup(page: Page, url: string, failed = 0) { + const control = { failed, posts: [] as { path: string; body: unknown }[] } + const servers = [{ id: 1, name: "Источник", host: "192.0.2.1", type: "exit-node", enabled: true, country: "RU", site: "TEST", asn: "64512", status: "online", latency: 1, os: "7", model: "CHR", wanUplinks: [] }] + const probes = ["p1", "p/2"].map(id => ({ id, srcServerId: "1", name: "Проверка сети", target: "192.0.2.10", filter: "main", rtt: null, loss: 100, status: "down", series: [0], enabled: true, showOnDashboard: false })) + await page.route("**/health", route => route.fulfill({ json: { ok: true } })) + await page.route("**/api/**", route => { + const request = route.request() + const path = new URL(request.url()).pathname + if (request.method() === "POST") control.posts.push({ path, body: request.postDataJSON() }) + if (path === "/api/uptime/overview" && control.failed) return route.fulfill({ status: control.failed, json: { error: "Мониторинг недоступен" } }) + const json = path === "/api/auth/config" ? { required: false } + : path === "/api/evobgp/settings" ? { enabled: false, baseUrl: "", secretConfigured: false } + : path === "/api/servers" ? servers + : path === "/api/uptime/overview" ? { probes, resources: [] } + : path === "/api/uptime/speed-probes" ? { probes: [] } + : path === "/api/uptime/speed-test/runs" ? { runs: [] } + : path === "/api/bgp/sessions" ? [] + : path === "/api/filters/gre-tunnels" || path === "/api/vxlan" ? { tunnels: [] } + : path === "/api/wireguard" ? { interfaces: [], peers: [] } + : path === "/api/traffic/servers" ? { servers: [] } + : path === "/api/events" ? { events: [] } + : path === "/api/internet-path/latest" ? { snapshot: { sampledAt: "2026-09-20T00:00:00Z", servers, greTunnels: [], filtersRulesets: [], speedProbes: [], routeLookupByServerId: {}, wanRuntimeByHomeId: {} } } + : {} + return route.fulfill({ json }) + }) + await page.goto(url) + return control +} + +test("очередь проверок: точный ID, чтение без ping, ручной ping только выбранной проверки", async ({ page }) => { + const control = await setup(page, "/dashboard") + const queue = page.getByRole("region", { name: "Требует внимания", exact: true }) + const link = queue.locator('a[href="/uptime?probe=p%2F2"]') + await expect(link).toBeVisible() + await link.click() + await expect(page).toHaveURL(/\/uptime\?probe=p%2F2$/) + const context = page.getByRole("status", { name: "Проверка по ссылке" }) + await expect(context).toContainText("Выбрана проверка: Проверка сети") + await expect(page.locator("[data-probe-id]")).toHaveCount(1) + await expect(page.locator('[data-probe-id="p/2"]')).toBeVisible() + expect(control.posts).toEqual([]) + await page.getByRole("button", { name: "Обновить данные", exact: true }).click() + await expect(context).toContainText("Список ограничен") + expect(control.posts).toEqual([]) + await page.getByRole("button", { name: "Обновить", exact: true }).click() + await expect.poll(() => control.posts).toEqual([{ path: "/api/uptime/probes/collect-group", body: { probeIds: ["p/2"] } }]) + await context.getByRole("link", { name: "К дашборду", exact: true }).click() + await expect(queue).toBeVisible() +}) + +test("проверка по прямой ссылке: сброс, возврат, перезагрузка и частичный сбой", async ({ page }) => { + const control = await setup(page, "/uptime?probe=p%2F2&keep=1") + const context = page.getByRole("status", { name: "Проверка по ссылке" }) + await expect(context).toContainText("Выбрана проверка") + await context.getByRole("link", { name: "Все проверки", exact: true }).click() + await expect(page).toHaveURL(/\/uptime\?keep=1$/) + await expect(page.locator("[data-probe-id]")).toHaveCount(2) + await page.goBack() + await expect(page.locator("[data-probe-id]")).toHaveCount(1) + await page.reload() + await expect(context).toContainText("Выбрана проверка") + control.failed = 503 + await page.getByRole("button", { name: "Обновить данные", exact: true }).click() + await expect(context).toContainText("сохранённые данные могут быть устаревшими") + await expect(page.locator('[data-probe-id="p/2"]')).toBeVisible() + control.failed = 0 + await page.getByRole("button", { name: "Обновить данные", exact: true }).click() + await expect(context).toContainText("Список ограничен") + await page.setViewportSize({ width: 390, height: 844 }) + await context.scrollIntoViewIfNeeded() + const box = await context.boundingBox() + expect(box!.x + box!.width).toBeLessThanOrEqual(390) + await page.screenshot({ path: "test-results/uptime-link-mobile.png", animations: "disabled" }) +}) + +for (const failed of [0, 403, 500]) { + test(`недоступная проверка: ${failed || "не найден ID"}`, async ({ page }) => { + const control = await setup(page, "/uptime?probe=missing", failed) + await expect(page.getByRole("status", { name: "Проверка по ссылке" })).toContainText(failed ? "Не удалось обновить данные проверки" : "Проверка не найдена или недоступна") + await expect(page.locator("[data-probe-id]")).toHaveCount(0) + expect(control.posts).toEqual([]) + }) +}