From 0734679fd6028232aba8caedda2481ab82b30f43 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Wed, 23 Sep 2026 09:28:03 +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=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D1=8B?= =?UTF-8?q?=20=D1=80=D0=B5=D1=81=D1=83=D1=80=D1=81=D0=BE=D0=B2=20=D0=B2=20?= =?UTF-8?q?URL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/uptime/page.tsx | 10 ++++-- docs/ux-ui-progress.md | 10 +++++- docs/ux-ui-stage-7.md | 2 +- hooks/use-uptime-url-state.ts | 7 +++- lib/data-filters/uptime-url-state.test.ts | 9 +++++ lib/data-filters/uptime-url-state.ts | 9 +++++ tests/e2e/uptime-links.spec.ts | 44 +++++++++++++++++++++++ 7 files changed, 85 insertions(+), 6 deletions(-) diff --git a/app/(main)/uptime/page.tsx b/app/(main)/uptime/page.tsx index 6ba043f8..2f7cc737 100644 --- a/app/(main)/uptime/page.tsx +++ b/app/(main)/uptime/page.tsx @@ -871,8 +871,8 @@ function SortIcon({ k, sortKey, sortAsc }: { k: ResSortKey; sortKey: ResSortKey; type ResTypeFilter = "all" | "jump-host" | "exit-node" | "home-router" function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerResource[]; serversList: Server[]; liveApi?: boolean }) { - const [resSearch, setResSearch] = useState("") - const [typeFilter, setTypeFilter] = useState("all") + const { resource, setResourceSearch: setResSearch, setResourceType: setTypeFilter, resetResources } = useUptimeUrlState() + const { search: resSearch, type: typeFilter, error: resourceError } = resource const rows = useMemo((): UptimeResourceRow[] => resources.map((r) => { const hasData = r.hasData !== false @@ -903,6 +903,7 @@ function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerRe // Filtered + sorted const visible = useMemo(() => { + if (resourceError) return [] let list = rows if (typeFilter !== "all") list = list.filter(r => r.server.type === typeFilter) if (resSearch.trim()) { @@ -914,7 +915,7 @@ function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerRe ) } return list - }, [rows, typeFilter, resSearch]) + }, [rows, typeFilter, resSearch, resourceError]) function exportCsv() { const header = ["Сервер", "Тип", "Площадка", "CPU %", "RAM %", "RAM использ.", "RAM всего", "HDD %", "HDD использ.", "HDD всего", "Uptime", "Температура °C", "RouterOS"] @@ -1023,6 +1024,9 @@ function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerRe ]} /> + {resourceError && {resourceError}} + {(resSearch || typeFilter !== "all" || resourceError) && } + {/* ── Table ─────────────────────────────────────────────────────────── */} readUptimeUrl(new URLSearchParams(raw)), [raw]) + const resource = useMemo(() => readResourceUrl(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 @@ -24,6 +25,10 @@ export function useUptimeUrlState() { write({ servers: next.length ? JSON.stringify(next) : null }) } return { + resource, + setResourceSearch: (rq: string) => write({ rq }, true), + setResourceType: (type: ResourceTypeFilter) => write({ rtype: type === "all" ? null : type }), + resetResources: () => write({ rq: null, rtype: null }), 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, diff --git a/lib/data-filters/uptime-url-state.test.ts b/lib/data-filters/uptime-url-state.test.ts index 5ccce688..eb357e8e 100644 --- a/lib/data-filters/uptime-url-state.test.ts +++ b/lib/data-filters/uptime-url-state.test.ts @@ -15,3 +15,12 @@ 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') }) + +test("фильтры Resources независимы от Ping и валидируются", async () => { + const { readResourceUrl } = await import("./uptime-url-state") + const params = new URLSearchParams('tab=resources&q=ping&status=down&rq=CHR&rtype=exit-node') + assert.deepEqual(readResourceUrl(params), { search: 'CHR', type: 'exit-node', error: null }) + assert.equal(readUptimeUrl(params).search, 'ping') + for (const raw of ['rtype=unknown', 'rtype=exit-node&rtype=jump-host', 'rq=a&rq=b']) assert.ok(readResourceUrl(new URLSearchParams(raw)).error) + assert.equal(patchUptimeUrl(params, { rq: null, rtype: null }), '/uptime?tab=resources&q=ping&status=down') +}) diff --git a/lib/data-filters/uptime-url-state.ts b/lib/data-filters/uptime-url-state.ts index 14d0d12e..c8a177a2 100644 --- a/lib/data-filters/uptime-url-state.ts +++ b/lib/data-filters/uptime-url-state.ts @@ -35,3 +35,12 @@ export function patchUptimeUrl(params: URLSearchParams, changes: Record = Dispatch> + +export type ResourceTypeFilter = "all" | "jump-host" | "exit-node" | "home-router" +export function readResourceUrl(params: URLSearchParams) { + const raw = params.get("rtype") + const valid = raw === null || ["jump-host", "exit-node", "home-router"].includes(raw) + const error = !valid || ["rq", "rtype"].some(key => params.getAll(key).length > 1) + ? "В ссылке указаны неверные или повторяющиеся фильтры ресурсов." : null + return { search: params.get("rq") ?? "", type: (valid && raw !== null ? raw : "all") as ResourceTypeFilter, error } +} diff --git a/tests/e2e/uptime-links.spec.ts b/tests/e2e/uptime-links.spec.ts index 57bd09bd..4adda1bf 100644 --- a/tests/e2e/uptime-links.spec.ts +++ b/tests/e2e/uptime-links.spec.ts @@ -523,3 +523,47 @@ for (const invalid of ["tab=unknown", "status=bad", "servers=%7B%7D", "q=a&q=b"] expect(control.writes).toEqual([]) }) } + +async function resourceSetup(page: Page, extra = "") { + const control = await setup(page, '/uptime?tab=resources&probe=p1&keep=1&q=ping' + extra, 0, false, "mm:uptime:read") + await page.route("**/api/uptime/overview?*", route => route.fulfill({ json: { probes: [], resources: [{ serverId: "1", hasData: true, cpu: 10, cpuHistory: [10], ramUsed: 100, ramTotal: 1000, hddUsed: 10, hddTotal: 100, uptimeSeconds: 60, boardName: "CHR" }] } })) + await page.reload() + return control +} + +test("Resources URL: фильтры, смена вкладки, история и сброс сохраняют Ping-контекст", async ({ page }) => { + const control = await resourceSetup(page) + const rows = page.locator("tr[data-row-id]") + await expect(rows).toHaveCount(1) + const search = page.getByPlaceholder("Поиск по имени, площадке…") + await search.fill("Источник") + await page.getByRole("button", { name: "JumpHost", exact: true }).click() + await expect(rows).toHaveCount(0) + await expect(page).toHaveURL(url => url.searchParams.get("rtype") === "jump-host" && url.searchParams.get("rq") === "Источник") + await page.goBack() + await expect(rows).toHaveCount(1) + await page.goForward() + await expect(rows).toHaveCount(0) + await page.reload() + await expect(search).toHaveValue("Источник") + await expect(rows).toHaveCount(0) + await page.getByRole("button", { name: /Пинг-пробы/ }).click() + await expect(page.getByPlaceholder("Имя, IP-адрес, фильтр…")).toHaveValue("ping") + await page.getByRole("button", { name: /Ресурсы/ }).click() + await expect(search).toHaveValue("Источник") + await expect(rows).toHaveCount(0) + await page.getByRole("button", { name: "Сбросить фильтры ресурсов", exact: true }).click() + await expect(rows).toHaveCount(1) + await expect(page).toHaveURL(url => !url.searchParams.has("rq") && !url.searchParams.has("rtype") && url.searchParams.get("q") === "ping" && url.searchParams.get("probe") === "p1" && url.searchParams.get("keep") === "1") + expect(control.writes).toEqual([]) +}) + +for (const extra of ["&rtype=unknown", "&rq=a&rq=b"]) { + test(`Resources URL: неверные фильтры ${extra}`, async ({ page }) => { + await resourceSetup(page, extra) + await expect(page.getByRole("alert").filter({ hasText: "фильтры ресурсов" })).toBeVisible() + await expect(page.locator("tr[data-row-id]")).toHaveCount(0) + await page.getByRole("button", { name: "Сбросить фильтры ресурсов", exact: true }).click() + await expect(page.locator("tr[data-row-id]")).toHaveCount(1) + }) +}