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()
+})