fix(backups): адаптировать историю и формы к узким экранам

This commit is contained in:
Denozordec committed 2026-10-01 16:45:41 +07:00
1 parent 11e357cc94
commit b74084b67c
16 files changed
+83 -10

No files matched your search

+8 -4
View File
@@ -1,6 +1,7 @@
"use client"
import { Suspense, useCallback, useEffect, useRef, useState } from "react"
import { Frame, FramePanel } from "@/components/reui/frame"
import { PageHeader } from "@/components/page-header"
import { backups as mockBackups, servers as mockServers, type Backup, type Server } from "@/lib/data"
import { useBackupsUrlState } from "@/hooks/use-backups-url-state"
@@ -147,7 +148,7 @@ function BackupsContent() {
canReadServers ? requestJson<unknown>(backendUrl, "/api/servers", { signal }).then(payload => {
const rows = serverListSchema.parse(payload)
if (!active()) return
setLiveServers(rows.map(toFrontendServer)); success("servers")
setLiveServers(rows.map(row => ({ ...toFrontendServer(row), status: row.status ?? "unknown" }))); success("servers")
}).catch(error => failure("servers", error)) : Promise.resolve(),
getBackupScheduleSettings(backendUrl, { signal }).then(row => {
if (!active()) return
@@ -366,7 +367,7 @@ function BackupsContent() {
}
return (
<div className="flex flex-col h-full">
<div className="flex min-h-0 min-w-0 flex-col h-full">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Бэкапы" }]}
actions={
@@ -394,13 +395,13 @@ function BackupsContent() {
}
/>
<div className="flex-1 overflow-y-auto p-6">
<div className="min-w-0 flex-1 overflow-y-auto p-3 sm:p-6">
<div className="flex flex-col gap-5">
{urlError && <Alert variant="warning" aria-label="Контекст резервных копий"><AlertTitle>Не удалось прочитать ссылку</AlertTitle><AlertDescription>{urlError}</AlertDescription><AlertAction><Button type="button" variant="outline" onClick={resetContext}>Сбросить контекст</Button></AlertAction></Alert>}
{!isLive && <p role="status" className="text-sm text-muted-foreground">Демонстрационные данные. Операции и сохранение недоступны.</p>}
{!canRead && <p role="status">Нет права mm:backups:read</p>}
{isLive && !canReadServers && <p role="status">Каталог устройств недоступен без mm:servers:read.</p>}
{isLive && canRead && <div className="flex items-center gap-3 text-sm text-muted-foreground">
{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>
<span role="status">{loading ? "Обновление данных…" : resources.backups.receivedAt ? "Получено: " + new Date(resources.backups.receivedAt).toLocaleString("ru-RU") : "Резервные копии пока неизвестны"}</span>
</div>}
@@ -419,6 +420,7 @@ function BackupsContent() {
</Alert>}
{opError && <Alert variant="warning" aria-label="Операция резервных копий"><AlertTitle>Операция не подтверждена</AlertTitle><AlertDescription>{opError}</AlertDescription></Alert>}
{(job.error || job.storageError) && <Alert variant="warning" aria-label="Задача резервного копирования"><AlertTitle>{jobTerminal ? "Задача завершена с ограничениями" : "Результат задачи пока неизвестен"}</AlertTitle><AlertDescription>{job.error || job.storageError}</AlertDescription></Alert>}
<div><h1 className="text-xl font-semibold">Резервные копии</h1><p className="text-sm text-muted-foreground">История файлов, расписание и подтверждения операций</p></div>
<KpiStatGrid
aria-label="Сводка бэкапов"
items={[
@@ -470,6 +472,7 @@ function BackupsContent() {
</AlertAction>
</Alert>
<Frame dense className="min-w-0"><FramePanel className="min-w-0 p-3 sm:p-5">
<Tabs
value={tab}
onValueChange={(v) => setTab(String(v) as PageTab)}
@@ -537,6 +540,7 @@ function BackupsContent() {
</TabsContent>
</Tabs>
</FramePanel></Frame>
</div>
</div>
+5 -3
View File
@@ -152,6 +152,7 @@ export function BackupsSettings({
<div className="flex flex-col gap-4">
<FormField label="Частота">
<SegmentedControl
className="w-full flex-wrap"
value={schedule.frequency}
onChange={(v) => onScheduleChange("frequency", v)}
options={[
@@ -164,7 +165,7 @@ export function BackupsSettings({
{schedule.frequency === "weekly" && (
<FormField label="День недели">
<div className="flex gap-1">
<div className="flex flex-wrap gap-1">
{WEEK_DAYS.map((d, i) => (
<button
key={d}
@@ -199,7 +200,7 @@ export function BackupsSettings({
)}
<FormField label="Время запуска">
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<Input
type="number"
min={0}
@@ -211,7 +212,7 @@ export function BackupsSettings({
/>
<span className="text-muted-foreground font-mono text-lg">:</span>
<Input aria-label="Минута запуска" type="number" min={0} max={59} className="font-mono w-20 text-center" value={schedule.minute} onChange={e => onScheduleChange("minute", e.target.value)} />
<div className="flex gap-1">
<div className="flex flex-wrap gap-1">
{[0, 15, 30, 45].map((m) => (
<button
key={m}
@@ -267,6 +268,7 @@ export function BackupsSettings({
{savedStorage?.lastTestAt && <p className="text-xs text-muted-foreground">Последняя проверка: {new Date(savedStorage.lastTestAt).toLocaleString("ru-RU")}. Текущая доступность может измениться.</p>}
<FormField label="Тип хранилища">
<SegmentedControl
className="w-full flex-wrap"
value={storage.provider}
onChange={(v) => onStorageChange("provider", v)}
options={[
+2 -1
View File
@@ -63,6 +63,7 @@ function BackupsDataGrid({
{
id: "filename",
accessorKey: "filename",
size: 280,
header: ({ column }) => <DataGridSortHeader column={column} title="Файл" />,
cell: ({ row }) => {
const b = row.original
@@ -77,7 +78,7 @@ function BackupsDataGrid({
{inS3 ? <CloudIcon /> : <HardDriveIcon />}
</IconTile>
<div className="min-w-0">
<span className="font-mono text-xs font-medium block truncate">{b.filename}</span>
<span title={b.filename} className="font-mono text-xs font-medium block break-all">{b.filename}</span>
{b.uploadError ? (
<span className="text-[11px] text-destructive truncate block">{b.uploadError}</span>
) : null}
+10 -1
View File
@@ -226,4 +226,13 @@ REST запрос → ожидание → подтверждённый output /
| Пользователи | [Профиль](ux-ui/screens/users-profile-desktop.png), [ошибка](ux-ui/screens/users-error-desktop.png) | [390](ux-ui/screens/users-profile-mobile.png), [320](ux-ui/screens/users-profile-narrow.png) |
| Контейнеры | [Обзор](ux-ui/screens/containers-overview-desktop.png), [снимок](ux-ui/screens/containers-export-desktop.png) | [390](ux-ui/screens/containers-export-mobile.png), [320](ux-ui/screens/containers-export-narrow.png) |
Актуальная точка продолжения: 11А.31 — Frame/адаптивность и визуальная приёмка резервных копий. URL/Filters контекст принят в 11А.30. Клиентский checkpoint restore принят в 11А.29.5. Backend outcome неизвестного импорта принят в 11А.29.4. Идентичность receipt принята в 11А.29.3. Backend cleanup удаления принят в 11А.29.2. Frontend delete/restore/download принят в 11А.29.1, затем URL/Frame/визуальная приёмка и scheduler cleanup. Frontend checkpoint/создание/serial polling приняты локально в 11А.28.2. API/основа наблюдения реализованы в 11А.28.1. Настройки расписания/S3 реализованы в 11А.27.1–11А.27.2. Каталоги/source/права реализованы в 11А.25, backend результат ручных операций исправлен в 11А.26. Сертификаты приняты локально в 11А.24, backups API стал строгим. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Резервные копии и весь 11А ещё не приняты.
Актуальная точка продолжения: 11А.31.2 — S3/ошибки/диалоги и визуальная приёмка резервных копий. Frame/первые 9 экранов приняты в 11А.31.1. URL/Filters контекст принят в 11А.30. Клиентский checkpoint restore принят в 11А.29.5. Backend outcome неизвестного импорта принят в 11А.29.4. Идентичность receipt принята в 11А.29.3. Backend cleanup удаления принят в 11А.29.2. Frontend delete/restore/download принят в 11А.29.1, затем URL/Frame/визуальная приёмка и scheduler cleanup. Frontend checkpoint/создание/serial polling приняты локально в 11А.28.2. API/основа наблюдения реализованы в 11А.28.1. Настройки расписания/S3 реализованы в 11А.27.1–11А.27.2. Каталоги/source/права реализованы в 11А.25, backend результат ручных операций исправлен в 11А.26. Сертификаты приняты локально в 11А.24, backups API стал строгим. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Резервные копии и весь 11А ещё не приняты.
| Экран | ПК | Телефон |
| --- | --- | --- |
| История | [PNG](ux-ui/screens/backups-history-desktop.png) | [390](ux-ui/screens/backups-history-mobile.png), [320](ux-ui/screens/backups-history-narrow.png) |
| Настройки | [PNG](ux-ui/screens/backups-settings-desktop.png) | [390](ux-ui/screens/backups-settings-mobile.png), [320](ux-ui/screens/backups-settings-narrow.png) |
| Создание | [PNG](ux-ui/screens/backups-create-desktop.png) | [390](ux-ui/screens/backups-create-mobile.png), [320](ux-ui/screens/backups-create-narrow.png) |
Референсы: [Settings 16](https://reui.io/preview/base/settings-16?ref=mcp), [Data Grid Filtering 2](https://reui.io/preview/base/data-grid-filtering-2?ref=mcp), [Sheet 8](https://reui.io/preview/base/sheet-8?ref=mcp). Схема: источник → сводка → история/настройки → immutable цель → подтверждение API/неизвестный результат.
+17 -1
View File
@@ -27,7 +27,7 @@
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.30; пользователи/контейнеры/сертификаты приняты локально; резервные копии: URL/Filters, далее 11А.31 |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.31.1; пользователи/контейнеры/сертификаты приняты локально; резервные копии: Frame/первые экраны, далее 11А.31.2 |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
@@ -36,6 +36,22 @@
## Блок 11А.31.1 — Frame и первые адаптивные экраны резервных копий (2026-10-01)
- История/настройки объединены существующим ReUI Frame/FramePanel, добавлены h1/краткая подпись, min-width и адаптивные отступы. Refresh и элементы настроек переносятся. Null status API становится «Неизвестно», а не offline/«недоступен».
- Длинное имя файла имеет увеличенную колонку, перенос и title. На 320 px screenshot обнаружил скрытую кнопку «Ежемесячно», несмотря на отсутствие body overflow: SegmentedControl/время/дни теперь переносятся; E2E проверяет границы каждой кнопки частоты.
- 9 PNG истории/настроек/create сохранены; desktop/390/320 просмотрены по ключевым состояниям. 3 keyboard/layout + 9 settings + 5 URL E2E, полный TypeScript/профильный ESLint прошли. Production frontend повторно прошёл после удаления повреждённого generated .next/dev/types/validator.ts; исходники этой ошибки не содержали.
- S3/error/delete/restore/partial job визуальная приёмка ещё не завершена; backup scheduler cleanup и итоговая профильная регрессия впереди. Реальный RouterOS/PostgreSQL/S3 не проверен.
- **Следующий блок 11А.31.2:** адаптивные состояния ошибок/диалогов и S3, затем scheduler cleanup/приёмка backups. Весь 11А ещё не принят.
| Экран | ПК | Телефон |
| --- | --- | --- |
| История | [PNG](ux-ui/screens/backups-history-desktop.png) | [390](ux-ui/screens/backups-history-mobile.png), [320](ux-ui/screens/backups-history-narrow.png) |
| Настройки | [PNG](ux-ui/screens/backups-settings-desktop.png) | [390](ux-ui/screens/backups-settings-mobile.png), [320](ux-ui/screens/backups-settings-narrow.png) |
| Создание | [PNG](ux-ui/screens/backups-create-desktop.png) | [390](ux-ui/screens/backups-create-mobile.png), [320](ux-ui/screens/backups-create-narrow.png) |
Референсы: [Settings 16](https://reui.io/preview/base/settings-16?ref=mcp), [Data Grid Filtering 2](https://reui.io/preview/base/data-grid-filtering-2?ref=mcp), [Sheet 8](https://reui.io/preview/base/sheet-8?ref=mcp). Схема: источник → сводка → история/настройки → immutable цель → подтверждение API/неизвестный результат.
## Блок 11А.30 — URL контекст истории и ReUI Filters (2026-10-01)
- Поиск q, kind и вкладка tab читаются из URL. Полный FilterQuery сохраняется как versioned filters после завершения значений; draft остаётся локальным до завершения. Back/Forward сбрасывает локальный draft и восстанавливает контекст. Search использует replace, вкладка/тип/Filters — push; чужие параметры и hash сохраняются.
+17
View File
@@ -418,3 +418,20 @@
- Создание/restore checkpoints остаются привязаны к источнику, URL не запускает операции. Реальные RouterOS/PostgreSQL/S3 не проверены.
- **Следующий блок 11А.31:** ReUI Frame, desktop/390/320, визуальные состояния истории/настроек/форм/ошибок, nullable статус устройств; затем scheduler cleanup и приёмка backups.
## Блок 11А.31.1 — Frame и первые адаптивные экраны резервных копий (2026-10-01)
- История/настройки объединены существующим ReUI Frame/FramePanel, добавлены h1/краткая подпись, min-width и адаптивные отступы. Refresh и элементы настроек переносятся. Null status API становится «Неизвестно», а не offline/«недоступен».
- Длинное имя файла имеет увеличенную колонку, перенос и title. На 320 px screenshot обнаружил скрытую кнопку «Ежемесячно», несмотря на отсутствие body overflow: SegmentedControl/время/дни теперь переносятся; E2E проверяет границы каждой кнопки частоты.
- 9 PNG истории/настроек/create сохранены; desktop/390/320 просмотрены по ключевым состояниям. 3 keyboard/layout + 9 settings + 5 URL E2E, полный TypeScript/профильный ESLint прошли. Production frontend повторно прошёл после удаления повреждённого generated .next/dev/types/validator.ts; исходники этой ошибки не содержали.
- S3/error/delete/restore/partial job визуальная приёмка ещё не завершена; backup scheduler cleanup и итоговая профильная регрессия впереди. Реальный RouterOS/PostgreSQL/S3 не проверен.
- **Следующий блок 11А.31.2:** адаптивные состояния ошибок/диалогов и S3, затем scheduler cleanup/приёмка backups. Весь 11А ещё не принят.
| Экран | ПК | Телефон |
| --- | --- | --- |
| История | [PNG](ux-ui/screens/backups-history-desktop.png) | [390](ux-ui/screens/backups-history-mobile.png), [320](ux-ui/screens/backups-history-narrow.png) |
| Настройки | [PNG](ux-ui/screens/backups-settings-desktop.png) | [390](ux-ui/screens/backups-settings-mobile.png), [320](ux-ui/screens/backups-settings-narrow.png) |
| Создание | [PNG](ux-ui/screens/backups-create-desktop.png) | [390](ux-ui/screens/backups-create-mobile.png), [320](ux-ui/screens/backups-create-narrow.png) |
Референсы: [Settings 16](https://reui.io/preview/base/settings-16?ref=mcp), [Data Grid Filtering 2](https://reui.io/preview/base/data-grid-filtering-2?ref=mcp), [Sheet 8](https://reui.io/preview/base/sheet-8?ref=mcp). Схема: источник → сводка → история/настройки → immutable цель → подтверждение API/неизвестный результат.
Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

+24
View File
@@ -0,0 +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 }) => {
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 })
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 })
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.keyboard.press("Escape"); await expect(dialog).toHaveCount(0); await expect(create).toBeFocused()
})