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). * *

Layout: *

 *   ┌────────────────────────────────────────────────────────┐
 *   │ 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                     │
 *   └────────────────────────────────────────────────────────┘
 * 
* *

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 ( {t('webhooks.histogram.title')} ) } if (stats.isError) { return ( {t('webhooks.histogram.title')} stats.refetch()} /> ) } const data = stats.data if (!data) return null return ( {t('webhooks.histogram.title')}

) } 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(null) const maxTotal = useMemo( () => Math.max(1, ...buckets.map((b) => b.total)), [buckets], ) // Если всё пусто — показываем empty state, не пустой chart с одним пикселем. if (buckets.every((b) => b.total === 0)) { return ( {t('webhooks.histogram.empty', { defaultValue: 'Нет доставок за выбранный период.', })} ) } 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 (
{/* Y-axis ticks (0, mid, max) */} {[0, 0.5, 1].map((frac) => { const y = CHART_PADDING.top + innerHeight * (1 - frac) const val = Math.round(maxTotal * frac) return ( {val} ) })} {/* 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 ( setHoveredIdx(idx)} onMouseLeave={() => setHoveredIdx(null)} className="cursor-pointer" > {/* Невидимая hover-zone — full column. Чтобы hover работал * даже на zero bucket'ах. */} {STACK_ORDER.map((status) => { const value = bucket[status] if (!value) return null const segH = (value / maxTotal) * innerHeight yCursor -= segH return ( ) })} ) })} {/* X-axis ticks */} {buckets.map((bucket, idx) => { if (idx % tickStep !== 0 && idx !== buckets.length - 1) return null const x = CHART_PADDING.left + idx * barW + barW / 2 return ( {formatTick(bucket.ts, bucketBy, i18n.language)} ) })} {/* Tooltip overlay */} {hoveredIdx !== null && ( )}
) } function Legend() { const { t } = useTranslation() return (
{STACK_ORDER.map((status) => (
{t(STATUS_TOKENS[status].labelKey)}
))}
) } function BucketTooltip({ bucket, bucketBy, locale, position, }: { bucket: WebhookStatsBucket bucketBy: 'hour' | 'day' locale: string position: 'left' | 'right' }) { const { t } = useTranslation() return (
{formatTooltip(bucket.ts, bucketBy, locale)}
{STACK_ORDER.map((status) => { const value = bucket[status] if (!value) return null return (
{t(STATUS_TOKENS[status].labelKey)} {value}
) })}
{t('webhooks.histogram.total', { defaultValue: 'итого' })} {bucket.total}
) } 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', }) }