From 1d98a2a66eb56975cd9a159ce98c9902216a0b3d Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 24 Sep 2026 15:58:23 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D1=8F=D1=82=D1=8C=20=D0=BF=D1=80=D0=BE=D0=BF=D1=83=D1=81=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B2=20=D0=BC=D0=B8=D0=BD=D0=B8-=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D1=84=D0=B8=D0=BA=D0=B0=D1=85=20=D0=B8=D0=B7=D0=BC=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/sparkline.tsx | 39 ++++++++++++++++++++----------- docs/ux-ui-progress.md | 7 ++++++ tests/e2e/traffic-details.spec.ts | 21 ++++++++++++++++- 3 files changed, 52 insertions(+), 15 deletions(-) diff --git a/components/sparkline.tsx b/components/sparkline.tsx index 24b299cf..8865a578 100644 --- a/components/sparkline.tsx +++ b/components/sparkline.tsx @@ -1,5 +1,7 @@ +import { Fragment } from "react" + interface SparklineProps { - data: number[] + data: Array width?: number height?: number color?: string @@ -7,25 +9,34 @@ interface SparklineProps { } export function Sparkline({ data, width = 120, height = 28, color = "currentColor", filled = false }: SparklineProps) { - const valid = data.filter((v) => v != null && isFinite(v)) - if (valid.length < 2) return — + const valid = data.filter((value): value is number => typeof value === "number" && Number.isFinite(value)) + if (!valid.length) return — const max = Math.max(...valid) const min = Math.min(...valid) const span = Math.max(1, max - min) - - const pts = data.map((v, i) => [ - (i / (data.length - 1)) * width, - height - ((v - min) / span) * (height - 2) - 1, - ]) - - const lineD = pts.map(([x, y], i) => `${i === 0 ? "M" : "L"}${x.toFixed(1)},${y.toFixed(1)}`).join(" ") - const areaD = `${lineD} L${width},${height} L0,${height} Z` + const segments: Array> = [] + let segment: Array<[number, number]> = [] + data.forEach((value, index) => { + if (typeof value !== "number" || !Number.isFinite(value)) { segment = []; return } + if (!segment.length) segments.push(segment) + segment.push([ + data.length === 1 ? width / 2 : index / (data.length - 1) * width, + height - ((value - min) / span) * (height - 2) - 1, + ]) + }) return ( - - {filled && } - + + {segments.map((points, index) => { + if (points.length === 1) return + const line = points.map(([x, y], i) => `${i === 0 ? "M" : "L"}${x.toFixed(1)},${y.toFixed(1)}`).join(" ") + const area = `${line} L${points[points.length - 1][0]},${height} L${points[0][0]},${height} Z` + return + {filled && } + + + })} ) } diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 7dcacdde..b3f79728 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -29,6 +29,13 @@ | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 8.4З — разрывы общего Sparkline (2026-09-24) + +- Sparkline принимает null/undefined и исключает нечисловые значения из расчётов, сохраняя исходную позицию каждой точки. Линия и заливка разделены на сегменты, пропуски не соединяются. Одиночная точка (включая ноль) отображается; пустая история имеет доступное имя. Масштаб для валидных значений сохранён. +- 16 E2E трафика/дашборда прошли, включая новый сценарий с null между сегментами, координатой следующего сегмента, одиночным нулём и пустой историей. Дополнительно прошли 4 профильных сценария мониторинга RTT/Resources URL. Это профильная, не полная регрессия мониторинга. +- Lint компонента/теста и diff-check чистые. Production build/TypeScript прошли (34 страницы). Использовано 62% пятичасового и 87% недельного лимита; локальный коммит без push. +- Далее: свежесть трафика, смена backend/сервера и поздний SSE, исправление оставшегося flowIface-effect и общая приёмка этапа 8. Реальные IPFIX/RouterOS не проверены; этап 8 не объявлен завершённым. + ## Блок 8.4Ж — история в карточках трафика (2026-09-24) - Две реализации MiniAreaChart заменены общим TrafficMiniChart. Пустые/полностью невалидные серии показывают «Нет истории трафика»; одна точка не создаёт NaN; нулевой TX не скрывается. Координаты RX/TX используют общую длину временной шкалы. Невалидные точки разделяют линии и заливку на сегменты. diff --git a/tests/e2e/traffic-details.spec.ts b/tests/e2e/traffic-details.spec.ts index 06c56c96..17a14481 100644 --- a/tests/e2e/traffic-details.spec.ts +++ b/tests/e2e/traffic-details.spec.ts @@ -1,7 +1,7 @@ import { test, expect, type Page } from '@playwright/test' async function setup(page: Page) { - const control = { holdRange: '', failIface: false, series: undefined as number[] | undefined, txSeries: undefined as number[] | undefined, release: () => {} } + const control = { holdRange: '', failIface: false, series: undefined as Array | undefined, txSeries: undefined as number[] | undefined, release: () => {} } const gate = new Promise(resolve => { control.release = resolve }) const server = { id: '1', name: 'Первый сервер', site: 'TEST', country: 'RU', status: 'online', rxNow: 2, txNow: 3, rxPeak: 5, txPeak: 6, rxTotal: 100, txTotal: 200, sessions: 1, rxSeries: [1, 2], txSeries: [2, 3] } await page.route('**/health', route => route.fulfill({ json: { ok: true } })) @@ -124,3 +124,22 @@ test('Карточка трафика: пустая история, одна т await expect(card.locator('[data-direction="tx"] polyline')).toHaveCount(1) expect(await card.locator('svg').last().innerHTML()).not.toMatch(/NaN|Infinity/) }) + + +test('Итоги трафика: Sparkline сохраняет разрывы и одиночные измерения', async ({ page }) => { + const control = await setup(page) + control.series = [1, 2, null, 3, 4] + await page.getByRole('button', { name: '24ч', exact: true }).click() + const spark = page.getByRole('img', { name: 'История измерений', exact: true }).first() + await expect(spark.locator('path[fill="none"]')).toHaveCount(2) + await expect(spark.locator('path[fill="none"]').nth(1)).toHaveAttribute('d', /^M135\.0,/) + expect(await spark.innerHTML()).not.toMatch(/NaN|Infinity/) + control.series = [0] + await page.getByRole('button', { name: '15м', exact: true }).click() + await expect(spark.locator('circle')).toHaveCount(1) + await expect(spark.locator('circle')).toHaveAttribute('cx', '90') + control.series = [] + await page.getByRole('button', { name: '4ч', exact: true }).click() + await expect(page.getByLabel('Нет истории измерений')).toHaveCount(2) + await expect(page.getByRole('img', { name: 'История измерений', exact: true })).toHaveCount(0) +})