From 8ac40ba78aa3b6ed8e757692c18d51556243904c Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 24 Sep 2026 01:36:36 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7=D1=8B?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=B6=D0=B4=D1=91=D0=BD=D0=BD=D1=8B=D0=B5=20=D0=B4=D0=B0?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D0=B5=20=D0=BC=D0=B5=D1=81=D1=8F=D1=87=D0=BD?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=20=D0=BE=D1=82=D1=87=D1=91=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/traffic/page.tsx | 61 +++++---------------- components/traffic/flow-analytics-panel.tsx | 25 +++++++-- docs/ux-ui-progress.md | 8 +++ tests/e2e/traffic-flow.spec.ts | 29 +++++++++- 4 files changed, 71 insertions(+), 52 deletions(-) diff --git a/app/(main)/traffic/page.tsx b/app/(main)/traffic/page.tsx index 742394e0..e06c7d9e 100644 --- a/app/(main)/traffic/page.tsx +++ b/app/(main)/traffic/page.tsx @@ -79,42 +79,6 @@ function flowEmptyHint(stats: FlowStatsDto | null, collectorAlive?: boolean): st return "IPFIX ещё не доходит до коллектора. На jump-host у target Src должен быть 0.0.0.0 (авто). На хосте MM проверьте bind 10.255.254.1:4739 после wg-flow." } -function monthlyToAnalytics(m: FlowMonthlyDto): FlowAnalyticsDto { - const emptySeries = Array(60).fill(0) as number[] - return { - bpsNow: 0, - bytes: m.bytes, - packets: 0, - conversations: 0, - conversationsRaw: 0, - uniqueSrc: 0, - uniqueDst: 0, - topProto: "—", - topCategory: "—", - rxSeries: emptySeries, - txSeries: emptySeries, - applications: [], - protocols: [], - sources: [], - destinations: [], - interfaces: [], - asns: m.asns, - countries: m.countries, - categories: [], - services: m.services, - mapEdges: [], - conversationsList: [], - paths: [], - ifaces: [], - live: false, - degraded: false, - bytesPayload: m.bytes, - bytesOverlay: 0, - bytesMesh: 0, - bytesWire: 0, - } -} - // ─── data model ─────────────────────────────────────────────────────────────── interface BoundIfaceTraffic { @@ -858,7 +822,7 @@ export default function TrafficPage() { const flowExporters = useMemo(() => currentFlows?.exporters ?? [], [currentFlows]) const flowClients = useMemo(() => currentFlows?.clients ?? [], [currentFlows]) const [analyticsRetry, setAnalyticsRetry] = useState(0) - const [analyticsResult, setAnalyticsResult] = useState<{ key: string; data?: FlowAnalyticsDto; error?: string } | null>(null) + const [analyticsResult, setAnalyticsResult] = useState<{ key: string; data?: FlowAnalyticsDto; monthly?: FlowMonthlyDto; error?: string } | null>(null) const [flowIface, setFlowIface] = useState("__all__") const [flowDedup, setFlowDedup] = useState(true) const [flowExcludeMesh, setFlowExcludeMesh] = useState(true) @@ -928,9 +892,9 @@ export default function TrafficPage() { if (!isLive || effectiveMode !== "flows") return let cancelled = false void Promise.all([ - getTrafficFlows(backendUrl, range), - getFlowExporters(backendUrl, range), - getFlowClients(backendUrl, range), + getTrafficFlows(backendUrl, range === "30d" ? "24h" : range), + getFlowExporters(backendUrl, range === "30d" ? "24h" : range), + getFlowClients(backendUrl, range === "30d" ? "24h" : range), ]).then(([stats, exporters, clients]) => { if (cancelled) return setFlowResult({ key: flowKey, stats, exporters: exporters.exporters, clients: clients.clients }) @@ -951,14 +915,14 @@ export default function TrafficPage() { if (!isLive || effectiveMode !== "flows" || !selectedId) return let cancelled = false const request = range === "30d" - ? getFlowMonthly(backendUrl, { month: new Date().toISOString().slice(0, 7), serverId: flowScope === "servers" ? selectedId : undefined }).then(monthlyToAnalytics) + ? getFlowMonthly(backendUrl, { month: new Date().toISOString().slice(0, 7), serverId: flowScope === "servers" ? selectedId : undefined }).then(monthly => ({ monthly })) : getFlowAnalytics(backendUrl, { range, serverId: flowScope === "servers" ? selectedId : undefined, userId: flowScope === "users" ? selectedId : undefined, iface: flowIface, dedup: flowDedup, excludeMesh: flowExcludeMesh, excludeOverlay: flowExcludeOverlay, - }) - void request.then(data => { - if (!cancelled) setAnalyticsResult({ key: analyticsKey, data }) + }).then(data => ({ data })) + void request.then(result => { + if (!cancelled) setAnalyticsResult({ key: analyticsKey, ...result }) }).catch(e => { if (!cancelled) setAnalyticsResult({ key: analyticsKey, error: e instanceof Error ? e.message : "Не удалось загрузить аналитику" }) }) @@ -1231,7 +1195,7 @@ export default function TrafficPage() {
- {(!isLive || !(effectiveMode === "flows" ? liveError : trafficError)) &&
-
+
))}
+ {range === "30d" &&

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

}
{sortedFlowCards.map((card) => ( setRange(r as Range)} selectedIface={flowIface} diff --git a/components/traffic/flow-analytics-panel.tsx b/components/traffic/flow-analytics-panel.tsx index e2d68b1e..30e70a20 100644 --- a/components/traffic/flow-analytics-panel.tsx +++ b/components/traffic/flow-analytics-panel.tsx @@ -4,7 +4,7 @@ import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data- import { useMemo, useState } from "react" import { type ColumnDef, useTable } from "@tanstack/react-table" -import type { FlowAnalyticsDto, FlowBreakdownRow, FlowEntityCard, FlowPathRow, FlowTalkerDto } from "@mmapp/contracts/traffic-flow" +import type { FlowMonthlyDto, FlowAnalyticsDto, FlowBreakdownRow, FlowEntityCard, FlowPathRow, FlowTalkerDto } from "@mmapp/contracts/traffic-flow" import { ArrowDownIcon, ArrowUpIcon, GitBranchIcon, GlobeIcon, LayersIcon, NetworkIcon, RouteIcon, ShieldIcon, UsersIcon } from "lucide-react" import { Badge } from "@/components/reui/badge" import { KpiStatGrid, type KpiStatItem } from "@/components/reui-kit/kpi-stat-grid" @@ -39,7 +39,7 @@ const RANGE_LABELS: Record = { "1h": "1ч", "4h": "4ч", "24h": "24ч", - "30d": "месяц", + "30d": "Текущий месяц", } function MiniAreaChart({ rx, tx, height = 44 }: { rx: number[]; tx: number[]; height?: number }) { @@ -137,11 +137,13 @@ function FlowBreakdownGrid({ rows, empty, country, + monthly = false, onPick, }: { rows: FlowBreakdownRow[] empty?: string country?: boolean + monthly?: boolean onPick?: (row: FlowBreakdownRow) => void }) { const columns = useMemo[]>( @@ -197,12 +199,13 @@ function FlowBreakdownGrid({ [country], ) + const visibleColumns = useMemo(() => monthly ? columns.filter(column => column.id !== "rate") : columns, [columns, monthly]) const table = useTable({ features: dataGridFeatures, manualPagination: true, manualSorting: true, data: rows, - columns, + columns: visibleColumns, getRowId: (row) => row.id, }) @@ -316,6 +319,8 @@ function FlowPathsGrid({ export function FlowAnalyticsDetail({ card, analytics, + monthly, + monthlyAvailable = true, range, onRange, selectedIface, @@ -331,6 +336,8 @@ export function FlowAnalyticsDetail({ }: { card: FlowEntityCard | null analytics: FlowAnalyticsDto | null + monthly?: FlowMonthlyDto + monthlyAvailable?: boolean range: string onRange: (r: string) => void selectedIface: string @@ -379,6 +386,7 @@ export function FlowAnalyticsDetail({
- {RANGE_KEYS.map((r) => ( + {RANGE_KEYS.filter(r => r !== "30d" || monthlyAvailable).map((r) => (
+ {range === "30d" &&

Календарный месяц по UTC. Фильтры интерфейса, дублей, overlay и mesh к этому отчёту не применяются.

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

Период: {monthly.month} (UTC) · Объём: {formatBytes(monthly.bytes)}

+

Страны

+

Сервисы

+

ASN

+
} {analytics?.ifaces && analytics.ifaces.length > 0 ? (
diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index b1c02ef9..80dd665b 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -29,6 +29,14 @@ | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 8.3В — достоверный месячный отчёт (2026-09-24) + +- «Текущий месяц» явно означает календарный месяц UTC. Удалён адаптер, заполнявший отсутствующие скорости, сессии и графики нулями. Месячный DTO отображается отдельно: объём, страны, сервисы и ASN; колонки скорости скрыты. +- Общая оперативная сводка скрыта в месячном режиме. Выбор экспортёров использует явный период 24h с пояснением; месячный отчёт справа запрашивает month/serverId. Фильтры дублей, overlay и mesh отключены с пояснением; интерфейсный фильтр не отображается. +- Месячный период доступен только для серверов: при переходе к клиентам выбран 1h. Иначе прежний API monthly возвращал глобальную сумму вместо клиентского среза. Группе переключения источника добавлено доступное имя. +- 5 flow-E2E прошли, включая контракт monthly, отсутствие неподтверждённых метрик, отключённые фильтры и переход к клиентам. Lint панели/тестов чистый, production build/TypeScript прошли (34 страницы). Реальная агрегация IPFIX не проверялась. +- Ограничение: выбор сервера пока зависит от списка активности 24h; архивный сервер без этой активности не появляется в списке. Выбор прошлых месяцев не добавлен. Следующий блок: 8.4 — адаптивность 320/390/1440, подписи единиц и пустые состояния; остаются отдельные проверки смены backend/сервера и позднего SSE, строгая валидация sample. На остановке использовано 84% пятичасового и 60% недельного лимита. + ## Блок 8.3Б3 — контекст flow-аналитики (2026-09-24) - Списки flow и аналитика привязаны к backend, режиму, периоду, объекту и фильтрам. Поздние ответы отменённого эффекта игнорируются. Ошибки списка/аналитики получили отдельные Alert и повтор без сброса контекста; ошибка списка не выдаётся за отсутствие экспортёров. diff --git a/tests/e2e/traffic-flow.spec.ts b/tests/e2e/traffic-flow.spec.ts index 125bd297..21de7650 100644 --- a/tests/e2e/traffic-flow.spec.ts +++ b/tests/e2e/traffic-flow.spec.ts @@ -8,6 +8,7 @@ async function setup(page: Page) { 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/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') { if (control.hold === range) await gate if (control.fail) return route.fulfill({ status: 500, json: { error: 'Срез временно недоступен' } }) @@ -17,7 +18,7 @@ async function setup(page: Page) { 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] } - : path === '/api/traffic/flow/clients' ? { clients: [] } + : 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: [] } : path.endsWith('/interfaces') || path.endsWith('/bound-interfaces') ? { interfaces: [] } @@ -75,3 +76,29 @@ test('Flow: завершённый SSE не помечает последние await expect(page.getByText('Последние данные · live недоступен', { exact: true }).filter({ visible: true })).toHaveCount(2) await expect(page.getByText('live', { exact: true })).toHaveCount(0) }) + + +test('Flow: месяц показывает только доступные агрегаты и не применяется к клиенту', async ({ page }) => { + await setup(page) + const monthlyRequest = page.waitForRequest(r => r.url().includes('/flow/monthly?')) + await page.getByRole('button', { name: 'Текущий месяц', exact: true }).click() + const url = new URL((await monthlyRequest).url()) + expect(url.searchParams.get('serverId')).toBe('1') + expect(url.searchParams.get('month')).toMatch(/^\d{4}-\d{2}$/) + expect([...url.searchParams.keys()].sort()).toEqual(['month', 'serverId']) + const report = page.getByRole('region', { name: 'Месячный отчёт' }) + await expect(report).toContainText('1.5 КБ') + await expect(report.getByRole('columnheader').filter({ hasText: 'Скорость' })).toHaveCount(0) + await expect(page.getByLabel('Скорость потоков')).toHaveCount(0) + await expect(page.getByLabel('Сводка трафика')).toHaveCount(0) + await expect(page.getByRole('switch', { name: 'Без дублей' })).toBeDisabled() + await expect(page.getByRole('switch', { name: 'Без overlay' })).toBeDisabled() + await expect(page.getByRole('switch', { name: 'Без mesh' })).toBeDisabled() + const clientRequest = page.waitForRequest(r => r.url().includes('/flow/analytics?') && r.url().includes('userId=client1')) + await page.getByRole('group', { name: 'Источник потоков' }).getByRole('button', { name: 'Клиенты', exact: true }).click() + expect(new URL((await clientRequest).url()).searchParams.get('range')).toBe('1h') + await expect(page.getByLabel('Скорость потоков')).toBeVisible() + await expect(report).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Текущий месяц', exact: true })).toHaveCount(0) + await expect(page.getByRole('switch', { name: 'Без дублей' })).toBeEnabled() +})