diff --git a/app/(main)/dashboard/page.tsx b/app/(main)/dashboard/page.tsx index acd3ba2f..1208fc3c 100644 --- a/app/(main)/dashboard/page.tsx +++ b/app/(main)/dashboard/page.tsx @@ -36,24 +36,34 @@ function fmtIntRu(n: number): string { } function AttentionRows({ rows }: { rows: AttentionRow[] }) { + function renderRow(row: AttentionRow) { + return ( + + {row.title} + + {row.hint} + + + ) + } + return (
- {rows.map((row) => ( - - {row.title} - - {row.hint} - - - ))} + {rows.slice(0, 5).map(renderRow)} + {rows.length > 5 && ( +
+ Ещё {rows.length - 5} + {rows.slice(5).map(renderRow)} +
+ )}
) } @@ -77,15 +87,15 @@ export default function DashboardPage() { items={[ { id: "fleet", - label: "Флот", + label: "Серверы онлайн", value: dash.availability.servers ? `${dash.onlineCount} / ${dash.totalServers}` : "—", hint: !dash.availability.servers ? "нет данных" : !dash.isLive ? "демо" : offline > 0 - ? `${offline} offline` + ? `${offline} не в сети` : dash.totalServers > 0 - ? "все online" - : "нет узлов", + ? "все в сети" + : "нет серверов", href: "/servers", icon: , iconClassName: "text-muted-foreground", @@ -93,9 +103,9 @@ export default function DashboardPage() { }, { id: "overlay", - label: "Оверлей", + label: "Туннели работают", value: dash.availability.overlay ? String(dash.overlay.up) : "—", - hint: !dash.availability.overlay ? "не все источники доступны" : dash.overlay.total > 0 ? `${dash.overlay.down} down` : "нет туннелей", + hint: !dash.availability.overlay ? "не все источники доступны" : dash.overlay.total > 0 ? `Не работают: ${dash.overlay.down}` : "нет туннелей", href: "/gre", icon: , iconClassName: "text-info", @@ -103,18 +113,18 @@ export default function DashboardPage() { }, { id: "bgp", - label: "BGP Rx", + label: "Префиксы BGP", value: dash.bgp ? fmtIntRu(dash.bgp.prefixSum) : "—", - hint: dash.bgp ? `${dash.bgp.establishedCount} Established` : "нет данных", + hint: dash.bgp ? `Сессий установлено: ${dash.bgp.establishedCount}` : "нет данных", href: "/bgp", icon: , iconClassName: "text-primary", }, { id: "health", - label: "Здоровье", + label: "Проверки с проблемами", value: dash.availability.probes ? String(health) : "—", - hint: dash.availability.probes ? `${dash.probeDown} down · ${dash.probeWarn} warn` : "нет данных", + hint: dash.availability.probes ? `${dash.probeDown} ошибок · ${dash.probeWarn} предупреждений` : "нет данных", href: "/uptime", icon: , iconClassName: health > 0 ? "text-destructive" : "text-muted-foreground", @@ -123,13 +133,169 @@ export default function DashboardPage() { ]} /> +
+

Требует внимания

+ 0 ? "Серверы без проблем" : "Нет серверов") : "Состояние не подтверждено", + emptyDescription: dash.reliable.servers ? "Нет включённых серверов с ошибками доступности." : "Проверьте состояние источников выше.", + children: , + }, + { + id: "overlay", + title: "Туннели", + icon: CableIcon, + iconClassName: "text-info", + count: dash.attentionOverlay.length, + countVariant: "destructive-light", + emptyTitle: dash.reliable.overlay ? (dash.overlay.total > 0 ? "Туннели работают" : "Нет туннелей") : "Состояние не подтверждено", + emptyDescription: dash.reliable.overlay ? "В GRE, WireGuard и VXLAN проблем не обнаружено." : "Проверьте состояние источников выше.", + children: , + }, + { + id: "probes", + title: "Проверки", + icon: HeartPulseIcon, + iconClassName: "text-destructive", + count: dash.attentionProbes.length, + countVariant: "destructive-light", + emptyTitle: dash.reliable.probes ? (dash.probeCount > 0 ? "Проверки без ошибок" : "Нет активных проверок") : "Состояние не подтверждено", + emptyDescription: dash.reliable.probes ? "Нет ошибок и предупреждений активных проверок." : "Проверьте состояние источников выше.", + children: , + }, + ]} + /> +
+ +
+ + {dash.latency.kind === "loading" && ( +
+ )} + {dash.latency.kind === "empty" && ( +

{dash.latency.message}

+ )} + {(dash.latency.kind === "mock" || dash.latency.kind === "live") && ( + + )} + + + + Все → + + } + > + + +
+ +
+ + {dash.traffic.demo ? демо : null} + RX {fmtRate(dash.traffic.rxNow)} + TX {fmtRate(dash.traffic.txNow)} + + Трафик → + +
+ ) : null + } + contentClassName="px-3 pb-3" + > + {dash.loading && !dash.traffic ? ( +
+ ) : !dash.traffic ? ( +

+ Нет данных трафика за последний час.{" "} + + Открыть трафик + +

+ ) : ( + + )} + + + +
+ + + + {dash.probesSubtitle} + } + headerRight={ + + Мониторинг → + + } + contentClassName="px-0 pb-0" + > + {dash.activeProbes.length === 0 && !dash.probesLoading ? ( + + Открыть мониторинг + + } + /> + ) : ( + + )} + + , iconClassName: "text-info", @@ -177,155 +343,6 @@ export default function DashboardPage() { ]} /> -
- - {dash.latency.kind === "loading" && ( -
- )} - {dash.latency.kind === "empty" && ( -

{dash.latency.message}

- )} - {(dash.latency.kind === "mock" || dash.latency.kind === "live") && ( - - )} - - - - Все → - - } - > - - -
- -
- - {dash.traffic.demo ? демо : null} - RX {fmtRate(dash.traffic.rxNow)} - TX {fmtRate(dash.traffic.txNow)} - - Трафик → - -
- ) : null - } - contentClassName="px-3 pb-3" - > - {dash.loading && !dash.traffic ? ( -
- ) : !dash.traffic ? ( -

- Нет данных трафика за последний час.{" "} - - Открыть трафик - -

- ) : ( - - )} - - - , - }, - { - id: "overlay", - title: "Оверлей", - icon: CableIcon, - iconClassName: "text-info", - count: dash.attentionOverlay.length, - countVariant: "destructive-light", - emptyTitle: dash.reliable.overlay ? "Туннели up" : "Состояние не подтверждено", - emptyDescription: dash.reliable.overlay ? "GRE и WireGuard в норме." : "Проверьте состояние источников выше.", - children: , - }, - { - id: "probes", - title: "Пробы", - icon: HeartPulseIcon, - iconClassName: "text-destructive", - count: dash.attentionProbes.length, - countVariant: "destructive-light", - emptyTitle: dash.reliable.probes ? "Пробы up" : "Состояние не подтверждено", - emptyDescription: dash.reliable.probes ? "Нет down или warn." : "Проверьте состояние источников выше.", - children: , - }, - ]} - /> -
- - - - {dash.probesSubtitle} - } - headerRight={ - - Мониторинг → - - } - contentClassName="px-0 pb-0" - > - {dash.activeProbes.length === 0 && !dash.probesLoading ? ( - - Открыть мониторинг - - } - /> - ) : ( - - )} -
diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index de3757a1..e4add406 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -20,7 +20,7 @@ | 3 | Основные таблицы и справочники | Завершён 2026-09-13, границы проверок ниже | | 4 | Остальные таблицы, удаление старых API, общая регрессия | Завершён 2026-09-20; границы приёмки ниже | | 5 | Состояния данных, свежесть, частичные сбои, гонки polling | Завершён 2026-09-20 для дашборда; границы проверок ниже | -| 6 | Навигация и дашборд | В работе: адаптивная шапка готова, далее навигация и очередь проблем | +| 6 | Навигация и дашборд | В работе: шапка, поиск и иерархия готовы; остаются переходы к объектам | | 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Не начат | | 8 | Трафик и статистика | Не начат | | 9 | VPN, Firewall и Filters | Не начат | @@ -399,6 +399,18 @@ CommandPalette использует установленные Command/cmdk и B Блок готов для локального коммита без push. Этап 6 продолжается: иерархия дашборда, затем переходы из очереди к конкретным объектам. +## Этап 6 — блок 3: сводка и приоритет проблем (2026-09-20) + +Дашборд сохраняет KpiStatGrid, AttentionQueue и Frame, но очередь «Требует внимания» теперь идёт сразу после сводки, до измерений. Быстрые переходы перенесены вниз. Подписи заменены на «Серверы онлайн», «Туннели работают», «Префиксы BGP», «Проверки с проблемами»; состояния в очереди названы по-русски. Длинные имена проблем переносятся вместо обрезки. Состояния пустого каталога серверов, отсутствия туннелей и активных проверок отделены от отсутствия ошибок. + +useDashboardLive больше не обрезает массивы проблем до пяти до подсчёта. Счётчики учитывают все элементы; UI сначала показывает пять и раскрывает остальные через «Ещё N» с клавиатуры. Загрузка/частичные сбои/свежесть/защита от поздних ответов этапа 5 сохранены. Запросы и сетевые операции не менялись. + +Приёмка текущей общей основы: **65 Playwright-тестов passed**, включая 6 сценариев дашборда, 4 навигации и 4 шапки; отдельные профильные запуски также успешны. Новый сценарий проверяет 7 проблем при 5 видимых строках, раскрытие клавиатурой, порядок очереди перед измерениями, пустые активные проверки и ширину 390. Просмотрены скриншоты дашборда 1440/390 и поиска в двух темах. **Production build успешен: контракты, TypeScript, 34 страницы.** Scoped ESLint нового кода успешен; исходная ошибка AppSidebar описана в блоке 2. ReUI checklist получен; vendored-примитивы, зависимости и тема не менялись. Проверки используют HTTP-моки, реальный RouterOS не проверен. + +**Этап 6 ещё не завершён.** Ссылки AttentionRow пока открывают профильный раздел (/servers, /uptime, /gre, /wireguard, /vxlan), без выбора конкретного объекта. Следующий точный шаг: спроектировать единый URL-контракт идентификатора объекта и сервера, подключить принимающие страницы и только после этого заменить href очереди. Начать с сервера и проверить «дашборд → конкретный сервер → назад», прямое открытие URL, удалённый/недоступный объект, затем адаптировать мониторинг и туннели. Не считать query-параметр рабочим, пока целевая страница его не обрабатывает. Полные формы/права/фильтры URL этапа 7 остаются следующим этапом. + +Границы: текущий mapper дашборда по-прежнему трактует null server.status как offline; неизвестные состояния устройств требуют отдельного исправления и проверки. Квота при завершении общей регрессии и запуске build: использовано 65% пятичасовой и 26% недельной. Сохранить блок локальным коммитом без push; изменения skills/AGENTS/.codex не включать. + ## Карта миграции (исходная инвентаризация этапа 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-6.md b/docs/ux-ui-stage-6.md index ee801ce6..ccc17114 100644 --- a/docs/ux-ui-stage-6.md +++ b/docs/ux-ui-stage-6.md @@ -42,3 +42,7 @@ 4. **Приёмка.** Просмотреть ПК/телефон, проверить переходы, пустое состояние, частичный сбой и клавиатуру; профильные тесты, TypeScript, lint и production build общей оболочки. HTTP-моки проверяют интерфейс и запросы. Реальные данные, права и RouterOS требуют тестового окружения. Формы и полный URL-контекст сервера относятся к этапу 7; сетевые операции в этапе 6 не менять. + +## Состояние реализации + +Шапка и навигация завершены. Иерархия дашборда, подписи и полные счётчики проблем проверены. Остаются ссылки очереди на конкретные объекты и их приёмка на принимающих страницах. Общая регрессия текущего состояния: 65 сценариев passed; production build успешен. Подробнее — ux-ui-progress.md. diff --git a/hooks/use-dashboard-live.ts b/hooks/use-dashboard-live.ts index 6e9b5eb2..3e70f891 100644 --- a/hooks/use-dashboard-live.ts +++ b/hooks/use-dashboard-live.ts @@ -630,11 +630,10 @@ export function useDashboardLive() { const attentionServers: AttentionRow[] = useMemo(() => { return servers .filter((s) => s.enabled && s.status !== "online") - .slice(0, 5) .map((s) => ({ id: s.id, title: s.name, - hint: s.status === "degraded" ? "degraded" : "offline", + hint: s.status === "degraded" ? "Ухудшение" : "Не в сети", href: "/servers", tone: s.status === "degraded" ? ("warning" as const) : ("destructive" as const), })) @@ -643,11 +642,10 @@ export function useDashboardLive() { const attentionOverlay: AttentionRow[] = useMemo(() => { return overlay.items .filter((i) => i.status !== "up") - .slice(0, 5) .map((i) => ({ id: i.id, title: i.name, - hint: i.status === "degraded" ? "degraded" : "down", + hint: i.status === "degraded" ? "Ухудшение" : "Не работает", href: i.href, tone: i.status === "degraded" ? ("warning" as const) : ("destructive" as const), })) @@ -656,11 +654,10 @@ export function useDashboardLive() { const attentionProbes: AttentionRow[] = useMemo(() => { return enabledProbes .filter((p) => p.status === "down" || p.status === "warn") - .slice(0, 5) .map((p) => ({ id: p.id, title: p.name, - hint: p.status === "warn" ? "warn" : "down", + hint: p.status === "warn" ? "Предупреждение" : "Ошибка", href: "/uptime", tone: p.status === "warn" ? ("warning" as const) : ("destructive" as const), })) @@ -714,6 +711,7 @@ export function useDashboardLive() { totalServers: servers.length, probeDown, probeWarn, + probeCount: enabledProbes.length, latency, recentEvents, eventsLoading, diff --git a/tests/e2e/dashboard-data.spec.ts b/tests/e2e/dashboard-data.spec.ts index 52da1656..457f3092 100644 --- a/tests/e2e/dashboard-data.spec.ts +++ b/tests/e2e/dashboard-data.spec.ts @@ -1,11 +1,11 @@ import { test, expect, type Page } from "@playwright/test" -async function setup(page: Page, initiallyFailed = false) { +async function setup(page: Page, initiallyFailed = false, serverCount = 1) { const control = { failed: initiallyFailed, partial: false, prefixes: 321, hold: false, holdPath: "/api/bgp/sessions", fallback: false, failFallback: false, pending: 0, release: () => {} } let release!: () => void const gate = new Promise(resolve => { release = resolve }) control.release = release - const servers = [{ id: 1, name: "Узел", host: "192.0.2.1", site: "TEST", country: "RU", asn: "64512", type: "exit-node", enabled: true, status: "online", latency: 10, os: "7", model: "CHR" }] + const servers = Array.from({ length: serverCount }, (_, index) => ({ id: index + 1, name: serverCount === 1 ? "Узел" : `Узел ${index + 1}`, host: `192.0.2.${index + 1}`, site: "TEST", country: "RU", asn: "64512", type: "exit-node", enabled: true, status: serverCount === 1 ? "online" : "offline", latency: 10, os: "7", model: "CHR" })) const snapshot = { sampledAt: "2026-09-20T00:00:00Z", servers, greTunnels: [], filtersRulesets: [], speedProbes: [], routeLookupByServerId: {}, wanRuntimeByHomeId: {} } await page.route("**/health", route => route.fulfill({ json: { ok: true } })) await page.route("**/api/**", async route => { @@ -38,16 +38,40 @@ async function setup(page: Page, initiallyFailed = false) { } const refresh = (page: Page) => page.getByRole("button", { name: "Обновить данные", exact: true }) -const bgp = (page: Page) => page.getByRole("link").filter({ hasText: "BGP Rx" }) +const bgp = (page: Page) => page.getByRole("link").filter({ hasText: "Префиксы BGP" }) + +test("Дашборд: очередь над измерениями, полный счётчик и раскрытие остальных проблем", async ({ page }) => { + await setup(page, false, 7) + await expect(refresh(page)).toBeEnabled() + const queue = page.getByRole("region", { name: "Требует внимания", exact: true }) + await expect(queue.getByText("7", { exact: true })).toBeVisible() + const serverLinks = queue.getByRole("link", { name: /^Узел \d/ }) + await expect(serverLinks).toHaveCount(5) + const more = queue.locator("summary").filter({ hasText: "Ещё 2" }) + await more.focus() + await more.press("Enter") + await expect(serverLinks).toHaveCount(7) + await more.press("Enter") + await expect(serverLinks).toHaveCount(5) + const queueBox = await queue.boundingBox() + const measurementBox = await page.getByText("Задержка до серверов", { exact: true }).boundingBox() + expect(queueBox!.y + queueBox!.height).toBeLessThan(measurementBox!.y) + await expect(queue.getByText("Нет активных проверок", { exact: true })).toBeVisible() + await page.screenshot({ path: "test-results/dashboard-priority-desktop.png", animations: "disabled" }) + await page.setViewportSize({ width: 390, height: 844 }) + await page.getByRole("navigation", { name: "Путь страницы" }).scrollIntoViewIfNeeded() + await page.screenshot({ path: "test-results/dashboard-priority-mobile.png", animations: "disabled" }) + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true) +}) test("Дашборд: первоначальная ошибка не подменяется нулями и исправным состоянием", async ({ page }) => { const control = await setup(page, true) await expect(refresh(page)).toBeEnabled() await expect(page.getByText("Не обновлены источники: 9", { exact: true }).first()).toBeVisible() - await expect(page.getByRole("link").filter({ hasText: "Флот" })).toContainText("—") + await expect(page.getByRole("link").filter({ hasText: "Серверы онлайн" })).toContainText("—") await expect(bgp(page)).toContainText("—") - await expect(page.getByText("Все online", { exact: true })).toHaveCount(0) - await expect(page.getByText("Пробы up", { exact: true })).toHaveCount(0) + await expect(page.getByText("Серверы без проблем", { exact: true })).toHaveCount(0) + await expect(page.getByText("Проверки без ошибок", { exact: true })).toHaveCount(0) control.failed = false await refresh(page).click() await expect(bgp(page)).toContainText("321")