From d267e40157bb2ba74a8d8b668b887c926e768528 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 20 Aug 2026 12:27:21 +0700 Subject: [PATCH] =?UTF-8?q?fix(health):=20=D0=B2=D1=8B=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=BD=D1=8F=D1=82=D1=8C=20=D0=B3=D1=80=D0=B0=D1=84=D0=B8=D0=BA?= =?UTF-8?q?=20=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=B8=20KPI=20=D0=BD=D0=B0=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=BA=D0=B5=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../reui-kit/health-source-tiles.tsx | 81 +++--- .../src/components/reui-kit/kpi-stat-grid.tsx | 25 +- .../components/reui-kit/uptime-chart.test.ts | 75 ++++++ .../src/components/reui-kit/uptime-chart.tsx | 245 ++++++++++++------ 4 files changed, 304 insertions(+), 122 deletions(-) create mode 100644 apps/web/src/components/reui-kit/uptime-chart.test.ts diff --git a/apps/web/src/components/reui-kit/health-source-tiles.tsx b/apps/web/src/components/reui-kit/health-source-tiles.tsx index 499c36e..db563ff 100644 --- a/apps/web/src/components/reui-kit/health-source-tiles.tsx +++ b/apps/web/src/components/reui-kit/health-source-tiles.tsx @@ -298,45 +298,50 @@ export function HealthSourceFilterBar({ const active = selected.length > 0 ? selected : enabled return ( - { - const values = next.filter((value): value is HealthProvider => - visible.some((item) => item.id === value), - ) - if (values.length === 0) return - onChange(values) - }} - > - {visible.map((item) => ( - - - {item.title} - - - ))} - + + + {item.title} + + + + ))} + + ) } diff --git a/apps/web/src/components/reui-kit/kpi-stat-grid.tsx b/apps/web/src/components/reui-kit/kpi-stat-grid.tsx index e75db71..6f4dadc 100644 --- a/apps/web/src/components/reui-kit/kpi-stat-grid.tsx +++ b/apps/web/src/components/reui-kit/kpi-stat-grid.tsx @@ -67,7 +67,7 @@ function resolveFooter(item: KpiStatItem): ReactNode { if (item.footer) return item.footer if (typeof item.hint === 'string') { return ( - + {item.hint} ) @@ -81,30 +81,39 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) { const valueVariant = item.variant ?? 'default' return ( -
+
{item.icon ? ( ) : null}
-
-
{item.label}
- {footer ?
{footer}
: null} +
+
+ {item.label} +
+ {footer ? ( +
+ {footer} +
+ ) : null}
{item.value}
+ {footer ? ( +
{footer}
+ ) : null}
) @@ -116,7 +125,7 @@ function panelClassName(item: KpiStatItem, className?: string) { const selected = isSelected(item) return cn( - 'relative isolate flex h-full flex-col', + 'relative isolate flex h-full min-w-0 flex-col', clickable && 'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2', selected && 'ring-primary/30 bg-muted/30 ring-1', diff --git a/apps/web/src/components/reui-kit/uptime-chart.test.ts b/apps/web/src/components/reui-kit/uptime-chart.test.ts new file mode 100644 index 0000000..b7e9155 --- /dev/null +++ b/apps/web/src/components/reui-kit/uptime-chart.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' + +import { toAlignedSeries, type UptimeProbe } from './uptime-chart' + +function probe( + overrides: Partial & Pick, +): UptimeProbe { + return { + status: 'up', + ok: true, + latency_ms: 10, + checked_at: '2026-01-01T00:00:00.000Z', + provider: 'local', + ...overrides, + } +} + +describe('toAlignedSeries', () => { + it('puts mixed-source probes in one 60s bucket instead of a sawtooth series', () => { + const { points, keys } = toAlignedSeries([ + probe({ + id: 1, + provider: 'local', + latency_ms: 4, + checked_at: '2026-01-01T00:00:10.000Z', + }), + probe({ + id: 2, + provider: 'cloudflare', + latency_ms: 284, + checked_at: '2026-01-01T00:00:12.000Z', + }), + probe({ + id: 3, + provider: 'globalping', + latency_ms: 38, + checked_at: '2026-01-01T00:00:40.000Z', + }), + ]) + + expect(points).toHaveLength(1) + expect(points[0]?.local).toBe(4) + expect(points[0]?.cloudflare).toBe(284) + expect(points[0]?.globalping).toBe(38) + expect(keys).toEqual(['local', 'cloudflare', 'globalping']) + }) + + it('does not plot down probes as latency 0', () => { + const { points } = toAlignedSeries([ + probe({ + id: 1, + status: 'down', + ok: false, + latency_ms: 12, + provider: 'local', + }), + ]) + + expect(points).toHaveLength(1) + expect(points[0]?.local).toBeNull() + expect(points[0]?.localOk).toBe(false) + expect(points[0]?.ok).toBe(false) + }) + + it('splits probes that fall into adjacent minutes', () => { + const { points } = toAlignedSeries([ + probe({ id: 1, latency_ms: 10, checked_at: '2026-01-01T00:00:50.000Z' }), + probe({ id: 2, latency_ms: 20, checked_at: '2026-01-01T00:01:10.000Z' }), + ]) + + expect(points).toHaveLength(2) + expect(points[0]?.local).toBe(10) + expect(points[1]?.local).toBe(20) + }) +}) diff --git a/apps/web/src/components/reui-kit/uptime-chart.tsx b/apps/web/src/components/reui-kit/uptime-chart.tsx index 05e03cf..6a8bef9 100644 --- a/apps/web/src/components/reui-kit/uptime-chart.tsx +++ b/apps/web/src/components/reui-kit/uptime-chart.tsx @@ -1,6 +1,6 @@ -import { useEffect, useId, useMemo, useState } from 'react' +import { useId, useMemo, useState } from 'react' import { ActivityIcon, InfoIcon, TrendingDownIcon, TrendingUpIcon } from 'lucide-react' -import { Area, AreaChart, XAxis } from 'recharts' +import { Area, ComposedChart, Line, XAxis } from 'recharts' import { EmptyState } from '@/components/empty-state' import { Badge } from '@/components/reui/badge' @@ -22,12 +22,13 @@ import { TooltipProvider, TooltipTrigger, } from '@cfdm/ui/components/tooltip' +import type { HealthCheckProvider } from '@cfdm/shared' /** * Uptime monitoring card — chart-17 DNA (Frame + value + AreaChart + period tabs). * Preview: https://reui.io/preview/base/chart-17 * Frame: https://reui.io/docs/components/base/frame - * Chart: shadcn Chart + Recharts AreaChart + * Chart: shadcn Chart + Recharts ComposedChart */ export interface UptimeProbe { @@ -36,6 +37,7 @@ export interface UptimeProbe { ok: boolean latency_ms: number | null checked_at: string + provider?: HealthCheckProvider } export type UptimePeriodKey = '5D' | '2W' | '1M' @@ -46,40 +48,107 @@ export const UPTIME_PERIODS: { key: UptimePeriodKey; label: string; days: number { key: '1M', label: '1M', days: 30 }, ] +export const UPTIME_BUCKET_MS = 60_000 + +export const UPTIME_PROVIDER_KEYS = ['local', 'cloudflare', 'globalping'] as const + +export type UptimeProviderKey = (typeof UPTIME_PROVIDER_KEYS)[number] + const chartConfig = { - latency: { - label: 'Задержка', - color: 'var(--chart-1)', + local: { + label: 'Local', + color: 'var(--info)', + }, + cloudflare: { + label: 'Cloudflare', + color: 'var(--warning)', + }, + globalping: { + label: 'Globalping', + color: 'var(--success)', }, } satisfies ChartConfig -interface ChartPoint { +export interface AlignedChartPoint { period: string - latency: number - ok: boolean at: string - status: UptimeProbe['status'] + ok: boolean + local?: number | null + cloudflare?: number | null + globalping?: number | null + localOk?: boolean + cloudflareOk?: boolean + globalpingOk?: boolean } -function toSeries(items: UptimeProbe[]): ChartPoint[] { - return [...items] - .sort((a, b) => probeTime(a.checked_at) - probeTime(b.checked_at)) - .map((item) => ({ - period: formatDate(item.checked_at), - latency: item.latency_ms ?? 0, - ok: item.ok && item.status !== 'down', - at: item.checked_at, - status: item.status, - })) +function isProviderKey(value: string | undefined): value is UptimeProviderKey { + return value === 'local' || value === 'cloudflare' || value === 'globalping' } -function uptimePercent(points: ChartPoint[]): number | null { +function bucketStart(time: number): number { + return Math.floor(time / UPTIME_BUCKET_MS) * UPTIME_BUCKET_MS +} + +function providerOf(item: UptimeProbe): UptimeProviderKey { + return isProviderKey(item.provider) ? item.provider : 'local' +} + +function probeOk(item: UptimeProbe): boolean { + return item.ok && item.status !== 'down' +} + +/** Align mixed-source probes onto a 60s time axis so Local/CF/GP do not zigzag. */ +export function toAlignedSeries(items: UptimeProbe[]): { + points: AlignedChartPoint[] + keys: UptimeProviderKey[] +} { + const buckets = new Map() + const used = new Set() + + const sorted = [...items].sort( + (a, b) => probeTime(a.checked_at) - probeTime(b.checked_at) || a.id - b.id, + ) + + for (const item of sorted) { + const key = providerOf(item) + used.add(key) + const start = bucketStart(probeTime(item.checked_at)) + let row = buckets.get(start) + if (!row) { + row = { + period: formatDate(item.checked_at), + at: item.checked_at, + ok: true, + } + buckets.set(start, row) + } + + const ok = probeOk(item) + row[`${key}Ok`] = ok + row[key] = ok ? item.latency_ms : null + } + + const points = [...buckets.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([, row]) => { + const present = UPTIME_PROVIDER_KEYS.filter((key) => row[`${key}Ok`] !== undefined) + return { + ...row, + ok: present.length === 0 ? row.ok : present.every((key) => row[`${key}Ok`] !== false), + } + }) + + const keys = UPTIME_PROVIDER_KEYS.filter((key) => used.has(key)) + return { points, keys } +} + +function uptimePercent(points: AlignedChartPoint[]): number | null { if (points.length === 0) return null const okCount = points.filter((point) => point.ok).length return (okCount / points.length) * 100 } -function deltaPercent(points: ChartPoint[]): number | null { +function deltaPercent(points: AlignedChartPoint[]): number | null { if (points.length < 4) return null const mid = Math.floor(points.length / 2) const prev = uptimePercent(points.slice(0, mid)) @@ -113,7 +182,7 @@ function UptimeDelta({ delta }: { delta: number }) { } export function probeUptimePercent(items: UptimeProbe[]): number | null { - return uptimePercent(toSeries(items)) + return uptimePercent(toAlignedSeries(items).points) } export function lastProbeLatency(items: UptimeProbe[]): number | null { @@ -129,6 +198,12 @@ function formatUptime(value: number | null): string { return `${value.toFixed(value >= 99.95 ? 2 : 1)}%` } +function formatPing(value: unknown, ok: boolean | undefined): string { + if (ok === false) return '—' + const ping = typeof value === 'number' ? value : Number(value) + return Number.isFinite(ping) ? `${ping} мс` : '—' +} + interface UptimeChartProps { items: UptimeProbe[] isLoading?: boolean @@ -151,27 +226,26 @@ export function UptimeChart({ }: UptimeChartProps) { const gradientId = useId().replace(/:/g, '') const [internalPeriod, setInternalPeriod] = useState('5D') - const [tooltipPortal, setTooltipPortal] = useState(null) + const [pinnedIndex, setPinnedIndex] = useState() const period = periodProp ?? internalPeriod const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5 - useEffect(() => { - setTooltipPortal(document.body) - }, []) - function handlePeriodChange(next: UptimePeriodKey) { onPeriodChange?.(next) if (periodProp == null) setInternalPeriod(next) + setPinnedIndex(undefined) } - const points = useMemo( - () => toSeries(skipPeriodFilter ? items : filterByPeriod(items, days)), + const { points, keys } = useMemo( + () => toAlignedSeries(skipPeriodFilter ? items : filterByPeriod(items, days)), [items, days, skipPeriodFilter], ) const uptime = uptimePercent(points) const delta = deltaPercent(points) const lastOk = points.at(-1)?.ok ?? true const tileClass = lastOk ? 'text-success' : 'text-destructive' + const single = keys.length <= 1 + const areaKey = keys[0] ?? 'local' const panel = ( @@ -248,42 +322,60 @@ export function UptimeChart({ className="h-full w-full overflow-visible rounded-b-xl" initialDimension={{ width: 320, height: 160 }} > - { + const index = state.activeTooltipIndex ?? state.activeIndex + if (index == null) return + const next = Number(index) + if (Number.isFinite(next)) setPinnedIndex(next) + }} > { - const point = item.payload as ChartPoint | undefined - const ping = Number(value) + labelFormatter={(_label, payload) => { + const at = (payload?.[0]?.payload as AlignedChartPoint | undefined)?.at + return at ? formatDate(at) : String(_label ?? '') + }} + formatter={(value, name, item) => { + const key = String(name) + const row = item.payload as AlignedChartPoint | undefined + const ok = + key === 'local' || key === 'cloudflare' || key === 'globalping' + ? row?.[`${key}Ok`] + : row?.ok + const label = chartConfig[key as UptimeProviderKey]?.label ?? key return (
- {point?.ok === false ? 'Down' : 'Пинг'} + {ok === false ? `${label} · Down` : `Пинг · ${label}`} - {Number.isFinite(ping) ? `${ping} мс` : '—'} + {formatPing(value, ok)}
) @@ -291,42 +383,43 @@ export function UptimeChart({ /> } /> - { - const { cx, cy, payload, index } = dotProps - if (cx == null || cy == null) return - const point = payload as ChartPoint | undefined - return ( - - ) - }} - activeDot={{ - r: 6, - stroke: 'var(--background)', - strokeWidth: 2, - }} - /> -
+ {single ? ( + + ) : ( + keys.map((key) => ( + + )) + )} +