fix(ui): сохранять вкладку и фильтры Ping в URL

This commit is contained in:
Denozordec committed 2026-09-23 09:24:18 +07:00
1 parent f7e0e20673
commit fd7a08d5dc
7 files changed
+157 -9

No files matched your search

+9 -7
View File
@@ -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<Server[]>([])
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<PingProbe[]>([])
const [resources, setResources] = useState<ServerResource[]>([])
const [isPaused, setIsPaused] = useState(false)
const [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState<"all" | "up" | "warn" | "down">("all")
const [serverFilter, setServerFilter] = useState<string[]>([])
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
const [sheetOpen, setSheetOpen] = useState(false)
const [opError, setOpError] = useState<string | null>(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<string, Map<string, { name: string; target: string; probes: PingProbe[] }>>()
@@ -2023,6 +2022,8 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
{probeSaveError && !sheetOpen && !speedSheetOpen && !deleteTarget && <ContextAlert variant="destructive" role="alert"><ContextDescription>{probeSaveError}</ContextDescription></ContextAlert>}
{urlError && <ContextAlert variant="warning" role="alert"><ContextTitle>Параметры ссылки требуют внимания</ContextTitle><ContextDescription>{urlError}<Button type="button" variant="outline" size="sm" onClick={resetUrl}>Сбросить параметры мониторинга</Button></ContextDescription></ContextAlert>}
{/* ── Live indicator ── */}
{!isPaused && (
<div className="flex items-center gap-2 px-6 py-1.5 border-b shrink-0 text-[11px]"
@@ -2048,6 +2049,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
{(["probes", "resources", "speed"] as const).map(t => (
<button
key={t}
type="button" aria-pressed={tab === t}
onClick={() => setTab(t)}
className={cn(
"px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px",
@@ -2408,7 +2410,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
const sProbes = probes.filter(p => p.srcServerId === s.id)
const hasIssues = sProbes.some(p => p.status !== "up")
return (
<button key={s.id} onClick={() => toggleServerFilter(s.id)}
<button key={s.id} type="button" aria-pressed={active} onClick={() => toggleServerFilter(s.id)}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all",
active
@@ -2442,7 +2444,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
{(search || statusFilter !== "all" || serverFilter.length > 0) && (
<Button variant="ghost" size="sm" className="text-muted-foreground h-8 px-2"
onClick={() => { setSearch(""); setStatusFilter("all"); setServerFilter([]) }}>
onClick={resetFilters}>
<XIcon className="size-3.5" />Сбросить
</Button>
)}
+10 -1
View File
@@ -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 отсекает ответы предыдущей операции/контекста, включая ошибки.
+1 -1
View File
@@ -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)
+33
View File
@@ -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<string, string | null>, 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<ProbeStatusFilter> = 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<string[]> = 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 }),
}
}
+17
View File
@@ -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')
})
+37
View File
@@ -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<string, string | null>) {
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<T> = Dispatch<SetStateAction<T>>
+50
View File
@@ -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([])
})
}