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>
)
}