diff --git a/app/(main)/traffic/page.tsx b/app/(main)/traffic/page.tsx index 54816d93..e42b0edc 100644 --- a/app/(main)/traffic/page.tsx +++ b/app/(main)/traffic/page.tsx @@ -797,7 +797,7 @@ export default function TrafficPage() { const [flowScope, setFlowScope] = useState("servers") const [flowRetry, setFlowRetry] = useState(0) const flowKey = JSON.stringify([isLive, groupMode, backendUrl, range, flowScope, flowRetry]) - const [flowResult, setFlowResult] = useState<{ key: string; receivedAt?: number; stats?: FlowStatsDto; exporters?: FlowEntityCard[]; clients?: FlowEntityCard[]; error?: string } | null>(null) + const [flowResult, setFlowResult] = useState<{ key: string; receivedAt?: number; monthlyServers?: ServerRead[]; stats?: FlowStatsDto; exporters?: FlowEntityCard[]; clients?: FlowEntityCard[]; error?: string } | null>(null) const currentFlows = flowResult?.key === flowKey ? flowResult : null const liveBusy = isLive && groupMode === "flows" && !currentFlows const liveError = currentFlows?.error @@ -879,10 +879,20 @@ export default function TrafficPage() { useEffect(() => { if (!isLive || effectiveMode !== "flows") return let cancelled = false + if (range === "30d") { + void listServers(backendUrl).then(servers => { + if (cancelled) return + setFlowResult({ key: flowKey, receivedAt: Date.now(), monthlyServers: servers }) + setSelectedId(prev => servers.some(server => String(server.id) === prev) ? prev : String(servers[0]?.id ?? "")) + }).catch(error => { + if (!cancelled) setFlowResult({ key: flowKey, error: error instanceof Error ? error.message : "Не удалось загрузить каталог серверов" }) + }) + return () => { cancelled = true } + } void Promise.all([ - getTrafficFlows(backendUrl, range === "30d" ? "24h" : range), - getFlowExporters(backendUrl, range === "30d" ? "24h" : range), - getFlowClients(backendUrl, range === "30d" ? "24h" : range), + getTrafficFlows(backendUrl, range), + getFlowExporters(backendUrl, range), + getFlowClients(backendUrl, range), ]).then(([stats, exporters, clients]) => { if (cancelled) return setFlowResult({ key: flowKey, receivedAt: Date.now(), stats, exporters: exporters.exporters, clients: clients.clients }) @@ -1049,7 +1059,12 @@ export default function TrafficPage() { return sortDir === "desc" ? -v : v }) }, [flowCards, q, sortField, sortDir]) - const selFlowCard = sortedFlowCards.find((c) => c.id === selectedId) ?? sortedFlowCards[0] ?? null + const monthlyServers = currentFlows?.monthlyServers ?? [] + const visibleMonthlyServers = monthlyServers.filter(server => !q || server.name.toLowerCase().includes(q)).sort((a, b) => a.name.localeCompare(b.name)) + const monthlyServer = monthlyServers.find(server => String(server.id) === selectedId) + const selFlowCard = range === "30d" + ? (monthlyServer ? { id: String(monthlyServer.id), name: monthlyServer.name, country: monthlyServer.country, site: monthlyServer.site } : null) + : flowCards.find((c) => c.id === selectedId) ?? null const displayedFlow = currentFlows && !liveError ? (flowLiveSample ?? currentAnalytics?.data ?? null) : null const selServer = useMemo(() => activeServerTraffic.find(s => s.id === selectedId) ?? activeServerTraffic[0], [selectedId, activeServerTraffic]) @@ -1242,7 +1257,7 @@ export default function TrafficPage() { className="w-full pl-8 pr-3 h-8 text-xs bg-muted/50 border border-border rounded-md outline-none focus:border-primary transition-colors" /> -
+
Сортировка: {visibleSortFields.map(({ field, label }) => (
- {range === "30d" &&

Выбор сервера: активность за последние 24 часа. Месячный отчёт — в деталях сервера.

} + {range === "30d" &&

Серверы из каталога, включая неактивные. Удалённые серверы в список не входят.

}
- {sortedFlowCards.map((card) => ( + {range === "30d" ? visibleMonthlyServers.map(server => ) : sortedFlowCards.map((card) => ( { setFlowIface("__all__"); setSelectedId(card.id) }} /> ))} - {!liveBusy && !liveError && sortedFlowCards.length === 0 ? ( + {!liveBusy && !liveError && (range === "30d" ? visibleMonthlyServers.length : sortedFlowCards.length) === 0 ? (

- {flowEmptyHint(flowStats, collectorAlive) ?? "Нет экспортёров IPFIX. Подключите jump-host."} + {range === "30d" ? (q ? "Нет серверов по этому запросу" : "В каталоге нет серверов") : flowEmptyHint(flowStats, collectorAlive) ?? "Нет экспортёров IPFIX. Подключите jump-host."}

) : null}
@@ -1281,11 +1296,11 @@ export default function TrafficPage() { {analyticsLoading &&

Загрузка аналитики…

} {currentAnalytics?.error && Аналитика недоступна{currentAnalytics.error}} - {isLive && (displayedFlow || currentAnalytics?.monthly) && } + {isLive && (displayedFlow || (monthlyServer && currentAnalytics?.monthly)) && } & Partial) | null analytics: FlowAnalyticsDto | null monthly?: FlowMonthlyDto monthlyAvailable?: boolean @@ -355,23 +355,15 @@ export function FlowAnalyticsDetail({ setSlice("sessions") } - if (!card) { - return ( -

- Выберите сервер или клиента слева -

- ) - } - return ( <>
- -

{card.name}

+ {card?.status && } +

{card?.name ?? "Выберите сервер или клиента"}

- {card.country !== "UN" ? : null} - {card.site} + {card?.country && card.country !== "UN" ? : null} + {card?.site}
@@ -482,8 +474,8 @@ export function FlowAnalyticsDetail({ {analytics && <> @@ -536,7 +528,7 @@ export function FlowAnalyticsDetail({ { id: "flows", label: "Сессии", - value: String(analytics?.conversations ?? card.sessions), + value: String(analytics?.conversations ?? card?.sessions ?? 0), hint: analytics?.conversationsRaw != null && analytics.conversationsRaw !== analytics.conversations ? `до дедупа ${analytics.conversationsRaw}` : undefined, diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 75c45b0a..c72591f2 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -88,8 +88,8 @@ flowchart LR Ближайший оставшийся объём: -1. Молчащий SSE и CRLF/heartbeat проверены в блоках 8.6–8.7. Одиночные точки графика исправлены в 8.8; ожидание первого sample проверено в 8.9. Выбор месяца реализован в 8.10; следующий шаг — архивные серверы и доступность. -2. Итоговая приёмка этапа 8: выбор серверов без активности за 24 часа, ReUI/accessibility checklist. Промежуточная матрица проверок сохранена в [этапе 8](ux-ui-stage-8.md). +1. Молчащий SSE и CRLF/heartbeat проверены в блоках 8.6–8.7. Одиночные точки графика исправлены в 8.8; ожидание первого sample проверено в 8.9. Выбор месяца реализован в 8.10, каталог неактивных серверов — в 8.11. Следующий шаг — доступность и итоговая приёмка. +2. Итоговая приёмка этапа 8: ReUI/accessibility checklist и регрессия; удалённые серверы остаются ограничением. Промежуточная матрица проверок сохранена в [этапе 8](ux-ui-stage-8.md). 3. После фиксации результата этапа 8 — этап 9. ## Порядок продолжения и журналирования diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index a8843a83..80910b91 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -24,13 +24,21 @@ | 5 | Состояния данных, свежесть, частичные сбои, гонки polling | Завершён 2026-09-20 для дашборда; границы проверок ниже | | 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже | | 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже | -| 8 | Трафик и статистика | В работе: выполнены блоки 8.1–8.10; выбор месяца готов, далее архивные серверы и доступность | +| 8 | Трафик и статистика | В работе: выполнены блоки 8.1–8.11; каталог месячного отчёта готов, далее доступность и приёмка | | 9 | VPN, Firewall и Filters | Не начат | | 10 | Маршрутизация, диагностика, карта и терминал | Не начат | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 8.11 — каталог серверов месячного отчёта (2026-09-28) + +- Для месячного режима список загружается через существующий listServers, независимо от активности IPFIX за 24 часа. Выбор доступен даже при полном отсутствии активных экспортёров. В списке только имена; скорости, графики и статус без подтверждения не подставляются. Сортировка по live-метрикам скрыта, поиск работает по имени. +- Ответ каталога привязан к flowKey и защищён флагом отмены; ошибка имеет повтор и скрывает недоступный отчёт. Выбор сохраняется при успешном повторе, если сервер остаётся в каталоге. Детали используют выбранный ID, а не первую карточку отфильтрованного поиска. +- Все 25 flow-E2E прошли. Новый сценарий проверяет сервер без потоков, правильный serverId, отсутствие мини-графиков, 403 каталога и повтор; после расширения отдельно проверен вход при полностью пустом списке экспортёров. Адаптивные 320/390/1440 входят в набор. Итоговые lint, diff-check и production build/TypeScript прошли (34 страницы). +- Граница: серверы, удалённые из каталога, не перечисляются; это явно указано в интерфейсе. API архивного каталога не добавлялся. Реальные RouterOS/IPFIX/JWT не проверены. +- Далее: ReUI/accessibility checklist для трафика/статистики и итоговая регрессия этапа 8; удалённые серверы и экспорт зафиксировать как оставшиеся ограничения. Использовано 77% пятичасового и 27% недельного лимита на контрольной точке. Локальный коммит без push. + ## Блок 8.10 — выбор месяца flow-отчёта (2026-09-28) - Месячный отчёт использует выбранный календарный месяц UTC через существующий GET /api/traffic/flow/monthly. Доступны текущий и прошлые месяцы; пустые, некорректные и будущие значения блокируют запрос и показывают связанную с полем ошибку. Текущий месяц остаётся начальным значением. diff --git a/docs/ux-ui-stage-8.md b/docs/ux-ui-stage-8.md index d483337c..c4e7aaec 100644 --- a/docs/ux-ui-stage-8.md +++ b/docs/ux-ui-stage-8.md @@ -52,3 +52,5 @@ Обновление 8.9 (2026-09-28): проверено ожидание первого sample в открытом SSE для flow и сервера, heartbeat без sample, таймаут и переход с истории на live. 28 профильных E2E, lint/TypeScript прошли; production-код не менялся. Далее — решение по месячному отчёту и доступность. Обновление 8.10 (2026-09-28): добавлен выбор прошлого/текущего месяца UTC; валидация не отправляет пустые/будущие значения, ключ запроса защищает от позднего отчёта. 23 прежних и 1 новый flow-E2E прошли, lint/build чистые. Ограничение списка серверов по активности 24 часа остаётся следующим шагом; полный аудит доступности ещё не завершён. + +Обновление 8.11 (2026-09-28): месячный режим выбирает сервер из каталога независимо от активности за 24 часа. 25 flow-E2E и дополнительная проверка пустого списка экспортёров прошли, как и lint/build. Удалённые из каталога серверы не перечисляются; далее доступность и итоговая регрессия этапа 8. diff --git a/tests/e2e/traffic-flow.spec.ts b/tests/e2e/traffic-flow.spec.ts index 7b783315..c3005f3d 100644 --- a/tests/e2e/traffic-flow.spec.ts +++ b/tests/e2e/traffic-flow.spec.ts @@ -2,13 +2,14 @@ import { installOpenLiveStream, resumeLiveStream, sendLiveHeartbeat } from "./li import { test, expect, type Page } from '@playwright/test' async function setup(page: Page) { - const control = { hold: '', holdIface: '', fail: false, failList: false, invalidSample: undefined as unknown, mergeSample: false, metrics: {} as Record, release: () => {} } + const control = { emptyExporters: false, failCatalog: false, hold: '', holdIface: '', fail: false, failList: false, invalidSample: undefined as unknown, mergeSample: false, metrics: {} as Record, release: () => {} } const gate = new Promise(resolve => { control.release = resolve }) const card = { id: '1', name: 'Экспортёр тест', subtitle: '', site: 'TEST', country: 'RU', status: 'online', rxNow: 2, txNow: 3, sessions: 1, rxSeries: [1, 2], txSeries: [2, 3], bytes: 200 } const data = { bpsNow: 1000000, bytes: 200, packets: 2, conversations: 1, uniqueSrc: 1, uniqueDst: 1, topProto: 'TCP', rxSeries: [1], txSeries: [2], applications: [], protocols: [], sources: [], destinations: [], interfaces: [], conversationsList: [], ifaces: [], live: false } await page.route('**/health', r => r.fulfill({ json: { ok: true } })) await page.route('**/api/**', async route => { const url = new URL(route.request().url()), path = url.pathname, range = url.searchParams.get('range') + if (path === '/api/servers') return route.fulfill(control.failCatalog ? { status: 403, json: { error: 'Каталог недоступен' } } : { json: [1, 2, 3].map(id => ({ id, name: id === 3 ? 'Сервер без потоков' : id === 2 ? 'Экспортёр второй' : 'Экспортёр тест', host: '127.0.0.1', port: 8728, useSsl: false, verifySsl: false, username: 'admin', password: '', type: 'jump-host', site: 'TEST', country: 'RU', asn: '', comment: '', enabled: true, lanSubnet: '', wanUplinks: [], createdAt: '', updatedAt: '', status: null, latency: null, os: null, model: null, uptime: null, cpuLoad: null, freeMemory: null, totalMemory: null, identityName: null, sessions: 0, polledAt: null })) }) if (path === '/api/traffic/flow/monthly' && control.hold === url.searchParams.get('month')) await gate if (path === '/api/traffic/flow/monthly') return route.fulfill({ json: { month: url.searchParams.get('month'), bytes: 1500, countries: [{ id: 'RU', label: 'Россия', bytes: 1500, packets: 1, bps: 0, percent: 100 }], services: [], asns: [] } }) if (path === '/api/traffic/flow/analytics') { @@ -19,7 +20,7 @@ async function setup(page: Page) { if (path === '/api/traffic/flow/live') return route.fulfill({ contentType: 'text/event-stream', body: 'event: sample\ndata: ' + JSON.stringify({ ...data, live: true }) + '\n\n' + (control.invalidSample === undefined ? '' : 'event: sample\ndata: ' + JSON.stringify(control.mergeSample ? { ...data, ...(control.invalidSample as object) } : control.invalidSample) + '\n\n') }) if (path === '/api/traffic/flow/exporters' && control.failList) return route.fulfill({ status: 403, json: { error: 'Нет доступа к экспортёрам' } }) const json = path === '/api/auth/config' ? { required: false } - : path === '/api/traffic/flow/exporters' ? { exporters: [card, { ...card, id: '2', name: 'Экспортёр второй' }] } + : path === '/api/traffic/flow/exporters' ? { exporters: control.emptyExporters ? [] : [card, { ...card, id: '2', name: 'Экспортёр второй' }] } : path === '/api/traffic/flow/clients' ? { clients: [{ ...card, id: 'client1', name: 'Клиент тест' }] } : path === '/api/traffic/flows' ? { exportersOnline: 1, bytesPerMin: 1, uniqueSrc: 1, uniqueDst: 1, topProto: 'TCP', talkers: [], listenerBound: true } : path === '/api/traffic/servers' ? { servers: [] } @@ -401,3 +402,28 @@ test('Flow: выбор месяца игнорирует поздний отчё await expect(field).toBeFocused() await expect(page.getByRole('button', { name: 'Месяц', exact: true })).toHaveAttribute('aria-pressed', 'true') }) + + +test('Flow: месячный отчёт доступен серверу без активных потоков; ошибка каталога допускает повтор', async ({ page }) => { + const control = await setup(page) + control.emptyExporters = true + await page.getByRole('button', { name: 'Обновить', exact: true }).click() + await expect(page.getByRole('heading', { name: 'Выберите сервер или клиента', exact: true })).toBeVisible() + await page.getByRole('button', { name: 'Месяц', exact: true }).click() + const inactive = page.getByRole('button', { name: 'Сервер без потоков', exact: true }) + const request = page.waitForRequest(r => r.url().includes('/flow/monthly?') && r.url().includes('serverId=3')) + await inactive.click() + await request + await expect(inactive).toHaveAttribute('aria-pressed', 'true') + await expect(page.getByRole('heading', { name: 'Сервер без потоков', exact: true })).toBeVisible() + await expect(page.getByRole('region', { name: 'Месячный отчёт' })).toBeVisible() + await expect(page.getByLabel('Мини-график истории RX/TX')).toHaveCount(0) + control.failCatalog = true + await page.getByRole('button', { name: 'Обновить', exact: true }).click() + await expect(page.getByRole('alert').filter({ hasText: 'Потоки недоступны' })).toContainText('Каталог недоступен') + await expect(page.getByRole('region', { name: 'Месячный отчёт' })).toHaveCount(0) + control.failCatalog = false + await page.getByRole('button', { name: 'Повторить загрузку потоков', exact: true }).click() + await expect(inactive).toHaveAttribute('aria-pressed', 'true') + await expect(page.getByRole('region', { name: 'Месячный отчёт' })).toBeVisible() +})