diff --git a/app/(main)/containers/page.tsx b/app/(main)/containers/page.tsx index cf5a08a3..cda03d14 100644 --- a/app/(main)/containers/page.tsx +++ b/app/(main)/containers/page.tsx @@ -1,6 +1,8 @@ "use client" -import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react" +import { useSearchParams } from "next/navigation" +import { readContainersUrl, patchContainersUrl, type ContainerStatusFilter } from "@/lib/containers-url-state" import { PageHeader } from "@/components/page-header" import { routerContainers as mockContainers, servers as mockServers } from "@/lib/data" import type { RouterContainer, Server } from "@/lib/data" @@ -98,6 +100,7 @@ function ExportSheet({ selection, onClose }: {selection: ContainerExport | null; return {selection?.source === "mock" ? "Демонстрационные данные." : "Чтение RouterOS: " + (selection?.collectedAt ? new Date(selection.collectedAt).toLocaleString("ru-RU") : "время неизвестно")}{selection?.failures.length ? " Настройки получены частично; ошибки включены в JSON." : ""}

} + returnFocus={() => Array.from(document.querySelectorAll("button[aria-label]")).find(button => button.getAttribute("aria-label") === "Действия контейнера " + selection?.container.name) ?? null} formats={formats} /> } @@ -240,7 +243,8 @@ function ContainerCard({ ) } -export default function ContainersPage() { +export default function ContainersPage() { return Загрузка контекста контейнеров…

}>
} +function ContainersSource() { const { mode, backendUrl, prefsHydrated } = useDataSource() if (!prefsHydrated) return

Загрузка настроек контейнеров…

return @@ -251,16 +255,35 @@ function ContainersContent() { const canRead = can("mm:dashboard:read") const canReadServers = can("mm:servers:read") const canWrite = can("mm:servers:write") - const [search, setSearch] = useState("") - const [statusFilter, setStatusFilter] = useState("all") + const params = useSearchParams() + const context = readContainersUrl(params) + const search = context.search + const statusFilter = context.status + const selectedServerId = context.server + const urlError = context.error + function writeContext(changes: Record, replace = false) { + const href = patchContainersUrl(new URLSearchParams(window.location.search), changes) + window.location.hash + if (replace) window.history.replaceState(null, "", href); else window.history.pushState(null, "", href) + } + const setSearch = (value: string) => writeContext({ q: value }, true) + const setStatusFilter = (value: ContainerStatusFilter) => writeContext({ status: value === "all" ? null : value }) + const setSelectedServerId = (value: string) => writeContext({ server: value === ALL_SERVERS_ID ? null : value }) const [exportContainer, setExportContainer] = useState(null) - const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID) const [state, setState] = useState(null) const [lastStates, setLastStates] = useState>({}) const [liveServers, setLiveServers] = useState([]) const [loading, setLoading] = useState(isLive && (canRead || canReadServers)) const [busyId, setBusyId] = useState(null) const [deleteTarget, setDeleteTarget] = useState(null) + const deleteReturnLabel = useRef(null) + useEffect(() => { + if (deleteTarget || busyId || !deleteReturnLabel.current) return + const frame = requestAnimationFrame(() => { + const target = Array.from(document.querySelectorAll("button[aria-label]")).find(button => button.getAttribute("aria-label") === deleteReturnLabel.current) + ;(target ?? Array.from(document.querySelectorAll("button")).find(button => button.textContent?.trim() === "Обновить"))?.focus() + }) + return () => cancelAnimationFrame(frame) + }, [deleteTarget, busyId]) const [operationError, setOperationError] = useState(null) const [operationNotice, setOperationNotice] = useState(null) const [commandNeedsRead, setCommandNeedsRead] = useState(false) @@ -309,7 +332,7 @@ function ContainersContent() { const relevantStates = state?.servers.filter(node => selectedServerId === ALL_SERVERS_ID || String(node.serverId) === selectedServerId) const known = !isLive || !!state && (selectedServerId === ALL_SERVERS_ID || !!relevantStates?.length) && relevantStates!.every(node => node.availability !== "error") const stale = isLive && !!liveError - const writeReady = !isLive || canWrite && !commandNeedsRead && !loading && !stale && !!serversReadAt && !serversError + const writeReady = !isLive || canWrite && !urlError && !commandNeedsRead && !loading && !stale && !!serversReadAt && !serversError const nodeFresh = (serverId: string) => !isLive || writeReady && state?.servers.some(node => String(node.serverId) === serverId && node.availability === "available" && displayServers.some(server => server.id === serverId)) const scoped = useMemo(() => { if (effectiveServerId === ALL_SERVERS_ID) return displayContainers @@ -391,6 +414,7 @@ function ContainersContent() { selectedId={effectiveServerId} onSelect={setSelectedServerId} showAll + preserveSelection allCount={displayServers.length} loading={isLive && loading && displayServers.length === 0} header={ @@ -418,6 +442,7 @@ function ContainersContent() { >
+ {urlError && {urlError}} {operationNotice &&

{operationNotice}

} {operationError && !deleteTarget && {operationError}} {isLive && !canRead &&

Для каталога контейнеров требуется mm:dashboard:read.

} @@ -529,7 +554,7 @@ function ContainersContent() { onStart={() => { void mutate(c, "start") }} onStop={() => { void mutate(c, "stop") }} onRestart={() => { void mutate(c, "restart") }} - onRemove={() => { setOperationError(null); setDeleteTarget(c) }} + onRemove={() => { deleteReturnLabel.current = "Действия контейнера " + c.name; setOperationError(null); setDeleteTarget(c) }} /> ))}
diff --git a/components/reui-kit/code-export-sheet.tsx b/components/reui-kit/code-export-sheet.tsx index cc66565e..15c25992 100644 --- a/components/reui-kit/code-export-sheet.tsx +++ b/components/reui-kit/code-export-sheet.tsx @@ -1,6 +1,6 @@ "use client" -import { useEffect, useMemo, useState, type ReactNode } from "react" +import { useMemo, useState, type ReactNode } from "react" import { Alert, AlertDescription, @@ -29,6 +29,7 @@ import { TriangleAlertIcon, } from "lucide-react" import { cn } from "@/lib/utils" +import { toast } from "sonner" export type CodeExportFormat = { id: string @@ -88,6 +89,7 @@ export interface CodeExportSheetProps { liveLabel?: string className?: string /** Extra content under format tabs (e.g. meta strip) */ + returnFocus?: () => HTMLElement | null beforeCode?: ReactNode } @@ -108,20 +110,18 @@ function CodeExportSheet({ liveLabel = "С роутера", className, beforeCode, + returnFocus, }: CodeExportSheetProps) { const firstId = formats[0]?.id ?? "default" - const [tab, setTab] = useState(initialFormatId ?? firstId) - const [copied, setCopied] = useState(false) - - useEffect(() => { - if (!open) return - setTab( - initialFormatId && formats.some((f) => f.id === initialFormatId) - ? initialFormatId - : firstId, - ) - setCopied(false) - }, [open, initialFormatId, firstId, formats]) + const initialTab = initialFormatId && formats.some(format => format.id === initialFormatId) ? initialFormatId : firstId + const resetKey = JSON.stringify([open, initialTab]) + const [previousKey, setPreviousKey] = useState(resetKey) + const [tab, setTab] = useState(initialTab) + const [copiedCode, setCopiedCode] = useState(null) + if (previousKey !== resetKey) { + setPreviousKey(resetKey) + if (open) { setTab(initialTab); setCopiedCode(null) } + } const active = useMemo( () => formats.find((f) => f.id === tab) ?? formats[0], @@ -129,23 +129,26 @@ function CodeExportSheet({ ) const code = active?.code ?? "" + const copied = !!code && copiedCode === code const emptyMessage = active?.emptyMessage const filename = active?.filename ?? "export.txt" const canCopy = Boolean(code) && !emptyMessage const isLive = Boolean(active?.isLive) const showTabs = formats.length > 1 - function handleCopy() { + async function handleCopy() { if (!canCopy) return - void navigator.clipboard.writeText(code).then(() => { - setCopied(true) - setTimeout(() => setCopied(false), 1800) - }) + setCopiedCode(null) + try { + await navigator.clipboard.writeText(code) + setCopiedCode(code) + setTimeout(() => setCopiedCode(null), 1800) + } catch { setCopiedCode(null); toast.error("Не удалось скопировать. Повторите действие или скачайте файл.") } } return ( { if (!v) onClose() }}> - params.getAll(key).length>1)) error="Параметры контекста контейнеров повторяются." + if(server!==null && (!/^[1-9]\d*$/.test(server)||!Number.isSafeInteger(Number(server)))) error="В ссылке указан неверный сервер." + if(status!==null&&!statuses.includes(status))error="В ссылке указан неизвестный статус." + if(search.length>200)error="Поиск в ссылке слишком длинный." + return {server:server??"all",status:(status??"all") as ContainerStatusFilter,search,error} +} +export function patchContainersUrl(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 "/containers"+(next.size?"?"+next.toString():"") +} diff --git a/tests/e2e/containers-url.spec.ts b/tests/e2e/containers-url.spec.ts new file mode 100644 index 00000000..e389cb2f --- /dev/null +++ b/tests/e2e/containers-url.spec.ts @@ -0,0 +1,24 @@ +import {test,expect,type Page} from "@playwright/test" +import {container,server,state} from "./fixtures/containers" +test.beforeEach(async({page})=>{await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",r=>r.fulfill({json:new URL(r.request().url()).pathname==="/api/auth/config"?{required:false}:new URL(r.request().url()).pathname==="/api/servers"?[server]:state()}))}) +for(const query of ["server=bad","server=1&server=2","status=bad","q=a&q=b"])test("Контейнеры URL: ошибочный контекст "+query,async({page})=>{ + await page.goto("/containers?"+query+"&keep=map#detail");await expect(page.getByRole("alert",{name:"Контекст контейнеров",exact:true})).toBeVisible();await page.getByRole("button",{name:"Сбросить контекст",exact:true}).click();await expect(page).toHaveURL(/containers\?keep=map#detail$/);await expect(page.getByText(container.name,{exact:true})).toBeVisible() +}) +test("Контейнеры URL: фильтр, reload и Back сохраняют контекст",async({page})=>{ + await page.goto("/containers?server=1&status=running&q=container&keep=map#detail");await expect(page.getByText(container.name,{exact:true})).toBeVisible();await expect(page.getByRole("textbox",{name:"Поиск контейнеров"})).toHaveValue("container");const group=page.getByRole("group",{name:"Статус контейнеров"});await group.getByRole("button",{name:"Остановлен",exact:true}).click();await expect(page).toHaveURL(/status=stopped/);await expect(page.getByText("Измените фильтр или поиск",{exact:true})).toBeVisible();await page.goBack();await expect(page.getByText(container.name,{exact:true})).toBeVisible();await page.reload();await expect(page.getByRole("textbox",{name:"Поиск контейнеров"})).toHaveValue("container");await expect(group.getByRole("button",{name:"Работает",exact:true})).toHaveAttribute("aria-pressed","true");await expect(page).toHaveURL(/keep=map#detail$/) +}) +test("Контейнеры URL: недоступный сервер не заменяется всеми",async({page})=>{ + await page.goto("/containers?server=2&keep=map");await expect(page.getByRole("alert").filter({hasText:"Выбранный сервер недоступен"})).toBeVisible();await expect(page.getByText(container.name,{exact:true})).toHaveCount(0);await expect(page).toHaveURL(/server=2/) +}) +async function openExport(page:Page){await page.goto("/containers");await page.getByRole("button",{name:"Действия контейнера "+container.name}).click();await page.getByRole("menuitem",{name:"Снимок JSON",exact:true}).click();return page.getByRole("dialog")} +for(const absent of [false,true])test("Контейнеры: Clipboard отказ "+absent,async({page})=>{ + await page.addInitScript(absent=>{Object.defineProperty(navigator,"clipboard",{configurable:true,value:absent?undefined:{writeText:async()=>{throw new Error("denied")}}})},absent) + const dialog=await openExport(page);await dialog.getByRole("button",{name:"Копировать",exact:true}).click();await expect(page.getByText("Не удалось скопировать. Повторите действие или скачайте файл.",{exact:true})).toBeVisible();await expect(dialog.getByRole("button",{name:"Скопировано",exact:true})).toHaveCount(0);await expect(dialog.getByRole("button",{name:"Файл",exact:true})).toBeEnabled() +}) +test("Контейнеры: успешный Clipboard и возврат фокуса",async({page})=>{ + await page.addInitScript(()=>{Object.defineProperty(navigator,"clipboard",{configurable:true,value:{writeText:async(value:string)=>{(window as unknown as {copied:string}).copied=value}}})}) + const dialog=await openExport(page);await dialog.getByRole("button",{name:"Копировать",exact:true}).click();await expect(dialog.getByRole("button",{name:"Скопировано",exact:true})).toBeVisible();expect(await page.evaluate(()=>(window as unknown as {copied:string}).copied)).toContain(container.name);await dialog.getByRole("button",{name:"Закрыть",exact:true}).first().click();await expect(page.getByRole("button",{name:"Действия контейнера "+container.name})).toBeFocused() +}) +test("Контейнеры: отмена удаления возвращает фокус",async({page})=>{ + await page.goto("/containers");await page.getByRole("button",{name:"Действия контейнера "+container.name}).click();await page.getByRole("menuitem",{name:"Удалить",exact:true}).click();await page.getByRole("alertdialog").getByRole("button",{name:"Отмена",exact:true}).click();await expect(page.getByRole("button",{name:"Действия контейнера "+container.name})).toBeFocused() +})