fix(health): выровнять график источников и KPI на карточке сервиса

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-08-20 12:27:21 +07:00
co-authored by Cursor
parent 634a9dc362
commit d267e40157
4 changed files with 304 additions and 122 deletions
@@ -298,45 +298,50 @@ export function HealthSourceFilterBar({
const active = selected.length > 0 ? selected : enabled const active = selected.length > 0 ? selected : enabled
return ( return (
<ToggleGroup <div className="@container min-w-0 w-full">
multiple <ToggleGroup
variant="outline" multiple
size="sm" variant="outline"
className="w-full min-w-0" size="sm"
value={active} className="flex w-full min-w-0 flex-wrap justify-start"
aria-label="Тип пробы" value={active}
onValueChange={(next) => { aria-label="Тип пробы"
const values = next.filter((value): value is HealthProvider => onValueChange={(next) => {
visible.some((item) => item.id === value), const values = next.filter((value): value is HealthProvider =>
) visible.some((item) => item.id === value),
if (values.length === 0) return )
onChange(values) if (values.length === 0) return
}} onChange(values)
> }}
{visible.map((item) => ( >
<ToggleGroupItem {visible.map((item) => (
key={item.id} <ToggleGroupItem
value={item.id} key={item.id}
aria-label={item.title} value={item.id}
className="min-w-0 flex-1 gap-1.5" aria-label={item.title}
> title={item.title}
<IconTile className="max-w-full min-w-0 flex-none justify-start gap-1.5 @[16rem]:min-w-[8.5rem]"
variant="elevated"
size="xs"
className={item.iconClassName}
aria-hidden="true"
> >
{item.icon} <IconTile
</IconTile> variant="elevated"
<span className="truncate">{item.title}</span> size="xs"
<HealthCheckBadge className={item.iconClassName}
status={statuses[item.id] ?? 'unknown'} aria-hidden="true"
provider={item.id} >
size="xs" {item.icon}
/> </IconTile>
</ToggleGroupItem> <span className="hidden min-w-0 truncate @[16rem]:inline">
))} {item.title}
</ToggleGroup> </span>
<HealthCheckBadge
status={statuses[item.id] ?? 'unknown'}
provider={item.id}
size="xs"
/>
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
) )
} }
@@ -67,7 +67,7 @@ function resolveFooter(item: KpiStatItem): ReactNode {
if (item.footer) return item.footer if (item.footer) return item.footer
if (typeof item.hint === 'string') { if (typeof item.hint === 'string') {
return ( return (
<Badge variant="outline" size="sm"> <Badge variant="outline" size="sm" className="max-w-[min(100%,11rem)] truncate">
{item.hint} {item.hint}
</Badge> </Badge>
) )
@@ -81,30 +81,39 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
const valueVariant = item.variant ?? 'default' const valueVariant = item.variant ?? 'default'
return ( return (
<div className="relative z-10 flex h-full items-start gap-3"> <div className="@container relative z-10 flex h-full min-w-0 items-start gap-3">
{item.icon ? ( {item.icon ? (
<IconTile <IconTile
variant="elevated" variant="elevated"
aria-hidden="true" aria-hidden="true"
className={cn('size-10.5', item.iconClassName ?? DEFAULT_ICON_CLASS)} className={cn('size-10.5 shrink-0', item.iconClassName ?? DEFAULT_ICON_CLASS)}
> >
{item.icon} {item.icon}
</IconTile> </IconTile>
) : null} ) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5"> <div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-start justify-between gap-2"> <div className="flex min-w-0 items-start justify-between gap-2">
<div className="text-muted-foreground text-sm font-medium">{item.label}</div> <div className="text-muted-foreground min-w-0 truncate text-sm font-medium">
{footer ? <div className="shrink-0">{footer}</div> : null} {item.label}
</div>
{footer ? (
<div className="hidden min-w-0 max-w-[min(100%,11rem)] shrink-0 @[20rem]:block">
{footer}
</div>
) : null}
</div> </div>
<div <div
className={cn( className={cn(
'text-2xl leading-none font-bold tabular-nums', 'min-w-0 break-all text-2xl leading-none font-bold tabular-nums',
VALUE_VARIANT_CLASS[valueVariant], VALUE_VARIANT_CLASS[valueVariant],
)} )}
> >
{item.value} {item.value}
</div> </div>
{footer ? (
<div className="min-w-0 max-w-full @[20rem]:hidden">{footer}</div>
) : null}
</div> </div>
</div> </div>
) )
@@ -116,7 +125,7 @@ function panelClassName(item: KpiStatItem, className?: string) {
const selected = isSelected(item) const selected = isSelected(item)
return cn( return cn(
'relative isolate flex h-full flex-col', 'relative isolate flex h-full min-w-0 flex-col',
clickable && clickable &&
'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2', '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', selected && 'ring-primary/30 bg-muted/30 ring-1',
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest'
import { toAlignedSeries, type UptimeProbe } from './uptime-chart'
function probe(
overrides: Partial<UptimeProbe> & Pick<UptimeProbe, 'id'>,
): 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)
})
})
+169 -76
View File
@@ -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 { 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 { EmptyState } from '@/components/empty-state'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
@@ -22,12 +22,13 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from '@cfdm/ui/components/tooltip' } from '@cfdm/ui/components/tooltip'
import type { HealthCheckProvider } from '@cfdm/shared'
/** /**
* Uptime monitoring card — chart-17 DNA (Frame + value + AreaChart + period tabs). * Uptime monitoring card — chart-17 DNA (Frame + value + AreaChart + period tabs).
* Preview: https://reui.io/preview/base/chart-17 * Preview: https://reui.io/preview/base/chart-17
* Frame: https://reui.io/docs/components/base/frame * Frame: https://reui.io/docs/components/base/frame
* Chart: shadcn Chart + Recharts AreaChart * Chart: shadcn Chart + Recharts ComposedChart
*/ */
export interface UptimeProbe { export interface UptimeProbe {
@@ -36,6 +37,7 @@ export interface UptimeProbe {
ok: boolean ok: boolean
latency_ms: number | null latency_ms: number | null
checked_at: string checked_at: string
provider?: HealthCheckProvider
} }
export type UptimePeriodKey = '5D' | '2W' | '1M' 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 }, { 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 = { const chartConfig = {
latency: { local: {
label: 'Задержка', label: 'Local',
color: 'var(--chart-1)', color: 'var(--info)',
},
cloudflare: {
label: 'Cloudflare',
color: 'var(--warning)',
},
globalping: {
label: 'Globalping',
color: 'var(--success)',
}, },
} satisfies ChartConfig } satisfies ChartConfig
interface ChartPoint { export interface AlignedChartPoint {
period: string period: string
latency: number
ok: boolean
at: string 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[] { function isProviderKey(value: string | undefined): value is UptimeProviderKey {
return [...items] return value === 'local' || value === 'cloudflare' || value === 'globalping'
.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 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<number, AlignedChartPoint>()
const used = new Set<UptimeProviderKey>()
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 if (points.length === 0) return null
const okCount = points.filter((point) => point.ok).length const okCount = points.filter((point) => point.ok).length
return (okCount / points.length) * 100 return (okCount / points.length) * 100
} }
function deltaPercent(points: ChartPoint[]): number | null { function deltaPercent(points: AlignedChartPoint[]): number | null {
if (points.length < 4) return null if (points.length < 4) return null
const mid = Math.floor(points.length / 2) const mid = Math.floor(points.length / 2)
const prev = uptimePercent(points.slice(0, mid)) const prev = uptimePercent(points.slice(0, mid))
@@ -113,7 +182,7 @@ function UptimeDelta({ delta }: { delta: number }) {
} }
export function probeUptimePercent(items: UptimeProbe[]): number | null { export function probeUptimePercent(items: UptimeProbe[]): number | null {
return uptimePercent(toSeries(items)) return uptimePercent(toAlignedSeries(items).points)
} }
export function lastProbeLatency(items: UptimeProbe[]): number | null { 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)}%` 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 { interface UptimeChartProps {
items: UptimeProbe[] items: UptimeProbe[]
isLoading?: boolean isLoading?: boolean
@@ -151,27 +226,26 @@ export function UptimeChart({
}: UptimeChartProps) { }: UptimeChartProps) {
const gradientId = useId().replace(/:/g, '') const gradientId = useId().replace(/:/g, '')
const [internalPeriod, setInternalPeriod] = useState<UptimePeriodKey>('5D') const [internalPeriod, setInternalPeriod] = useState<UptimePeriodKey>('5D')
const [tooltipPortal, setTooltipPortal] = useState<HTMLElement | null>(null) const [pinnedIndex, setPinnedIndex] = useState<number | undefined>()
const period = periodProp ?? internalPeriod const period = periodProp ?? internalPeriod
const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5 const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5
useEffect(() => {
setTooltipPortal(document.body)
}, [])
function handlePeriodChange(next: UptimePeriodKey) { function handlePeriodChange(next: UptimePeriodKey) {
onPeriodChange?.(next) onPeriodChange?.(next)
if (periodProp == null) setInternalPeriod(next) if (periodProp == null) setInternalPeriod(next)
setPinnedIndex(undefined)
} }
const points = useMemo( const { points, keys } = useMemo(
() => toSeries(skipPeriodFilter ? items : filterByPeriod(items, days)), () => toAlignedSeries(skipPeriodFilter ? items : filterByPeriod(items, days)),
[items, days, skipPeriodFilter], [items, days, skipPeriodFilter],
) )
const uptime = uptimePercent(points) const uptime = uptimePercent(points)
const delta = deltaPercent(points) const delta = deltaPercent(points)
const lastOk = points.at(-1)?.ok ?? true const lastOk = points.at(-1)?.ok ?? true
const tileClass = lastOk ? 'text-success' : 'text-destructive' const tileClass = lastOk ? 'text-success' : 'text-destructive'
const single = keys.length <= 1
const areaKey = keys[0] ?? 'local'
const panel = ( const panel = (
<FramePanel className="flex flex-col gap-6"> <FramePanel className="flex flex-col gap-6">
@@ -248,42 +322,60 @@ export function UptimeChart({
className="h-full w-full overflow-visible rounded-b-xl" className="h-full w-full overflow-visible rounded-b-xl"
initialDimension={{ width: 320, height: 160 }} initialDimension={{ width: 320, height: 160 }}
> >
<AreaChart <ComposedChart
data={points} data={points}
margin={{ top: 16, left: 8, right: 8, bottom: 4 }} margin={{ top: 16, left: 8, right: 8, bottom: 4 }}
accessibilityLayer
onClick={(state) => {
const index = state.activeTooltipIndex ?? state.activeIndex
if (index == null) return
const next = Number(index)
if (Number.isFinite(next)) setPinnedIndex(next)
}}
> >
<defs> <defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1"> <linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop <stop
offset="5%" offset="5%"
stopColor="var(--color-latency)" stopColor={`var(--color-${areaKey})`}
stopOpacity={0.8} stopOpacity={0.8}
/> />
<stop <stop
offset="95%" offset="95%"
stopColor="var(--color-latency)" stopColor={`var(--color-${areaKey})`}
stopOpacity={0.1} stopOpacity={0.1}
/> />
</linearGradient> </linearGradient>
</defs> </defs>
<XAxis dataKey="period" hide /> <XAxis dataKey="period" hide />
<ChartTooltip <ChartTooltip
key={pinnedIndex ?? 'hover'}
cursor={{ stroke: 'var(--border)', strokeDasharray: '4 4' }} cursor={{ stroke: 'var(--border)', strokeDasharray: '4 4' }}
allowEscapeViewBox={{ x: true, y: true }} filterNull={false}
portal={tooltipPortal ?? undefined} defaultIndex={pinnedIndex}
active={pinnedIndex != null ? true : undefined}
wrapperStyle={{ zIndex: 50, pointerEvents: 'none' }} wrapperStyle={{ zIndex: 50, pointerEvents: 'none' }}
content={ content={
<ChartTooltipContent <ChartTooltipContent
formatter={(value, _name, item) => { labelFormatter={(_label, payload) => {
const point = item.payload as ChartPoint | undefined const at = (payload?.[0]?.payload as AlignedChartPoint | undefined)?.at
const ping = Number(value) 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 ( return (
<div className="flex flex-1 items-center justify-between gap-4"> <div className="flex flex-1 items-center justify-between gap-4">
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{point?.ok === false ? 'Down' : 'Пинг'} {ok === false ? `${label} · Down` : `Пинг · ${label}`}
</span> </span>
<span className="text-foreground font-mono font-medium tabular-nums"> <span className="text-foreground font-mono font-medium tabular-nums">
{Number.isFinite(ping) ? `${ping} мс` : '—'} {formatPing(value, ok)}
</span> </span>
</div> </div>
) )
@@ -291,42 +383,43 @@ export function UptimeChart({
/> />
} }
/> />
<Area {single ? (
dataKey="latency" <Area
name="latency" dataKey={areaKey}
type="natural" name={areaKey}
fill={`url(#${gradientId})`} type="monotone"
stroke="var(--color-latency)" fill={`url(#${gradientId})`}
strokeWidth={2} stroke={`var(--color-${areaKey})`}
isAnimationActive={false} strokeWidth={2}
dot={(dotProps) => { connectNulls={false}
const { cx, cy, payload, index } = dotProps isAnimationActive={false}
if (cx == null || cy == null) return <g key={index} /> activeDot={{
const point = payload as ChartPoint | undefined r: 6,
return ( stroke: 'var(--background)',
<circle strokeWidth: 2,
key={index} }}
cx={cx} />
cy={cy} ) : (
r={4} keys.map((key) => (
fill={ <Line
point?.ok key={key}
? 'var(--color-latency)' dataKey={key}
: 'var(--destructive)' name={key}
} type="monotone"
stroke="var(--background)" stroke={`var(--color-${key})`}
strokeWidth={2} strokeWidth={2}
pointerEvents="none" connectNulls={false}
/> isAnimationActive={false}
) dot={{ r: 3, strokeWidth: 1, stroke: 'var(--background)' }}
}} activeDot={{
activeDot={{ r: 6,
r: 6, stroke: 'var(--background)',
stroke: 'var(--background)', strokeWidth: 2,
strokeWidth: 2, }}
}} />
/> ))
</AreaChart> )}
</ComposedChart>
</ChartContainer> </ChartContainer>
</div> </div>
</div> </div>