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()
+})