From fd7a08d5dc10366e516ecbad17a9d6f92c953fa8 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Wed, 23 Sep 2026 09:24:18 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D1=8F=D1=82=D1=8C=20=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D1=83?= =?UTF-8?q?=20=D0=B8=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D1=8B=20Ping?= =?UTF-8?q?=20=D0=B2=20URL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/uptime/page.tsx | 16 ++++---- docs/ux-ui-progress.md | 11 ++++- docs/ux-ui-stage-7.md | 2 +- hooks/use-uptime-url-state.ts | 33 +++++++++++++++ lib/data-filters/uptime-url-state.test.ts | 17 ++++++++ lib/data-filters/uptime-url-state.ts | 37 +++++++++++++++++ tests/e2e/uptime-links.spec.ts | 50 +++++++++++++++++++++++ 7 files changed, 157 insertions(+), 9 deletions(-) create mode 100644 hooks/use-uptime-url-state.ts create mode 100644 lib/data-filters/uptime-url-state.test.ts create mode 100644 lib/data-filters/uptime-url-state.ts diff --git a/app/(main)/uptime/page.tsx b/app/(main)/uptime/page.tsx index 3b2edddf..6ba043f8 100644 --- a/app/(main)/uptime/page.tsx +++ b/app/(main)/uptime/page.tsx @@ -4,6 +4,7 @@ import probeStyles from "./probe-rows.module.css" import { Suspense, useState, useMemo, useEffect, useRef, useCallback } from "react" import Link from "next/link" +import { useUptimeUrlState } from "@/hooks/use-uptime-url-state" import { serverOperationError } from "@/lib/server-operations" import { can } from "@/lib/auth" import { useSearchParams } from "next/navigation" @@ -1078,13 +1079,10 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId const [overviewState, setOverviewState] = useState<"loading" | "ready" | "error">("loading") const [allServers, setAllServers] = useState([]) - const [tab, setTab] = useState<"probes" | "resources" | "speed">("probes") + const { tab, setTab, search, setSearch, statusFilter, setStatusFilter, serverFilter, setServerFilter, error: urlError, resetFilters, resetUrl } = useUptimeUrlState() const [probes, setProbes] = useState([]) const [resources, setResources] = useState([]) const [isPaused, setIsPaused] = useState(false) - const [search, setSearch] = useState("") - const [statusFilter, setStatusFilter] = useState<"all" | "up" | "warn" | "down">("all") - const [serverFilter, setServerFilter] = useState([]) const [collapsed, setCollapsed] = useState>(new Set()) const [sheetOpen, setSheetOpen] = useState(false) const [opError, setOpError] = useState(null) @@ -1438,6 +1436,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId const selectedProbeServer = allServers.find(server => server.id === selectedProbe?.srcServerId) const filtered = useMemo(() => probes.filter(p => { + if (urlError) return false 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 @@ -1450,7 +1449,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId ) } return true - }), [probes, serverFilter, statusFilter, search, selectedProbeId]) + }), [probes, serverFilter, statusFilter, search, selectedProbeId, urlError]) const grouped = useMemo(() => { const byServer = new Map>() @@ -2023,6 +2022,8 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId {probeSaveError && !sheetOpen && !speedSheetOpen && !deleteTarget && {probeSaveError}} + {urlError && Параметры ссылки требуют внимания{urlError}} + {/* ── Live indicator ── */} {!isPaused && (
( )} diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 2dce231c..0c9dfe40 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -21,7 +21,7 @@ | 4 | Остальные таблицы, удаление старых API, общая регрессия | Завершён 2026-09-20; границы приёмки ниже | | 5 | Состояния данных, свежесть, частичные сбои, гонки polling | Завершён 2026-09-20 для дашборда; границы проверок ниже | | 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже | -| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | В работе: 7.1–7.2, права серверов и ограничения действий мониторинга выполнены; формы проб сохраняются после API; переключатели подтверждаются API; удаление и звезда подтверждаются API; Speed-test блокирует запись; Ping сериализован с записью; далее URL мониторинга | +| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | В работе: 7.1–7.2, права серверов и ограничения действий мониторинга выполнены; формы проб сохраняются после API; переключатели подтверждаются API; удаление и звезда подтверждаются API; Speed-test блокирует запись; Ping сериализован с записью; вкладка и Ping-фильтры в URL; далее Resources | | 8 | Трафик и статистика | Не начат | | 9 | VPN, Firewall и Filters | Не начат | | 10 | Маршрутизация, диагностика, карта и терминал | Не начат | @@ -29,6 +29,15 @@ | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 7.4В1 — URL вкладки и фильтров Ping (2026-09-23) + +- Новый codec lib/data-filters/uptime-url-state.ts и hook useUptimeUrlState делают URL источником вкладки и Ping-фильтров. tab=probes/resources/speed (по умолчанию probes), q=поиск, status=up/warn/down (отсутствует для всех), servers=JSON массива строковых ID. probe и посторонние параметры сохраняются. +- Поиск использует native replaceState, дискретные действия — pushState с чтением актуального window.location.search. Сброс Ping-фильтров атомарно удаляет q/status/servers, сохраняя tab/probe; восстановление ошибочной ссылки удаляет также tab. Back/Forward/reload восстанавливают контролы без POST/PUT/PATCH/DELETE. +- Проверяются допустимые tab/status, дубли одиночных параметров и формат servers (до 50 уникальных непустых ID длиной до 128, JSON до 8192 символов). Неизвестные ID не подменяются доступными. При ошибке показывается Alert и Ping-строки не выводятся до исправления/сброса. Фильтры Ping не применяются к Resources/Speed; их значения сохраняются при смене вкладки. +- Проверки: 3 unit-теста codec и все 43 E2E uptime-links прошли (6 новых URL-сценариев). TypeScript прошёл; новые файлы и тесты lint-clean, uptime — прежние 1 ошибка и 6 предупреждений. ReUI audit: композиция и установленные компоненты не менялись; выбранная вкладка/сервер имеют aria-pressed. Финальный production build прошёл: 34 страницы. +- Следующий шаг 7.4В2: отдельные параметры поиска и типа серверов Resources (сейчас локальный state), затем приёмка этапа 7. Состояние раскрытия графиков/групп и черновики форм в URL не помещались. Реальные JWT/RouterOS не проверялись. +- Квота после проверок: использовано 52% пятичасовой / 8% недельной. Коммит локальный, без push. + ## Блок 7.4Б3б — ручной Ping и таймаут Speed-test (2026-09-23) - collect-now и collect-group занимают общий savingRef до POST и удерживают его до обновления данных. Двойной запуск, CRUD и Speed-test не пересекаются в одном смонтированном экране. GET, запрошенный владельцем операции для обновления результатов, разрешён под блокировкой; фоновые GET пропускаются. saveContext отсекает ответы предыдущей операции/контекста, включая ошибки. diff --git a/docs/ux-ui-stage-7.md b/docs/ux-ui-stage-7.md index f45636a5..316050f3 100644 --- a/docs/ux-ui-stage-7.md +++ b/docs/ux-ui-stage-7.md @@ -61,7 +61,7 @@ URL — источник сохранённого состояния. Back/Forwa 3. **7.4 — формы, детали и полный контекст мониторинга.** Восстановление вкладки/фильтров из URL, понятные единицы и подписи, возврат к списку после действия. Не дублировать сохранённые исправления этапа 1; проверить загрузку, отмену, 400/403/500, timeout и повторное нажатие. 4. **7.5 — приёмка этапа.** Сценарий «проблема → сервер/проверка → действие → возврат» с сохранением контекста; профильные E2E, права в HTTP-моках, TypeScript, lint и build. Реальные JWT/RouterOS отдельно в тестовом окружении. -Следующий исполняемый шаг: полный URL-контекст мониторинга (вкладка, поиск, статус, серверы). Блок 7.4Б3б завершён: ручные Ping занимают общий gate с CRUD/Speed, Speed-test ограничен 120 секундами ожидания UI. Затем приёмка 7.5; реальные RouterOS/JWT, длительный Speed и смена backend/mode требуют отдельной проверки. Полную регрессию всей системы здесь не повторять без изменений общей основы или найденной регрессии. +Следующий исполняемый шаг: 7.4В2 — URL поиска и типа серверов Resources. Вкладка и Ping-фильтры (q/status/servers) уже сохраняются в URL; переходы сохраняют probe и чужие параметры. Затем приёмка 7.5 с учётом границ HTTP-моков и сетевых операций из журнала. ## Решение по синхронизации URL (7.1) diff --git a/hooks/use-uptime-url-state.ts b/hooks/use-uptime-url-state.ts new file mode 100644 index 00000000..7096e41f --- /dev/null +++ b/hooks/use-uptime-url-state.ts @@ -0,0 +1,33 @@ +"use client" + +import { useMemo } from "react" +import { useSearchParams } from "next/navigation" +import { readUptimeUrl, patchUptimeUrl, type UptimeTab, type ProbeStatusFilter, type UrlSetter } from "@/lib/data-filters/uptime-url-state" + +export function useUptimeUrlState() { + const raw = useSearchParams().toString() + const state = useMemo(() => readUptimeUrl(new URLSearchParams(raw)), [raw]) + function write(changes: Record, replace = false) { + const href = patchUptimeUrl(new URLSearchParams(window.location.search), changes) + window.location.hash + if (href === window.location.pathname + window.location.search + window.location.hash) return + if (replace) window.history.replaceState(null, "", href) + else window.history.pushState(null, "", href) + } + const setStatusFilter: UrlSetter = value => { + const current = readUptimeUrl(new URLSearchParams(window.location.search)).status + const next = typeof value === "function" ? value(current) : value + write({ status: next === "all" ? null : next }) + } + const setServerFilter: UrlSetter = value => { + const current = readUptimeUrl(new URLSearchParams(window.location.search)).servers + const next = typeof value === "function" ? value(current) : value + write({ servers: next.length ? JSON.stringify(next) : null }) + } + return { + tab: state.tab, search: state.search, statusFilter: state.status, serverFilter: state.servers, error: state.error, + setTab: (tab: UptimeTab) => write({ tab: tab === "probes" ? null : tab }), + setSearch: (q: string) => write({ q }, true), setStatusFilter, setServerFilter, + resetFilters: () => write({ q: null, status: null, servers: null }), + resetUrl: () => write({ tab: null, q: null, status: null, servers: null }), + } +} diff --git a/lib/data-filters/uptime-url-state.test.ts b/lib/data-filters/uptime-url-state.test.ts new file mode 100644 index 00000000..5ccce688 --- /dev/null +++ b/lib/data-filters/uptime-url-state.test.ts @@ -0,0 +1,17 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { readUptimeUrl, patchUptimeUrl } from "./uptime-url-state" + +test("вкладка и фильтры Ping восстанавливаются из ссылки", () => { + const state = readUptimeUrl(new URLSearchParams({ tab: "speed", q: "узел", status: "down", servers: '["1","x/2"]' })) + assert.deepEqual(state, { tab: "speed", search: "узел", status: "down", servers: ["1", "x/2"], error: null }) +}) +test("некорректные и повторяющиеся параметры дают ошибку", () => { + for (const raw of ['tab=unknown', 'status=online', 'q=a&q=b', 'servers={}', 'servers=[1]', 'servers=["1","1"]', 'servers=[""]']) { + assert.ok(readUptimeUrl(new URLSearchParams(raw)).error, raw) + } +}) +test("сброс оставляет пробу, вкладку и чужие параметры", () => { + const result = patchUptimeUrl(new URLSearchParams('probe=p%2F2&keep=1&tab=speed&q=a&status=down&servers=%5B%221%22%5D'), { q: null, status: null, servers: null }) + assert.equal(result, '/uptime?probe=p%2F2&keep=1&tab=speed') +}) diff --git a/lib/data-filters/uptime-url-state.ts b/lib/data-filters/uptime-url-state.ts new file mode 100644 index 00000000..14d0d12e --- /dev/null +++ b/lib/data-filters/uptime-url-state.ts @@ -0,0 +1,37 @@ +import type { Dispatch, SetStateAction } from "react" + +export type UptimeTab = "probes" | "resources" | "speed" +export type ProbeStatusFilter = "all" | "up" | "warn" | "down" +export function readUptimeUrl(params: URLSearchParams) { + const rawTab = params.get("tab") + const rawStatus = params.get("status") + const search = params.get("q") ?? "" + const tab: UptimeTab = rawTab === "resources" || rawTab === "speed" ? rawTab : "probes" + const status: ProbeStatusFilter = rawStatus === "up" || rawStatus === "warn" || rawStatus === "down" ? rawStatus : "all" + let error: string | null = null + let servers: string[] = [] + if ((rawTab !== null && !["probes", "resources", "speed"].includes(rawTab)) || + (rawStatus !== null && !["up", "warn", "down"].includes(rawStatus)) || + ["tab", "q", "status", "servers"].some(key => params.getAll(key).length > 1)) error = "В ссылке указаны неверные или повторяющиеся параметры мониторинга." + const rawServers = params.get("servers") + if (rawServers !== null) { + try { + if (rawServers.length > 8192) throw new Error() + const value: unknown = JSON.parse(rawServers) + if (!Array.isArray(value) || value.length > 50 || !value.every(id => typeof id === "string" && id.length > 0 && id.length <= 128) || new Set(value).size !== value.length) throw new Error() + servers = value + } catch { error = "Не удалось прочитать выбранные серверы из ссылки." } + } + return { tab, search, status, servers, error } +} + +export function patchUptimeUrl(params: URLSearchParams, changes: Record) { + const next = new URLSearchParams(params) + for (const [key, value] of Object.entries(changes)) { + if (value === null || value === "") next.delete(key) + else next.set(key, value) + } + return "/uptime" + (next.size ? "?" + next.toString() : "") +} + +export type UrlSetter = Dispatch> diff --git a/tests/e2e/uptime-links.spec.ts b/tests/e2e/uptime-links.spec.ts index 8a393e33..57bd09bd 100644 --- a/tests/e2e/uptime-links.spec.ts +++ b/tests/e2e/uptime-links.spec.ts @@ -473,3 +473,53 @@ test("ручной Ping: поздняя ошибка выбранной проб await expect(page.getByText("Ошибка прежнего контекста", { exact: true })).toHaveCount(0) await expect(page.getByRole("button", { name: "Новая проба", exact: true })).toBeEnabled() }) + +test("URL мониторинга: вкладка, поиск, reload и история без записи", async ({ page }) => { + const control = await setup(page, "/uptime?probe=p1&keep=1", 0, false, "mm:uptime:read") + const search = page.getByPlaceholder("Имя, IP-адрес, фильтр…") + await search.fill("нет совпадений") + await expect(page.locator("[data-probe-id]")).toHaveCount(0) + await page.getByRole("button", { name: /Скорость/ }).click() + await expect(page).toHaveURL(url => url.searchParams.get("tab") === "speed" && url.searchParams.get("q") === "нет совпадений" && url.searchParams.get("probe") === "p1") + await page.reload() + await expect(page.getByRole("button", { name: /Скорость/ })).toHaveAttribute("aria-pressed", "true") + await page.goBack() + await expect(search).toHaveValue("нет совпадений") + await expect(page.locator("[data-probe-id]")).toHaveCount(0) + await page.goForward() + await expect(page.getByRole("button", { name: /Скорость/ })).toHaveAttribute("aria-pressed", "true") + await page.getByRole("button", { name: /Пинг-пробы/ }).click() + await page.getByRole("button", { name: "Сбросить", exact: true }).click() + await expect(page).toHaveURL(/\/uptime\?probe=p1&keep=1$/) + await expect(page.locator("[data-probe-id]")).toHaveCount(1) + expect(control.writes).toEqual([]) +}) + +test("URL мониторинга: статус и неизвестный сервер не расширяют выдачу", async ({ page }) => { + const control = await setup(page, '/uptime?probe=p1&keep=1&status=down&servers=%5B%22999%22%5D', 0, false, "mm:uptime:read") + await expect(page.locator("[data-probe-id]")).toHaveCount(0) + await page.getByRole("button", { name: "Сбросить", exact: true }).click() + await expect(page.locator("[data-probe-id]")).toHaveCount(1) + await page.getByRole("button", { name: /^OK/ }).click() + await expect(page).toHaveURL(url => url.searchParams.get("status") === "up") + await expect(page.locator("[data-probe-id]")).toHaveCount(0) + await page.goBack() + await expect(page.locator("[data-probe-id]")).toHaveCount(1) + await page.locator('button[aria-pressed]').filter({ hasText: "Источник" }).click() + await expect(page).toHaveURL(url => url.searchParams.get("servers") === '["1"]') + await page.reload() + await expect(page.locator('button[aria-pressed]').filter({ hasText: "Источник" })).toHaveAttribute("aria-pressed", "true") + expect(control.writes).toEqual([]) +}) + +for (const invalid of ["tab=unknown", "status=bad", "servers=%7B%7D", "q=a&q=b"]) { + test(`URL мониторинга: неверный параметр ${invalid}`, async ({ page }) => { + const control = await setup(page, "/uptime?probe=p1&keep=1&" + invalid) + await expect(page.getByRole("alert").filter({ hasText: "Параметры ссылки требуют внимания" })).toBeVisible() + await expect(page.locator("[data-probe-id]")).toHaveCount(0) + await page.getByRole("button", { name: "Сбросить параметры мониторинга", exact: true }).click() + await expect(page).toHaveURL(/\/uptime\?probe=p1&keep=1$/) + await expect(page.locator("[data-probe-id]")).toHaveCount(1) + expect(control.writes).toEqual([]) + }) +}