quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / changes (push) Successful in 8s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 55s
CD / quality (push) Successful in 1m7s
CD / publish (push) Successful in 1m38s
Если в минутном бакете есть живой IP, линия строится по его пингу, а не по null от Down. Co-authored-by: Cursor <[email protected]>
485 lines
16 KiB
TypeScript
485 lines
16 KiB
TypeScript
import { useId, useMemo, useState } from 'react'
|
||
import { ActivityIcon, InfoIcon, TrendingDownIcon, TrendingUpIcon } from 'lucide-react'
|
||
import { Area, ComposedChart, Line, XAxis, YAxis } from 'recharts'
|
||
|
||
import { EmptyState } from '@/components/empty-state'
|
||
import { Badge } from '@/components/reui/badge'
|
||
import { Frame, FramePanel } from '@/components/reui/frame'
|
||
import { IconTile } from '@/components/reui/icon-tile'
|
||
import { filterByPeriod, probeTime } from '@/lib/health-log'
|
||
import { formatDate } from '@/lib/format'
|
||
import { Button } from '@cfdm/ui/components/button'
|
||
import {
|
||
ChartContainer,
|
||
ChartTooltip,
|
||
ChartTooltipContent,
|
||
type ChartConfig,
|
||
} from '@cfdm/ui/components/chart'
|
||
import { Tabs, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
|
||
import {
|
||
Tooltip,
|
||
TooltipContent,
|
||
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 ComposedChart
|
||
*/
|
||
|
||
export interface UptimeProbe {
|
||
id: number
|
||
status: 'up' | 'down' | 'degraded' | 'unknown'
|
||
ok: boolean
|
||
latency_ms: number | null
|
||
checked_at: string
|
||
provider?: HealthCheckProvider
|
||
}
|
||
|
||
export type UptimePeriodKey = '5D' | '2W' | '1M'
|
||
|
||
export const UPTIME_PERIODS: { key: UptimePeriodKey; label: string; days: number }[] = [
|
||
{ key: '5D', label: '5D', days: 5 },
|
||
{ key: '2W', label: '2W', days: 14 },
|
||
{ 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 = {
|
||
local: {
|
||
label: 'Local',
|
||
color: 'var(--info)',
|
||
},
|
||
cloudflare: {
|
||
label: 'Cloudflare',
|
||
color: 'var(--warning)',
|
||
},
|
||
globalping: {
|
||
label: 'Globalping',
|
||
color: 'var(--success)',
|
||
},
|
||
} satisfies ChartConfig
|
||
|
||
export interface AlignedChartPoint {
|
||
period: string
|
||
at: string
|
||
ok: boolean
|
||
local?: number | null
|
||
cloudflare?: number | null
|
||
globalping?: number | null
|
||
localOk?: boolean
|
||
cloudflareOk?: boolean
|
||
globalpingOk?: boolean
|
||
}
|
||
|
||
function isProviderKey(value: string | undefined): value is UptimeProviderKey {
|
||
return value === 'local' || value === 'cloudflare' || value === 'globalping'
|
||
}
|
||
|
||
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)
|
||
const prevOk = row[`${key}Ok`]
|
||
row[`${key}Ok`] = prevOk === true || ok
|
||
if (ok && item.latency_ms != null) {
|
||
row[key] = item.latency_ms
|
||
} else if (row[key] === undefined) {
|
||
row[key] = 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: AlignedChartPoint[]): number | null {
|
||
if (points.length < 4) return null
|
||
const mid = Math.floor(points.length / 2)
|
||
const prev = uptimePercent(points.slice(0, mid))
|
||
const next = uptimePercent(points.slice(mid))
|
||
if (prev == null || next == null) return null
|
||
return next - prev
|
||
}
|
||
|
||
function UptimeDelta({ delta }: { delta: number }) {
|
||
if (Math.abs(delta) < 0.05) {
|
||
return <span className="text-muted-foreground">без изменений за период</span>
|
||
}
|
||
|
||
if (delta > 0) {
|
||
return (
|
||
<>
|
||
<TrendingUpIcon className="text-success size-4" aria-hidden="true" />
|
||
<span className="text-success font-medium">+{delta.toFixed(1)} п.п.</span>
|
||
<span className="text-muted-foreground">с начала периода</span>
|
||
</>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<TrendingDownIcon className="text-destructive size-4" aria-hidden="true" />
|
||
<span className="text-destructive font-medium">{delta.toFixed(1)} п.п.</span>
|
||
<span className="text-muted-foreground">с начала периода</span>
|
||
</>
|
||
)
|
||
}
|
||
|
||
export function probeUptimePercent(items: UptimeProbe[]): number | null {
|
||
return uptimePercent(toAlignedSeries(items).points)
|
||
}
|
||
|
||
export function lastProbeLatency(items: UptimeProbe[]): number | null {
|
||
if (items.length === 0) return null
|
||
const latest = [...items].sort(
|
||
(a, b) => probeTime(b.checked_at) - probeTime(a.checked_at),
|
||
)[0]
|
||
return latest?.latency_ms ?? null
|
||
}
|
||
|
||
function formatUptime(value: number | null): string {
|
||
if (value == null) return '—'
|
||
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
|
||
period?: UptimePeriodKey
|
||
onPeriodChange?: (period: UptimePeriodKey) => void
|
||
skipPeriodFilter?: boolean
|
||
embedded?: boolean
|
||
/** dashboard-4: chrome живёт в родительском FrameHeader (переключатель серий). */
|
||
hideHeader?: boolean
|
||
}
|
||
|
||
export function UptimeChart({
|
||
items,
|
||
isLoading = false,
|
||
period: periodProp,
|
||
onPeriodChange,
|
||
skipPeriodFilter = false,
|
||
embedded = false,
|
||
hideHeader = false,
|
||
}: UptimeChartProps) {
|
||
const gradientId = useId().replace(/:/g, '')
|
||
const [internalPeriod, setInternalPeriod] = useState<UptimePeriodKey>('5D')
|
||
const [hovered, setHovered] = useState<AlignedChartPoint | null>(null)
|
||
const period = periodProp ?? internalPeriod
|
||
const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5
|
||
|
||
function handlePeriodChange(next: UptimePeriodKey) {
|
||
onPeriodChange?.(next)
|
||
if (periodProp == null) setInternalPeriod(next)
|
||
setHovered(null)
|
||
}
|
||
|
||
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 hoverPings = hovered
|
||
? keys.map((key) => {
|
||
const ok = hovered[`${key}Ok`]
|
||
const label = chartConfig[key].label
|
||
return `${label} ${formatPing(hovered[key], ok)}`
|
||
})
|
||
: []
|
||
|
||
function syncHover(state: {
|
||
activeTooltipIndex?: unknown
|
||
activeIndex?: unknown
|
||
}) {
|
||
const index = Number(state.activeTooltipIndex ?? state.activeIndex)
|
||
if (!Number.isFinite(index)) return
|
||
setHovered(points[index] ?? null)
|
||
}
|
||
|
||
const panel = (
|
||
<FramePanel className="flex flex-col gap-6 overflow-visible">
|
||
{hideHeader ? null : (
|
||
<div className="border-border flex items-center justify-between gap-2 border-b border-dashed pb-4">
|
||
<div className="flex items-center gap-2.5">
|
||
<IconTile
|
||
variant="elevated"
|
||
className={`size-10.5 ${tileClass}`}
|
||
aria-hidden="true"
|
||
>
|
||
<ActivityIcon />
|
||
</IconTile>
|
||
<div className="flex flex-col justify-center">
|
||
<h3 className="text-base font-semibold">Uptime</h3>
|
||
<p className="text-muted-foreground text-sm">
|
||
Пробы health-check за период
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<TooltipProvider delay={150}>
|
||
<Tooltip>
|
||
<TooltipTrigger
|
||
render={
|
||
<Button
|
||
aria-label="О графике uptime"
|
||
className="text-muted-foreground/70 -mr-1"
|
||
size="icon-sm"
|
||
type="button"
|
||
variant="ghost"
|
||
/>
|
||
}
|
||
>
|
||
<InfoIcon data-icon="inline-start" aria-hidden="true" />
|
||
</TooltipTrigger>
|
||
<TooltipContent side="top" sideOffset={8}>
|
||
<p>Доля успешных проб и задержка (мс) по журналу health-log.</p>
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
</TooltipProvider>
|
||
</div>
|
||
)}
|
||
|
||
{isLoading ? (
|
||
<div className="bg-muted h-40 w-full animate-pulse rounded-xl" />
|
||
) : points.length === 0 ? (
|
||
<EmptyState
|
||
icon={ActivityIcon}
|
||
title="Нет проб за период"
|
||
description="Результаты появятся после health-check"
|
||
stackedIcon={false}
|
||
centered={false}
|
||
/>
|
||
) : (
|
||
<div className="flex flex-col gap-4">
|
||
<div className="flex flex-col gap-1">
|
||
<div className="text-foreground text-3xl font-semibold tabular-nums">
|
||
{formatUptime(uptime)}
|
||
</div>
|
||
<div className="flex items-center gap-2 text-sm">
|
||
{delta == null ? (
|
||
<Badge variant="outline" size="sm">
|
||
{points.length} проб
|
||
</Badge>
|
||
) : (
|
||
<UptimeDelta delta={delta} />
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{hoverPings.length > 0 ? (
|
||
<p className="text-muted-foreground min-h-4 min-w-0 text-xs tabular-nums">
|
||
<span className="text-foreground font-medium">Пинг</span>
|
||
{' · '}
|
||
{formatDate(hovered?.at)}
|
||
{' · '}
|
||
{hoverPings.join(' · ')}
|
||
</p>
|
||
) : (
|
||
<p className="text-muted-foreground min-h-4 text-xs">
|
||
Наведите на точку графика
|
||
</p>
|
||
)}
|
||
|
||
<div className="h-40 w-full overflow-visible">
|
||
<ChartContainer
|
||
config={chartConfig}
|
||
className="[&_.recharts-tooltip-wrapper]:z-50 [&_.recharts-wrapper]:overflow-visible h-full w-full overflow-visible rounded-b-xl"
|
||
initialDimension={{ width: 320, height: 160 }}
|
||
>
|
||
<ComposedChart
|
||
data={points}
|
||
margin={{ top: 24, left: 8, right: 8, bottom: 8 }}
|
||
accessibilityLayer
|
||
onMouseMove={syncHover}
|
||
onMouseLeave={() => setHovered(null)}
|
||
onClick={syncHover}
|
||
>
|
||
<defs>
|
||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||
<stop
|
||
offset="5%"
|
||
stopColor={`var(--color-${areaKey})`}
|
||
stopOpacity={0.8}
|
||
/>
|
||
<stop
|
||
offset="95%"
|
||
stopColor={`var(--color-${areaKey})`}
|
||
stopOpacity={0.1}
|
||
/>
|
||
</linearGradient>
|
||
</defs>
|
||
<XAxis dataKey="at" hide />
|
||
<YAxis hide domain={['auto', 'auto']} />
|
||
<ChartTooltip
|
||
cursor={{ stroke: 'var(--border)', strokeDasharray: '4 4' }}
|
||
filterNull={false}
|
||
shared
|
||
isAnimationActive={false}
|
||
allowEscapeViewBox={{ x: true, y: true }}
|
||
wrapperStyle={{ zIndex: 50, pointerEvents: 'none' }}
|
||
content={
|
||
<ChartTooltipContent
|
||
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 (
|
||
<div className="flex flex-1 items-center justify-between gap-4">
|
||
<span className="text-muted-foreground">
|
||
{ok === false ? `${label} · Down` : `Пинг · ${label}`}
|
||
</span>
|
||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||
{formatPing(value, ok)}
|
||
</span>
|
||
</div>
|
||
)
|
||
}}
|
||
/>
|
||
}
|
||
/>
|
||
{single ? (
|
||
<Area
|
||
dataKey={areaKey}
|
||
name={areaKey}
|
||
type="monotone"
|
||
fill={`url(#${gradientId})`}
|
||
stroke={`var(--color-${areaKey})`}
|
||
strokeWidth={2}
|
||
connectNulls={false}
|
||
isAnimationActive={false}
|
||
dot={{ r: 3, strokeWidth: 1, stroke: 'var(--background)' }}
|
||
activeDot={{
|
||
r: 6,
|
||
stroke: 'var(--background)',
|
||
strokeWidth: 2,
|
||
}}
|
||
/>
|
||
) : (
|
||
keys.map((key) => (
|
||
<Line
|
||
key={key}
|
||
dataKey={key}
|
||
name={key}
|
||
type="monotone"
|
||
stroke={`var(--color-${key})`}
|
||
strokeWidth={2}
|
||
connectNulls={false}
|
||
isAnimationActive={false}
|
||
dot={{ r: 3, strokeWidth: 1, stroke: 'var(--background)' }}
|
||
activeDot={{
|
||
r: 6,
|
||
stroke: 'var(--background)',
|
||
strokeWidth: 2,
|
||
}}
|
||
/>
|
||
))
|
||
)}
|
||
</ComposedChart>
|
||
</ChartContainer>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<Tabs
|
||
value={period}
|
||
onValueChange={(value) => handlePeriodChange(value as UptimePeriodKey)}
|
||
>
|
||
<TabsList className="w-full">
|
||
{UPTIME_PERIODS.map((entry) => (
|
||
<TabsTrigger key={entry.key} value={entry.key} className="flex-1">
|
||
{entry.label}
|
||
</TabsTrigger>
|
||
))}
|
||
</TabsList>
|
||
</Tabs>
|
||
</FramePanel>
|
||
)
|
||
|
||
if (embedded) return panel
|
||
|
||
return (
|
||
<Frame spacing="sm" className="min-w-0 w-full">
|
||
{panel}
|
||
</Frame>
|
||
)
|
||
}
|