415 lines
14 KiB
TypeScript
415 lines
14 KiB
TypeScript
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',
|
|
})
|
|
}
|