fix(ui): сохранять вкладку и фильтры Ping в URL
This commit is contained in:
1 parent
f7e0e20673
commit
fd7a08d5dc
7 files changed
+157
-9
No files matched your search
@@ -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
@@ -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 отсекает ответы предыдущей операции/контекста, включая ошибки.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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>>
|
||||
@@ -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([])
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user