From f3568d836b28a51721f4e4619b8afc9432afccbb Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 24 Sep 2026 15:54:53 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D0=BA=D0=BE=D1=80=D1=80=D0=B5=D0=BA?= =?UTF-8?q?=D1=82=D0=BD=D0=BE=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D0=B8=D1=81=D1=82=D0=BE=D1=80=D0=B8=D1=8E?= =?UTF-8?q?=20=D0=B2=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B0?= =?UTF-8?q?=D1=85=20=D1=82=D1=80=D0=B0=D1=84=D0=B8=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/traffic/page.tsx | 26 +++------------ components/reui-kit/traffic-mini-chart.tsx | 37 +++++++++++++++++++++ components/traffic/flow-analytics-panel.tsx | 24 ++----------- docs/ux-ui-progress.md | 7 ++++ tests/e2e/traffic-details.spec.ts | 22 +++++++++++- 5 files changed, 71 insertions(+), 45 deletions(-) create mode 100644 components/reui-kit/traffic-mini-chart.tsx diff --git a/app/(main)/traffic/page.tsx b/app/(main)/traffic/page.tsx index 8fb12402..03581c00 100644 --- a/app/(main)/traffic/page.tsx +++ b/app/(main)/traffic/page.tsx @@ -5,6 +5,7 @@ import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert" import { PageHeader } from "@/components/page-header" import { Frame, FramePanel } from "@/components/reui/frame" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" +import { TrafficMiniChart } from "@/components/reui-kit/traffic-mini-chart" import { TrafficRxTxChart } from "@/components/reui-kit/traffic-rx-tx-chart" import { Button } from "@/components/ui/button" import { Sparkline } from "@/components/sparkline" @@ -331,25 +332,6 @@ function StatChip({ icon, label, value }: { icon: ReactNode; label: string; valu ) } -function MiniAreaChart({ rx, tx, height = 44 }: { rx: number[]; tx: number[]; height?: number }) { - const W = 300, H = height - const maxVal = Math.max(...rx, ...tx, 1) * 1.1 - const xAt = (i: number) => (i / (rx.length - 1)) * W - const yAt = (v: number) => H - (v / maxVal) * H - const area = (arr: number[]) => { - const pts = arr.map((v, i) => `${xAt(i).toFixed(1)},${yAt(v).toFixed(1)}`).join(" L ") - return `M 0,${H} L ${pts} L ${W},${H} Z` - } - const line = (arr: number[]) => arr.map((v, i) => `${xAt(i).toFixed(1)},${yAt(v).toFixed(1)}`).join(" ") - return ( - - - - - - - ) -} // ─── left-panel entity cards ────────────────────────────────────────────────── @@ -369,7 +351,7 @@ function ServerCard({ s, selected, onClick }: { s: ServerTraffic; selected: bool {s.site} - +
@@ -413,7 +395,7 @@ function UserCard({ u, selected, onClick }: { u: UserTraffic; selected: boolean;
{hasClients ? ( <> - +
@@ -451,7 +433,7 @@ function IfaceCard({ c, selected, onClick }: { c: BoundIfaceTraffic; selected: b {c.serverSite}
- +
diff --git a/components/reui-kit/traffic-mini-chart.tsx b/components/reui-kit/traffic-mini-chart.tsx new file mode 100644 index 00000000..4d7568c9 --- /dev/null +++ b/components/reui-kit/traffic-mini-chart.tsx @@ -0,0 +1,37 @@ +import { Fragment } from "react" + +/** Compact RX/TX history shared by traffic cards. Gaps remain gaps. */ +export function TrafficMiniChart({ rx, tx, height = 44 }: { rx: number[]; tx: number[]; height?: number }) { + const valid = (value: number) => Number.isFinite(value) && value >= 0 + const values = [...rx, ...tx].filter(valid) + if (!values.length) return

Нет истории трафика

+ const width = 300 + const count = Math.max(rx.length, tx.length) + const max = Math.max(...values, 1) * 1.1 + const segments = (series: number[]) => { + const result: Array> = [] + let current: Array<{ x: number; y: number }> = [] + series.forEach((value, index) => { + if (!valid(value)) { current = []; return } + if (!current.length) result.push(current) + current.push({ x: count <= 1 ? width / 2 : index / (count - 1) * width, y: height - value / max * height }) + }) + return result + } + return ( + + {([[rx, "var(--chart-rx)"], [tx, "var(--chart-tx)"]] as const).map(([series, color], direction) => ( + + {segments(series).map((points, index) => { + if (points.length === 1) return + const line = points.map(p => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ") + return + + + + })} + + ))} + + ) +} diff --git a/components/traffic/flow-analytics-panel.tsx b/components/traffic/flow-analytics-panel.tsx index 9d8834e8..158748b3 100644 --- a/components/traffic/flow-analytics-panel.tsx +++ b/components/traffic/flow-analytics-panel.tsx @@ -8,6 +8,7 @@ import type { FlowMonthlyDto, FlowAnalyticsDto, FlowBreakdownRow, FlowEntityCard 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" +import { TrafficMiniChart } from "@/components/reui-kit/traffic-mini-chart" import { TrafficRxTxChart } from "@/components/reui-kit/traffic-rx-tx-chart" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Switch } from "@/components/ui/switch" @@ -46,27 +47,6 @@ const RANGE_LABELS: Record = { "30d": "Текущий месяц", } -function MiniAreaChart({ rx, tx, height = 44 }: { rx: number[]; tx: number[]; height?: number }) { - const W = 300 - const H = height - const maxVal = Math.max(...rx, ...tx, 1) * 1.1 - const xAt = (i: number) => (rx.length <= 1 ? 0 : (i / (rx.length - 1)) * W) - const yAt = (v: number) => H - (v / maxVal) * H - const area = (arr: number[]) => { - const pts = arr.map((v, i) => `${xAt(i).toFixed(1)},${yAt(v).toFixed(1)}`).join(" L ") - return `M 0,${H} L ${pts} L ${W},${H} Z` - } - const line = (arr: number[]) => arr.map((v, i) => `${xAt(i).toFixed(1)},${yAt(v).toFixed(1)}`).join(" ") - return ( - - - - {tx.some((v) => v > 0) ? ( - - ) : null} - - ) -} export function FlowEntityCardView({ card, @@ -97,7 +77,7 @@ export function FlowEntityCardView({ {card.site}
- +
diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index f434e99b..7dcacdde 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -29,6 +29,13 @@ | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 8.4Ж — история в карточках трафика (2026-09-24) + +- Две реализации MiniAreaChart заменены общим TrafficMiniChart. Пустые/полностью невалидные серии показывают «Нет истории трафика»; одна точка не создаёт NaN; нулевой TX не скрывается. Координаты RX/TX используют общую длину временной шкалы. Невалидные точки разделяют линии и заливку на сегменты. +- 31 E2E деталей/flow/мобильных экранов прошёл. Новый сценарий проверяет пустую историю, одну точку, нулевой TX, разрыв RX и отсутствие NaN/Infinity в SVG. Проверены существующие ширины 320/390/1440. +- Lint нового компонента, flow-панели и теста чистый; diff-check, production build/TypeScript прошли (34 страницы). На странице остаётся ранее известная ошибка lint flowIface. Использовано 42% пятичасового и 84% недельного лимита; локальный коммит без push. +- Далее: общий Sparkline в итогах (его обработка разрывов не изменена этим блоком), свежесть трафика, проверки смены backend/сервера и позднего SSE, приёмка этапа 8. Реальные IPFIX/RouterOS не проверены. + ## Блок 8.4Е — отдельные отсутствующие направления RX/TX (2026-09-24) - Общий график поясняет отсутствие RX или TX, если второе направление содержит измерения. Линия отсутствующего направления не создаётся; подсказка содержит только доступное направление. diff --git a/tests/e2e/traffic-details.spec.ts b/tests/e2e/traffic-details.spec.ts index bbbcba2e..06c56c96 100644 --- a/tests/e2e/traffic-details.spec.ts +++ b/tests/e2e/traffic-details.spec.ts @@ -15,7 +15,7 @@ async function setup(page: Page) { return route.fulfill({ json: { server: { ...server, rxSeries: control.series ?? server.rxSeries, txSeries: control.txSeries ?? control.series ?? server.txSeries, name: `Детали ${range} ${iface}`, rxNow: range === '24h' ? 24 : 15 } } }) } const json = path === '/api/auth/config' ? { required: false } - : path === '/api/traffic/servers' ? { servers: [server] } + : path === '/api/traffic/servers' ? { servers: [{ ...server, rxSeries: control.series ?? server.rxSeries, txSeries: control.txSeries ?? control.series ?? server.txSeries }] } : path === '/api/traffic/servers/1/interfaces' ? { interfaces: [{ name: 'ether2', running: true, disabled: false, rxNow: 0, txNow: 0 }] } : path.endsWith('/interfaces') || path === '/api/traffic/bound-interfaces' ? { interfaces: [] } : path === '/api/traffic/users' ? { users: [] } @@ -104,3 +104,23 @@ for (const missing of ['RX', 'TX'] as const) { await expect(chart.getByText((missing === 'RX' ? 'TX' : 'RX') + ':', { exact: true })).toBeVisible() }) } + + +test('Карточка трафика: пустая история, одна точка, нулевой TX и разрыв', async ({ page }) => { + const control = await setup(page) + const card = page.getByRole('button').filter({ hasText: 'Первый сервер' }) + control.series = [] + control.txSeries = [] + await page.getByRole('button', { name: '24ч', exact: true }).click() + await expect(card).toContainText('Нет истории трафика') + control.series = [2] + await page.getByRole('button', { name: '15м', exact: true }).click() + await expect(card.locator('circle')).toHaveCount(1) + await expect(card.locator('circle')).toHaveAttribute('cx', '150') + control.series = [2, 3, -1, 4, 5] + control.txSeries = [0, 0, 0, 0, 0] + await page.getByRole('button', { name: '4ч', exact: true }).click() + await expect(card.locator('[data-direction="rx"] polyline')).toHaveCount(2) + await expect(card.locator('[data-direction="tx"] polyline')).toHaveCount(1) + expect(await card.locator('svg').last().innerHTML()).not.toMatch(/NaN|Infinity/) +})