Value: {event.value}
diff --git a/dashboard/src/components/NotificationDetailsDrawer.test.tsx b/dashboard/src/components/NotificationDetailsDrawer.test.tsx
index c461856..2af0a9b 100644
--- a/dashboard/src/components/NotificationDetailsDrawer.test.tsx
+++ b/dashboard/src/components/NotificationDetailsDrawer.test.tsx
@@ -107,5 +107,68 @@ describe('NotificationDetailsDrawer', () => {
expect(await screen.findByText(/Failed to load details: boom/i)).toBeInTheDocument();
});
+
+ it('renders Notification ID row with copy button when relatedNotificationId is present', () => {
+ const notification = makeNotification({ relatedNotificationId: 'notif-42' });
+ const onClose = jest.fn();
+
+ render(
+
+ );
+
+ expect(screen.getByText('Notification ID')).toBeInTheDocument();
+ // The shortened version should be displayed
+ expect(screen.getByTitle('notif-42')).toBeInTheDocument();
+ // A copy button for the Notification ID should be present
+ const copyButtons = screen.getAllByRole('button', { name: 'Copy' });
+ expect(copyButtons.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('does not render Notification ID row when relatedNotificationId is absent', () => {
+ const notification = makeNotification();
+ const onClose = jest.fn();
+
+ render(
+
+ );
+
+ expect(screen.queryByText('Notification ID')).not.toBeInTheDocument();
+ });
+
+ it('copies notification ID to clipboard and shows toast feedback', async () => {
+ Object.assign(navigator, {
+ clipboard: {
+ writeText: jest.fn().mockResolvedValue(undefined),
+ },
+ });
+
+ const notification = makeNotification({ relatedNotificationId: 'notif-42' });
+ const onClose = jest.fn();
+
+ render(
+
+ );
+
+ // Find the Notification ID row's copy button (first copy button in Blockchain Context)
+ const notifIdTitle = screen.getByTitle('notif-42');
+ const notifIdRow = notifIdTitle.closest('.drawer__row')!;
+ const copyBtn = notifIdRow.querySelector('button')!;
+ fireEvent.click(copyBtn);
+
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith('notif-42');
+ expect(await screen.findByText('Notification ID copied')).toBeInTheDocument();
+ });
});
diff --git a/dashboard/src/components/NotificationDetailsDrawer.tsx b/dashboard/src/components/NotificationDetailsDrawer.tsx
index 397f608..967680c 100644
--- a/dashboard/src/components/NotificationDetailsDrawer.tsx
+++ b/dashboard/src/components/NotificationDetailsDrawer.tsx
@@ -194,6 +194,21 @@ export function NotificationDetailsDrawer({
Blockchain Context
+ {notification.relatedNotificationId && (
+
+ Notification ID
+
+ {shorten(notification.relatedNotificationId, 10, 6)}
+
+
+
+ )}
Ledger
{notification.ledger.toLocaleString()}
diff --git a/dashboard/src/components/NotificationTimelineView.test.tsx b/dashboard/src/components/NotificationTimelineView.test.tsx
index e518dbc..35f9a0c 100644
--- a/dashboard/src/components/NotificationTimelineView.test.tsx
+++ b/dashboard/src/components/NotificationTimelineView.test.tsx
@@ -109,4 +109,62 @@ describe('NotificationTimelineView', () => {
resolve(MOCK_TIMELINE);
await waitFor(() => expect(screen.getByRole('button', { name: /view timeline/i })).not.toBeDisabled());
});
+
+ it('renders a copy button for the notification ID in the timeline summary', async () => {
+ mockFetch.mockResolvedValue(MOCK_TIMELINE);
+ renderView();
+
+ fireEvent.change(screen.getByLabelText(/notification id/i), { target: { value: '7' } });
+ fireEvent.submit(screen.getByRole('search'));
+
+ await waitFor(() => expect(mockFetch).toHaveBeenCalledWith(7));
+
+ // Verify the copy button for notification ID is rendered
+ const copyBtn = screen.getByRole('button', { name: /copy notification id/i });
+ expect(copyBtn).toBeInTheDocument();
+ });
+
+ it('copies notification ID to clipboard when copy button is clicked in timeline', async () => {
+ Object.assign(navigator, {
+ clipboard: {
+ writeText: jest.fn().mockResolvedValue(undefined),
+ },
+ });
+
+ mockFetch.mockResolvedValue(MOCK_TIMELINE);
+ renderView();
+
+ fireEvent.change(screen.getByLabelText(/notification id/i), { target: { value: '7' } });
+ fireEvent.submit(screen.getByRole('search'));
+
+ await waitFor(() => expect(mockFetch).toHaveBeenCalledWith(7));
+
+ const copyBtn = screen.getByRole('button', { name: /copy notification id/i });
+ fireEvent.click(copyBtn);
+
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith('7');
+ });
+
+ it('shows copied feedback after clicking the notification ID copy button', async () => {
+ Object.assign(navigator, {
+ clipboard: {
+ writeText: jest.fn().mockResolvedValue(undefined),
+ },
+ });
+
+ mockFetch.mockResolvedValue(MOCK_TIMELINE);
+ renderView();
+
+ fireEvent.change(screen.getByLabelText(/notification id/i), { target: { value: '7' } });
+ fireEvent.submit(screen.getByRole('search'));
+
+ await waitFor(() => expect(mockFetch).toHaveBeenCalledWith(7));
+
+ const copyBtn = screen.getByRole('button', { name: /copy notification id/i });
+ fireEvent.click(copyBtn);
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /notification id copied/i })).toBeInTheDocument();
+ });
+ });
});
diff --git a/dashboard/src/components/NotificationTimelineView.tsx b/dashboard/src/components/NotificationTimelineView.tsx
index 8062e80..b9cbce5 100644
--- a/dashboard/src/components/NotificationTimelineView.tsx
+++ b/dashboard/src/components/NotificationTimelineView.tsx
@@ -2,6 +2,8 @@ import { useState, useCallback } from 'react';
import type { NotificationTimeline, TimelineEntry, TimelineStatus } from '../types/timeline';
import { fetchTimeline } from '../services/timelineApi';
import { formatTimestamp } from '../utils/formatTime';
+import { EmptyState } from './EmptyState';
+import { CopyButton } from './CopyButton';
// ─── status helpers ──────────────────────────────────────────────────────────
@@ -147,12 +149,16 @@ export function NotificationTimelineView() {
{/* Empty state — searched but no entries */}
{!loading && timeline && timeline.entries.length === 0 && (
-
-
No history entries found for notification #{timeline.notificationId}.
+
Current status: {STATUS_LABEL[overallStatus!] ?? overallStatus}
-
+
)}
{/* Timeline entries */}
@@ -162,6 +168,7 @@ export function NotificationTimelineView() {
Notification #{timeline.notificationId}
+
- Enter a notification ID above to view its delivery history.
-
+
)}
);
diff --git a/dashboard/src/components/WebhookDeliveryChart.tsx b/dashboard/src/components/WebhookDeliveryChart.tsx
index 9e893bc..2502122 100644
--- a/dashboard/src/components/WebhookDeliveryChart.tsx
+++ b/dashboard/src/components/WebhookDeliveryChart.tsx
@@ -1,5 +1,6 @@
import { memo, useMemo } from 'react';
import type { WebhookMetricBucket } from '../types/webhook';
+import { EmptyState } from './EmptyState';
interface WebhookDeliveryChartProps {
buckets: WebhookMetricBucket[];
@@ -147,9 +148,12 @@ export const WebhookDeliveryChart = memo(function WebhookDeliveryChart({
})}
) : buckets.length === 0 ? (
-
- No data for the selected range
-
+
) : (