fix(ui): корректно отображать историю в карточках трафика

This commit is contained in:
Denozordec committed 2026-09-24 15:54:53 +07:00
1 parent ccbc1aa029
commit f3568d836b
5 files changed
+71 -45

No files matched your search

+4 -22
View File
@@ -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 (
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height, display: "block" }} preserveAspectRatio="none">
<path d={area(rx)} fill="var(--chart-rx)" fillOpacity={0.12} />
<polyline points={line(rx)} fill="none" stroke="var(--chart-rx)" strokeWidth="1.4" strokeLinejoin="round" />
<path d={area(tx)} fill="var(--chart-tx)" fillOpacity={0.10} />
<polyline points={line(tx)} fill="none" stroke="var(--chart-tx)" strokeWidth="1.4" strokeLinejoin="round" />
</svg>
)
}
// ─── left-panel entity cards ──────────────────────────────────────────────────
@@ -369,7 +351,7 @@ function ServerCard({ s, selected, onClick }: { s: ServerTraffic; selected: bool
<Flag code={s.country} />{s.site}
</span>
</div>
<MiniAreaChart rx={s.rxSeries} tx={s.txSeries} />
<TrafficMiniChart rx={s.rxSeries} tx={s.txSeries} />
<div className="flex justify-between mt-2 gap-2">
<div className="flex items-center gap-1 text-[11px]">
<ArrowDownIcon className="size-3 text-emerald-500" />
@@ -413,7 +395,7 @@ function UserCard({ u, selected, onClick }: { u: UserTraffic; selected: boolean;
</div>
{hasClients ? (
<>
<MiniAreaChart rx={u.rxSeries} tx={u.txSeries} />
<TrafficMiniChart rx={u.rxSeries} tx={u.txSeries} />
<div className="flex justify-between mt-2 gap-2">
<div className="flex items-center gap-1 text-[11px]">
<ArrowDownIcon className="size-3 text-emerald-500" />
@@ -451,7 +433,7 @@ function IfaceCard({ c, selected, onClick }: { c: BoundIfaceTraffic; selected: b
<Flag code={c.serverCountry} />{c.serverSite}
</span>
</div>
<MiniAreaChart rx={c.rxSeries} tx={c.txSeries} />
<TrafficMiniChart rx={c.rxSeries} tx={c.txSeries} />
<div className="flex justify-between mt-2 gap-2">
<div className="flex items-center gap-1 text-[11px]">
<ArrowDownIcon className="size-3 text-emerald-500" />
@@ -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 <p className="text-xs text-muted-foreground">Нет истории трафика</p>
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<Array<{ x: number; y: number }>> = []
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 (
<svg role="img" aria-label="Мини-график истории RX/TX" viewBox={`0 0 ${width} ${height}`} style={{ width: "100%", height, display: "block" }} preserveAspectRatio="none">
{([[rx, "var(--chart-rx)"], [tx, "var(--chart-tx)"]] as const).map(([series, color], direction) => (
<g key={color} data-direction={direction === 0 ? "rx" : "tx"}>
{segments(series).map((points, index) => {
if (points.length === 1) return <circle key={index} cx={points[0].x} cy={points[0].y} r="1.5" fill={color} />
const line = points.map(p => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ")
return <Fragment key={index}>
<path d={`M ${points[0].x},${height} L ${line} L ${points[points.length - 1].x},${height} Z`} fill={color} fillOpacity={0.12} />
<polyline points={line} fill="none" stroke={color} strokeWidth="1.4" strokeLinejoin="round" />
</Fragment>
})}
</g>
))}
</svg>
)
}
+2 -22
View File
@@ -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<string, string> = {
"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 (
<svg viewBox={`0 0 ${W} ${H}`} className="w-full h-11" preserveAspectRatio="none">
<path d={area(rx)} fill="var(--chart-rx)" fillOpacity="0.15" />
<polyline points={line(rx)} fill="none" stroke="var(--chart-rx)" strokeWidth="1.5" />
{tx.some((v) => v > 0) ? (
<polyline points={line(tx)} fill="none" stroke="var(--chart-tx)" strokeWidth="1.5" />
) : null}
</svg>
)
}
export function FlowEntityCardView({
card,
@@ -97,7 +77,7 @@ export function FlowEntityCardView({
{card.site}
</span>
</div>
<MiniAreaChart rx={card.rxSeries} tx={card.txSeries} />
<TrafficMiniChart rx={card.rxSeries} tx={card.txSeries} />
<div className="flex justify-between mt-2 gap-2">
<div className="flex items-center gap-1 text-[11px]">
<ArrowDownIcon className="size-3 text-success" />
+7
View File
@@ -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, если второе направление содержит измерения. Линия отсутствующего направления не создаётся; подсказка содержит только доступное направление.
+21 -1
View File
@@ -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/)
})