fix(health): выровнять график источников и KPI на карточке сервиса
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user