From d864a8561a3f34440d2ef2e0413037bd40c1c73c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=97=D0=B8=D0=BC=D0=B8=D0=BD?= Date: Tue, 12 May 2026 12:25:15 +0000 Subject: [PATCH] =?UTF-8?q?feat(webhooks):=20stats=20cards=20dashboard=20?= =?UTF-8?q?=D0=BD=D0=B0=20subscription=20detail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/webhooks/WebhookStatsCards.tsx | 153 ++++++++++++++++++ ordinis-admin-ui/src/i18n.ts | 15 ++ ordinis-admin-ui/src/routes/webhooks.$id.tsx | 6 + 3 files changed, 174 insertions(+) create mode 100644 ordinis-admin-ui/src/components/webhooks/WebhookStatsCards.tsx diff --git a/ordinis-admin-ui/src/components/webhooks/WebhookStatsCards.tsx b/ordinis-admin-ui/src/components/webhooks/WebhookStatsCards.tsx new file mode 100644 index 0000000..2f5ed4d --- /dev/null +++ b/ordinis-admin-ui/src/components/webhooks/WebhookStatsCards.tsx @@ -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. + * + *

Implementation: 4 parallel React Query subscriptions с + * {@code size=1, page=0} per status — backend возвращает + * {@code totalElements} count'ом без heavy payload. Cheap, дешевле + * чем добавлять отдельный /stats endpoint backend. + * + *

Refetch piggybacks на cache invalidation которая уже работает + * (после retry / status filter changes). Поэтому без явного refetchInterval. + * + *

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 + } + + 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 ( +

+ } + label={t('webhooks.stats.pending', { defaultValue: 'В очереди' })} + value={pCount} + tone="neutral" + /> + } + label={t('webhooks.stats.retrying', { defaultValue: 'Retry' })} + value={rCount} + tone="warn" + /> + } + label={t('webhooks.stats.delivered', { defaultValue: 'Доставлено' })} + value={dCount} + tone="success" + /> + } + label={t('webhooks.stats.dlq', { defaultValue: 'DLQ' })} + value={dlqCount} + tone="danger" + /> + {/* 5-я карточка только когда хоть один terminal был — иначе rate + undefined и показ его как «—» бесполезный noise. */} + {successRate !== null && ( + + )} +
+ ) +} + +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 = { + 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 ( +
+
+
+ {label} +
+ {icon} +
+
+ {value.toLocaleString()} +
+
+ ) +} + +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 ( +
+
+
+ {t('webhooks.stats.successRate', { defaultValue: 'Success rate' })} +
+
+
+ {rate}% + + {t('webhooks.stats.successRateOf', { + count: total, + defaultValue: `из ${total} завершённых`, + })} + +
+
+ ) +} diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index a634c1d..504b1d2 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -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', diff --git a/ordinis-admin-ui/src/routes/webhooks.$id.tsx b/ordinis-admin-ui/src/routes/webhooks.$id.tsx index e82d9e8..0b9a6d5 100644 --- a/ordinis-admin-ui/src/routes/webhooks.$id.tsx +++ b/ordinis-admin-ui/src/routes/webhooks.$id.tsx @@ -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() { + {/* Stats cards — счётчики per status + success rate. Aggregate + из 4 параллельных queries с size=1 (cheap). Visual time-series + chart — deferred follow-up (требует /stats endpoint). */} + +