feat(webhook): time-series histogram stats endpoint + frontend chart
This commit is contained in:
@@ -330,6 +330,24 @@ export type WebhookDeliveryPage = {
|
||||
size: number
|
||||
}
|
||||
|
||||
/** Один bucket histogram time-series stats. ISO ts + counts по status. */
|
||||
export type WebhookStatsBucket = {
|
||||
ts: string
|
||||
pending: number
|
||||
retrying: number
|
||||
delivered: number
|
||||
dlq: number
|
||||
total: number
|
||||
}
|
||||
|
||||
/** Response от GET /admin/webhooks/subscriptions/{id}/stats. */
|
||||
export type WebhookDeliveryStats = {
|
||||
bucketBy: 'hour' | 'day'
|
||||
from: string
|
||||
to: string
|
||||
buckets: WebhookStatsBucket[]
|
||||
}
|
||||
|
||||
// === Approval Workflow v2 ===
|
||||
|
||||
export type DraftStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | 'WITHDRAWN'
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type SchemaDependent,
|
||||
type SearchResponse,
|
||||
type WebhookDeliveryPage,
|
||||
type WebhookDeliveryStats,
|
||||
type WebhookSubscription,
|
||||
} from './client'
|
||||
|
||||
@@ -440,6 +441,47 @@ export const webhookDlqQuery = (page: number, size: number) =>
|
||||
export const useWebhookDlq = (page: number, size: number) =>
|
||||
useQuery(webhookDlqQuery(page, size))
|
||||
|
||||
export const webhookStatsQuery = (
|
||||
id: string,
|
||||
bucketBy: 'hour' | 'day',
|
||||
fromIso?: string,
|
||||
toIso?: string,
|
||||
) =>
|
||||
queryOptions({
|
||||
queryKey: [
|
||||
'webhooks',
|
||||
'stats',
|
||||
id,
|
||||
bucketBy,
|
||||
fromIso ?? null,
|
||||
toIso ?? null,
|
||||
] as const,
|
||||
queryFn: async (): Promise<WebhookDeliveryStats> => {
|
||||
const params: Record<string, string> = { bucketBy }
|
||||
if (fromIso) params.from = fromIso
|
||||
if (toIso) params.to = toIso
|
||||
const { data } = await apiClient.get<WebhookDeliveryStats>(
|
||||
`/admin/webhooks/subscriptions/${encodeURIComponent(id)}/stats`,
|
||||
{ params },
|
||||
)
|
||||
return data
|
||||
},
|
||||
// Stats для admin dashboard — fresh data важна, но автополлинг мы
|
||||
// отключаем (юзер сам refresh'ит / меняет range).
|
||||
staleTime: 30 * 1000,
|
||||
})
|
||||
|
||||
export const useWebhookStats = (
|
||||
id: string,
|
||||
bucketBy: 'hour' | 'day',
|
||||
fromIso?: string,
|
||||
toIso?: string,
|
||||
) =>
|
||||
useQuery({
|
||||
...webhookStatsQuery(id, bucketBy, fromIso, toIso),
|
||||
enabled: Boolean(id),
|
||||
})
|
||||
|
||||
// === Dependents (Phase 1 dict-relationships-v2) ===
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
@@ -184,6 +184,17 @@ i18n
|
||||
'webhooks.stats.successRateOf_one': 'из {{count}} завершённой',
|
||||
'webhooks.stats.successRateOf_few': 'из {{count}} завершённых',
|
||||
'webhooks.stats.successRateOf_many': 'из {{count}} завершённых',
|
||||
// Status labels (used by histogram chart legend + tooltip)
|
||||
'webhooks.status.pending': 'в очереди',
|
||||
'webhooks.status.retrying': 'retry',
|
||||
'webhooks.status.delivered': 'доставлено',
|
||||
'webhooks.status.dlq': 'DLQ',
|
||||
// Histogram chart
|
||||
'webhooks.histogram.title': 'История доставок',
|
||||
'webhooks.histogram.bucket.hour': '24ч',
|
||||
'webhooks.histogram.bucket.day': '7д',
|
||||
'webhooks.histogram.empty': 'Нет доставок за выбранный период.',
|
||||
'webhooks.histogram.total': 'итого',
|
||||
// === Friendly query error states ===
|
||||
'queryError.retry': 'Повторить',
|
||||
'queryError.network.title': 'Нет соединения с сервером',
|
||||
@@ -870,6 +881,15 @@ i18n
|
||||
'webhooks.stats.successRate': 'Success rate',
|
||||
'webhooks.stats.successRateOf_one': 'of {{count}} terminal',
|
||||
'webhooks.stats.successRateOf_other': 'of {{count}} terminal',
|
||||
'webhooks.status.pending': 'pending',
|
||||
'webhooks.status.retrying': 'retrying',
|
||||
'webhooks.status.delivered': 'delivered',
|
||||
'webhooks.status.dlq': 'DLQ',
|
||||
'webhooks.histogram.title': 'Delivery history',
|
||||
'webhooks.histogram.bucket.hour': '24h',
|
||||
'webhooks.histogram.bucket.day': '7d',
|
||||
'webhooks.histogram.empty': 'No deliveries in the selected period.',
|
||||
'webhooks.histogram.total': 'total',
|
||||
// === Friendly query error states ===
|
||||
'queryError.retry': 'Retry',
|
||||
'queryError.network.title': 'No connection to server',
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
import type { WebhookTestPingResult } from '@/api/client'
|
||||
import { SCOPE_DOT } from '@/lib/scope-style'
|
||||
import { WebhookStatsCards } from '@/components/webhooks/WebhookStatsCards'
|
||||
import { WebhookStatsHistogram } from '@/components/webhooks/WebhookStatsHistogram'
|
||||
|
||||
export const Route = createFileRoute('/webhooks/$id')({
|
||||
component: WebhookDetailPage,
|
||||
@@ -207,10 +208,13 @@ function WebhookDetailPage() {
|
||||
</Panel>
|
||||
|
||||
{/* Stats cards — счётчики per status + success rate. Aggregate
|
||||
из 4 параллельных queries с size=1 (cheap). Visual time-series
|
||||
chart — deferred follow-up (требует /stats endpoint). */}
|
||||
из 4 параллельных queries с size=1 (cheap). */}
|
||||
<WebhookStatsCards subscriptionId={id} />
|
||||
|
||||
{/* Time-series histogram — backend /stats?bucketBy=hour|day. SVG
|
||||
stacked bars без recharts depend'ы. 24h / 7d toggle. */}
|
||||
<WebhookStatsHistogram subscriptionId={id} />
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-sans text-title-md text-accent">
|
||||
|
||||
Reference in New Issue
Block a user