From 51fe8360e90cbd74b29655a770d6df43c0b15b49 Mon Sep 17 00:00:00 2001 From: benedictworks-home Date: Fri, 26 Jun 2026 06:19:08 -0700 Subject: [PATCH] feat: implement real-time webhook delivery monitoring dashboard with performance metrics and failure filters --- dashboard/src/App.tsx | 12 +- .../src/components/WebhookDeliveryChart.tsx | 251 +++++++ .../src/components/WebhookFailedTable.tsx | 312 ++++++++ .../src/components/WebhookFiltersBar.tsx | 161 ++++ .../src/components/WebhookSummaryCards.tsx | 91 +++ dashboard/src/hooks/useWebhookDashboard.ts | 137 ++++ dashboard/src/index.css | 698 ++++++++++++++++++ dashboard/src/pages/WebhookDashboardPage.tsx | 83 +++ dashboard/src/services/webhookApi.ts | 31 + dashboard/src/types/webhook.ts | 68 ++ dashboard/src/utils/webhookData.ts | 272 +++++++ 11 files changed, 2115 insertions(+), 1 deletion(-) create mode 100644 dashboard/src/components/WebhookDeliveryChart.tsx create mode 100644 dashboard/src/components/WebhookFailedTable.tsx create mode 100644 dashboard/src/components/WebhookFiltersBar.tsx create mode 100644 dashboard/src/components/WebhookSummaryCards.tsx create mode 100644 dashboard/src/hooks/useWebhookDashboard.ts create mode 100644 dashboard/src/pages/WebhookDashboardPage.tsx create mode 100644 dashboard/src/services/webhookApi.ts create mode 100644 dashboard/src/types/webhook.ts create mode 100644 dashboard/src/utils/webhookData.ts diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index d564ae6..1ffd3f2 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -2,8 +2,9 @@ import { useState } from 'react'; import { EventExplorerPage } from './pages/EventExplorerPage'; import { NotificationTimelineView } from './components/NotificationTimelineView'; import { ActivityFeed } from './components/ActivityFeed'; +import { WebhookDashboardPage } from './pages/WebhookDashboardPage'; -type Tab = 'explorer' | 'timeline' | 'activity'; +type Tab = 'explorer' | 'timeline' | 'activity' | 'webhooks'; export function App() { const [tab, setTab] = useState('explorer'); @@ -35,11 +36,20 @@ export function App() { > Activity Feed + {tab === 'explorer' && } {tab === 'timeline' && } {tab === 'activity' && } + {tab === 'webhooks' && } ); } diff --git a/dashboard/src/components/WebhookDeliveryChart.tsx b/dashboard/src/components/WebhookDeliveryChart.tsx new file mode 100644 index 0000000..9e893bc --- /dev/null +++ b/dashboard/src/components/WebhookDeliveryChart.tsx @@ -0,0 +1,251 @@ +import { memo, useMemo } from 'react'; +import type { WebhookMetricBucket } from '../types/webhook'; + +interface WebhookDeliveryChartProps { + buckets: WebhookMetricBucket[]; + isLoading: boolean; +} + +const CHART_HEIGHT = 180; +const CHART_PADDING_TOP = 16; +const CHART_PADDING_BOTTOM = 36; // space for x-axis labels +const CHART_PADDING_LEFT = 48; // space for y-axis labels +const CHART_PADDING_RIGHT = 16; + +const SKELETON_BUCKET_COUNT = 24; + +/** Build an SVG polyline `points` string from value array + chart geometry. */ +function buildPolylinePoints( + values: number[], + maxValue: number, + chartWidth: number, + chartHeight: number +): string { + if (values.length < 2) return ''; + const drawWidth = chartWidth - CHART_PADDING_LEFT - CHART_PADDING_RIGHT; + const drawHeight = chartHeight - CHART_PADDING_TOP - CHART_PADDING_BOTTOM; + const safeMax = maxValue > 0 ? maxValue : 1; + + return values + .map((v, i) => { + const x = CHART_PADDING_LEFT + (i / (values.length - 1)) * drawWidth; + const y = CHART_PADDING_TOP + drawHeight - (v / safeMax) * drawHeight; + return `${x.toFixed(1)},${y.toFixed(1)}`; + }) + .join(' '); +} + +/** Build an SVG path for a filled area under the line. */ +function buildAreaPath( + values: number[], + maxValue: number, + chartWidth: number, + chartHeight: number +): string { + if (values.length < 2) return ''; + const drawWidth = chartWidth - CHART_PADDING_LEFT - CHART_PADDING_RIGHT; + const drawHeight = chartHeight - CHART_PADDING_TOP - CHART_PADDING_BOTTOM; + const safeMax = maxValue > 0 ? maxValue : 1; + const baseY = CHART_PADDING_TOP + drawHeight; + + const points = values.map((v, i) => { + const x = CHART_PADDING_LEFT + (i / (values.length - 1)) * drawWidth; + const y = CHART_PADDING_TOP + drawHeight - (v / safeMax) * drawHeight; + return `${x.toFixed(1)},${y.toFixed(1)}`; + }); + + const firstX = CHART_PADDING_LEFT.toFixed(1); + const lastX = (CHART_PADDING_LEFT + drawWidth).toFixed(1); + + return `M ${firstX},${baseY} L ${points.join(' L ')} L ${lastX},${baseY} Z`; +} + +/** Select a subset of tick labels to avoid crowding. */ +function getTickIndices(count: number, maxTicks = 8): number[] { + if (count <= maxTicks) return Array.from({ length: count }, (_, i) => i); + const step = Math.ceil(count / maxTicks); + const indices: number[] = []; + for (let i = 0; i < count; i += step) indices.push(i); + if (indices[indices.length - 1] !== count - 1) indices.push(count - 1); + return indices; +} + +export const WebhookDeliveryChart = memo(function WebhookDeliveryChart({ + buckets, + isLoading, +}: WebhookDeliveryChartProps) { + const { successValues, failedValues, maxValue, tickIndices } = useMemo(() => { + const successValues = buckets.map((b) => b.successCount); + const failedValues = buckets.map((b) => b.failedCount); + const maxValue = Math.max(1, ...successValues, ...failedValues); + const tickIndices = getTickIndices(buckets.length); + return { successValues, failedValues, maxValue, tickIndices }; + }, [buckets]); + + // Responsive: use a viewBox-based SVG so it scales naturally + const viewBoxWidth = 800; + const viewBoxHeight = CHART_HEIGHT; + + const drawWidth = viewBoxWidth - CHART_PADDING_LEFT - CHART_PADDING_RIGHT; + const drawHeight = viewBoxHeight - CHART_PADDING_TOP - CHART_PADDING_BOTTOM; + + const successPoints = buildPolylinePoints(successValues, maxValue, viewBoxWidth, viewBoxHeight); + const failedPoints = buildPolylinePoints(failedValues, maxValue, viewBoxWidth, viewBoxHeight); + const successArea = buildAreaPath(successValues, maxValue, viewBoxWidth, viewBoxHeight); + const failedArea = buildAreaPath(failedValues, maxValue, viewBoxWidth, viewBoxHeight); + + // Y-axis ticks: 5 levels + const yTicks = [0, 0.25, 0.5, 0.75, 1].map((ratio) => ({ + value: Math.round(maxValue * ratio), + y: CHART_PADDING_TOP + drawHeight - ratio * drawHeight, + })); + + const baseY = CHART_PADDING_TOP + drawHeight; + + return ( +
+ {/* Legend */} + + + {isLoading ? ( + /* Skeleton state */ + + {/* Skeleton bars */} + {Array.from({ length: SKELETON_BUCKET_COUNT }).map((_, i) => { + const barW = (drawWidth / SKELETON_BUCKET_COUNT) * 0.6; + const x = CHART_PADDING_LEFT + (i / SKELETON_BUCKET_COUNT) * drawWidth + barW * 0.3; + const h = 20 + ((i * 37 + 13) % 80); + return ( + + ); + })} + + ) : buckets.length === 0 ? ( +
+ No data for the selected range +
+ ) : ( + + + + + + + + + + + + + {/* Grid lines + Y-axis labels */} + {yTicks.map(({ value, y }) => ( + + + + {value} + + + ))} + + {/* X-axis baseline */} + + + {/* Filled areas */} + + + + {/* Lines */} + + + + {/* X-axis labels */} + {tickIndices.map((idx) => { + const bucket = buckets[idx]; + const x = + CHART_PADDING_LEFT + + (buckets.length > 1 + ? (idx / (buckets.length - 1)) * drawWidth + : drawWidth / 2); + return ( + + {bucket.displayLabel} + + ); + })} + + )} +
+ ); +}); diff --git a/dashboard/src/components/WebhookFailedTable.tsx b/dashboard/src/components/WebhookFailedTable.tsx new file mode 100644 index 0000000..0361d9b --- /dev/null +++ b/dashboard/src/components/WebhookFailedTable.tsx @@ -0,0 +1,312 @@ +import { memo, useState, useMemo, useCallback } from 'react'; +import type { WebhookDelivery } from '../types/webhook'; +import { formatTimestamp } from '../utils/formatTime'; +import { getErrorCategory } from '../utils/webhookData'; + +interface WebhookFailedTableProps { + deliveries: WebhookDelivery[]; + isLoading: boolean; +} + +type SortField = 'attemptedAt' | 'httpStatus' | 'latencyMs' | 'targetUrl' | 'eventType'; +type SortDir = 'asc' | 'desc'; + +const PAGE_SIZE_OPTIONS = [10, 20, 50]; + +/** Truncate a URL for display without breaking layout. */ +function truncateUrl(url: string, maxLen = 52): string { + if (url.length <= maxLen) return url; + return `${url.slice(0, maxLen - 1)}…`; +} + +/** Map HTTP status to a CSS modifier. */ +function statusCodeClass(code: number | null): string { + if (code === null) return 'webhook-failed-table__code--network'; + if (code >= 500) return 'webhook-failed-table__code--5xx'; + if (code >= 400) return 'webhook-failed-table__code--4xx'; + return ''; +} + +const SkeletonRow = () => ( + + {Array.from({ length: 6 }).map((_, i) => ( + + + + ))} + +); + +function SortButton({ + field, + currentField, + currentDir, + label, + onSort, +}: { + field: SortField; + currentField: SortField; + currentDir: SortDir; + label: string; + onSort: (f: SortField) => void; +}) { + const isActive = field === currentField; + const arrow = isActive ? (currentDir === 'asc' ? ' ↑' : ' ↓') : ''; + return ( + + ); +} + +export const WebhookFailedTable = memo(function WebhookFailedTable({ + deliveries, + isLoading, +}: WebhookFailedTableProps) { + const [sortField, setSortField] = useState('attemptedAt'); + const [sortDir, setSortDir] = useState('desc'); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [expandedId, setExpandedId] = useState(null); + + const handleSort = useCallback((field: SortField) => { + setSortField((prev) => { + if (prev === field) { + setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); + return field; + } + setSortDir('desc'); + return field; + }); + setPage(1); + }, []); + + const sorted = useMemo(() => { + return [...deliveries].sort((a, b) => { + let aVal: number | string | null; + let bVal: number | string | null; + + switch (sortField) { + case 'attemptedAt': + aVal = a.attemptedAt; + bVal = b.attemptedAt; + break; + case 'httpStatus': + aVal = a.httpStatus ?? -1; + bVal = b.httpStatus ?? -1; + break; + case 'latencyMs': + aVal = a.latencyMs ?? -1; + bVal = b.latencyMs ?? -1; + break; + case 'targetUrl': + aVal = a.targetUrl; + bVal = b.targetUrl; + break; + case 'eventType': + aVal = a.eventType; + bVal = b.eventType; + break; + default: + return 0; + } + + if (aVal === bVal) return 0; + const cmp = aVal < bVal ? -1 : 1; + return sortDir === 'asc' ? cmp : -cmp; + }); + }, [deliveries, sortField, sortDir]); + + const pageCount = Math.max(1, Math.ceil(sorted.length / pageSize)); + const safePage = Math.min(page, pageCount); + const pageItems = sorted.slice((safePage - 1) * pageSize, safePage * pageSize); + + const toggleExpand = useCallback((id: string) => { + setExpandedId((prev) => (prev === id ? null : id)); + }, []); + + return ( +
+
+

+ Failed Deliveries +

+ + {isLoading ? '—' : `${deliveries.length.toLocaleString()} records`} + +
+ +
+ + + + + + + + + + + + + {isLoading ? ( + Array.from({ length: 5 }).map((_, i) => ) + ) : pageItems.length === 0 ? ( + + + + ) : ( + pageItems.map((d) => { + const isExpanded = expandedId === d.id; + const category = getErrorCategory(d); + return ( + <> + + + + + + + + + {isExpanded && d.errorPayload && ( + + + + )} + + ); + }) + )} + +
+ + + + + + + + + + + Error Category +
+ No failed deliveries for the selected filters +
+ + + {d.eventType} + + {truncateUrl(d.targetUrl)} + + + {d.httpStatus !== null ? d.httpStatus : 'None'} + + + {d.latencyMs !== null ? `${d.latencyMs} ms` : '—'} + +
+ {category && ( + + {category} + + )} + {d.errorPayload && ( + + )} +
+
+
+                            {(() => {
+                              try {
+                                return JSON.stringify(JSON.parse(d.errorPayload), null, 2);
+                              } catch {
+                                return d.errorPayload;
+                              }
+                            })()}
+                          
+
+
+ + {/* Pagination */} + {!isLoading && deliveries.length > 0 && ( +
+ + {deliveries.length === 0 + ? '0 records' + : `${(safePage - 1) * pageSize + 1}–${Math.min(safePage * pageSize, sorted.length)} of ${sorted.length.toLocaleString()}`} + +
+ + + {safePage} / {pageCount} + + + + +
+
+ )} +
+ ); +}); diff --git a/dashboard/src/components/WebhookFiltersBar.tsx b/dashboard/src/components/WebhookFiltersBar.tsx new file mode 100644 index 0000000..145607f --- /dev/null +++ b/dashboard/src/components/WebhookFiltersBar.tsx @@ -0,0 +1,161 @@ +import { memo } from 'react'; +import type { WebhookDateRange, WebhookFilters } from '../types/webhook'; + +interface WebhookFiltersBarProps { + filters: WebhookFilters; + eventTypeOptions: string[]; + onFiltersChange: (patch: Partial) => void; + lastRefreshedAt: number | null; + isMockData: boolean; + onRefresh: () => void; + isLoading: boolean; +} + +const DATE_RANGE_OPTIONS: { value: WebhookDateRange; label: string }[] = [ + { value: '1h', label: 'Last 1 h' }, + { value: '6h', label: 'Last 6 h' }, + { value: '24h', label: 'Last 24 h' }, + { value: '7d', label: 'Last 7 d' }, +]; + +const STATUS_OPTIONS = [ + { value: 'all', label: 'All statuses' }, + { value: 'success', label: 'Success' }, + { value: 'failed', label: 'Failed' }, +]; + +const ERROR_CATEGORY_OPTIONS = [ + { value: 'all', label: 'All errors' }, + { value: '4xx', label: '4xx Client' }, + { value: '5xx', label: '5xx Server' }, + { value: 'timeout', label: 'Timeout' }, + { value: 'network', label: 'Network' }, +]; + +export const WebhookFiltersBar = memo(function WebhookFiltersBar({ + filters, + eventTypeOptions, + onFiltersChange, + lastRefreshedAt, + isMockData, + onRefresh, + isLoading, +}: WebhookFiltersBarProps) { + const refreshedLabel = lastRefreshedAt + ? `Updated ${new Date(lastRefreshedAt).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + })}` + : ''; + + return ( +
+ {/* Date range toggle */} +
+ + Time range + +
+ {DATE_RANGE_OPTIONS.map(({ value, label }) => ( + + ))} +
+
+ + {/* Event type */} +
+ + +
+ + {/* Status filter */} +
+ + +
+ + {/* Error category — only meaningful when status is "failed" or "all" */} +
+ + +
+ + {/* Refresh + last-updated */} +
+ {isMockData && ( + + Demo + + )} + {refreshedLabel && ( + {refreshedLabel} + )} + +
+
+ ); +}); diff --git a/dashboard/src/components/WebhookSummaryCards.tsx b/dashboard/src/components/WebhookSummaryCards.tsx new file mode 100644 index 0000000..0b94e54 --- /dev/null +++ b/dashboard/src/components/WebhookSummaryCards.tsx @@ -0,0 +1,91 @@ +import { memo } from 'react'; +import type { WebhookSummaryMetrics } from '../types/webhook'; + +interface WebhookSummaryCardsProps { + summary: WebhookSummaryMetrics; + isLoading: boolean; +} + +function MetricCard({ + label, + value, + subValue, + accent, + isLoading, +}: { + label: string; + value: string; + subValue?: string; + accent: 'blue' | 'green' | 'red' | 'yellow'; + isLoading: boolean; +}) { + return ( +
+
{label}
+ {isLoading ? ( +
+
+ ) : ( +
+ {value} + {subValue && ( + {subValue} + )} +
+ )} +
+ ); +} + +export const WebhookSummaryCards = memo(function WebhookSummaryCards({ + summary, + isLoading, +}: WebhookSummaryCardsProps) { + const successRateStr = isLoading ? '—' : `${summary.successRate.toFixed(2)}%`; + const totalStr = isLoading ? '—' : summary.totalAttempts.toLocaleString(); + const failedStr = isLoading ? '—' : summary.failedCount.toLocaleString(); + const avgLatencyStr = isLoading + ? '—' + : summary.avgLatencyMs !== null + ? `${summary.avgLatencyMs.toLocaleString()} ms` + : '—'; + const p95LatencyStr = + !isLoading && summary.p95LatencyMs !== null + ? `p95: ${summary.p95LatencyMs.toLocaleString()} ms` + : undefined; + + // Determine accent for success rate + const rateAccent: 'green' | 'yellow' | 'red' = + summary.successRate >= 95 ? 'green' : summary.successRate >= 80 ? 'yellow' : 'red'; + + return ( +
+ + + + +
+ ); +}); diff --git a/dashboard/src/hooks/useWebhookDashboard.ts b/dashboard/src/hooks/useWebhookDashboard.ts new file mode 100644 index 0000000..2ffc6e4 --- /dev/null +++ b/dashboard/src/hooks/useWebhookDashboard.ts @@ -0,0 +1,137 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { WebhookDelivery, WebhookFilters } from '../types/webhook'; +import { + bucketDeliveriesByTime, + computeSummaryMetrics, + filterDeliveries, + getEventTypeOptions, +} from '../utils/webhookData'; +import { fetchWebhookDeliveries, getMockWebhookDeliveries } from '../services/webhookApi'; + +const POLL_INTERVAL_MS = 30_000; + +const RANGE_HOURS_MAP: Record = { + '1h': 1, + '6h': 6, + '24h': 24, + '7d': 168, +}; + +const DEFAULT_FILTERS: WebhookFilters = { + dateRange: '24h', + eventType: 'all', + errorCategory: 'all', + statusFilter: 'all', +}; + +export interface UseWebhookDashboardResult { + isLoading: boolean; + error: string | null; + isMockData: boolean; + filters: WebhookFilters; + setFilters: (patch: Partial) => void; + /** All deliveries currently visible after filtering */ + filteredDeliveries: WebhookDelivery[]; + /** Failed deliveries only (used for the failed deliveries table) */ + failedDeliveries: WebhookDelivery[]; + summary: ReturnType; + chartBuckets: ReturnType; + eventTypeOptions: string[]; + refresh: () => void; + lastRefreshedAt: number | null; +} + +export function useWebhookDashboard(): UseWebhookDashboardResult { + const [allDeliveries, setAllDeliveries] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [isMockData, setIsMockData] = useState(false); + const [filters, setFiltersState] = useState(DEFAULT_FILTERS); + const [lastRefreshedAt, setLastRefreshedAt] = useState(null); + + // Track mounted state to prevent state updates after unmount + const mountedRef = useRef(true); + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const load = useCallback(async () => { + if (!mountedRef.current) return; + setIsLoading(true); + setError(null); + + try { + const response = await fetchWebhookDeliveries(); + if (!mountedRef.current) return; + setAllDeliveries(response.deliveries); + setIsMockData(false); + setLastRefreshedAt(Date.now()); + } catch { + if (!mountedRef.current) return; + const mock = getMockWebhookDeliveries(); + setAllDeliveries(mock.deliveries); + setIsMockData(true); + setError('Webhook API unavailable — showing generated demo data.'); + setLastRefreshedAt(Date.now()); + } finally { + if (mountedRef.current) { + setIsLoading(false); + } + } + }, []); + + // Initial load + polling + useEffect(() => { + load(); + const intervalId = setInterval(load, POLL_INTERVAL_MS); + return () => clearInterval(intervalId); + }, [load]); + + const setFilters = useCallback((patch: Partial) => { + setFiltersState((prev) => ({ ...prev, ...patch })); + }, []); + + // Derived data — only recomputed when allDeliveries or filters change + const filteredDeliveries = useMemo( + () => filterDeliveries(allDeliveries, filters), + [allDeliveries, filters] + ); + + const failedDeliveries = useMemo( + () => filteredDeliveries.filter((d) => d.status === 'failed'), + [filteredDeliveries] + ); + + const summary = useMemo( + () => computeSummaryMetrics(filteredDeliveries), + [filteredDeliveries] + ); + + const chartBuckets = useMemo( + () => bucketDeliveriesByTime(filteredDeliveries, RANGE_HOURS_MAP[filters.dateRange] ?? 24), + [filteredDeliveries, filters.dateRange] + ); + + const eventTypeOptions = useMemo( + () => getEventTypeOptions(allDeliveries), + [allDeliveries] + ); + + return { + isLoading, + error, + isMockData, + filters, + setFilters, + filteredDeliveries, + failedDeliveries, + summary, + chartBuckets, + eventTypeOptions, + refresh: load, + lastRefreshedAt, + }; +} diff --git a/dashboard/src/index.css b/dashboard/src/index.css index fa38bf2..e9dd25a 100644 --- a/dashboard/src/index.css +++ b/dashboard/src/index.css @@ -1757,3 +1757,701 @@ body { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } + + +/* ══════════════════════════════════════════════════════════════════ + Webhook Performance Dashboard + ══════════════════════════════════════════════════════════════════ */ + +/* ── Page layout ─────────────────────────────────────────────────── */ + +.webhook-dashboard { + display: grid; + gap: 24px; + padding-bottom: 40px; +} + +.webhook-dashboard__header { + padding: 24px 0 8px; +} + +.webhook-dashboard__eyebrow { + margin: 0 0 8px; + font-size: 0.75rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: #fb923c; +} + +.webhook-dashboard__title { + margin: 0 0 10px; + font-size: clamp(1.75rem, 2.5vw, 2.5rem); +} + +.webhook-dashboard__lead { + margin: 0; + max-width: 680px; + color: #cbd5e1; + line-height: 1.75; +} + +/* Error / mock-data banner */ +.webhook-dashboard__banner { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + padding: 14px 18px; + border: 1px solid rgba(244, 180, 0, 0.22); + border-radius: 14px; + background: rgba(244, 180, 0, 0.07); + color: #fde68a; + font-size: 0.9rem; +} + +.webhook-dashboard__banner-retry { + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 10px; + background: rgba(255, 255, 255, 0.06); + color: inherit; + padding: 8px 14px; + font-weight: 600; + font-size: 0.85rem; + cursor: pointer; + white-space: nowrap; + transition: background 0.15s ease, border-color 0.15s ease; +} + +.webhook-dashboard__banner-retry:hover, +.webhook-dashboard__banner-retry:focus-visible { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.24); + outline: none; +} + +/* Section headings inside the dashboard */ +.webhook-dashboard__section-title { + margin: 0 0 4px; + font-size: 1.05rem; + font-weight: 600; +} + +.webhook-dashboard__section-sub { + margin: 0 0 14px; + color: #9aa0a6; + font-size: 0.88rem; +} + +.webhook-dashboard__chart-section { + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + padding: 20px; + background: rgba(255, 255, 255, 0.02); +} + +/* ── Summary cards ───────────────────────────────────────────────── */ + +.webhook-summary-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 14px; + margin: 0; +} + +.webhook-metric-card { + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + padding: 18px 20px 16px; + background: rgba(255, 255, 255, 0.02); + display: flex; + flex-direction: column; + gap: 10px; + transition: border-color 0.15s ease; +} + +.webhook-metric-card--blue { border-left: 3px solid #60a5fa; } +.webhook-metric-card--green { border-left: 3px solid #34d399; } +.webhook-metric-card--red { border-left: 3px solid #f87171; } +.webhook-metric-card--yellow { border-left: 3px solid #f4b400; } + +.webhook-metric-card__label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.07em; + color: #9aa0a6; + font-weight: 600; +} + +.webhook-metric-card__value { + margin: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.webhook-metric-card__number { + font-size: 1.75rem; + font-weight: 700; + font-variant-numeric: tabular-nums; + line-height: 1; + letter-spacing: -0.02em; + font-family: 'Courier New', Courier, monospace; +} + +.webhook-metric-card--blue .webhook-metric-card__number { color: #93c5fd; } +.webhook-metric-card--green .webhook-metric-card__number { color: #6ee7b7; } +.webhook-metric-card--red .webhook-metric-card__number { color: #fca5a5; } +.webhook-metric-card--yellow .webhook-metric-card__number { color: #fde047; } + +.webhook-metric-card__sub { + font-size: 0.78rem; + color: #9aa0a6; + font-family: 'Courier New', Courier, monospace; +} + +/* Card skeleton */ +.webhook-metric-card__skeleton { + display: block; + height: 36px; + width: 80%; + border-radius: 6px; + background: linear-gradient( + 90deg, + rgba(255, 255, 255, 0.06) 25%, + rgba(255, 255, 255, 0.12) 50%, + rgba(255, 255, 255, 0.06) 75% + ); + background-size: 200% 100%; + animation: wh-shimmer 1.4s ease-in-out infinite; +} + +@keyframes wh-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +/* ── Filters bar ─────────────────────────────────────────────────── */ + +.webhook-filters { + display: flex; + flex-wrap: wrap; + gap: 12px 16px; + align-items: flex-end; + padding: 16px 18px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 14px; + background: rgba(255, 255, 255, 0.02); +} + +.webhook-filters__group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.webhook-filters__label { + font-size: 0.78rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #9aa0a6; +} + +.webhook-filters__select { + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + padding: 8px 10px; + background: #12151c; + color: inherit; + font-size: 0.9rem; + min-width: 140px; + cursor: pointer; +} + +.webhook-filters__select:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +/* Date range button group */ +.webhook-filters__btn-group { + display: flex; + gap: 4px; +} + +.webhook-filters__range-btn { + padding: 7px 13px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); + color: #9aa0a6; + font-size: 0.85rem; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; +} + +.webhook-filters__range-btn:hover { + background: rgba(255, 255, 255, 0.07); + color: #e8eaed; +} + +.webhook-filters__range-btn--active { + background: rgba(251, 146, 60, 0.18); + border-color: rgba(251, 146, 60, 0.45); + color: #fb923c; + font-weight: 600; +} + +/* Refresh + meta */ +.webhook-filters__actions { + display: flex; + align-items: center; + gap: 10px; + margin-left: auto; + flex-wrap: wrap; +} + +.webhook-filters__updated { + font-size: 0.8rem; + color: #9aa0a6; + white-space: nowrap; +} + +.webhook-filters__mock-badge { + display: inline-block; + padding: 3px 9px; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + background: rgba(244, 180, 0, 0.16); + color: #f4b400; +} + +.webhook-filters__refresh-btn { + padding: 8px 14px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + background: rgba(255, 255, 255, 0.05); + color: inherit; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: background 0.15s ease, border-color 0.15s ease; +} + +.webhook-filters__refresh-btn:hover:not(:disabled), +.webhook-filters__refresh-btn:focus-visible { + background: rgba(255, 255, 255, 0.09); + border-color: rgba(255, 255, 255, 0.24); + outline: none; +} + +.webhook-filters__refresh-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ── Delivery chart ──────────────────────────────────────────────── */ + +.webhook-delivery-chart { + display: flex; + flex-direction: column; + gap: 10px; +} + +.webhook-delivery-chart__legend { + display: flex; + gap: 18px; +} + +.webhook-delivery-chart__legend-item { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.82rem; + color: #9aa0a6; +} + +.webhook-delivery-chart__legend-item--success { color: #34d399; } +.webhook-delivery-chart__legend-item--failed { color: #f87171; } + +.webhook-delivery-chart__svg { + width: 100%; + height: auto; + display: block; + overflow: visible; +} + +.webhook-delivery-chart__skeleton-bar { + fill: rgba(255, 255, 255, 0.07); + animation: wh-shimmer 1.5s ease-in-out infinite; +} + +.webhook-delivery-chart__empty { + display: flex; + align-items: center; + justify-content: center; + height: 120px; + color: #9aa0a6; + font-size: 0.9rem; + border: 1px dashed rgba(255, 255, 255, 0.1); + border-radius: 10px; +} + +/* ── Failed deliveries table ─────────────────────────────────────── */ + +.webhook-failed-table-section { + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + background: rgba(255, 255, 255, 0.02); + overflow: hidden; +} + +.webhook-failed-table-section__header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 18px 20px 14px; + border-bottom: 1px solid rgba(255, 255, 255, 0.07); +} + +.webhook-failed-table-section__title { + margin: 0; + font-size: 1.05rem; + font-weight: 600; +} + +.webhook-failed-table-section__count { + font-size: 0.85rem; + color: #9aa0a6; +} + +.webhook-failed-table__wrapper { + overflow-x: auto; +} + +.webhook-failed-table { + width: 100%; + border-collapse: collapse; + text-align: left; + font-size: 0.88rem; +} + +.webhook-failed-table__th { + padding: 0; + font-weight: 500; + background: rgba(255, 255, 255, 0.02); + border-bottom: 1px solid rgba(255, 255, 255, 0.07); + white-space: nowrap; +} + +.webhook-failed-table__sort-btn { + display: block; + width: 100%; + padding: 12px 16px; + background: none; + border: none; + text-align: left; + font-size: 0.78rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #9aa0a6; + cursor: pointer; + white-space: nowrap; + transition: color 0.15s ease; +} + +.webhook-failed-table__sort-btn:hover, +.webhook-failed-table__sort-btn:focus-visible { + color: #e8eaed; + outline: none; +} + +.webhook-failed-table__sort-btn--active { + color: #e8eaed; +} + +.webhook-failed-table__row { + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + transition: background 0.12s ease; +} + +.webhook-failed-table__row:last-child { + border-bottom: none; +} + +.webhook-failed-table__row:hover { + background: rgba(255, 255, 255, 0.03); +} + +.webhook-failed-table__row--expanded { + background: rgba(255, 255, 255, 0.04); +} + +.webhook-failed-table__cell { + padding: 13px 16px; + vertical-align: middle; + color: #e8eaed; +} + +.webhook-failed-table__cell--mono { + font-family: 'Courier New', Courier, monospace; + font-size: 0.82rem; + color: #cbd5e1; + white-space: nowrap; +} + +.webhook-failed-table__cell--url { + font-family: 'Courier New', Courier, monospace; + font-size: 0.82rem; + color: #93c5fd; + max-width: 260px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.webhook-failed-table__cell-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +/* Event type badge */ +.webhook-failed-table__event-badge { + display: inline-block; + padding: 2px 9px; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + background: rgba(255, 255, 255, 0.08); + color: #e2e8f0; + letter-spacing: 0.02em; + text-transform: lowercase; +} + +/* HTTP status code badges */ +.webhook-failed-table__code { + display: inline-block; + padding: 3px 10px; + border-radius: 6px; + font-size: 0.8rem; + font-weight: 700; + font-family: 'Courier New', Courier, monospace; + background: rgba(255, 255, 255, 0.07); + color: #e8eaed; +} + +.webhook-failed-table__code--4xx { + background: rgba(251, 146, 60, 0.15); + color: #fb923c; +} + +.webhook-failed-table__code--5xx { + background: rgba(248, 113, 113, 0.15); + color: #f87171; +} + +.webhook-failed-table__code--network { + background: rgba(167, 139, 250, 0.15); + color: #c4b5fd; +} + +/* Error category badges */ +.webhook-failed-table__category { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.webhook-failed-table__category--4xx { background: rgba(251, 146, 60, 0.14); color: #fb923c; } +.webhook-failed-table__category--5xx { background: rgba(248, 113, 113, 0.14); color: #f87171; } +.webhook-failed-table__category--timeout { background: rgba(244, 180, 0, 0.14); color: #f4b400; } +.webhook-failed-table__category--network { background: rgba(167, 139, 250, 0.14); color: #a78bfa; } + +/* Expand/collapse payload button */ +.webhook-failed-table__expand-btn { + padding: 3px 9px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 6px; + background: rgba(255, 255, 255, 0.04); + color: #9aa0a6; + font-size: 0.75rem; + cursor: pointer; + white-space: nowrap; + transition: background 0.12s ease, color 0.12s ease; +} + +.webhook-failed-table__expand-btn:hover, +.webhook-failed-table__expand-btn:focus-visible { + background: rgba(255, 255, 255, 0.08); + color: #e8eaed; + outline: none; +} + +/* Expanded error payload row */ +.webhook-failed-table__detail-row { + background: rgba(0, 0, 0, 0.2); +} + +.webhook-failed-table__detail-cell { + padding: 0 16px 14px; +} + +.webhook-failed-table__error-payload { + margin: 0; + padding: 12px 16px; + border: 1px solid rgba(248, 113, 113, 0.14); + border-radius: 8px; + background: rgba(248, 113, 113, 0.06); + color: #fca5a5; + font-family: 'Courier New', Courier, monospace; + font-size: 0.8rem; + line-height: 1.6; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-all; +} + +/* Empty state */ +.webhook-failed-table__empty { + text-align: center; + padding: 40px 24px; + color: #9aa0a6; + font-size: 0.9rem; +} + +/* Skeleton rows */ +.webhook-failed-table__row--skeleton { + pointer-events: none; +} + +.webhook-failed-table__skeleton { + display: inline-block; + height: 13px; + border-radius: 4px; + background: linear-gradient( + 90deg, + rgba(255, 255, 255, 0.05) 25%, + rgba(255, 255, 255, 0.1) 50%, + rgba(255, 255, 255, 0.05) 75% + ); + background-size: 200% 100%; + animation: wh-shimmer 1.4s ease-in-out infinite; +} + +/* Pagination */ +.webhook-failed-table__pagination { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 14px 20px; + border-top: 1px solid rgba(255, 255, 255, 0.06); + flex-wrap: wrap; +} + +.webhook-failed-table__pagination-info { + font-size: 0.85rem; + color: #9aa0a6; +} + +.webhook-failed-table__pagination-controls { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.webhook-failed-table__page-btn { + padding: 7px 13px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + background: rgba(255, 255, 255, 0.04); + color: inherit; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease; +} + +.webhook-failed-table__page-btn:hover:not(:disabled), +.webhook-failed-table__page-btn:focus-visible { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.22); + outline: none; +} + +.webhook-failed-table__page-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.webhook-failed-table__page-indicator { + font-size: 0.85rem; + color: #9aa0a6; + white-space: nowrap; +} + +.webhook-failed-table__page-size-label { + font-size: 0.8rem; + color: #9aa0a6; +} + +.webhook-failed-table__page-size-select { + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + padding: 7px 10px; + background: #12151c; + color: inherit; + font-size: 0.85rem; +} + +/* ── Responsive adjustments ──────────────────────────────────────── */ + +@media (max-width: 900px) { + .webhook-summary-cards { + grid-template-columns: repeat(2, 1fr); + } + + .webhook-failed-table__cell--url { + max-width: 160px; + } +} + +@media (max-width: 600px) { + .webhook-summary-cards { + grid-template-columns: 1fr 1fr; + } + + .webhook-filters { + flex-direction: column; + align-items: stretch; + } + + .webhook-filters__actions { + margin-left: 0; + justify-content: space-between; + } + + .webhook-failed-table__pagination { + flex-direction: column; + align-items: flex-start; + } +} + +@media (max-width: 420px) { + .webhook-summary-cards { + grid-template-columns: 1fr; + } +} diff --git a/dashboard/src/pages/WebhookDashboardPage.tsx b/dashboard/src/pages/WebhookDashboardPage.tsx new file mode 100644 index 0000000..c5948a6 --- /dev/null +++ b/dashboard/src/pages/WebhookDashboardPage.tsx @@ -0,0 +1,83 @@ +import { useWebhookDashboard } from '../hooks/useWebhookDashboard'; +import { WebhookSummaryCards } from '../components/WebhookSummaryCards'; +import { WebhookDeliveryChart } from '../components/WebhookDeliveryChart'; +import { WebhookFiltersBar } from '../components/WebhookFiltersBar'; +import { WebhookFailedTable } from '../components/WebhookFailedTable'; + +export function WebhookDashboardPage() { + const { + isLoading, + error, + isMockData, + filters, + setFilters, + failedDeliveries, + summary, + chartBuckets, + eventTypeOptions, + refresh, + lastRefreshedAt, + } = useWebhookDashboard(); + + return ( +
+
+
+

Webhook Monitoring

+

+ Delivery Performance +

+

+ Real-time visibility into webhook delivery attempts, latency trends, and failure + diagnostics across all registered endpoints. +

+
+
+ + {/* Error banner (API unavailable / mock data notice) */} + {error && ( +
+ {error} + +
+ )} + + {/* Filters */} + + + {/* KPI summary cards */} + + + {/* Delivery trend chart */} +
+

+ Delivery Trends +

+

+ Successful vs failed delivery attempts over the selected time range. +

+ +
+ + {/* Failed deliveries table */} + +
+ ); +} diff --git a/dashboard/src/services/webhookApi.ts b/dashboard/src/services/webhookApi.ts new file mode 100644 index 0000000..331343a --- /dev/null +++ b/dashboard/src/services/webhookApi.ts @@ -0,0 +1,31 @@ +import type { WebhookDelivery } from '../types/webhook'; +import { generateMockWebhookDeliveries } from '../utils/webhookData'; + +const BASE_URL = + (typeof import.meta !== 'undefined' && (import.meta as { env?: Record }).env?.VITE_EVENTS_API_URL) || + 'http://localhost:8787'; + +export interface WebhookDeliveryResponse { + deliveries: WebhookDelivery[]; + total: number; +} + +/** + * Fetch webhook delivery records from the API. + * Falls back to mock data if the API is unreachable. + */ +export async function fetchWebhookDeliveries(): Promise { + const response = await fetch(`${BASE_URL}/api/webhooks/deliveries`); + if (!response.ok) { + throw new Error(`Failed to fetch webhook deliveries: ${response.status}`); + } + return response.json() as Promise; +} + +/** + * Generates mock deliveries for local development / API unavailable scenarios. + */ +export function getMockWebhookDeliveries(): WebhookDeliveryResponse { + const deliveries = generateMockWebhookDeliveries(600, 168); + return { deliveries, total: deliveries.length }; +} diff --git a/dashboard/src/types/webhook.ts b/dashboard/src/types/webhook.ts new file mode 100644 index 0000000..0b7df95 --- /dev/null +++ b/dashboard/src/types/webhook.ts @@ -0,0 +1,68 @@ +/** + * Types for the Webhook Delivery Performance Dashboard. + */ + +export type WebhookStatusCode = + | 200 | 201 | 204 + | 400 | 401 | 403 | 404 | 408 | 409 | 422 | 429 + | 500 | 502 | 503 | 504; + +export type WebhookDeliveryStatus = 'success' | 'failed' | 'pending' | 'retrying'; + +export type WebhookErrorCategory = '4xx' | '5xx' | 'timeout' | 'network'; + +export interface WebhookDelivery { + id: string; + /** The webhook event type (e.g. "transfer", "mint", "burn") */ + eventType: string; + /** Target endpoint URL */ + targetUrl: string; + /** HTTP status code returned by the target (null if no response) */ + httpStatus: WebhookStatusCode | null; + /** Raw error payload or message, if any */ + errorPayload: string | null; + /** Delivery outcome */ + status: WebhookDeliveryStatus; + /** Round-trip latency in milliseconds (null if failed before response) */ + latencyMs: number | null; + /** Unix timestamp (ms) when the delivery attempt was made */ + attemptedAt: number; + /** Which attempt number (1 = first, 2+ = retry) */ + attemptNumber: number; +} + +export interface WebhookMetricBucket { + /** ISO date-hour string, e.g. "2026-06-26T14" */ + label: string; + /** Human-friendly label for display */ + displayLabel: string; + successCount: number; + failedCount: number; + totalCount: number; + /** Average latency for successful deliveries in this bucket (ms) */ + avgLatencyMs: number | null; +} + +export interface WebhookSummaryMetrics { + totalAttempts: number; + successCount: number; + failedCount: number; + /** Overall success rate as a percentage (0-100), rounded to 2 decimal places */ + successRate: number; + /** Average latency across all successful deliveries (ms), or null if none */ + avgLatencyMs: number | null; + /** P95 latency across successful deliveries (ms), or null if none */ + p95LatencyMs: number | null; +} + +export type WebhookDateRange = '1h' | '6h' | '24h' | '7d'; + +export interface WebhookFilters { + dateRange: WebhookDateRange; + /** "all" | specific event type string */ + eventType: string; + /** "all" | "4xx" | "5xx" | "timeout" | "network" */ + errorCategory: string; + /** "all" | "success" | "failed" */ + statusFilter: string; +} diff --git a/dashboard/src/utils/webhookData.ts b/dashboard/src/utils/webhookData.ts new file mode 100644 index 0000000..db6320d --- /dev/null +++ b/dashboard/src/utils/webhookData.ts @@ -0,0 +1,272 @@ +import type { + WebhookDelivery, + WebhookDeliveryStatus, + WebhookErrorCategory, + WebhookFilters, + WebhookMetricBucket, + WebhookStatusCode, + WebhookSummaryMetrics, +} from '../types/webhook'; + +// ─── Mock data generation ────────────────────────────────────────────────── + +const EVENT_TYPES = ['transfer', 'mint', 'burn', 'swap', 'stake', 'unstake', 'vote']; +const TARGET_URLS = [ + 'https://api.example.com/webhooks/notify', + 'https://hooks.slack.com/services/T000/B000/xxxx', + 'https://discord.com/api/webhooks/000/yyyy', + 'https://my-app.vercel.app/api/chain-events', + 'https://alerts.internal.io/soroban', +]; + +const SUCCESS_CODES: WebhookStatusCode[] = [200, 201, 204]; +const CLIENT_ERROR_CODES: WebhookStatusCode[] = [400, 401, 403, 404, 408, 409, 422, 429]; +const SERVER_ERROR_CODES: WebhookStatusCode[] = [500, 502, 503, 504]; + +const ERROR_PAYLOADS: Record = { + 400: '{"error":"Bad Request","message":"Malformed event payload"}', + 401: '{"error":"Unauthorized","message":"Invalid or missing API key"}', + 403: '{"error":"Forbidden","message":"IP address not allowlisted"}', + 404: '{"error":"Not Found","message":"Webhook endpoint not registered"}', + 408: '{"error":"Request Timeout","message":"Upstream timed out after 10s"}', + 409: '{"error":"Conflict","message":"Duplicate event ID detected"}', + 422: '{"error":"Unprocessable Entity","message":"Event schema validation failed"}', + 429: '{"error":"Too Many Requests","message":"Rate limit exceeded; retry after 60s"}', + 500: '{"error":"Internal Server Error","message":"Unexpected exception in handler"}', + 502: '{"error":"Bad Gateway","message":"Upstream service returned invalid response"}', + 503: '{"error":"Service Unavailable","message":"Target service is under maintenance"}', + 504: '{"error":"Gateway Timeout","message":"Connection to upstream timed out"}', +}; + +function seededRandom(seed: number): () => number { + let s = seed; + return () => { + s = (s * 1664525 + 1013904223) & 0xffffffff; + return (s >>> 0) / 0xffffffff; + }; +} + +/** + * Generates a deterministic set of mock webhook deliveries over the past `hours` hours. + * A fixed seed ensures the data is stable across re-renders. + */ +export function generateMockWebhookDeliveries( + count: number = 400, + hours: number = 24, + seed: number = 42 +): WebhookDelivery[] { + const rand = seededRandom(seed); + const now = Date.now(); + const windowMs = hours * 60 * 60 * 1000; + const deliveries: WebhookDelivery[] = []; + + for (let i = 0; i < count; i++) { + const offsetMs = rand() * windowMs; + const attemptedAt = now - offsetMs; + + const eventType = EVENT_TYPES[Math.floor(rand() * EVENT_TYPES.length)]; + const targetUrl = TARGET_URLS[Math.floor(rand() * TARGET_URLS.length)]; + const attemptNumber = rand() < 0.85 ? 1 : Math.floor(rand() * 3) + 2; + + // ~78% success rate to make the dashboard interesting + const outcome = rand(); + let status: WebhookDeliveryStatus; + let httpStatus: WebhookStatusCode | null; + let errorPayload: string | null = null; + let latencyMs: number | null; + + if (outcome < 0.78) { + // Success + status = 'success'; + httpStatus = SUCCESS_CODES[Math.floor(rand() * SUCCESS_CODES.length)]; + latencyMs = Math.round(80 + rand() * 520); // 80–600 ms + } else if (outcome < 0.88) { + // 4xx client error + status = 'failed'; + httpStatus = CLIENT_ERROR_CODES[Math.floor(rand() * CLIENT_ERROR_CODES.length)]; + errorPayload = ERROR_PAYLOADS[httpStatus] ?? null; + latencyMs = Math.round(50 + rand() * 200); + } else if (outcome < 0.96) { + // 5xx server error + status = 'failed'; + httpStatus = SERVER_ERROR_CODES[Math.floor(rand() * SERVER_ERROR_CODES.length)]; + errorPayload = ERROR_PAYLOADS[httpStatus] ?? null; + latencyMs = Math.round(200 + rand() * 800); + } else { + // Network / timeout + status = 'failed'; + httpStatus = null; + errorPayload = '{"error":"Network Error","message":"ECONNREFUSED — target host unreachable"}'; + latencyMs = null; + } + + deliveries.push({ + id: `wh-${i.toString().padStart(5, '0')}-${Math.floor(rand() * 0xffff).toString(16)}`, + eventType, + targetUrl, + httpStatus, + errorPayload, + status, + latencyMs, + attemptedAt, + attemptNumber, + }); + } + + // Sort chronologically descending (most recent first) + return deliveries.sort((a, b) => b.attemptedAt - a.attemptedAt); +} + +// ─── Metric computation ──────────────────────────────────────────────────── + +/** Compute aggregate KPI summary from a list of deliveries. */ +export function computeSummaryMetrics(deliveries: WebhookDelivery[]): WebhookSummaryMetrics { + if (deliveries.length === 0) { + return { + totalAttempts: 0, + successCount: 0, + failedCount: 0, + successRate: 0, + avgLatencyMs: null, + p95LatencyMs: null, + }; + } + + const successCount = deliveries.filter((d) => d.status === 'success').length; + const failedCount = deliveries.filter((d) => d.status === 'failed').length; + const totalAttempts = deliveries.length; + + // Use integer-safe arithmetic: multiply before dividing to avoid float drift + const successRate = Math.round((successCount / totalAttempts) * 10000) / 100; + + const successLatencies = deliveries + .filter((d) => d.status === 'success' && d.latencyMs !== null) + .map((d) => d.latencyMs as number) + .sort((a, b) => a - b); + + let avgLatencyMs: number | null = null; + let p95LatencyMs: number | null = null; + + if (successLatencies.length > 0) { + const sum = successLatencies.reduce((acc, v) => acc + v, 0); + avgLatencyMs = Math.round(sum / successLatencies.length); + const p95Index = Math.ceil(successLatencies.length * 0.95) - 1; + p95LatencyMs = successLatencies[Math.max(0, p95Index)]; + } + + return { totalAttempts, successCount, failedCount, successRate, avgLatencyMs, p95LatencyMs }; +} + +/** + * Bucket deliveries into time slots for the chart. + * Each bucket is one hour wide for ≤24h ranges, or 6 hours for 7d. + */ +export function bucketDeliveriesByTime( + deliveries: WebhookDelivery[], + rangeHours: number +): WebhookMetricBucket[] { + const bucketHours = rangeHours <= 24 ? 1 : 6; + const bucketMs = bucketHours * 60 * 60 * 1000; + const now = Date.now(); + const numBuckets = Math.ceil(rangeHours / bucketHours); + + const buckets: WebhookMetricBucket[] = Array.from({ length: numBuckets }, (_, i) => { + const bucketEnd = now - i * bucketMs; + const bucketStart = bucketEnd - bucketMs; + const d = new Date(bucketStart); + + let displayLabel: string; + if (bucketHours === 1) { + displayLabel = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }); + } else { + displayLabel = d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }); + } + + return { + label: d.toISOString().slice(0, 13), + displayLabel, + successCount: 0, + failedCount: 0, + totalCount: 0, + avgLatencyMs: null, + }; + }).reverse(); // oldest first for left-to-right chart rendering + + // Assign each delivery to its bucket + for (const delivery of deliveries) { + const bucketIndex = Math.floor((now - delivery.attemptedAt) / bucketMs); + const idx = numBuckets - 1 - bucketIndex; // map to 0-indexed oldest-first + if (idx < 0 || idx >= numBuckets) continue; + + const bucket = buckets[idx]; + bucket.totalCount++; + if (delivery.status === 'success') { + bucket.successCount++; + } else { + bucket.failedCount++; + } + } + + // Compute per-bucket average latency + for (let i = 0; i < numBuckets; i++) { + const bucketIndex = numBuckets - 1 - i; + const bucketEnd = now - bucketIndex * bucketMs; + const bucketStart = bucketEnd - bucketMs; + const latencies = deliveries + .filter( + (d) => + d.status === 'success' && + d.latencyMs !== null && + d.attemptedAt >= bucketStart && + d.attemptedAt < bucketEnd + ) + .map((d) => d.latencyMs as number); + + if (latencies.length > 0) { + buckets[i].avgLatencyMs = Math.round( + latencies.reduce((acc, v) => acc + v, 0) / latencies.length + ); + } + } + + return buckets; +} + +/** Derive the error category from a delivery. */ +export function getErrorCategory(delivery: WebhookDelivery): WebhookErrorCategory | null { + if (delivery.status === 'success') return null; + if (delivery.httpStatus === null) return 'network'; + if (delivery.httpStatus === 408 || delivery.httpStatus === 504) return 'timeout'; + if (delivery.httpStatus >= 400 && delivery.httpStatus < 500) return '4xx'; + return '5xx'; +} + +/** Apply filters to a flat list of deliveries. */ +export function filterDeliveries( + deliveries: WebhookDelivery[], + filters: WebhookFilters +): WebhookDelivery[] { + const rangeHoursMap: Record = { '1h': 1, '6h': 6, '24h': 24, '7d': 168 }; + const rangeHours = rangeHoursMap[filters.dateRange] ?? 24; + const cutoffMs = Date.now() - rangeHours * 60 * 60 * 1000; + + return deliveries.filter((d) => { + if (d.attemptedAt < cutoffMs) return false; + if (filters.eventType !== 'all' && d.eventType !== filters.eventType) return false; + if (filters.statusFilter !== 'all') { + if (filters.statusFilter === 'success' && d.status !== 'success') return false; + if (filters.statusFilter === 'failed' && d.status !== 'failed') return false; + } + if (filters.errorCategory !== 'all') { + const cat = getErrorCategory(d); + if (cat !== filters.errorCategory) return false; + } + return true; + }); +} + +/** Get all unique event types from a list of deliveries. */ +export function getEventTypeOptions(deliveries: WebhookDelivery[]): string[] { + return Array.from(new Set(deliveries.map((d) => d.eventType))).sort(); +}