From 7cd0f532de006982f160e81c8e54cf25c15a22a0 Mon Sep 17 00:00:00 2001 From: Yusrah Mohammed Date: Sun, 26 Jul 2026 17:27:48 +0100 Subject: [PATCH] Add retry stats, form validation, column resize, and user activity timeline Co-authored-by: Cursor --- .gitignore | 22 + dashboard/package-lock.json | 53 -- dashboard/src/App.tsx | 39 +- .../components/EventExplorerTable.test.tsx | 90 +++ .../src/components/EventExplorerTable.tsx | 118 +++- .../components/RetryStatisticsPanel.test.tsx | 68 ++ .../src/components/RetryStatisticsPanel.tsx | 129 ++++ .../components/UserActivityTimeline.test.tsx | 104 +++ .../src/components/UserActivityTimeline.tsx | 114 ++++ dashboard/src/index.css | 213 +++++- .../src/pages/NotificationPreferencesPage.tsx | 18 +- dashboard/src/services/retryStatisticsApi.ts | 32 + dashboard/src/services/userActivityApi.ts | 53 ++ dashboard/src/types/retryStatistics.test.ts | 13 + dashboard/src/types/retryStatistics.ts | 26 + dashboard/src/types/userActivity.ts | 23 + frontend/.gitignore | 10 + frontend/package-lock.json | 39 -- frontend/src/components/SubscriptionForm.tsx | 120 +++- .../__tests__/SubscriptionForm.test.tsx | 52 +- listener/package-lock.json | 624 ++++++------------ listener/package.json | 5 +- listener/src/api/events-server.ts | 63 ++ listener/src/services/notification-api.ts | 21 + .../src/services/retry-statistics.test.ts | 96 +++ listener/src/services/retry-statistics.ts | 155 +++++ 26 files changed, 1719 insertions(+), 581 deletions(-) create mode 100644 .gitignore create mode 100644 dashboard/src/components/EventExplorerTable.test.tsx create mode 100644 dashboard/src/components/RetryStatisticsPanel.test.tsx create mode 100644 dashboard/src/components/RetryStatisticsPanel.tsx create mode 100644 dashboard/src/components/UserActivityTimeline.test.tsx create mode 100644 dashboard/src/components/UserActivityTimeline.tsx create mode 100644 dashboard/src/services/retryStatisticsApi.ts create mode 100644 dashboard/src/services/userActivityApi.ts create mode 100644 dashboard/src/types/retryStatistics.test.ts create mode 100644 dashboard/src/types/retryStatistics.ts create mode 100644 dashboard/src/types/userActivity.ts create mode 100644 frontend/.gitignore create mode 100644 listener/src/services/retry-statistics.test.ts create mode 100644 listener/src/services/retry-statistics.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f77ebb --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Dependencies +node_modules/ + +# Build outputs +dist/ +build/ +.next/ +out/ +coverage/ + +# Env / local +.env +.env.* +!.env.example +*.log +.DS_Store + +# Tooling / data +data/ +test-data/ +*.db +*.db-journal diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 758e0d8..ca3faa6 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -972,23 +972,6 @@ "node": ">= 6" } }, - "node_modules/@creit.tech/stellar-wallets-kit/node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "extraneous": true, - "license": "Apache-2.0", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=4.2.0" - } - }, "node_modules/@creit.tech/xbull-wallet-connect": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@creit.tech/xbull-wallet-connect/-/xbull-wallet-connect-0.4.0.tgz", @@ -7987,24 +7970,6 @@ "ws": "^7.5.1" } }, - "node_modules/@walletconnect/jsonrpc-ws-connection/node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "extraneous": true, - "hasInstallScript": true, - "license": "MIT", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/@walletconnect/jsonrpc-ws-connection/node_modules/ws": { "version": "7.5.11", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", @@ -13130,24 +13095,6 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT" }, - "node_modules/jayson/node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "extraneous": true, - "hasInstallScript": true, - "license": "MIT", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/jayson/node_modules/ws": { "version": "7.5.11", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 967b943..b494728 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -2,6 +2,8 @@ import { useState } from 'react'; import { EventExplorerPage } from './pages/EventExplorerPage'; import { NotificationTimelineView } from './components/NotificationTimelineView'; import { ActivityFeed } from './components/ActivityFeed'; +import { UserActivityTimeline } from './components/UserActivityTimeline'; +import { RetryStatisticsPanel } from './components/RetryStatisticsPanel'; import { WebhookDashboardPage } from './pages/WebhookDashboardPage'; import { ExportHistoryPage } from './pages/ExportHistoryPage'; import { NotificationSearchPage } from './pages/NotificationSearchPage'; @@ -16,6 +18,8 @@ type Tab = | 'explorer' | 'timeline' | 'activity' + | 'user-activity' + | 'retry-stats' | 'webhooks' | 'export-history' | 'search' @@ -32,18 +36,10 @@ export function App() {
- | 'preferences'; - -export function App() { - const [tab, setTab] = useState('explorer'); - - return ( -
+ ))} -
+
{events.map((event) => ( ({ + fetchRetryStatistics: (...args: unknown[]) => mockFetchRetryStatistics(...args), + generateMockRetryStatistics: (...args: unknown[]) => mockGenerateMockRetryStatistics(...args), +})); + +const SAMPLE = { + totalNotifications: 10, + totalRetryAttempts: 4, + notificationsWithRetries: 3, + permanentFailures: 1, + recoveredAfterRetry: 2, + averageRetriesPerNotification: 0.4, + maxObservedRetryCount: 2, + retryRate: 0.3, + distribution: [ + { retryCount: 0, count: 7, successCount: 7, failureCount: 0 }, + { retryCount: 1, count: 2, successCount: 1, failureCount: 1 }, + { retryCount: 2, count: 1, successCount: 1, failureCount: 0 }, + ], +}; + +describe('RetryStatisticsPanel', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGenerateMockRetryStatistics.mockReturnValue(SAMPLE); + }); + + it('loads and displays retry statistics from the API', async () => { + mockFetchRetryStatistics.mockResolvedValue(SAMPLE); + + render(); + + expect(await screen.findByText('0.40')).toBeInTheDocument(); + expect(screen.getByText('Notification Retry Statistics')).toBeInTheDocument(); + expect(screen.getByText('4')).toBeInTheDocument(); + expect(screen.getByText('30.0%')).toBeInTheDocument(); + expect(screen.getByText('Retry distribution')).toBeInTheDocument(); + }); + + it('falls back to mock data when the API fails', async () => { + mockFetchRetryStatistics.mockRejectedValue(new Error('offline')); + + render(); + + expect(await screen.findByText(/sample data/i)).toBeInTheDocument(); + expect(mockGenerateMockRetryStatistics).toHaveBeenCalled(); + }); + + it('refreshes statistics on demand', async () => { + mockFetchRetryStatistics.mockResolvedValue(SAMPLE); + render(); + + await screen.findByText('0.40'); + expect(mockFetchRetryStatistics).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole('button', { name: /refresh/i })); + + await waitFor(() => { + expect(mockFetchRetryStatistics).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/dashboard/src/components/RetryStatisticsPanel.tsx b/dashboard/src/components/RetryStatisticsPanel.tsx new file mode 100644 index 0000000..d0f41b2 --- /dev/null +++ b/dashboard/src/components/RetryStatisticsPanel.tsx @@ -0,0 +1,129 @@ +import { useCallback, useEffect, useState } from 'react'; +import { + fetchRetryStatistics, + generateMockRetryStatistics, +} from '../services/retryStatisticsApi'; +import type { RetryStatistics } from '../types/retryStatistics'; +import { formatAverageRetries, formatRetryRate } from '../types/retryStatistics'; + +export function RetryStatisticsPanel() { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [usingMock, setUsingMock] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = await fetchRetryStatistics(); + setStats(data); + setUsingMock(false); + } catch { + setStats(generateMockRetryStatistics()); + setUsingMock(true); + setError(null); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + return ( +
+
+
+

+ Notification Retry Statistics +

+

+ Delivery monitoring for failed notifications and retry attempts + {usingMock ? ' (sample data)' : ''} +

+
+ +
+ + {loading && !stats && ( +

+ Loading retry statistics… +

+ )} + + {error && ( +
+ {error} +
+ )} + + {stats && ( + <> +
+
+

Total retries

+

{stats.totalRetryAttempts}

+
+
+

Notifications with retries

+

{stats.notificationsWithRetries}

+
+
+

Recovered after retry

+

{stats.recoveredAfterRetry}

+
+
+

Permanent failures

+

{stats.permanentFailures}

+
+
+

Avg retries / notification

+

+ {formatAverageRetries(stats.averageRetriesPerNotification)} +

+
+
+

Retry rate

+

{formatRetryRate(stats.retryRate)}

+
+
+ +
+

Retry distribution

+ {stats.distribution.length === 0 ? ( +

+ No retry data recorded yet. +

+ ) : ( + + + + + + + + + + + + {stats.distribution.map((bucket) => ( + + + + + + + ))} + +
Notifications grouped by retry count
Retry countTotalSucceededFailed
{bucket.retryCount}{bucket.count}{bucket.successCount}{bucket.failureCount}
+ )} +
+ + )} +
+ ); +} diff --git a/dashboard/src/components/UserActivityTimeline.test.tsx b/dashboard/src/components/UserActivityTimeline.test.tsx new file mode 100644 index 0000000..d4fdb72 --- /dev/null +++ b/dashboard/src/components/UserActivityTimeline.test.tsx @@ -0,0 +1,104 @@ +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { UserActivityTimeline } from './UserActivityTimeline'; +import type { UserActivityEvent } from '../types/userActivity'; +import { sortUserActivityChronologically } from '../services/userActivityApi'; + +const mockFetchUserActivityTimeline = jest.fn(); +const mockGenerateMockUserActivity = jest.fn(); + +jest.mock('../services/userActivityApi', () => ({ + fetchUserActivityTimeline: (...args: unknown[]) => mockFetchUserActivityTimeline(...args), + generateMockUserActivity: (...args: unknown[]) => mockGenerateMockUserActivity(...args), + sortUserActivityChronologically: jest.requireActual('../services/userActivityApi') + .sortUserActivityChronologically, +})); + +const EVENTS: UserActivityEvent[] = [ + { + id: 'a1', + action: 'subscription_created', + timestamp: 3_000, + summary: 'Created a subscription group', + details: 'Team Alpha', + }, + { + id: 'a2', + action: 'notification_preference_changed', + timestamp: 5_000, + summary: 'Changed notification preferences', + }, + { + id: 'a3', + action: 'export_requested', + timestamp: 1_000, + summary: 'Requested a notification export', + }, +]; + +describe('UserActivityTimeline', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('loads events and renders them chronologically (newest first)', async () => { + mockFetchUserActivityTimeline.mockResolvedValue({ events: EVENTS, total: EVENTS.length }); + + render(); + + expect(await screen.findByText(/Changed notification preferences/i)).toBeInTheDocument(); + + const items = screen.getAllByRole('listitem'); + expect(items[0]).toHaveTextContent(/Changed notification preferences/i); + expect(items[1]).toHaveTextContent(/Created a subscription group/i); + expect(items[2]).toHaveTextContent(/Requested a notification export/i); + }); + + it('handles empty state', async () => { + mockFetchUserActivityTimeline.mockResolvedValue({ events: [], total: 0 }); + + render(); + + expect(await screen.findByText(/No recent activity yet/i)).toBeInTheDocument(); + expect( + screen.getByText(/Subscription and notification actions will appear here/i) + ).toBeInTheDocument(); + }); + + it('falls back to mock data when the API fails', async () => { + mockFetchUserActivityTimeline.mockRejectedValue(new Error('offline')); + mockGenerateMockUserActivity.mockReturnValue([ + { + id: 'mock-1', + action: 'subscription_updated', + timestamp: Date.now(), + summary: 'Updated subscription settings', + }, + ]); + + render(); + + expect(await screen.findByText(/sample data/i)).toBeInTheDocument(); + expect(screen.getByText(/Updated subscription settings/i)).toBeInTheDocument(); + }); + + it('refreshes on demand', async () => { + mockFetchUserActivityTimeline.mockResolvedValue({ events: EVENTS, total: EVENTS.length }); + render(); + + await screen.findByText(/Changed notification preferences/i); + expect(mockFetchUserActivityTimeline).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole('button', { name: /refresh/i })); + + await waitFor(() => { + expect(mockFetchUserActivityTimeline).toHaveBeenCalledTimes(2); + }); + }); +}); + +describe('sortUserActivityChronologically', () => { + it('orders newest events first', () => { + const sorted = sortUserActivityChronologically(EVENTS); + expect(sorted.map((e) => e.id)).toEqual(['a2', 'a1', 'a3']); + }); +}); diff --git a/dashboard/src/components/UserActivityTimeline.tsx b/dashboard/src/components/UserActivityTimeline.tsx new file mode 100644 index 0000000..6e5d6e4 --- /dev/null +++ b/dashboard/src/components/UserActivityTimeline.tsx @@ -0,0 +1,114 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + fetchUserActivityTimeline, + generateMockUserActivity, + sortUserActivityChronologically, +} from '../services/userActivityApi'; +import type { UserActivityEvent } from '../types/userActivity'; +import { formatTimestamp } from '../utils/formatTime'; + +const ACTION_LABELS: Record = { + subscription_created: 'Subscription created', + subscription_updated: 'Subscription updated', + subscription_cancelled: 'Subscription cancelled', + notification_preference_changed: 'Preferences changed', + notification_muted: 'Notification muted', + notification_unmuted: 'Notification unmuted', + template_managed: 'Template managed', + export_requested: 'Export requested', +}; + +function ActivityItem({ event }: { event: UserActivityEvent }) { + return ( +
  • + +
    +
    + {ACTION_LABELS[event.action]} + +
    +

    {event.summary}

    + {event.details &&

    {event.details}

    } +
    +
  • + ); +} + +export function UserActivityTimeline() { + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [usingMock, setUsingMock] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = await fetchUserActivityTimeline(); + setEvents(sortUserActivityChronologically(data.events)); + setUsingMock(false); + } catch { + setEvents(sortUserActivityChronologically(generateMockUserActivity())); + setUsingMock(true); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const chronological = useMemo( + () => sortUserActivityChronologically(events), + [events] + ); + + return ( +
    +
    +
    +

    + User Activity Timeline +

    +

    + Recent actions related to subscriptions and notification management + {usingMock ? ' (sample data)' : ''} +

    +
    + +
    + + {loading && chronological.length === 0 && ( +

    + Loading activity timeline… +

    + )} + + {error && ( +
    + {error} +
    + )} + + {!loading && chronological.length === 0 ? ( +
    +

    No recent activity yet.

    +

    Subscription and notification actions will appear here chronologically.

    +
    + ) : chronological.length > 0 ? ( +
      + {chronological.map((event) => ( + + ))} +
    + ) : null} +
    + ); +} diff --git a/dashboard/src/index.css b/dashboard/src/index.css index 2cfb1ef..9ef3df5 100644 --- a/dashboard/src/index.css +++ b/dashboard/src/index.css @@ -598,6 +598,12 @@ body { outline: 2px solid rgba(91, 125, 255, 0.4); } +.notification-preferences__field--invalid input, +.notification-preferences__input--invalid { + border-color: #f87171 !important; + box-shadow: 0 0 0 1px rgba(248, 113, 113, 0.45); +} + .notification-preferences__field-error { margin: 0; color: #f4b400; @@ -2373,13 +2379,59 @@ body { letter-spacing: 0.06em; } +.event-explorer__column-header { + position: relative; + display: flex; + align-items: center; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding-right: 10px; +} + +.event-explorer__resize-handle { + position: absolute; + top: -8px; + right: -8px; + width: 14px; + height: calc(100% + 16px); + padding: 0; + border: 0; + background: transparent; + cursor: col-resize; + z-index: 2; +} + +.event-explorer__resize-handle::after { + content: ''; + position: absolute; + top: 20%; + bottom: 20%; + left: 6px; + width: 2px; + border-radius: 1px; + background: rgba(148, 163, 184, 0.45); +} + +.event-explorer__resize-handle:hover::after, +.event-explorer__resize-handle:focus-visible::after { + background: #60a5fa; +} + +body.event-explorer--resizing { + cursor: col-resize; + user-select: none; +} + .event-explorer__table-body { display: grid; + --event-explorer-columns: 1.5fr 1fr 0.8fr 1.3fr 0.8fr 1fr; } .event-explorer__row { display: grid; - grid-template-columns: 1.5fr 1fr 0.8fr 1.3fr 0.8fr 1fr; + grid-template-columns: var(--event-explorer-columns, 1.5fr 1fr 0.8fr 1.3fr 0.8fr 1fr); gap: 16px; padding: 18px; border-top: 1px solid rgba(255, 255, 255, 0.06); @@ -4947,3 +4999,162 @@ body { margin-top: 6px; } } + +/* ── Retry statistics ─────────────────────────────────────────── */ +.retry-stats, +.user-activity { + display: grid; + gap: 20px; + padding: 24px; +} + +.retry-stats__header, +.user-activity__header { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: flex-start; +} + +.retry-stats__title, +.user-activity__title { + margin: 0 0 6px; + font-size: 1.35rem; +} + +.retry-stats__subtitle, +.user-activity__subtitle { + margin: 0; + color: #9aa0a6; +} + +.retry-stats__refresh, +.user-activity__refresh { + border: 1px solid rgba(148, 163, 184, 0.35); + background: rgba(255, 255, 255, 0.04); + color: inherit; + border-radius: 10px; + padding: 8px 14px; + cursor: pointer; +} + +.retry-stats__cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 12px; +} + +.retry-stats__card { + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 14px; + padding: 14px 16px; + background: rgba(255, 255, 255, 0.03); +} + +.retry-stats__card-label { + margin: 0 0 8px; + color: #9aa0a6; + font-size: 0.85rem; +} + +.retry-stats__card-value { + margin: 0; + font-size: 1.5rem; + font-weight: 700; +} + +.retry-stats__table { + width: 100%; + border-collapse: collapse; +} + +.retry-stats__table th, +.retry-stats__table td { + text-align: left; + padding: 10px 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.retry-stats__empty, +.user-activity__empty, +.user-activity__loading, +.retry-stats__loading { + border: 1px dashed rgba(148, 163, 184, 0.35); + border-radius: 14px; + padding: 28px; + color: #9aa0a6; + text-align: center; +} + +.user-activity__list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0; +} + +.user-activity__item { + display: grid; + grid-template-columns: 24px 1fr; + gap: 12px; +} + +.user-activity__rail { + position: relative; + display: flex; + justify-content: center; +} + +.user-activity__rail::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + width: 2px; + background: rgba(148, 163, 184, 0.25); +} + +.user-activity__dot { + position: relative; + z-index: 1; + width: 10px; + height: 10px; + margin-top: 18px; + border-radius: 50%; + background: #60a5fa; + box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.15); +} + +.user-activity__card { + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 14px; + padding: 14px 16px; + margin-bottom: 12px; + background: rgba(255, 255, 255, 0.03); +} + +.user-activity__meta { + display: flex; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; + color: #9aa0a6; + font-size: 0.85rem; +} + +.user-activity__action { + color: #93c5fd; + text-transform: capitalize; +} + +.user-activity__summary { + margin: 0 0 4px; + font-weight: 600; +} + +.user-activity__details { + margin: 0; + color: #9aa0a6; + font-size: 0.92rem; +} diff --git a/dashboard/src/pages/NotificationPreferencesPage.tsx b/dashboard/src/pages/NotificationPreferencesPage.tsx index b001146..d9bfdfb 100644 --- a/dashboard/src/pages/NotificationPreferencesPage.tsx +++ b/dashboard/src/pages/NotificationPreferencesPage.tsx @@ -249,7 +249,7 @@ export function NotificationPreferencesPage() {

    Provide delivery details for email and Telegram routing.

    -
    +
    {emailInvalid && ( -

    Enter a valid email when Email notifications are enabled.

    + )}
    -
    +
    {telegramInvalid && ( -

    Telegram handle must start with @ and contain at least 3 characters.

    + )}
    diff --git a/dashboard/src/services/retryStatisticsApi.ts b/dashboard/src/services/retryStatisticsApi.ts new file mode 100644 index 0000000..73c8df6 --- /dev/null +++ b/dashboard/src/services/retryStatisticsApi.ts @@ -0,0 +1,32 @@ +import type { RetryStatistics } from '../types/retryStatistics'; +import { getEventsApiBaseUrl } from '../config/eventsApiUrl'; + +const BASE_URL = getEventsApiBaseUrl(); + +export async function fetchRetryStatistics(): Promise { + const response = await fetch(`${BASE_URL}/api/schedule/retry-statistics`); + if (!response.ok) { + throw new Error(`Failed to fetch retry statistics: ${response.status}`); + } + return response.json() as Promise; +} + +/** Deterministic mock used when the API is unavailable (dev / offline). */ +export function generateMockRetryStatistics(): RetryStatistics { + return { + totalNotifications: 48, + totalRetryAttempts: 27, + notificationsWithRetries: 14, + permanentFailures: 5, + recoveredAfterRetry: 9, + averageRetriesPerNotification: 0.56, + maxObservedRetryCount: 3, + retryRate: 14 / 48, + distribution: [ + { retryCount: 0, count: 34, successCount: 30, failureCount: 4 }, + { retryCount: 1, count: 8, successCount: 6, failureCount: 2 }, + { retryCount: 2, count: 4, successCount: 2, failureCount: 2 }, + { retryCount: 3, count: 2, successCount: 1, failureCount: 1 }, + ], + }; +} diff --git a/dashboard/src/services/userActivityApi.ts b/dashboard/src/services/userActivityApi.ts new file mode 100644 index 0000000..45d9c2e --- /dev/null +++ b/dashboard/src/services/userActivityApi.ts @@ -0,0 +1,53 @@ +import type { + UserActivityEvent, + UserActivityTimelineResponse, + UserActivityAction, +} from '../types/userActivity'; +import { getEventsApiBaseUrl } from '../config/eventsApiUrl'; + +const BASE_URL = getEventsApiBaseUrl(); + +export async function fetchUserActivityTimeline( + limit: number = 50 +): Promise { + const response = await fetch(`${BASE_URL}/api/user-activity?limit=${limit}`); + if (!response.ok) { + throw new Error(`Failed to fetch user activity timeline: ${response.status}`); + } + return response.json() as Promise; +} + +const ACTION_SUMMARIES: Record = { + subscription_created: 'Created a subscription group', + subscription_updated: 'Updated subscription settings', + subscription_cancelled: 'Cancelled a subscription', + notification_preference_changed: 'Changed notification preferences', + notification_muted: 'Muted a notification category', + notification_unmuted: 'Unmuted a notification category', + template_managed: 'Managed a notification template', + export_requested: 'Requested a notification export', +}; + +/** Chronological (newest first) mock timeline for offline / empty-API use. */ +export function generateMockUserActivity(count: number = 12): UserActivityEvent[] { + const actions = Object.keys(ACTION_SUMMARIES) as UserActivityAction[]; + const now = Date.now(); + + return Array.from({ length: count }, (_, index) => { + const action = actions[index % actions.length]; + return { + id: `user-activity-${index + 1}`, + action, + timestamp: now - index * 15 * 60 * 1000, + summary: ACTION_SUMMARIES[action], + details: `Related to subscriptions and notification management (#${index + 1})`, + resourceId: `resource-${index + 1}`, + }; + }); +} + +export function sortUserActivityChronologically( + events: UserActivityEvent[] +): UserActivityEvent[] { + return [...events].sort((a, b) => b.timestamp - a.timestamp); +} diff --git a/dashboard/src/types/retryStatistics.test.ts b/dashboard/src/types/retryStatistics.test.ts new file mode 100644 index 0000000..4e73008 --- /dev/null +++ b/dashboard/src/types/retryStatistics.test.ts @@ -0,0 +1,13 @@ +import { formatAverageRetries, formatRetryRate } from '../types/retryStatistics'; + +describe('retryStatistics format helpers', () => { + it('formats retry rate as a percentage', () => { + expect(formatRetryRate(0.3)).toBe('30.0%'); + expect(formatRetryRate(0)).toBe('0.0%'); + }); + + it('formats average retries to two decimals', () => { + expect(formatAverageRetries(1.2)).toBe('1.20'); + expect(formatAverageRetries(0)).toBe('0.00'); + }); +}); diff --git a/dashboard/src/types/retryStatistics.ts b/dashboard/src/types/retryStatistics.ts new file mode 100644 index 0000000..2aa7ea3 --- /dev/null +++ b/dashboard/src/types/retryStatistics.ts @@ -0,0 +1,26 @@ +export interface RetryDistributionBucket { + retryCount: number; + count: number; + successCount: number; + failureCount: number; +} + +export interface RetryStatistics { + totalNotifications: number; + totalRetryAttempts: number; + notificationsWithRetries: number; + permanentFailures: number; + recoveredAfterRetry: number; + averageRetriesPerNotification: number; + maxObservedRetryCount: number; + retryRate: number; + distribution: RetryDistributionBucket[]; +} + +export function formatRetryRate(rate: number): string { + return `${(rate * 100).toFixed(1)}%`; +} + +export function formatAverageRetries(avg: number): string { + return avg.toFixed(2); +} diff --git a/dashboard/src/types/userActivity.ts b/dashboard/src/types/userActivity.ts new file mode 100644 index 0000000..ef535a7 --- /dev/null +++ b/dashboard/src/types/userActivity.ts @@ -0,0 +1,23 @@ +export type UserActivityAction = + | 'subscription_created' + | 'subscription_updated' + | 'subscription_cancelled' + | 'notification_preference_changed' + | 'notification_muted' + | 'notification_unmuted' + | 'template_managed' + | 'export_requested'; + +export interface UserActivityEvent { + id: string; + action: UserActivityAction; + timestamp: number; + summary: string; + details?: string; + resourceId?: string; +} + +export interface UserActivityTimelineResponse { + events: UserActivityEvent[]; + total: number; +} diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..8ddefe0 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +dist/ +build/ +.next/ +coverage/ +.env +.env.* +!.env.example +*.log +.DS_Store diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 71428a1..a0b75fb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1049,9 +1049,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1066,9 +1063,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1083,9 +1077,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1100,9 +1091,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1117,9 +1105,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1134,9 +1119,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1151,9 +1133,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1168,9 +1147,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1185,9 +1161,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1202,9 +1175,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1219,9 +1189,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1236,9 +1203,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1253,9 +1217,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/frontend/src/components/SubscriptionForm.tsx b/frontend/src/components/SubscriptionForm.tsx index c4b95bb..3a98422 100644 --- a/frontend/src/components/SubscriptionForm.tsx +++ b/frontend/src/components/SubscriptionForm.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; // --------------------------------------------------------------------------- // Wallet UX states @@ -13,7 +13,49 @@ type WalletState = "disconnected" | "connected" | "waiting_for_signature" | "err interface FormValues { groupName: string; - usageCount: number; + usageCount: number | ""; +} + +interface FieldErrors { + groupName?: string; + usageCount?: string; +} + +const GROUP_NAME_MIN = 3; +const GROUP_NAME_MAX = 64; +const USAGE_MIN = 1; +const USAGE_MAX = 10_000; + +export function validateGroupName(value: string): string | undefined { + const trimmed = value.trim(); + if (!trimmed) return "Group name is required."; + if (trimmed.length < GROUP_NAME_MIN) { + return `Group name must be at least ${GROUP_NAME_MIN} characters.`; + } + if (trimmed.length > GROUP_NAME_MAX) { + return `Group name must be at most ${GROUP_NAME_MAX} characters.`; + } + return undefined; +} + +export function validateUsageCount(value: number | ""): string | undefined { + if (value === "" || Number.isNaN(Number(value))) { + return "Initial usages is required."; + } + const n = Number(value); + if (!Number.isInteger(n)) return "Initial usages must be a whole number."; + if (n < USAGE_MIN) return `Initial usages must be at least ${USAGE_MIN}.`; + if (n > USAGE_MAX) return `Initial usages must be at most ${USAGE_MAX.toLocaleString()}.`; + return undefined; +} + +export function validateSubscriptionForm(form: FormValues): FieldErrors { + const errors: FieldErrors = {}; + const groupNameError = validateGroupName(form.groupName); + const usageCountError = validateUsageCount(form.usageCount); + if (groupNameError) errors.groupName = groupNameError; + if (usageCountError) errors.usageCount = usageCountError; + return errors; } export default function SubscriptionForm() { @@ -21,9 +63,16 @@ export default function SubscriptionForm() { const [publicKey, setPublicKey] = useState(null); const [errorMessage, setErrorMessage] = useState(null); const [form, setForm] = useState({ groupName: "", usageCount: 10 }); + const [touched, setTouched] = useState<{ groupName: boolean; usageCount: boolean }>({ + groupName: false, + usageCount: false, + }); const [txHash, setTxHash] = useState(null); - // -- wallet helpers -------------------------------------------------------- + const errors = useMemo(() => validateSubscriptionForm(form), [form]); + const showGroupNameError = touched.groupName && Boolean(errors.groupName); + const showUsageCountError = touched.usageCount && Boolean(errors.usageCount); + const isFormValid = Object.keys(errors).length === 0; async function connectWallet() { setErrorMessage(null); @@ -46,15 +95,17 @@ export default function SubscriptionForm() { async function handleSubmit(e: React.FormEvent) { e.preventDefault(); - if (walletState !== "connected" || !publicKey) return; + setTouched({ groupName: true, usageCount: true }); + if (!isFormValid || walletState !== "connected" || !publicKey) return; setErrorMessage(null); setWalletState("waiting_for_signature"); try { - // Build a minimal transaction envelope. In a real integration you would - // call the Soroban SDK here; this stub demonstrates the UX flow. - const xdrEnvelope = buildSubscriptionTx(form, publicKey); + const xdrEnvelope = buildSubscriptionTx( + { groupName: form.groupName.trim(), usageCount: Number(form.usageCount) }, + publicKey + ); // @ts-expect-error – freighter is injected by the browser extension const signed: { signedTxXdr: string } = await window.freighter.signTransaction( @@ -62,7 +113,6 @@ export default function SubscriptionForm() { { network: "TESTNET", networkPassphrase: "Test SDF Network ; September 2015" } ); - // Submit to Stellar RPC (stubbed). const hash = await submitTransaction(signed.signedTxXdr); setTxHash(hash); setWalletState("connected"); @@ -77,8 +127,6 @@ export default function SubscriptionForm() { setWalletState(publicKey ? "connected" : "disconnected"); } - // -- render ---------------------------------------------------------------- - return (

    Create Subscription Group

    @@ -92,7 +140,7 @@ export default function SubscriptionForm() { /> {walletState === "connected" && ( -
    +
    @@ -115,17 +173,34 @@ export default function SubscriptionForm() { setForm({ ...form, usageCount: Number(e.target.value) })} - className="mt-1 block w-full border rounded px-3 py-2 text-sm" + onChange={(e) => { + const raw = e.target.value; + setForm({ + ...form, + usageCount: raw === "" ? "" : Number(raw), + }); + }} + onBlur={() => setTouched((t) => ({ ...t, usageCount: true }))} + aria-invalid={showUsageCountError} + aria-describedby={showUsageCountError ? "usageCount-error" : undefined} + className={`mt-1 block w-full border rounded px-3 py-2 text-sm ${ + showUsageCountError ? "border-red-500 ring-1 ring-red-500" : "border-gray-300" + }`} /> + {showUsageCountError && ( + + )}