fix(ui): сохранить контекст и фокус контейнеров

This commit is contained in:
Denozordec committed 2026-10-01 11:13:29 +07:00
1 parent 3ceb8c038c
commit 71cc6fa3a7
12 files changed
+128 -27

No files matched your search

+32 -7
View File
@@ -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 <CodeExportSheet open={!!selection} onClose={onClose} title="Снимок контейнера" description="Данные каталога; конфигурация устройства и резервная копия в снимок не входят."
className="data-[side=right]:w-full data-[side=right]:sm:max-w-xl min-w-0 [&_[data-slot=sheet-footer]]:flex-wrap"
beforeCode={<p className="text-xs text-muted-foreground">{selection?.source === "mock" ? "Демонстрационные данные." : "Чтение RouterOS: " + (selection?.collectedAt ? new Date(selection.collectedAt).toLocaleString("ru-RU") : "время неизвестно")}{selection?.failures.length ? " Настройки получены частично; ошибки включены в JSON." : ""}</p>}
returnFocus={() => Array.from(document.querySelectorAll<HTMLButtonElement>("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 <Suspense fallback={<p role="status">Загрузка контекста контейнеров…</p>}><ContainersSource /></Suspense> }
function ContainersSource() {
const { mode, backendUrl, prefsHydrated } = useDataSource()
if (!prefsHydrated) return <p role="status">Загрузка настроек контейнеров…</p>
return <ContainersContent key={JSON.stringify([mode, backendUrl])} />
@@ -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<ContainerView["status"] | "all">("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<string, string | null>, 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<ContainerExport | null>(null)
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
const [state, setState] = useState<ContainersState | null>(null)
const [lastStates, setLastStates] = useState<Record<number, ContainerServerState>>({})
const [liveServers, setLiveServers] = useState<ContainerServer[]>([])
const [loading, setLoading] = useState(isLive && (canRead || canReadServers))
const [busyId, setBusyId] = useState<string | null>(null)
const [deleteTarget, setDeleteTarget] = useState<ContainerView | null>(null)
const deleteReturnLabel = useRef<string | null>(null)
useEffect(() => {
if (deleteTarget || busyId || !deleteReturnLabel.current) return
const frame = requestAnimationFrame(() => {
const target = Array.from(document.querySelectorAll<HTMLButtonElement>("button[aria-label]")).find(button => button.getAttribute("aria-label") === deleteReturnLabel.current)
;(target ?? Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find(button => button.textContent?.trim() === "Обновить"))?.focus()
})
return () => cancelAnimationFrame(frame)
}, [deleteTarget, busyId])
const [operationError, setOperationError] = useState<string | null>(null)
const [operationNotice, setOperationNotice] = useState<string | null>(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() {
>
<div className="flex flex-col min-w-0 gap-5 [overflow-wrap:anywhere]">
{urlError && <Alert role="alert" aria-label="Контекст контейнеров" variant="warning"><AlertDescription>{urlError}<Button type="button" variant="outline" onClick={() => writeContext({server:null,status:null,q:null})}>Сбросить контекст</Button></AlertDescription></Alert>}
{operationNotice && <p role="status" className="text-sm [overflow-wrap:anywhere]">{operationNotice}</p>}
{operationError && !deleteTarget && <Alert role="alert" aria-label="Команда контейнера" variant="warning"><AlertDescription>{operationError}</AlertDescription></Alert>}
{isLive && !canRead && <p role="status">Для каталога контейнеров требуется mm:dashboard:read.</p>}
@@ -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) }}
/>
))}
</div>