fix(ui): ограничить каталог бэкапов и вернуть фокус действий

This commit is contained in:
Denozordec committed 2026-10-04 05:01:29 +07:00
1 parent 5d9a6f3757
commit 611e542ea7
11 files changed
+123 -35

No files matched your search

+23 -3
View File
@@ -74,6 +74,24 @@ function BackupsContent() {
const canWrite = isLive && can("mm:backups:write")
const canReadServers = can("mm:servers:read")
const [resources, setResources] = useState<Record<Resource, ResourceState>>(() => ({ backups: unknownResource(), servers: unknownResource(), schedule: unknownResource(), storage: unknownResource() }))
const dialogTrigger = useRef<{ element: HTMLElement; rowId: string | null; label: string | null } | null>(null)
const refreshTrigger = useRef<HTMLButtonElement | null>(null)
const captureDialogFocus = () => {
const element = document.activeElement
dialogTrigger.current = element instanceof HTMLElement
? { element, rowId: element.closest("[data-row-id]")?.getAttribute("data-row-id") ?? null, label: element.getAttribute("aria-label") }
: null
}
const returnDialogFocus = () => {
const trigger = dialogTrigger.current
if (trigger?.element.isConnected) return trigger.element
// Cell renderers can remount when operation state changes; resolve the same row/action.
if (trigger?.rowId && trigger.label) {
const replacement = document.querySelector<HTMLElement>(`[data-row-id="${CSS.escape(trigger.rowId)}"] button[aria-label="${CSS.escape(trigger.label)}"]`)
if (replacement) return replacement
}
return refreshTrigger.current
}
const reader = useRef<AbortController | null>(null)
const revision = useRef(0)
const alive = useRef(true)
@@ -403,7 +421,7 @@ function BackupsContent() {
{!canRead && <p role="status">Нет права mm:backups:read</p>}
{isLive && !canReadServers && <p role="status">Каталог устройств недоступен без mm:servers:read.</p>}
{isLive && canRead && <div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<Button type="button" variant="outline" size="sm" onClick={() => void loadLive()} disabled={loading || scheduleSaveBusy || opBusy}>Обновить</Button>
<Button type="button" variant="outline" size="sm" ref={refreshTrigger} onClick={() => void loadLive()} disabled={loading || scheduleSaveBusy || opBusy}>Обновить</Button>
<span role="status">{loading ? "Обновление данных…" : resources.backups.receivedAt ? "Получено: " + new Date(resources.backups.receivedAt).toLocaleString("ru-RU") : "Резервные копии пока неизвестны"}</span>
</div>}
{isLive && (Object.keys(resources) as Resource[]).filter(resource => resources[resource].error).map(resource => <Alert key={resource} variant="warning" aria-label={"Данные резервных копий: " + resource}>
@@ -493,8 +511,8 @@ function BackupsContent() {
filters={advancedFilters}
onFiltersChange={setAdvancedFilters}
onDownload={handleDownload}
onRestore={backup => { setOpError(null); setRestoreTarget(backup) }}
onDelete={backup => { setOpError(null); setDeleteTarget(backup) }}
onRestore={backup => { captureDialogFocus(); setOpError(null); setRestoreTarget(backup) }}
onDelete={backup => { captureDialogFocus(); setOpError(null); setDeleteTarget(backup) }}
onCreate={() => openCreate(false)}
canCreate={canCreate}
canWrite={canWrite && !opBusy && !scheduleSaveBusy && !backupJobId}
@@ -578,6 +596,7 @@ function BackupsContent() {
/>
<BackupDeleteDialog
returnFocus={returnDialogFocus}
backup={deleteTarget}
busy={opBusy}
error={opError}
@@ -585,6 +604,7 @@ function BackupsContent() {
onCancel={() => setDeleteTarget(null)}
/>
<BackupRestoreDialog
returnFocus={returnDialogFocus}
backup={restoreTarget}
busy={opBusy}
blocked={!restoreGuard.canStart}
@@ -21,16 +21,18 @@ export function BackupDeleteDialog({
error,
onConfirm,
onCancel,
returnFocus,
}: {
backup: Backup | null
busy: boolean
error?: string | null
onConfirm: () => void
onCancel: () => void
returnFocus?: () => HTMLElement | null
}) {
return (
<AlertDialog open={Boolean(backup)} onOpenChange={(v) => { if (!v && !busy) onCancel() }}>
<AlertDialogContent>
<AlertDialogContent finalFocus={returnFocus}>
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive">
<AlertCircleIcon />
@@ -61,6 +63,7 @@ export function BackupRestoreDialog({
error,
onConfirm,
onCancel,
returnFocus,
}: {
backup: Backup | null
busy: boolean
@@ -68,10 +71,11 @@ export function BackupRestoreDialog({
error?: string | null
onConfirm: () => void
onCancel: () => void
returnFocus?: () => HTMLElement | null
}) {
return (
<AlertDialog open={Boolean(backup)} onOpenChange={(v) => { if (!v && !busy) onCancel() }}>
<AlertDialogContent>
<AlertDialogContent finalFocus={returnFocus}>
<AlertDialogHeader>
<AlertDialogMedia className="bg-warning/10 text-warning">
<TriangleAlertIcon />
+25 -20
View File
@@ -1,8 +1,8 @@
"use client"
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
import { useMemo, type ReactNode } from "react"
import { useEffect, useMemo, type ReactNode } from "react"
import {
type ColumnDef,
useTable,
@@ -10,9 +10,9 @@ import {
import type { Backup } from "@/lib/data"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/reui/badge"
import { IconTile } from "@/components/reui/icon-tile"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DEFAULT_DATA_GRID_LAYOUT,
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
@@ -20,7 +20,6 @@ import {
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { EmptyState } from "@/components/empty-state"
import {
CloudIcon,
DownloadIcon,
HardDriveIcon,
RefreshCwIcon,
@@ -63,20 +62,12 @@ function BackupsDataGrid({
{
id: "filename",
accessorKey: "filename",
size: 280,
size: 110,
header: ({ column }) => <DataGridSortHeader column={column} title="Файл" />,
cell: ({ row }) => {
const b = row.original
const inS3 = b.storage === "s3" || b.storage === "both"
return (
<div className="flex items-center gap-3 min-w-0">
<IconTile
variant="elevated"
className={inS3 ? "size-10.5 shrink-0 text-info" : "size-10.5 shrink-0 text-muted-foreground"}
aria-hidden="true"
>
{inS3 ? <CloudIcon /> : <HardDriveIcon />}
</IconTile>
<div className="min-w-0">
<span title={b.filename} className="font-mono text-xs font-medium block break-all">{b.filename}</span>
{b.uploadError ? (
@@ -190,12 +181,12 @@ function BackupsDataGrid({
cell: ({ row }) => {
const b = row.original
return (
<div className="flex items-center gap-1 justify-end opacity-0 transition-opacity group-hover/row:opacity-100 focus-within:opacity-100">
<div className="flex items-center gap-1 justify-end sm:opacity-0 transition-opacity group-hover/row:opacity-100 focus-within:opacity-100">
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
className="size-11 min-w-[44px] min-h-[44px] sm:size-7 sm:min-w-0 sm:min-h-0"
title="Скачать"
aria-label={`Скачать ${b.filename}`}
disabled={!canDownload}
@@ -207,7 +198,7 @@ function BackupsDataGrid({
type="button"
variant="ghost"
size="icon"
className="size-7"
className="size-11 min-w-[44px] min-h-[44px] sm:size-7 sm:min-w-0 sm:min-h-0"
title="Восстановить"
aria-label={`Восстановить ${b.filename}`}
disabled={!canWrite || !canRestore || !b.serverId}
@@ -219,7 +210,7 @@ function BackupsDataGrid({
type="button"
variant="ghost"
size="icon"
className="size-7 text-destructive hover:text-destructive"
className="size-11 min-w-[44px] min-h-[44px] sm:size-7 sm:min-w-0 sm:min-h-0 text-destructive hover:text-destructive"
title="Удалить"
aria-label={`Удалить ${b.filename}`}
disabled={!canWrite}
@@ -231,7 +222,7 @@ function BackupsDataGrid({
)
},
enableSorting: false,
size: 120,
size: 176,
meta: {
headerClassName: DATA_GRID_CELL_PAD_LAST,
cellClassName: DATA_GRID_CELL_PAD_LAST,
@@ -243,12 +234,20 @@ function BackupsDataGrid({
const table = useTable({
features: dataGridFeatures,
manualPagination: true,
manualPagination: false,
autoResetPageIndex: false,
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["filename"], end: [] } },
data: backups,
columns,
getRowId: (row) => row.id,
})
const { pageIndex, pageSize } = table.state.pagination
useEffect(() => {
const lastPage = Math.max(0, Math.ceil(backups.length / pageSize) - 1)
if (pageIndex > lastPage) table.setPageIndex(lastPage)
}, [backups.length, pageSize, pageIndex, table])
if (backups.length === 0) {
return (
<EmptyState
@@ -261,7 +260,13 @@ function BackupsDataGrid({
)
}
return <DataGridShell table={table} recordCount={backups.length} />
return <DataGridShell table={table} recordCount={backups.length}
pagination={backups.length > 50 || pageSize !== 50}
tableLayout={{ ...DEFAULT_DATA_GRID_LAYOUT, columnsPinnable: true }}>
<DataGridContainer border={false} className="w-full rounded-none border-0">
<DataGridScrollArea orientation="horizontal"><DataGridTable /></DataGridScrollArea>
</DataGridContainer>
</DataGridShell>
}
export { BackupsDataGrid, type BackupsDataGridProps }
+3 -2
View File
@@ -19,7 +19,6 @@
| Файл | Следующая проверка |
| --- | --- |
| [backups-data-grid.tsx](../components/data-grids/backups-data-grid.tsx) | Потребитель API, контекст/детали и fixture1000 |
| [communities-data-grid.tsx](../components/data-grids/communities-data-grid.tsx) | Потребитель API, контекст/детали и fixture1000 |
| [data-collection-scheduler-data-grid.tsx](../components/data-grids/data-collection-scheduler-data-grid.tsx) | UI строится из11 SCHEDULER_JOB_KEYS; fixture1000 не соответствует источнику |
| [filters-data-grid.tsx](../components/data-grids/filters-data-grid.tsx) | Порядок/drag и операции до ограничения DOM |
@@ -42,7 +41,7 @@
| [wireguard-data-grid.tsx](../components/data-grids/wireguard-data-grid.tsx) | Потребитель API, контекст/детали и fixture1000 |
| [wireguard-peers-grid.tsx](../components/data-grids/wireguard-peers-grid.tsx) | Потребитель API, контекст/детали и fixture1000 |
Осталось22 специализированных grids; ни один из оставшегося перечня не принят этим документом на больших данных. CompactDataGrid дополнительно проверен на повторном получении100 active probes: страница сохраняется; сокращение100→1 ограничивает индекс. Speed probes и uptime resources приняты в блоках12.3.7–12.3.9 и исключены из оставшегося перечня. Следующие большие профили: probes-schedule, затем сетевые/admin grids с учётом границ API. Общий журнал: [ux-ui-progress.md](ux-ui-progress.md).
Осталось21 специализированных grids; ни один из оставшегося перечня не принят этим документом на больших данных. CompactDataGrid дополнительно проверен на повторном получении100 active probes: страница сохраняется; сокращение100→1 ограничивает индекс. Speed probes и uptime resources приняты в блоках12.3.7–12.3.9 и исключены из оставшегося перечня. Следующие большие профили: probes-schedule, затем сетевые/admin grids с учётом границ API. Общий журнал: [ux-ui-progress.md](ux-ui-progress.md).
12.3.6:1000 speed probes baseline —1000 rendered rows/23590 DOM nodes/3741ms dev ready; профиль collector-probes4 cases и ESLint прошли. Далее12.3.7 ограничение DOM speed grid; этот baseline не означает принятую производительность.
@@ -71,3 +70,5 @@ Statistics pivot в backend/src/services/statistics-aggregate.ts огранич
12.3.17: закреплённое имя и значения полностью видимы на320px после корректировки размеров; pivot action при focus не перекрывается.29 browser/axe/typecheck/build прошли. Certificate final50 rows/2145 nodes; pinning ограничивает положение колонок, не число загружаемых DTO.
12.3.18:users1000/263680 JSON bytes —1000→50 rows/37505→2393 DOM nodes/921ms dev ready. Whole-catalog name/login/email search, global sort/page20/refresh/details/error recovery/1000→1/правильный пользователь в форме/320px/axe проверены;32 browser/type/lint/build. Поиск не сохраняется в URL; полные DTO остаются в памяти.
12.3.19:backups1000/171812 JSON bytes —1000→50 rows/43580→2462 DOM nodes/898ms dev ready. Whole-catalog search/Filters/URL/reload/page20/refresh/503 cache/1000→1/правильный filename/возврат focus/320px/axe приняты;39+4 browser/type/lint/build. Полный DB список ещё загружается, производительность PG не измерялась.
+1 -1
View File
@@ -4,7 +4,7 @@
## Актуальная точка продолжения — 2026-10-04
Этап 11Б завершён в границах локальной приёмки (11Б.7); внешний выпуск не проверен. 12.1 принят локально: доступность оболочки/клавиатура/health в описанных сценариях. 12.2.1 принят локально: strict BGP catalog/partial/nullable counters. 12.2.2 принят локально: strict traffic DTO и агрегация без выдуманных точек. 12.2.3 принят локально: observations для server traffic от backend до dashboard. 12.2.4 принят локально: strict servers/probes и unknown server status. 12.2.5 принят локально: реальные ping samples/unknown/ось RTT/Recharts. 12.2.6 принят локально: strict GRE/WireGuard/VXLAN и device failures. 12.2.7 принят локально: strict Internet path snapshot/fallback. 12.2.8 принят локально: подтверждение полного backend сбора. 12.2.9 принят локально: метрики участков и наблюдаемый WAN без эвристической подмены. 12.3.1 принят локально:1000 серверов/штатная пагинация. 12.3.2 принят локально: пагинация CompactDataGrid/1000 строк статистики. 12.3.3 принят локально: порционный DOM очереди проблем. 12.3.4 принят локально: большой BGP каталог/страница при обновлении. Production build и123 профильных browser приняты локально на3978976. 12.2.10 принят локально: обязательные RouterOS поля. 12.3.5 принят локально: страница CompactDataGrid сохраняется при опросе,89 browser и повторный production build прошли. 12.3.6 сохранён: baseline1000 speed probes (ограничение DOM пока не выполнено). 12.3.7 принят локально:50 строк speed-проб/сохранение страницы/глобальная сортировка/1000→1; каталог1001 устройства остаётся в DOM. 12.3.8 принят локально:50 строк ресурсов, URL/поиск/сохранение страницы/1000→1; исправлена pagination оболочки с children, production build и8 больших browser прошли. 12.3.9 принят локально:порционное раскрытие1000 предупреждений ресурсов/клавиатура/axe; исходная ошибка ESLint speedDraft подтверждена. 12.3.10 принят локально:недоступные интерфейсы очищаются в обработчиках,54 browser/полный ESLint/production build прошли. 12.3.11 принят локально:ServerTileRail50 устройств/URL/поиск/клавиатура/две площадки/mobile Sheet/axe обе темы;47 browser и production build прошли. 12.3.12 принят локально:BGP ручная страница при выбранной URL сессии/refresh/reload,16 browser/build прошли. 12.4.1 записан:main1091 сценарий/1088 passed/3 устаревших fixtures; оба исправленных файла6 passed. 12.4.2 принят локально:context205/24 файла и unit28 прошли. 12.3.13 принят локально:wide pivot51×16/ScrollArea/axe/24+2 browser/builds. 12.3.14 принят локально:history200/50 DOM/54+1 browser/build. 12.3.15 принят локально:live расписание1000/page20 error cache/15 browser. 12.3.16 принят локально:certificates1000/50 DOM/refresh деталей/19+3 browser/build. 12.3.17 принят локально:pinning идентификатора/видимость значений/29 browser/build. 12.3.18 принят локально:users1000/поиск всего списка/детали и ошибка refresh/32 browser/build. Следующий блок12.3.19:backups1000, затем network grids и отдельная достоверность history;12.4/12.5 ещё не завершены. См. docs/ux-ui-stage-12.md. Внешние ограничения 11Б сохраняются.
Этап 11Б завершён в границах локальной приёмки (11Б.7); внешний выпуск не проверен. 12.1 принят локально: доступность оболочки/клавиатура/health в описанных сценариях. 12.2.1 принят локально: strict BGP catalog/partial/nullable counters. 12.2.2 принят локально: strict traffic DTO и агрегация без выдуманных точек. 12.2.3 принят локально: observations для server traffic от backend до dashboard. 12.2.4 принят локально: strict servers/probes и unknown server status. 12.2.5 принят локально: реальные ping samples/unknown/ось RTT/Recharts. 12.2.6 принят локально: strict GRE/WireGuard/VXLAN и device failures. 12.2.7 принят локально: strict Internet path snapshot/fallback. 12.2.8 принят локально: подтверждение полного backend сбора. 12.2.9 принят локально: метрики участков и наблюдаемый WAN без эвристической подмены. 12.3.1 принят локально:1000 серверов/штатная пагинация. 12.3.2 принят локально: пагинация CompactDataGrid/1000 строк статистики. 12.3.3 принят локально: порционный DOM очереди проблем. 12.3.4 принят локально: большой BGP каталог/страница при обновлении. Production build и123 профильных browser приняты локально на3978976. 12.2.10 принят локально: обязательные RouterOS поля. 12.3.5 принят локально: страница CompactDataGrid сохраняется при опросе,89 browser и повторный production build прошли. 12.3.6 сохранён: baseline1000 speed probes (ограничение DOM пока не выполнено). 12.3.7 принят локально:50 строк speed-проб/сохранение страницы/глобальная сортировка/1000→1; каталог1001 устройства остаётся в DOM. 12.3.8 принят локально:50 строк ресурсов, URL/поиск/сохранение страницы/1000→1; исправлена pagination оболочки с children, production build и8 больших browser прошли. 12.3.9 принят локально:порционное раскрытие1000 предупреждений ресурсов/клавиатура/axe; исходная ошибка ESLint speedDraft подтверждена. 12.3.10 принят локально:недоступные интерфейсы очищаются в обработчиках,54 browser/полный ESLint/production build прошли. 12.3.11 принят локально:ServerTileRail50 устройств/URL/поиск/клавиатура/две площадки/mobile Sheet/axe обе темы;47 browser и production build прошли. 12.3.12 принят локально:BGP ручная страница при выбранной URL сессии/refresh/reload,16 browser/build прошли. 12.4.1 записан:main1091 сценарий/1088 passed/3 устаревших fixtures; оба исправленных файла6 passed. 12.4.2 принят локально:context205/24 файла и unit28 прошли. 12.3.13 принят локально:wide pivot51×16/ScrollArea/axe/24+2 browser/builds. 12.3.14 принят локально:history200/50 DOM/54+1 browser/build. 12.3.15 принят локально:live расписание1000/page20 error cache/15 browser. 12.3.16 принят локально:certificates1000/50 DOM/refresh деталей/19+3 browser/build. 12.3.17 принят локально:pinning идентификатора/видимость значений/29 browser/build. 12.3.18 принят локально:users1000/поиск всего списка/детали и ошибка refresh/32 browser/build. 12.3.19 принят локально:backups1000/все мобильные действия/возврат focus/39+4 browser/build. Следующий блок12.3.20:достоверность speed history, затем network grids и финальная матрица12.4;12.5 отдельно. См. docs/ux-ui-stage-12.md. Внешние ограничения 11Б сохраняются.
Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md).
+8
View File
@@ -36,6 +36,14 @@
## 12.3.19 — резервные копии1000 и возврат фокуса (2026-10-04)
GET /backups/listBackups возвращает весь каталог без DB cap. Fixture1000 проходит backupItemDtoSchema,171812 JSON bytes. Baseline1000 rows/43580 DOM nodes/2626ms dev ready; финально50 rows/2462 nodes/898ms. Client pagination50/autoResetPageIndex=false/clamp, полный search/Filters до pagination, штатный ReUI horizontal ScrollArea и pinning filename. Проверены page20/refresh/503 cache/recovery, весь поиск с URL/reload, числовая глобальная сортировка,1000→1, отсутствие запросов записи,320px и tagged axe.
На телефоне действия постоянно видимы и имеют явный минимум44px; rem-based size-11 при root font-size проекта давал около40px. Имя110/actions176 сохраняют видимость всех трёх кнопок в реальном viewport266px внутри Frame. Декоративная иконка хранения удалена из имени: статус остаётся в отдельной колонке. Воспроизведена потеря focus после отмены: cell renderer remount менял элемент. Штатный finalFocus разрешает тот же стабильный row/action, при удалении строки fallback на Обновить; проверены оба диалога и правильный filename после sort.
После финального production кода39 из40 browser прошли, narrow layout прерван Windows UNKNOWN open historical PNG. Backups layout и dialog layout теперь пишут info.outputPath, assertions сохранены; повтор layout+large4 passed. TypeScript/ESLint и production contracts/frontend34 страницы прошли. Мобильный снимок просмотрен. Осталось21 manualPagination:true файла; это не21 доказанный дефект. Следующий блок12.3.20: строгая достоверность speed history и независимые состояния чтения, затем network grids/финальная матрица12.4;12.5 внешняя приёмка отдельно. Коммит локальный, без push.
## 12.3.18 — каталог пользователей1000 и поиск по всему списку (2026-10-04)
Strict appUserReadSchema fixture1000/263680 JSON bytes. Baseline1000 строк/37505 DOM nodes/2174ms dev ready; после client pagination50:50 строк/2393 nodes/921ms. Поиск по имени, логину и email добавлен через общую DataPageToolbar, фильтрует весь каталог. KPI и данные формы остаются полными; поиск локальный, без URL persistence. Stable ID/autoResetPageIndex=false/autoResetExpanded=false и clamp сохраняют страницу и детали при refresh/503 и корректно сокращают1000→1.
+8
View File
@@ -289,3 +289,11 @@ Strict appUserReadSchema fixture1000/263680 JSON bytes. Baseline1000 строк/
Штатные ReUI ScrollArea и pinning имени180/actions106 обеспечивают полностью видимые действия на320px; декоративный avatar скрыт на телефоне. Проверены page20, клавиатура/детали, ошибка и recovery, глобальные поиск и сортировка, правильный пользователь в форме после sort, возврат focus, отсутствие POST, reflow и tagged axe.32 browser users-data/layout/operations/large-users прошли; TypeScript и ESLint без ошибок. Мобильный снимок просмотрен. Production build прошёл:34 страницы и contracts.
Следующий блок12.3.19: backups1000, проверка фактического источника/пагинации/поиска/refresh/действий/320px. Осталось22 manualPagination:true файла, включая ограниченные и demo источники. Достоверность speed history и финальная матрица12.4 остаются; внешняя приёмка12.5 отдельно. Коммит локальный, без push.
## 12.3.19 — резервные копии1000 и возврат фокуса (2026-10-04)
GET /backups/listBackups возвращает весь каталог без DB cap. Fixture1000 проходит backupItemDtoSchema,171812 JSON bytes. Baseline1000 rows/43580 DOM nodes/2626ms dev ready; финально50 rows/2462 nodes/898ms. Client pagination50/autoResetPageIndex=false/clamp, полный search/Filters до pagination, штатный ReUI horizontal ScrollArea и pinning filename. Проверены page20/refresh/503 cache/recovery, весь поиск с URL/reload, числовая глобальная сортировка,1000→1, отсутствие запросов записи,320px и tagged axe.
На телефоне действия постоянно видимы и имеют явный минимум44px; rem-based size-11 при root font-size проекта давал около40px. Имя110/actions176 сохраняют видимость всех трёх кнопок в реальном viewport266px внутри Frame. Декоративная иконка хранения удалена из имени: статус остаётся в отдельной колонке. Воспроизведена потеря focus после отмены: cell renderer remount менял элемент. Штатный finalFocus разрешает тот же стабильный row/action, при удалении строки fallback на Обновить; проверены оба диалога и правильный filename после sort.
После финального production кода39 из40 browser прошли, narrow layout прерван Windows UNKNOWN open historical PNG. Backups layout и dialog layout теперь пишут info.outputPath, assertions сохранены; повтор layout+large4 passed. TypeScript/ESLint и production contracts/frontend34 страницы прошли. Мобильный снимок просмотрен. Осталось21 manualPagination:true файла; это не21 доказанный дефект. Следующий блок12.3.20: строгая достоверность speed history и независимые состояния чтения, затем network grids/финальная матрица12.4;12.5 внешняя приёмка отдельно. Коммит локальный, без push.
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

+3 -3
View File
@@ -1,6 +1,6 @@
import { test, expect } from "@playwright/test"
import { backup, setupBackups } from "./fixtures/backups"
for (const [name, width] of [["desktop", 1440], ["mobile", 390], ["narrow", 320]] as const) test("Бэкапы: длинный файл и ошибки диалогов/" + name, async ({ page }) => {
for (const [name, width] of [["desktop", 1440], ["mobile", 390], ["narrow", 320]] as const) test("Бэкапы: длинный файл и ошибки диалогов/" + name, async ({ page }, info) => {
await page.setViewportSize({ width, height: 900 }); await setupBackups(page)
const filename = "a".repeat(130) + ".rsc"
await page.route("**/api/backups", r => r.fulfill({ json: [{ ...backup, filename }] }))
@@ -9,11 +9,11 @@ for (const [name, width] of [["desktop", 1440], ["mobile", 390], ["narrow", 320]
await page.goto("/backups"); await page.getByRole("button", { name: "Удалить " + filename, exact: true }).click()
const dialog = page.getByRole("alertdialog"); await dialog.getByRole("button", { name: "Удалить", exact: true }).click(); await expect(dialog).toContainText("Запись резервной копии сохранена")
expect(await dialog.evaluate(element => element.scrollWidth <= element.clientWidth + 1)).toBe(true)
await page.screenshot({ path: "docs/ux-ui/screens/backups-delete-error-" + name + ".png", fullPage: true })
await page.screenshot({ path: info.outputPath("backups-delete-error-" + name + ".png"), fullPage: true })
await dialog.getByRole("button", { name: "Отмена", exact: true }).click(); await page.getByRole("button", { name: "Восстановить " + filename, exact: true }).click()
await dialog.getByRole("button", { name: "Восстановить", exact: true }).click(); await expect(dialog).toContainText("Результат импорта неизвестен"); await expect(dialog.getByRole("button", { name: "Восстановить", exact: true })).toBeDisabled()
expect(await dialog.evaluate(element => element.scrollWidth <= element.clientWidth + 1)).toBe(true)
const box = await dialog.boundingBox(); expect(box && box.x >= 0 && box.y >= 0 && box.x + box.width <= width + 1 && box.y + box.height <= 901).toBe(true)
await page.screenshot({ path: "docs/ux-ui/screens/backups-restore-unknown-" + name + ".png", fullPage: true })
await page.screenshot({ path: info.outputPath("backups-restore-unknown-" + name + ".png"), fullPage: true })
await page.keyboard.press("Escape"); await expect(dialog).toHaveCount(0)
})
+4 -4
View File
@@ -1,24 +1,24 @@
import { test, expect } from "@playwright/test"
import { backup, server, setupBackups } from "./fixtures/backups"
for (const [name, width] of [["desktop", 1440], ["mobile", 390], ["narrow", 320]] as const) test("Бэкапы: клавиатура, Frame и адаптивность/" + name, async ({ page }) => {
for (const [name, width] of [["desktop", 1440], ["mobile", 390], ["narrow", 320]] as const) test("Бэкапы: клавиатура, Frame и адаптивность/" + name, async ({ page }, info) => {
await page.setViewportSize({ width, height: 900 }); await setupBackups(page)
await page.route("**/api/servers", r => r.fulfill({ json: [{ ...server, status: null }] }))
const long = { ...backup, filename: "backup-of-router-with-a-very-long-name-for-mobile-screens.rsc", serverName: "Очень длинное имя роутера для проверки адаптивности", notes: "Длинная заметка: проверка подтверждённых резервных копий и адаптивной таблицы" }
await page.route("**/api/backups", r => r.fulfill({ json: [long] }))
await page.goto("/backups"); await expect(page.getByText(long.filename, { exact: true })).toBeVisible(); await expect(page.getByRole("heading", { level: 1, name: "Резервные копии" })).toBeVisible()
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true)
await page.screenshot({ path: "docs/ux-ui/screens/backups-history-" + name + ".png", fullPage: true })
await page.screenshot({ path: info.outputPath("backups-history-" + name + ".png"), fullPage: true })
const settings = page.getByRole("tab", { name: "Настройки", exact: true }); await settings.focus(); await page.keyboard.press("Enter")
await expect(page.getByText("Неизвестно", { exact: true })).toBeVisible(); await expect(page.getByText("Оффлайн", { exact: true })).toHaveCount(0)
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true)
await page.getByText("Автоматический бэкап", { exact: true }).scrollIntoViewIfNeeded()
expect(await page.locator("fieldset").evaluateAll(elements => elements.every(element => element.scrollWidth <= element.clientWidth + 1))).toBe(true)
for (const label of ["Ежедневно", "Еженедельно", "Ежемесячно"]) { const box = await page.getByRole("button", { name: label, exact: true }).boundingBox(); expect(box && box.x >= 0 && box.x + box.width <= width + 1).toBe(true) }
await page.screenshot({ path: "docs/ux-ui/screens/backups-settings-" + name + ".png", fullPage: true })
await page.screenshot({ path: info.outputPath("backups-settings-" + name + ".png"), fullPage: true })
const create = page.getByRole("button", { name: "Новый бэкап", exact: true }).first(); await create.focus(); await page.keyboard.press("Enter")
const dialog = page.getByRole("dialog", { name: "Новый бэкап", exact: true }); await expect(dialog).toBeVisible(); await expect(dialog.getByText("Неизвестно", { exact: true })).toBeVisible(); await expect(dialog.getByText("недоступен", { exact: true })).toHaveCount(0)
await expect.poll(async () => { const box = await dialog.boundingBox(); return !!box && box.x >= 0 && box.x + box.width <= width + 1 && (width >= 640 || box.width >= width - 2) }).toBe(true)
expect(await dialog.evaluate(element => element.scrollWidth <= element.clientWidth + 1)).toBe(true)
await page.screenshot({ path: "docs/ux-ui/screens/backups-create-" + name + ".png", fullPage: true })
await page.screenshot({ path: info.outputPath("backups-create-" + name + ".png"), fullPage: true })
await page.keyboard.press("Escape"); await expect(dialog).toHaveCount(0); await expect(create).toBeFocused()
})
+42
View File
@@ -0,0 +1,42 @@
import { test, expect } from "@playwright/test"
import { backupItemDtoSchema } from "../../packages/contracts/src/backups"
import { backup, setupBackups } from "./fixtures/backups"
import axe from "axe-core"
import { mkdirSync } from "node:fs"
test("Backups1000: весь поиск, страница при refresh, отказ и правильный файл", async ({ page }, info) => {
test.setTimeout(90000)
let catalog = Array.from({ length: 1000 }, (_, i) => backupItemDtoSchema.parse({ ...backup, id: "backup-" + (i + 1), filename: "config-" + String(i + 1).padStart(4, "0") + ".rsc", sizeBytes: (i + 1) * 1024 }))
let failed = false, calls = 0; const writes: string[] = []
await setupBackups(page)
await page.route("**/api/**", r => { if(r.request().method() !== "GET") writes.push(r.request().method() + " " + new URL(r.request().url()).pathname); return r.fallback() })
await page.route("**/api/backups", r => { calls++; return r.fulfill(failed ? { status: 503, json: { error: "История недоступна" } } : { json: catalog }) })
const start = Date.now(); await page.goto("/backups")
const rows = page.locator("tr[data-row-id]"); await expect(page.getByText("config-0001.rsc", { exact: true })).toBeVisible()
const sample = { fixtureRows: 1000, fixtureBytes: Buffer.byteLength(JSON.stringify(catalog)), renderedRows: await rows.count(), domNodes: await page.locator("*").count(), readyMs: Date.now() - start }
console.log(JSON.stringify(sample)); await info.attach("large-backups-metrics", { body: JSON.stringify(sample), contentType: "application/json" })
expect(sample.renderedRows).toBe(50); expect(sample.domNodes).toBeLessThan(5000)
for(let i=0;i<3;i++) await page.getByRole("button", { name: "…", exact: true }).last().click()
await page.getByRole("button", { name: "Перейти на страницу 20", exact: true }).click()
const refresh = page.getByRole("button", { name: "Обновить", exact: true }); const before = calls; await refresh.click(); await expect.poll(() => calls).toBeGreaterThan(before); await expect(refresh).toBeEnabled()
await expect(page.getByText("951–1000 из 1000", { exact: true })).toBeVisible()
failed = true; await refresh.click(); await expect(page.getByRole("alert", { name: "Данные резервных копий: backups", exact: true })).toContainText("Последние данные сохранены"); await expect(rows).toHaveCount(50)
failed = false; await refresh.click(); await expect(page.getByRole("alert", { name: "Данные резервных копий: backups", exact: true })).toHaveCount(0)
const search = page.getByPlaceholder("Поиск по файлу, серверу, заметке…"); await search.fill("config-1000"); await expect(rows).toHaveCount(1); await expect(page).toHaveURL(/q=config-1000/)
await page.reload(); await expect(rows).toHaveCount(1); await expect(rows.first()).toHaveAttribute("data-row-id", "backup-1000"); await search.fill(""); await expect(rows).toHaveCount(50)
await page.getByRole("button", { name: "Размер", exact: true }).click(); await expect(rows.first()).toHaveAttribute("data-row-id", "backup-1000")
await page.setViewportSize({ width: 320, height: 1000 })
const scroll = page.locator('[data-slot="data-grid"] [data-slot="scroll-area-viewport"]'); await scroll.evaluate(el => { el.scrollLeft = el.scrollWidth })
for (const action of ["Скачать", "Восстановить", "Удалить"]) {
const button = rows.first().getByRole("button", { name: action + " config-1000.rsc", exact: true })
expect(await button.evaluate(el => { const box=el.getBoundingClientRect(), viewport=el.closest('[data-slot="scroll-area-viewport"]')!.getBoundingClientRect(), pinned=el.closest("tr")!.querySelector("td")!.getBoundingClientRect(); return box.width >= 44 && box.height >= 44 && box.left >= pinned.right-1 && box.right <= viewport.right+1 })).toBe(true)
}
const restore = rows.first().getByRole("button", { name: "Восстановить config-1000.rsc", exact: true }); await restore.focus(); await page.keyboard.press("Enter"); await expect(page.getByRole("alertdialog")).toContainText("config-1000.rsc"); await page.getByRole("alertdialog").getByRole("button", { name: "Отмена", exact: true }).click(); await expect(restore).toBeFocused()
const remove = rows.first().getByRole("button", { name: "Удалить config-1000.rsc", exact: true })
expect(await remove.evaluate(el => { const value=el.getBoundingClientRect(), viewport=el.closest('[data-slot="scroll-area-viewport"]')!.getBoundingClientRect(), pinned=el.closest("tr")!.querySelector("td")!.getBoundingClientRect(); return value.left >= pinned.right-1 && value.right <= viewport.right+1 })).toBe(true)
await remove.focus(); await page.keyboard.press("Enter"); await expect(page.getByRole("alertdialog")).toContainText("config-1000.rsc"); await page.getByRole("alertdialog").getByRole("button", { name: "Отмена", exact: true }).click(); await expect(remove).toBeFocused()
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
await page.addScriptTag({ content: axe.source }); const audit=await page.evaluate(async () => (window as unknown as {axe:typeof axe}).axe.run(document.querySelector('[data-slot="data-grid"]')!, { runOnly: { type:"tag", values:["wcag2a","wcag2aa","wcag21a","wcag21aa"] } })); expect(audit.violations).toEqual([])
await page.addStyleTag({ content:"nextjs-portal{display:none!important}" }); mkdirSync("docs/ux-ui/screens",{recursive:true}); await page.getByRole("table").scrollIntoViewIfNeeded(); await page.screenshot({path:"docs/ux-ui/screens/large-backups-320.png",animations:"disabled"})
await page.getByRole("button",{name:"Следующая страница",exact:true}).click(); catalog=[catalog[999]]; const previous=calls; await refresh.click(); await expect.poll(() => calls).toBeGreaterThan(previous); await expect(rows).toHaveCount(1); await expect(rows.first()).toHaveAttribute("data-row-id","backup-1000"); expect(writes).toEqual([])
})