{item.icon ? (
{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) => (
+
+ ))
+ )}
+