feat(webhooks): stats cards dashboard на subscription detail

This commit is contained in:
Александр Зимин
2026-05-12 12:25:15 +00:00
parent 59a5c65606
commit d864a8561a
3 changed files with 174 additions and 0 deletions
@@ -0,0 +1,153 @@
import { useTranslation } from 'react-i18next'
import {
CheckCircleIcon,
ArrowClockwiseIcon,
ClockIcon,
XCircleIcon,
} from '@phosphor-icons/react'
import { useWebhookDeliveries } from '@/api/queries'
import { LoadingBlock } from '@/ui'
/**
* Stat cards для webhook subscription detail. 4 status counters
* (pending / retrying / delivered / dlq) + computed success rate.
*
* <p>Implementation: 4 parallel React Query subscriptions с
* {@code size=1, page=0} per status — backend возвращает
* {@code totalElements} count'ом без heavy payload. Cheap, дешевле
* чем добавлять отдельный /stats endpoint backend.
*
* <p>Refetch piggybacks на cache invalidation которая уже работает
* (после retry / status filter changes). Поэтому без явного refetchInterval.
*
* <p>Phase B (followup): real-time bar chart per hour за последние 24h —
* требует /stats?bucketBy=hour endpoint в backend.
*/
export function WebhookStatsCards({ subscriptionId }: { subscriptionId: string }) {
const { t } = useTranslation()
// 4 parallel queries — все по одному status'у с size=1 чтобы получить
// только totalElements. React Query dedupe гарантирует что параллельные
// деs c одинаковым key не дублируют network call.
const pending = useWebhookDeliveries(subscriptionId, 0, 1, 'pending')
const retrying = useWebhookDeliveries(subscriptionId, 0, 1, 'retrying')
const delivered = useWebhookDeliveries(subscriptionId, 0, 1, 'delivered')
const dlq = useWebhookDeliveries(subscriptionId, 0, 1, 'dlq')
const loading =
pending.isLoading || retrying.isLoading || delivered.isLoading || dlq.isLoading
if (loading) {
return <LoadingBlock size="md" label={t('loading')} />
}
const pCount = pending.data?.totalElements ?? 0
const rCount = retrying.data?.totalElements ?? 0
const dCount = delivered.data?.totalElements ?? 0
const dlqCount = dlq.data?.totalElements ?? 0
// Success rate = delivered / (terminal) — DLQ это failed terminal. Pending/retrying
// ещё в полёте, не учитываем (rate был бы заниженный).
const terminal = dCount + dlqCount
const successRate = terminal > 0 ? Math.round((dCount / terminal) * 100) : null
return (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-3">
<StatCard
icon={<ClockIcon weight="duotone" size={20} />}
label={t('webhooks.stats.pending', { defaultValue: 'В очереди' })}
value={pCount}
tone="neutral"
/>
<StatCard
icon={<ArrowClockwiseIcon weight="duotone" size={20} />}
label={t('webhooks.stats.retrying', { defaultValue: 'Retry' })}
value={rCount}
tone="warn"
/>
<StatCard
icon={<CheckCircleIcon weight="duotone" size={20} />}
label={t('webhooks.stats.delivered', { defaultValue: 'Доставлено' })}
value={dCount}
tone="success"
/>
<StatCard
icon={<XCircleIcon weight="duotone" size={20} />}
label={t('webhooks.stats.dlq', { defaultValue: 'DLQ' })}
value={dlqCount}
tone="danger"
/>
{/* 5-я карточка только когда хоть один terminal был — иначе rate
undefined и показ его как «—» бесполезный noise. */}
{successRate !== null && (
<SuccessRateCard rate={successRate} total={terminal} />
)}
</div>
)
}
function StatCard({
icon,
label,
value,
tone,
}: {
icon: React.ReactNode
label: React.ReactNode
value: number
tone: 'success' | 'warn' | 'danger' | 'neutral'
}) {
// Tone → background + text colour для icon + value highlight.
// bg-* / text-* tokens из tailwind theme.
const styles: Record<typeof tone, { iconClass: string; valueClass: string }> = {
success: { iconClass: 'text-green', valueClass: 'text-green' },
warn: { iconClass: 'text-warn', valueClass: 'text-warn' },
danger: { iconClass: 'text-pink', valueClass: 'text-pink' },
neutral: { iconClass: 'text-mute', valueClass: 'text-ink' },
}
const { iconClass, valueClass } = styles[tone]
return (
<div className="rounded-lg border border-line bg-surface p-3 flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<div className="text-cap text-mute uppercase tracking-wider truncate">
{label}
</div>
<span className={`shrink-0 ${iconClass}`}>{icon}</span>
</div>
<div className={`text-title-md font-semibold tabular-nums ${valueClass}`}>
{value.toLocaleString()}
</div>
</div>
)
}
function SuccessRateCard({ rate, total }: { rate: number; total: number }) {
const { t } = useTranslation()
// Tone по rate'у: ≥95 success, 70-94 warn, <70 danger.
// 95% — health threshold по умолчанию для webhook reliability.
const tone: 'success' | 'warn' | 'danger' =
rate >= 95 ? 'success' : rate >= 70 ? 'warn' : 'danger'
const styles = {
success: 'text-green border-green/30 bg-green-bg',
warn: 'text-warn border-warn/30 bg-warn-bg',
danger: 'text-pink border-pink/30 bg-pink-bg',
} as const
return (
<div
className={`rounded-lg border p-3 flex flex-col gap-1.5 ${styles[tone]}`}
>
<div className="flex items-center justify-between">
<div className="text-cap uppercase tracking-wider truncate">
{t('webhooks.stats.successRate', { defaultValue: 'Success rate' })}
</div>
</div>
<div className="flex items-baseline gap-2">
<span className="text-title-md font-semibold tabular-nums">{rate}%</span>
<span className="text-cell opacity-70 tabular-nums">
{t('webhooks.stats.successRateOf', {
count: total,
defaultValue: `из ${total} завершённых`,
})}
</span>
</div>
</div>
)
}
+15
View File
@@ -175,6 +175,14 @@ i18n
'webhooks.secret.acknowledge': 'Сохранил',
'webhooks.deliveries.title': 'История доставок',
'webhooks.deliveries.empty': 'Доставок ещё не было',
'webhooks.stats.pending': 'В очереди',
'webhooks.stats.retrying': 'Retry',
'webhooks.stats.delivered': 'Доставлено',
'webhooks.stats.dlq': 'DLQ',
'webhooks.stats.successRate': 'Success rate',
'webhooks.stats.successRateOf_one': 'из {{count}} завершённой',
'webhooks.stats.successRateOf_few': 'из {{count}} завершённых',
'webhooks.stats.successRateOf_many': 'из {{count}} завершённых',
'webhooks.deliveries.col.eventId': 'Event ID',
'webhooks.deliveries.col.attempts': 'Попыток',
'webhooks.deliveries.col.lastAttempt': 'Последняя попытка',
@@ -815,6 +823,13 @@ i18n
'webhooks.secret.acknowledge': 'Saved it',
'webhooks.deliveries.title': 'Delivery history',
'webhooks.deliveries.empty': 'No deliveries yet',
'webhooks.stats.pending': 'Pending',
'webhooks.stats.retrying': 'Retrying',
'webhooks.stats.delivered': 'Delivered',
'webhooks.stats.dlq': 'DLQ',
'webhooks.stats.successRate': 'Success rate',
'webhooks.stats.successRateOf_one': 'of {{count}} terminal',
'webhooks.stats.successRateOf_other': 'of {{count}} terminal',
'webhooks.deliveries.col.eventId': 'Event ID',
'webhooks.deliveries.col.attempts': 'Attempts',
'webhooks.deliveries.col.lastAttempt': 'Last attempt',
@@ -36,6 +36,7 @@ import {
} from '@/api/mutations'
import type { WebhookTestPingResult } from '@/api/client'
import { SCOPE_DOT } from '@/lib/scope-style'
import { WebhookStatsCards } from '@/components/webhooks/WebhookStatsCards'
export const Route = createFileRoute('/webhooks/$id')({
component: WebhookDetailPage,
@@ -205,6 +206,11 @@ function WebhookDetailPage() {
</div>
</Panel>
{/* Stats cards счётчики per status + success rate. Aggregate
из 4 параллельных queries с size=1 (cheap). Visual time-series
chart deferred follow-up (требует /stats endpoint). */}
<WebhookStatsCards subscriptionId={id} />
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="font-sans text-title-md text-accent">