From 668c7c68237cda85afc3bce9f2fdfff8acf6626e Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sun, 20 Sep 2026 16:51:28 +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=8B=D0=B9=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=20=D0=B8?= =?UTF-8?q?=D0=B7=20=D0=BE=D1=87=D0=B5=D1=80=D0=B5=D0=B4=D0=B8=20=D0=BF?= =?UTF-8?q?=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)/servers/page.tsx | 46 ++++++++++++++++++++++++------ docs/ux-ui-progress.md | 12 ++++++++ hooks/use-dashboard-live.ts | 2 +- tests/e2e/dashboard-data.spec.ts | 12 +++++++- tests/e2e/servers.spec.ts | 48 ++++++++++++++++++++++++++++++-- 5 files changed, 106 insertions(+), 14 deletions(-) diff --git a/app/(main)/servers/page.tsx b/app/(main)/servers/page.tsx index 45e3c89b..d7eb78c7 100644 --- a/app/(main)/servers/page.tsx +++ b/app/(main)/servers/page.tsx @@ -1,6 +1,8 @@ "use client" -import { useEffect, useMemo, useRef, useState } from "react" +import { Suspense, useEffect, useMemo, useRef, useState } from "react" +import Link from "next/link" +import { useSearchParams } from "next/navigation" import { serverCreateSchema } from "@mmapp/contracts/servers" import { OperationGate, pollServers, serverOperationError, type PollResult } from "@/lib/server-operations" import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert" @@ -218,12 +220,17 @@ type TypeFilter = "all" | ServerType type SheetMode = "add" | "edit" // ════════════════════════════════════════════════════════════════════════════ -export default function ServersPage() { +function ServersPageContent() { + const searchParams = useSearchParams() + const selectedServerId = searchParams.get("server") + const clearParams = new URLSearchParams(searchParams.toString()) + clearParams.delete("server") + const allServersHref = "/servers" + (clearParams.size ? "?" + clearParams : "") const { mode, backendUrl } = useDataSource() const isLive = mode === "live" const [serverList, setServerList] = useState([]) - const [_backendOk, setBackendOk] = useState(false) + const [listState, setListState] = useState<"loading" | "ready" | "error">("loading") const [search, setSearch] = useState("") const [typeFilter, setTypeFilter] = useState("all") const [advancedFilters, setAdvancedFilters] = useState(() => createFilterQuery()) @@ -283,25 +290,26 @@ export default function ServersPage() { if (cancelled) return formVersion.current++; testVersion.current++ setOpen(false); setDeleteTarget(null); setOperationError(""); setOperationMessage(""); setPollResults([]) + setListState("loading") }) if (mode !== "live") { queueMicrotask(() => { if (cancelled) return setServerList(initialServers) - setBackendOk(false) + setListState("ready") }) return invalidate } listServers(backendUrl) .then(data => { if (cancelled || version !== contextVersion.current) return - setBackendOk(true) + setListState("ready") setServerList(data.map(toFrontendServer)) }) .catch((error) => { if (cancelled || version !== contextVersion.current) return setOperationError(serverOperationError(error)) - setBackendOk(false) + setListState("error") setServerList([]) }) return invalidate @@ -550,15 +558,18 @@ export default function ServersPage() { // ── derived ────────────────────────────────────────────────────────────── + const selectedServer = serverList.find(server => server.id === selectedServerId) + const filtered = useMemo(() => { const base = serverList.filter(sv => { + if (selectedServerId !== null && sv.id !== selectedServerId) return false if (typeFilter !== "all" && sv.type !== typeFilter) return false if (!search) return true const q = search.toLowerCase() return sv.name.toLowerCase().includes(q) || sv.host.includes(q) || sv.site.toLowerCase().includes(q) }) return applyFilterQuery(base, advancedFilters, SERVER_FILTER_ACCESSORS) - }, [serverList, search, typeFilter, advancedFilters]) + }, [serverList, search, typeFilter, advancedFilters, selectedServerId]) const counts = useMemo(() => ({ all: serverList.length, @@ -585,8 +596,8 @@ export default function ServersPage() { crumbs={[{ label: "Управление" }, { label: "Серверы" }]} actions={ <> - @@ -609,6 +620,19 @@ export default function ServersPage() { )} + {selectedServerId !== null && ( + + {selectedServer ? "Выбран сервер: " + selectedServer.name : "Сервер по ссылке"} + +

{listState === "loading" ? "Загрузка сервера…" : listState === "error" ? "Не удалось загрузить сервер. Проверьте сообщение об ошибке выше." : selectedServer ? selectedServer.host + " · Список ограничен выбранным сервером. Сводка относится ко всем серверам." : "Сервер не найден или недоступен. Возможно, он удалён или у вас нет доступа."}

+
+ Все серверы + К дашборду +
+
+
+ )} + ) } + +export default function ServersPage() { + return Загрузка серверов…

}>
+} diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index e4add406..71233072 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -411,6 +411,18 @@ useDashboardLive больше не обрезает массивы пробле Границы: текущий mapper дашборда по-прежнему трактует null server.status как offline; неизвестные состояния устройств требуют отдельного исправления и проверки. Квота при завершении общей регрессии и запуске build: использовано 65% пятичасовой и 26% недельной. Сохранить блок локальным коммитом без push; изменения skills/AGENTS/.codex не включать. +## Этап 6 — блок 4: переход к серверу (2026-09-20) + +Очередь серверов дашборда формирует `/servers?server=`. Страница серверов читает параметр в Suspense, ограничивает таблицу точным ID и показывает контекст перед общей сводкой. «Все серверы» снимает только параметр server, сохраняя остальные query-параметры; «К дашборду» и браузерный возврат доступны. Прямой URL и перезагрузка сохраняют выбор. При отсутствующем ID или 403/500 другой сервер не подставляется. Состояния загрузки, ошибки получения и отсутствующего объекта разделены. + +При выбранном сервере верхняя кнопка «Проверить сервер» опрашивает только его через существующую очередь операций. Без параметра сохраняется прежний массовый опрос. API, права и правила мутаций не менялись. Контекст использует установленный ReUI Alert; состав общей сводки явно указан. Полная синхронизация поиска/Filters/форм с URL остаётся этапу 7. + +Проверки: 27 профильных сценариев servers/dashboard passed; после переноса контекста над сводкой повторно пройден сценарий выбора/опроса/возврата и просмотрен мобильный экран 390. ESLint изменённых файлов успешен. Production build прошёл: контракты, TypeScript, 34 страницы. HTTP-фикстура дашборда дополнена и проверяется serverReadSchema: переход на сервер использует строгий API-контракт. Реальный RouterOS не проверялся. + +Окружение: предыдущий запуск остановился из-за исчерпанной квоты автоматической проверки разрешений. После сброса обнаружен dev-кеш Next.js, возвращавший 404 для существующего маршрута; .next/dev сохранён как .next/dev-before-server-links, чистый кеш устранил ошибку, пять URL-сценариев и вся профильная регрессия прошли. Это игнорируемые сборочные артефакты, не исходники. + +Квота нового окна: 1% использовано на старте, 26% при запуске итоговой сборки. Блок сохранить отдельным локальным коммитом без push. Этап 6 открыт: далее `/uptime?probe=` и обработка выбранной проверки, затем туннели с идентификатором устройства и объекта. + ## Карта миграции (исходная инвентаризация этапа 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/hooks/use-dashboard-live.ts b/hooks/use-dashboard-live.ts index 3e70f891..f3c43f23 100644 --- a/hooks/use-dashboard-live.ts +++ b/hooks/use-dashboard-live.ts @@ -634,7 +634,7 @@ export function useDashboardLive() { id: s.id, title: s.name, hint: s.status === "degraded" ? "Ухудшение" : "Не в сети", - href: "/servers", + href: "/servers?server=" + encodeURIComponent(s.id), tone: s.status === "degraded" ? ("warning" as const) : ("destructive" as const), })) }, [servers]) diff --git a/tests/e2e/dashboard-data.spec.ts b/tests/e2e/dashboard-data.spec.ts index 457f3092..9aed57ba 100644 --- a/tests/e2e/dashboard-data.spec.ts +++ b/tests/e2e/dashboard-data.spec.ts @@ -1,11 +1,15 @@ import { test, expect, type Page } from "@playwright/test" +import { serverReadSchema } from "../../packages/contracts/src/servers" async function setup(page: Page, initiallyFailed = false, serverCount = 1) { const control = { failed: initiallyFailed, partial: false, prefixes: 321, hold: false, holdPath: "/api/bgp/sessions", fallback: false, failFallback: false, pending: 0, release: () => {} } let release!: () => void const gate = new Promise(resolve => { release = resolve }) control.release = release - const servers = Array.from({ length: serverCount }, (_, index) => ({ id: index + 1, name: serverCount === 1 ? "Узел" : `Узел ${index + 1}`, host: `192.0.2.${index + 1}`, site: "TEST", country: "RU", asn: "64512", type: "exit-node", enabled: true, status: serverCount === 1 ? "online" : "offline", latency: 10, os: "7", model: "CHR" })) + const servers = Array.from({ length: serverCount }, (_, index) => ({ id: index + 1, name: serverCount === 1 ? "Узел" : `Узел ${index + 1}`, host: `192.0.2.${index + 1}`, site: "TEST", country: "RU", asn: "64512", type: "exit-node", enabled: true, status: serverCount === 1 ? "online" : "offline", latency: 10, os: "7", model: "CHR" })).map(row => serverReadSchema.parse({ + port: 443, useSsl: true, verifySsl: true, username: "test", password: "fixture-only", comment: "", lanSubnet: "", wanUplinks: [], + createdAt: "2026-09-20T00:00:00Z", updatedAt: "2026-09-20T00:00:00Z", uptime: null, cpuLoad: null, freeMemory: null, totalMemory: null, identityName: null, sessions: 0, polledAt: null, ...row, + })) const snapshot = { sampledAt: "2026-09-20T00:00:00Z", servers, greTunnels: [], filtersRulesets: [], speedProbes: [], routeLookupByServerId: {}, wanRuntimeByHomeId: {} } await page.route("**/health", route => route.fulfill({ json: { ok: true } })) await page.route("**/api/**", async route => { @@ -62,6 +66,12 @@ test("Дашборд: очередь над измерениями, полный await page.getByRole("navigation", { name: "Путь страницы" }).scrollIntoViewIfNeeded() await page.screenshot({ path: "test-results/dashboard-priority-mobile.png", animations: "disabled" }) expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true) + await serverLinks.first().click() + await expect(page).toHaveURL(/\/servers\?server=1$/) + await expect(page.getByRole("status", { name: "Сервер по ссылке" })).toContainText("Выбран сервер: Узел 1") + await expect(page.locator("tr[data-row-id]")).toHaveCount(1) + await page.goBack() + await expect(queue).toBeVisible() }) test("Дашборд: первоначальная ошибка не подменяется нулями и исправным состоянием", async ({ page }) => { diff --git a/tests/e2e/servers.spec.ts b/tests/e2e/servers.spec.ts index 8f9fb30b..d772eb6f 100644 --- a/tests/e2e/servers.spec.ts +++ b/tests/e2e/servers.spec.ts @@ -14,7 +14,7 @@ function server(id: number): ServerRead { }) } -async function setup(page: Page, intercept?: (route: Route, path: string) => Promise, count = 2) { +async function setup(page: Page, intercept?: (route: Route, path: string) => Promise, count = 2, url = "/servers") { const rows = Array.from({ length: count }, (_, i) => server(i + 1)) await page.route("**/health", route => route.fulfill({ json: { ok: true } })) await page.route("**/api/**", async route => { @@ -36,8 +36,8 @@ async function setup(page: Page, intercept?: (route: Route, path: string) => Pro } await route.fulfill({ json: body }) }) - await page.goto("/servers") - await expect(page.getByText("Проверка 1", { exact: true })).toBeVisible() + await page.goto(url) + if (url === "/servers") await expect(page.getByText("Проверка 1", { exact: true })).toBeVisible() } async function edit(page: Page, id = 1) { @@ -45,6 +45,48 @@ async function edit(page: Page, id = 1) { await page.getByRole("menuitem", { name: "Редактировать", exact: true }).click() } +test("ссылка на сервер: выбор, опрос одного устройства, сброс и возврат", async ({ page }) => { + const calls: string[] = [] + await setup(page, async (route, path) => { + if (!path.endsWith("/poll")) return false + calls.push(path) + await route.fulfill({ json: {} }) + return true + }, 2, "/servers?server=2&keep=1") + const context = page.getByRole("status", { name: "Сервер по ссылке" }) + await expect(context).toContainText("Выбран сервер: Проверка 2") + await expect(page.locator("tr[data-row-id]")).toHaveCount(1) + await page.getByRole("button", { name: "Проверить сервер", exact: true }).click() + await expect(page.getByText("Опрос: успешно 1 из 1", { exact: true })).toBeVisible() + expect(calls).toEqual(["/api/servers/2/poll"]) + await context.getByRole("link", { name: "Все серверы", exact: true }).click() + await expect(page).toHaveURL(/\/servers\?keep=1$/) + await expect(page.locator("tr[data-row-id]")).toHaveCount(2) + await page.goBack() + await expect(context).toContainText("Выбран сервер: Проверка 2") + await expect(page.locator("tr[data-row-id]")).toHaveCount(1) + await page.reload() + await expect(context).toContainText("Выбран сервер: Проверка 2") + await page.setViewportSize({ width: 390, height: 844 }) + await context.scrollIntoViewIfNeeded() + await page.screenshot({ path: "test-results/server-link-mobile.png", animations: "disabled" }) + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true) +}) + +for (const apiStatus of [200, 403, 500]) { + test(`ссылка на отсутствующий сервер: ответ ${apiStatus} не выбирает другое устройство`, async ({ page }) => { + await setup(page, async (route, path) => { + if (path !== "/api/servers" || apiStatus === 200) return false + await route.fulfill({ status: apiStatus, json: { error: "Список недоступен" } }) + return true + }, 2, "/servers?server=999") + const context = page.getByRole("status", { name: "Сервер по ссылке" }) + await expect(context).toContainText(apiStatus === 200 ? "Сервер не найден или недоступен" : "Не удалось загрузить сервер") + await expect(page.locator("tr[data-row-id]")).toHaveCount(0) + await expect(page.getByRole("button", { name: "Проверить сервер", exact: true })).toBeDisabled() + }) +} + test("таблица v9 сохраняет все строки, сортировку и раскрытие деталей", async ({ page }) => { await setup(page, undefined, 12) const rows = page.locator('tr[data-row-id]')