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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -175,6 +175,14 @@ i18n
|
|||||||
'webhooks.secret.acknowledge': 'Сохранил',
|
'webhooks.secret.acknowledge': 'Сохранил',
|
||||||
'webhooks.deliveries.title': 'История доставок',
|
'webhooks.deliveries.title': 'История доставок',
|
||||||
'webhooks.deliveries.empty': 'Доставок ещё не было',
|
'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.eventId': 'Event ID',
|
||||||
'webhooks.deliveries.col.attempts': 'Попыток',
|
'webhooks.deliveries.col.attempts': 'Попыток',
|
||||||
'webhooks.deliveries.col.lastAttempt': 'Последняя попытка',
|
'webhooks.deliveries.col.lastAttempt': 'Последняя попытка',
|
||||||
@@ -815,6 +823,13 @@ i18n
|
|||||||
'webhooks.secret.acknowledge': 'Saved it',
|
'webhooks.secret.acknowledge': 'Saved it',
|
||||||
'webhooks.deliveries.title': 'Delivery history',
|
'webhooks.deliveries.title': 'Delivery history',
|
||||||
'webhooks.deliveries.empty': 'No deliveries yet',
|
'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.eventId': 'Event ID',
|
||||||
'webhooks.deliveries.col.attempts': 'Attempts',
|
'webhooks.deliveries.col.attempts': 'Attempts',
|
||||||
'webhooks.deliveries.col.lastAttempt': 'Last attempt',
|
'webhooks.deliveries.col.lastAttempt': 'Last attempt',
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
} from '@/api/mutations'
|
} from '@/api/mutations'
|
||||||
import type { WebhookTestPingResult } from '@/api/client'
|
import type { WebhookTestPingResult } from '@/api/client'
|
||||||
import { SCOPE_DOT } from '@/lib/scope-style'
|
import { SCOPE_DOT } from '@/lib/scope-style'
|
||||||
|
import { WebhookStatsCards } from '@/components/webhooks/WebhookStatsCards'
|
||||||
|
|
||||||
export const Route = createFileRoute('/webhooks/$id')({
|
export const Route = createFileRoute('/webhooks/$id')({
|
||||||
component: WebhookDetailPage,
|
component: WebhookDetailPage,
|
||||||
@@ -205,6 +206,11 @@ function WebhookDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Panel>
|
</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>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h2 className="font-sans text-title-md text-accent">
|
<h2 className="font-sans text-title-md text-accent">
|
||||||
|
|||||||
Reference in New Issue
Block a user