fix(ui): сохранить контекст и фокус контейнеров
This commit is contained in:
1 parent
3ceb8c038c
commit
71cc6fa3a7
12 files changed
+128
-27
No files matched your search
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user