feat(webhook): time-series histogram stats endpoint + frontend chart

This commit is contained in:
Александр Зимин
2026-05-12 14:30:22 +00:00
parent 91f4310a77
commit 5479142093
11 changed files with 1049 additions and 2 deletions
@@ -0,0 +1,414 @@
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ArrowsClockwiseIcon } from '@phosphor-icons/react'
import {
Alert,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
LoadingBlock,
QueryErrorState,
} from '@/ui'
import { useWebhookStats } from '@/api/queries'
import type { WebhookStatsBucket } from '@/api/client'
import { cn } from '@/lib/utils'
/**
* Time-series histogram delivery'и подписки. Stacked bar chart на чистом SVG
* (без recharts/chart-js dependency'и — bundle stays slim).
*
* <p>Layout:
* <pre>
* ┌────────────────────────────────────────────────────────┐
* │ Header: title + bucketBy toggle (hour/day) + refresh │
* ├────────────────────────────────────────────────────────┤
* │ Legend: pending · retrying · delivered · dlq │
* ├────────────────────────────────────────────────────────┤
* │ Chart area: │
* │ stacked bars per bucket, y-axis = count │
* │ x-axis ticks: каждый 4-й bucket (hour) / day name │
* │ hover tooltip → counts breakdown │
* └────────────────────────────────────────────────────────┘
* </pre>
*
* <p>Empty state: «Нет данных за выбранный период» — когда все buckets total=0.
* Visible-only: фильтруем chart area до bucket'ов, где total > 0, чтобы
* sparse data не растягивала ось. X-axis labels всё равно показывают полный
* диапазон чтобы юзер видел временное окно.
*/
export function WebhookStatsHistogram({
subscriptionId,
}: {
subscriptionId: string
}) {
const { t } = useTranslation()
const [bucketBy, setBucketBy] = useState<'hour' | 'day'>('hour')
const stats = useWebhookStats(subscriptionId, bucketBy)
if (stats.isLoading) {
return (
<Card>
<CardHeader>
<CardTitle>{t('webhooks.histogram.title')}</CardTitle>
</CardHeader>
<CardContent>
<LoadingBlock />
</CardContent>
</Card>
)
}
if (stats.isError) {
return (
<Card>
<CardHeader>
<CardTitle>{t('webhooks.histogram.title')}</CardTitle>
</CardHeader>
<CardContent>
<QueryErrorState error={stats.error} onRetry={() => stats.refetch()} />
</CardContent>
</Card>
)
}
const data = stats.data
if (!data) return null
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-3">
<CardTitle>{t('webhooks.histogram.title')}</CardTitle>
<div className="flex items-center gap-2">
<div className="inline-flex rounded-md border border-line overflow-hidden text-cap">
<button
type="button"
onClick={() => setBucketBy('hour')}
className={cn(
'px-2.5 py-1 transition-colors',
bucketBy === 'hour'
? 'bg-accent text-on-accent'
: 'bg-surface text-ink-2 hover:bg-surface-2',
)}
>
{t('webhooks.histogram.bucket.hour', { defaultValue: '24ч' })}
</button>
<button
type="button"
onClick={() => setBucketBy('day')}
className={cn(
'px-2.5 py-1 transition-colors border-l border-line',
bucketBy === 'day'
? 'bg-accent text-on-accent'
: 'bg-surface text-ink-2 hover:bg-surface-2',
)}
>
{t('webhooks.histogram.bucket.day', { defaultValue: '7д' })}
</button>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => stats.refetch()}
disabled={stats.isFetching}
aria-label={t('common.refresh')}
title={t('common.refresh')}
>
<ArrowsClockwiseIcon
size={14}
weight="regular"
className={stats.isFetching ? 'animate-spin' : undefined}
/>
</Button>
</div>
</CardHeader>
<CardContent>
<HistogramChart buckets={data.buckets} bucketBy={data.bucketBy} />
</CardContent>
</Card>
)
}
const STATUS_TOKENS = {
delivered: { color: 'fill-jade', labelKey: 'webhooks.status.delivered' },
retrying: { color: 'fill-amber', labelKey: 'webhooks.status.retrying' },
pending: { color: 'fill-mute/60', labelKey: 'webhooks.status.pending' },
dlq: { color: 'fill-mars', labelKey: 'webhooks.status.dlq' },
} as const
type StatusKey = keyof typeof STATUS_TOKENS
const STACK_ORDER: StatusKey[] = ['delivered', 'retrying', 'pending', 'dlq']
const CHART_HEIGHT = 180
const CHART_PADDING = { top: 8, right: 8, bottom: 28, left: 32 }
function HistogramChart({
buckets,
bucketBy,
}: {
buckets: WebhookStatsBucket[]
bucketBy: 'hour' | 'day'
}) {
const { t, i18n } = useTranslation()
const [hoveredIdx, setHoveredIdx] = useState<number | null>(null)
const maxTotal = useMemo(
() => Math.max(1, ...buckets.map((b) => b.total)),
[buckets],
)
// Если всё пусто — показываем empty state, не пустой chart с одним пикселем.
if (buckets.every((b) => b.total === 0)) {
return (
<Alert variant="info" className="mt-2">
{t('webhooks.histogram.empty', {
defaultValue: 'Нет доставок за выбранный период.',
})}
</Alert>
)
}
const innerHeight = CHART_HEIGHT - CHART_PADDING.top - CHART_PADDING.bottom
const bucketCount = buckets.length
// ViewBox единиц: используем pixel-like coords чтобы text/тики читались.
// SVG растягивается через preserveAspectRatio (none) на full container width.
const VB_W = 800
const VB_H = CHART_HEIGHT
const innerW = VB_W - CHART_PADDING.left - CHART_PADDING.right
const barW = innerW / bucketCount
const barGap = Math.max(1, Math.min(2, barW * 0.15))
const actualBarW = Math.max(1, barW - barGap)
const tickStep = bucketBy === 'hour'
? Math.max(1, Math.floor(bucketCount / 6)) // 4 ticks per 24h
: 1 // каждый день для 7d view
return (
<div className="space-y-2">
<Legend />
<div className="relative">
<svg
viewBox={`0 0 ${VB_W} ${VB_H}`}
preserveAspectRatio="none"
className="w-full h-44"
role="img"
aria-label={t('webhooks.histogram.title')}
>
{/* Y-axis ticks (0, mid, max) */}
<g className="text-mute" fontSize="10" fontFamily="ui-monospace, monospace">
{[0, 0.5, 1].map((frac) => {
const y = CHART_PADDING.top + innerHeight * (1 - frac)
const val = Math.round(maxTotal * frac)
return (
<g key={frac}>
<line
x1={CHART_PADDING.left}
x2={VB_W - CHART_PADDING.right}
y1={y}
y2={y}
stroke="currentColor"
strokeOpacity={frac === 0 ? 0.4 : 0.15}
strokeWidth={1}
/>
<text
x={CHART_PADDING.left - 4}
y={y + 3}
textAnchor="end"
fill="currentColor"
>
{val}
</text>
</g>
)
})}
</g>
{/* Stacked bars */}
{buckets.map((bucket, idx) => {
const x = CHART_PADDING.left + idx * barW + barGap / 2
let yCursor = CHART_PADDING.top + innerHeight
const isHovered = hoveredIdx === idx
return (
<g
key={bucket.ts}
onMouseEnter={() => setHoveredIdx(idx)}
onMouseLeave={() => setHoveredIdx(null)}
className="cursor-pointer"
>
{/* Невидимая hover-zone — full column. Чтобы hover работал
* даже на zero bucket'ах. */}
<rect
x={x - barGap / 2}
y={CHART_PADDING.top}
width={barW}
height={innerHeight}
fill="transparent"
/>
{STACK_ORDER.map((status) => {
const value = bucket[status]
if (!value) return null
const segH = (value / maxTotal) * innerHeight
yCursor -= segH
return (
<rect
key={status}
x={x}
y={yCursor}
width={actualBarW}
height={segH}
className={cn(
STATUS_TOKENS[status].color,
'transition-opacity',
isHovered ? 'opacity-100' : 'opacity-85',
)}
/>
)
})}
</g>
)
})}
{/* X-axis ticks */}
<g className="text-mute" fontSize="10" fontFamily="ui-monospace, monospace">
{buckets.map((bucket, idx) => {
if (idx % tickStep !== 0 && idx !== buckets.length - 1) return null
const x = CHART_PADDING.left + idx * barW + barW / 2
return (
<text
key={bucket.ts}
x={x}
y={VB_H - 10}
textAnchor="middle"
fill="currentColor"
>
{formatTick(bucket.ts, bucketBy, i18n.language)}
</text>
)
})}
</g>
</svg>
{/* Tooltip overlay */}
{hoveredIdx !== null && (
<BucketTooltip
bucket={buckets[hoveredIdx]}
bucketBy={bucketBy}
locale={i18n.language}
// Position: справа от hovered bar если в первой половине, иначе слева
position={hoveredIdx < buckets.length / 2 ? 'right' : 'left'}
/>
)}
</div>
</div>
)
}
function Legend() {
const { t } = useTranslation()
return (
<div className="flex flex-wrap items-center gap-3 text-cap text-mute">
{STACK_ORDER.map((status) => (
<div key={status} className="inline-flex items-center gap-1.5">
<svg width={10} height={10} className="shrink-0">
<rect
x={0}
y={0}
width={10}
height={10}
className={STATUS_TOKENS[status].color}
rx={2}
/>
</svg>
<span>{t(STATUS_TOKENS[status].labelKey)}</span>
</div>
))}
</div>
)
}
function BucketTooltip({
bucket,
bucketBy,
locale,
position,
}: {
bucket: WebhookStatsBucket
bucketBy: 'hour' | 'day'
locale: string
position: 'left' | 'right'
}) {
const { t } = useTranslation()
return (
<div
className={cn(
'pointer-events-none absolute top-1 z-10 rounded-md border border-line bg-surface-2 px-3 py-2 shadow-lg text-cap',
position === 'right' ? 'left-2' : 'right-2',
)}
>
<div className="text-mono text-ink-2 font-semibold mb-1">
{formatTooltip(bucket.ts, bucketBy, locale)}
</div>
<div className="space-y-0.5">
{STACK_ORDER.map((status) => {
const value = bucket[status]
if (!value) return null
return (
<div key={status} className="flex items-center gap-2">
<svg width={8} height={8} className="shrink-0">
<rect
x={0}
y={0}
width={8}
height={8}
className={STATUS_TOKENS[status].color}
rx={2}
/>
</svg>
<span className="text-ink-2">{t(STATUS_TOKENS[status].labelKey)}</span>
<span className="text-mono text-ink ml-auto">{value}</span>
</div>
)
})}
<div className="border-t border-line mt-1 pt-1 flex items-center justify-between text-mute">
<span>{t('webhooks.histogram.total', { defaultValue: 'итого' })}</span>
<span className="text-mono text-ink">{bucket.total}</span>
</div>
</div>
</div>
)
}
const formatTick = (iso: string, bucketBy: 'hour' | 'day', locale: string): string => {
const d = new Date(iso)
if (bucketBy === 'hour') {
return d.toLocaleTimeString(locale === 'ru' ? 'ru-RU' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
})
}
return d.toLocaleDateString(locale === 'ru' ? 'ru-RU' : 'en-US', {
day: '2-digit',
month: '2-digit',
})
}
const formatTooltip = (iso: string, bucketBy: 'hour' | 'day', locale: string): string => {
const d = new Date(iso)
if (bucketBy === 'hour') {
return d.toLocaleString(locale === 'ru' ? 'ru-RU' : 'en-US', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
return d.toLocaleDateString(locale === 'ru' ? 'ru-RU' : 'en-US', {
day: '2-digit',
month: 'long',
year: 'numeric',
})
}