diff --git a/app/(main)/uptime/page.tsx b/app/(main)/uptime/page.tsx index 2af42809..5e2a6b85 100644 --- a/app/(main)/uptime/page.tsx +++ b/app/(main)/uptime/page.tsx @@ -1,5 +1,7 @@ "use client" +import probeStyles from "./probe-rows.module.css" + import { Suspense, useState, useMemo, useEffect, useRef, useCallback } from "react" import Link from "next/link" import { useSearchParams } from "next/navigation" @@ -2422,7 +2424,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId {/* ── probe groups ── */} -
+
{grouped.length === 0 && (
@@ -2439,18 +2441,18 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId const warnCount = allServerProbes.filter(p => p.status === "warn").length return ( - + {/* server header */} -
-
+
Интерфейс @@ -2517,33 +2518,33 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
- toggleProbe(p.id, v)} /> - + +
- {p.srcInterface || "auto"} - {p.name} - {p.filter === "—" + />{!p.enabled ? "Отключена" : p.status === "up" ? "Доступна" : p.status === "warn" ? "Предупреждение" : "Недоступна"}
+
Интерфейс{p.srcInterface || "auto"}
+
{p.name}
+
Фильтр{p.filter === "—" ? — - : {p.filter} - } - + : {p.filter} + }
+
RTT {p.rtt === null ? "—" : `${p.rtt}мс`} - -
+
Потери= 100 ? "text-red-600 dark:text-red-400" : p.loss > 5 ? "text-red-600 dark:text-red-400" : "text-amber-600 dark:text-amber-400", )}> {p.loss}% - -
+
+
BT · ping {!linkedSp?.lastRunAt ? ( — ) : linkedSp.lastStatus === "error" && linkedSp.lastError ? ( @@ -2577,8 +2578,8 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId )}
- -
+
График RTT
+
На дашборде
-
+
@@ -2615,7 +2617,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId open={probeRttChartOpen[p.id] ?? false} onOpenChange={(open) => setProbeRttChartOpen((m) => ({ ...m, [p.id]: open }))} > - + type="button" + className="w-full flex items-center gap-2 px-4 py-2 text-left wrap-anywhere text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t"> Добавить интерфейс для {group.name} ({group.target}) diff --git a/app/(main)/uptime/probe-rows.module.css b/app/(main)/uptime/probe-rows.module.css new file mode 100644 index 00000000..61844ed5 --- /dev/null +++ b/app/(main)/uptime/probe-rows.module.css @@ -0,0 +1,33 @@ +.container { container-type: inline-size; } +.header { display: none; } +.serverName { flex: 1 1 160px; } +.row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + grid-template-areas: "name name" "status toggle" "iface filter" "rtt loss" "bt graph" "star actions"; +} +.row > * { min-width: 0; overflow-wrap: anywhere; } +.name { grid-area: name; } +.status { grid-area: status; display: flex; align-items: center; gap: 0.5rem; font-size: 0.75rem; } +.toggle { grid-area: toggle; display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; justify-content: flex-end; } +.iface { grid-area: iface; } +.filter { grid-area: filter; } +.rtt { grid-area: rtt; } +.loss { grid-area: loss; } +.bt { grid-area: bt; } +.graph { grid-area: graph; } +.star { grid-area: star; display: flex; flex-wrap: wrap; gap: 0.25rem; align-items: center; } +.actions { grid-area: actions; } +.label { display: block; font-size: 0.625rem; color: var(--muted-foreground); } +.actions button, .star button { min-width: 2rem; min-height: 2rem; } + +@container (min-width: 1100px) { + .header, .row { + display: grid; + grid-template-columns: 36px 16px 130px 120px 140px 70px 44px minmax(132px, 1fr) 96px 36px 72px; + grid-template-areas: "toggle status iface name filter rtt loss bt graph star actions"; + } + .label, .mobile { display: none; } + .toggle, .star { justify-content: center; } + .actions button, .star button { min-width: unset; min-height: unset; } +} diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index b162ec34..c8a27a01 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -21,7 +21,7 @@ | 4 | Остальные таблицы, удаление старых API, общая регрессия | Завершён 2026-09-20; границы приёмки ниже | | 5 | Состояния данных, свежесть, частичные сбои, гонки polling | Завершён 2026-09-20 для дашборда; границы проверок ниже | | 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже | -| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | В работе: блок 7.1 URL-фильтров серверов завершён; далее 7.2 | +| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | В работе: блоки 7.1–7.2 завершены; далее права действий 7.3 | | 8 | Трафик и статистика | Не начат | | 9 | VPN, Firewall и Filters | Не начат | | 10 | Маршрутизация, диагностика, карта и терминал | Не начат | @@ -487,6 +487,16 @@ Production build успешен: контракты, TypeScript, 34 страни Квота: использовано 1% на старте, 30% перед заключительной сборкой (недельной 66%). Сохранить локальным коммитом без push. **Следующий шаг — 7.2:** мобильные строки Ping-проб в uptime/page.tsx, доступность имени/цели/состояния и меню без обрезки на 320/390 px с сохранением действий, выбора и раскрытия; затем права по блоку 7.3. Этап 7 в целом остаётся открытым. +## Этап 7 — блок 7.2: мобильные строки Ping-проб (2026-09-21) + +Устранена отмеченная на этапе 6 обрезка правой части строк Ping-проб. В uptime/page.tsx сохранён один набор контролов и прежние обработчики. Frame-контейнер и локальный probe-rows.module.css переключают строку между двумя колонками с подписями и широкой сеткой при доступной ширине от 1100 px. Длинные имя, фильтр и цель переносятся, серверный заголовок и действия группы тоже допускают перенос. FormToggle получил label, кнопки звезды/редактирования/удаления — aria-label, заголовок группы сервера — aria-expanded. Вендорные ReUI-компоненты, тема, данные и сетевые правила не менялись. + +Проверки: **11 E2E passed** (uptime-links и collector-probes-tables). Новые сценарии 320/390/1440 px проверяют длинные подписи, доступность кнопок в пределах экрана, отсутствие горизонтального переполнения страницы, широкую/узкую композицию, переключатель, раскрытие графика Enter, редактирование/Escape, PATCH звезды и PUT включения. Скриншоты 320/1440 просмотрены после исправлений переноса. TypeScript и production build успешны, 34 страницы. Новый тест проходит lint; у uptime/page.tsx прежние 1 ошибка set-state-in-effect и 6 предупреждений, тот же baseline подтверждён на HEAD. Не заявлять чистый lint всего проекта. + +Ограничения: проверены HTTP-моки. Общая адаптивность вкладок Resources/Speed, подтверждение удаления и обработка ошибок существующего persistProbes не входили в этот блок; относятся к дальнейшей работе с формами и операциями. Этап 7 остаётся открытым. Квота: использовано 47% на старте, 81% перед фиксацией (недельной 74%). Новые блоки в этом окне не начинать; сохранить локальным коммитом без push. + +**Следующий шаг — 7.3:** подключить разрешения действий серверов и мониторинга по can/hasPermission и действующим backend-маршрутам; проверить read-only, write/admin, auth disabled и серверный 403 без потери формы. Начать с серверов, затем мониторинг; UI-проверки не заменяют backend. + ## Карта миграции (исходная инвентаризация этапа 0) Исходная связка на этапе 0: @tanstack/react-table ^8.21.3, @tanstack/react-virtual ^3.14.4, base-nova. По итогам этапа 4 используется @tanstack/react-table 9.2.4 с useTable/dataGridFeatures и дерево FilterQuery вместо старого filters/onChange. Список ниже сохранён как историческая инвентаризация. diff --git a/docs/ux-ui-stage-7.md b/docs/ux-ui-stage-7.md index bf973d68..a9e1f406 100644 --- a/docs/ux-ui-stage-7.md +++ b/docs/ux-ui-stage-7.md @@ -1,6 +1,6 @@ # Этап 7: серверы и мониторинг -Подготовка 2026-09-20 по текущему коду после этапа 6. Блок 7.1 реализован 2026-09-21; остальные блоки ожидают выполнения. Основа: ReUI PRO, base-nova, Frame; переиспользовать установленные Filters/DataGrid, ServerFilters, PageHeader и Sheet. Не менять vendored-компоненты и правила сетевых операций. +Подготовка 2026-09-20 по текущему коду после этапа 6. Блоки 7.1 и 7.2 реализованы 2026-09-21; далее права действий, блок 7.3. Основа: ReUI PRO, base-nova, Frame; переиспользовать установленные Filters/DataGrid, ServerFilters, PageHeader и Sheet. Не менять vendored-компоненты и правила сетевых операций. ## Инвентаризация @@ -56,13 +56,19 @@ URL — источник сохранённого состояния. Back/Forwa ## Блоки после 7.1 -1. **7.2 — мобильные проверки.** Устранить обрезку Ping-проб, сохранив выбор строк, раскрытие, запуск и меню. Проверить 320/390/1440 px, клавиатуру/Escape, отсутствие скрытых обязательных действий и горизонтального скролла всей страницы. +1. **7.2 — мобильные проверки (выполнено).** Устранить обрезку Ping-проб, сохранив выбор строк, раскрытие, запуск и меню. Проверить 320/390/1440 px, клавиатуру/Escape, отсутствие скрытых обязательных действий и горизонтального скролла всей страницы. 2. **7.3 — права действий.** Источник политики — `backend/src/lib/permissions.ts`, применение — `backend/src/plugins/auth.ts`. GET `/api/servers*` требует `mm:servers:read`, запись — `mm:servers:write`; GET `/api/uptime*` — `mm:uptime:read`, запись — `mm:uptime:write`. Учитывать наследование write/admin через существующий hasPermission и режим auth disabled. Проверить конкретные маршруты опроса/теста соединения, не считать их чтением только по названию. UI не заменяет backend-проверку: серверный 403 сохраняет форму и сообщает отказ. Не выдавать доступ только по is_admin без подтверждения текущей политики. 3. **7.4 — формы, детали и полный контекст мониторинга.** Восстановление вкладки/фильтров из URL, понятные единицы и подписи, возврат к списку после действия. Не дублировать сохранённые исправления этапа 1; проверить загрузку, отмену, 400/403/500, timeout и повторное нажатие. 4. **7.5 — приёмка этапа.** Сценарий «проблема → сервер/проверка → действие → возврат» с сохранением контекста; профильные E2E, права в HTTP-моках, TypeScript, lint и build. Реальные JWT/RouterOS отдельно в тестовом окружении. -Следующий исполняемый шаг: блок 7.2 — мобильные строки Ping-проб. Codec URL серверов, hook и подключение к ServersPageContent выполнены. Полную регрессию всей системы здесь не повторять без изменений общей основы или найденной регрессии. +Следующий исполняемый шаг: блок 7.3 — права действий серверов и мониторинга. Codec URL серверов, hook и подключение к ServersPageContent выполнены. Полную регрессию всей системы здесь не повторять без изменений общей основы или найденной регрессии. ## Решение по синхронизации URL (7.1) Поиск использует native replaceState без debounce: установленный Next App Router синхронизирует useSearchParams, не запрашивая серверные данные. Браузерный тест подтвердил быстрый посимвольный ввод и неизменное количество GET /api/servers. Это устраняет отложенную запись, способную затереть последующий выбор type/filters. Дискретные изменения используют pushState, читая актуальный window.location.search. Черновик Filters привязан к сохранённому filters и сбрасывается при popstate. Неверные type/filters блокируют выдачу до исправления/сброса; server и чужие параметры сохраняются. + +## Реализация адаптивных строк (7.2) + +Один DOM и прежние обработчики Ping-проб сохранены. Frame задаёт контейнер ширины; локальный CSS module перестраивает существующую строку в две колонки с подписями при ширине контейнера менее 1100 px. При достаточной ширине возвращается сетка 11 колонок. Имена, цель, интерфейс и фильтр переносятся; состояние подписано на узком экране. Переключатель получил доступное имя через label, кнопки — aria-label, серверный заголовок — aria-expanded. Используются прежние FormToggle/Button/Collapsible/Sparkline; ReUI и общая тема не изменены. + +11 профильных E2E прошли, в том числе длинные подписи на 320/390/1440 px, видимость действий, Enter для раскрытия графика, редактирование/Escape, запрос звезды и включение пробы. Скриншоты 320/1440 просмотрены, обрезка строки устранена. Проверка не означает адаптивность всех вкладок uptime: Resources/Speed и их формы остаются отдельными частями плана. diff --git a/tests/e2e/uptime-links.spec.ts b/tests/e2e/uptime-links.spec.ts index 6c6bddfc..489c1369 100644 --- a/tests/e2e/uptime-links.spec.ts +++ b/tests/e2e/uptime-links.spec.ts @@ -1,13 +1,18 @@ import { test, expect, type Page } from "@playwright/test" -async function setup(page: Page, url: string, failed = 0) { - const control = { failed, posts: [] as { path: string; body: unknown }[] } +async function setup(page: Page, url: string, failed = 0, longNames = false) { + const control = { writes: [] as { method: string; path: string; body: unknown }[], failed, posts: [] as { path: string; body: unknown }[] } const servers = [{ id: 1, name: "Источник", host: "192.0.2.1", type: "exit-node", enabled: true, country: "RU", site: "TEST", asn: "64512", status: "online", latency: 1, os: "7", model: "CHR", wanUplinks: [] }] const probes = ["p1", "p/2"].map(id => ({ id, srcServerId: "1", name: "Проверка сети", target: "192.0.2.10", filter: "main", rtt: null, loss: 100, status: "down", series: [0], enabled: true, showOnDashboard: false })) + if (longNames) { + servers[0].name = "Источник-с-длинным-названием-площадки-для-проверки-переноса" + probes.forEach(p => { p.name = "Проверка-доступности-длинного-имени-сетевого-узла"; p.target = "router-with-a-long-hostname.example.test"; p.filter = "Фильтр-с-длинным-названием" }) + } await page.route("**/health", route => route.fulfill({ json: { ok: true } })) await page.route("**/api/**", route => { const request = route.request() const path = new URL(request.url()).pathname + if (!["GET", "HEAD"].includes(request.method())) control.writes.push({ method: request.method(), path, body: request.postDataJSON() }) if (request.method() === "POST") control.posts.push({ path, body: request.postDataJSON() }) if (path === "/api/uptime/overview" && control.failed) return route.fulfill({ status: control.failed, json: { error: "Мониторинг недоступен" } }) const json = path === "/api/auth/config" ? { required: false } @@ -83,3 +88,41 @@ for (const failed of [0, 403, 500]) { expect(control.posts).toEqual([]) }) } + +for (const width of [320, 390, 1440]) { + test(`Ping-пробы ${width}px: длинные подписи, доступные действия и раскрытие`, async ({ page }) => { + await page.setViewportSize({ width, height: 1000 }) + const control = await setup(page, "/uptime?probe=p%2F2", 0, true) + const row = page.locator('[data-probe-id="p/2"]') + await expect(row).toBeVisible() + await expect(row.getByText("График RTT", { exact: true })).toBeVisible({ visible: width < 1440 }) + if (width < 1440) expect(await row.getByText("Фильтр-с-длинным-названием", { exact: true }).evaluate(el => el.getBoundingClientRect().right)).toBeLessThanOrEqual(width) + for (const name of ["Редактировать пробу", "Удалить пробу", "Показать на дашборде"]) { + const button = row.getByRole("button", { name, exact: true }) + await button.scrollIntoViewIfNeeded() + const bounds = await button.boundingBox() + expect(bounds!.x).toBeGreaterThanOrEqual(0) + expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(width) + } + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width) + const toggle = row.getByRole("switch", { name: /Включить пробу/ }) + await expect(toggle).toBeChecked() + const chart = row.getByRole("button", { name: /Подробный график RTT/ }) + await chart.focus() + await page.keyboard.press("Enter") + await expect(chart).toHaveAttribute("aria-expanded", "true") + await page.keyboard.press("Enter") + await expect(chart).toHaveAttribute("aria-expanded", "false") + await row.scrollIntoViewIfNeeded() + await page.screenshot({ path: `test-results/ping-row-${width}.png`, animations: "disabled" }) + expect(control.writes).toEqual([]) + await row.getByRole("button", { name: "Редактировать пробу", exact: true }).click() + await expect(page.getByRole("dialog")).toBeVisible() + await page.keyboard.press("Escape") + await expect(page.getByRole("dialog")).toBeHidden() + await row.getByRole("button", { name: "Показать на дашборде", exact: true }).click() + await expect.poll(() => control.writes.some(w => w.method === "PATCH" && w.path === "/api/uptime/probes/p%2F2" && (w.body as { showOnDashboard: boolean }).showOnDashboard === true)).toBe(true) + await toggle.click() + await expect.poll(() => control.writes.some(w => w.method === "PUT" && w.path === "/api/uptime/probes")).toBe(true) + }) +}