feat(webhooks): stats cards dashboard на subscription detail
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user