diff --git a/docs/clipboard.md b/docs/clipboard.md index 34fbe0c..a48f65f 100644 --- a/docs/clipboard.md +++ b/docs/clipboard.md @@ -29,6 +29,9 @@ Callers branch on `result.ok`: `TextField` so it can still be copied by hand. - `events/Client.tsx` shows an error toast on failure and expands the event's payload `
` block so it's visible and selectable.
+- `stats/Client.tsx` confirms successful snapshot copies with a toast; on
+ failure it shows an error toast and the exact snapshot in a selectable,
+ read-only text area.
Never call `navigator.clipboard.writeText` directly in page/component code —
route it through `writeToClipboard` so insecure-context and permission
diff --git a/docs/stats-export.md b/docs/stats-export.md
index 866e896..3ff137b 100644
--- a/docs/stats-export.md
+++ b/docs/stats-export.md
@@ -1,7 +1,7 @@
# Stats snapshot export
-The Stats page (`src/app/stats/Client.tsx`) can export a point-in-time
-snapshot of the router metrics shown in its `StatTile` grid, as JSON or CSV.
+The Stats page (`src/app/stats/Client.tsx`) can copy or download a
+point-in-time snapshot of the router metrics shown in its `StatTile` grid.
## Snapshot shape
@@ -31,17 +31,41 @@ display string:
## Serialisation
-Two pure, DOM-free functions turn a snapshot into text:
+Three pure, DOM-free functions turn a snapshot into text:
+- `statsSnapshotToText(snapshot)` — a concise heading, one line per displayed
+ metric, and the ISO 8601 capture timestamp.
- `statsSnapshotToJson(snapshot)` — `JSON.stringify(snapshot, null, 2)`.
- `statsSnapshotToCsv(snapshot)` — a `label,value,display,capturedAt` header
followed by one row per metric. Fields containing a comma, quote, or
newline (e.g. `formatNumber`'s thousands separators) are quoted and
internal quotes doubled, per standard CSV escaping.
-Both are exported from `src/app/stats/Client.tsx` and can be unit tested
+All three are exported from `src/app/stats/Client.tsx` and can be unit tested
without touching the DOM.
+The plain-text form mirrors the values visible in the tiles. For example:
+
+```text
+StableRoute stats snapshot
+Pairs: 1,234
+Status: Live
+Captured: 2026-07-23T05:30:00.000Z
+```
+
+## Copy
+
+The **Copy stats snapshot** button passes the plain-text form to the shared
+`writeToClipboard` helper. A successful write produces a notification. The
+button is disabled while the write is pending so repeated clicks cannot start
+duplicate clipboard operations.
+
+If the Clipboard API is unavailable or permission is denied, an error
+notification explains the failure and the page shows the exact snapshot in a
+read-only text area. Focusing that field selects all of its contents so the
+operator can copy it manually. This fallback also works when the current stats
+contain zero pairs.
+
## Download
`downloadStatsSnapshot(data, format)` (`format` is `"json"` or `"csv"`)
@@ -60,6 +84,7 @@ filesystems.
## UI
-Once stats load successfully, the Stats page renders **Download JSON** and
-**Download CSV** buttons next to the metric tiles. They're hidden while
-loading or on error, since there's no snapshot to export yet.
+Once stats load successfully, the Stats page renders **Copy stats snapshot**,
+**Download JSON**, and **Download CSV** buttons next to the metric tiles.
+They're hidden while loading or on error, since there's no snapshot to export
+yet.
diff --git a/src/app/stats/Client.tsx b/src/app/stats/Client.tsx
index b897a41..2a7e7c9 100644
--- a/src/app/stats/Client.tsx
+++ b/src/app/stats/Client.tsx
@@ -1,12 +1,14 @@
'use client';
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { useApi } from '@/lib/useApi';
import { formatNumber, formatTimestamp } from '@/lib/format';
import { Button } from '@/components/Button';
import { EmptyState } from '@/components/EmptyState';
import { Spinner } from '@/components/Spinner';
import { StatTile } from '@/components/StatTile';
+import { useToast } from '@/components/ToastProvider';
+import { writeToClipboard } from '@/lib/clipboard';
type Stats = { totalPairs: number; paused: boolean };
@@ -89,6 +91,15 @@ export function statsSnapshotToJson(snapshot: StatsSnapshot): string {
return JSON.stringify(snapshot, null, 2);
}
+/** Pure serialiser: concise plain text using the values shown in the UI. */
+export function statsSnapshotToText(snapshot: StatsSnapshot): string {
+ return [
+ 'StableRoute stats snapshot',
+ ...snapshot.metrics.map((metric) => `${metric.label}: ${metric.display}`),
+ `Captured: ${snapshot.capturedAt}`,
+ ].join('\n');
+}
+
function csvEscape(field: string): string {
if (/["\n,]/.test(field)) {
return `"${field.replace(/"/g, '""')}"`;
@@ -152,8 +163,12 @@ export function downloadStatsSnapshot(
}
export default function StatsClient() {
+ const { push } = useToast();
const result = useApi('/api/v1/stats');
const [lastUpdatedAt, setLastUpdatedAt] = useState(null);
+ const [isCopying, setIsCopying] = useState(false);
+ const [copyFallback, setCopyFallback] = useState(null);
+ const copyInFlightRef = useRef(false);
const { refetch } = result;
const status = result.status;
const error = status === 'error' ? result.error : null;
@@ -168,6 +183,29 @@ export default function StatsClient() {
if (status === 'success' && data) setLastUpdatedAt(Date.now());
}, [status, data]);
+ const copyStatsSnapshot = async (stats: Stats) => {
+ if (copyInFlightRef.current) return;
+ copyInFlightRef.current = true;
+ setIsCopying(true);
+ try {
+ const text = statsSnapshotToText(buildStatsSnapshot(stats));
+ const copyResult = await writeToClipboard(text);
+ if (copyResult.ok) {
+ setCopyFallback(null);
+ push('Stats snapshot copied.');
+ return;
+ }
+ setCopyFallback(text);
+ push(
+ "Couldn't copy automatically. Select the snapshot below to copy it.",
+ 'error'
+ );
+ } finally {
+ copyInFlightRef.current = false;
+ setIsCopying(false);
+ }
+ };
+
return (
{lastUpdatedAt !== null && }
-
+
+
+ {copyFallback !== null && (
+
+ )}
)}
{status === 'success' && data && data.totalPairs === 0 && (
diff --git a/src/app/stats/page.test.tsx b/src/app/stats/page.test.tsx
index 17c8de8..b4d2e81 100644
--- a/src/app/stats/page.test.tsx
+++ b/src/app/stats/page.test.tsx
@@ -5,6 +5,7 @@ import {
screen,
waitFor,
} from '@testing-library/react';
+import { ToastProvider } from '@/components/ToastProvider';
import StatsPage from './page';
import {
buildStatsSnapshot,
@@ -12,6 +13,7 @@ import {
formatStatsAge,
statsSnapshotToCsv,
statsSnapshotToJson,
+ statsSnapshotToText,
} from './Client';
const mockFetch = (data: unknown) => {
@@ -21,6 +23,13 @@ const mockFetch = (data: unknown) => {
} as unknown as Response);
};
+const renderStatsPage = () =>
+ render(
+
+
+
+ );
+
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
@@ -29,14 +38,14 @@ afterEach(() => {
describe('StatsPage', () => {
it('renders the heading', async () => {
mockFetch({ totalPairs: 0, paused: false });
- render( );
+ renderStatsPage();
expect(screen.getByRole('heading', { name: /stats/i })).toBeInTheDocument();
await screen.findByText('Live');
});
it('renders one canonical stats page region and heading', async () => {
mockFetch({ totalPairs: 0, paused: false });
- render( );
+ renderStatsPage();
expect(screen.getAllByRole('heading', { name: /stats/i })).toHaveLength(1);
expect(document.querySelectorAll('#main-content')).toHaveLength(1);
@@ -45,7 +54,7 @@ describe('StatsPage', () => {
it('names the metrics panel with an accessible region', async () => {
mockFetch({ totalPairs: 12, paused: false });
- render( );
+ renderStatsPage();
await waitFor(() => {
expect(
@@ -56,28 +65,28 @@ describe('StatsPage', () => {
it('formats totalPairs with thousands separators via formatNumber', async () => {
mockFetch({ totalPairs: 1234567, paused: false });
- render( );
+ renderStatsPage();
const pairs = await screen.findByText('1,234,567');
expect(pairs).toBeInTheDocument();
});
it('renders Live when paused is false', async () => {
mockFetch({ totalPairs: 0, paused: false });
- render( );
+ renderStatsPage();
const status = await screen.findByText('Live');
expect(status).toBeInTheDocument();
});
it('renders Paused when paused is true', async () => {
mockFetch({ totalPairs: 0, paused: true });
- render( );
+ renderStatsPage();
const status = await screen.findByText('Paused');
expect(status).toBeInTheDocument();
});
it('renders error message on fetch failure', async () => {
global.fetch = jest.fn().mockRejectedValue(new Error('Network error'));
- render( );
+ renderStatsPage();
await waitFor(() => {
const alert = screen.getByRole('alert');
expect(alert).toHaveTextContent(/network request failed/i);
@@ -99,7 +108,7 @@ describe('StatsPage', () => {
Promise.resolve(JSON.stringify({ totalPairs: 2000, paused: true })),
} as unknown as Response);
- render( );
+ renderStatsPage();
expect(await screen.findByText('1')).toBeInTheDocument();
expect(await screen.findByText('Live')).toBeInTheDocument();
@@ -121,7 +130,7 @@ describe('StatsPage', () => {
Promise.resolve(JSON.stringify({ totalPairs: 42, paused: false })),
} as unknown as Response);
- const { unmount } = render( );
+ const { unmount } = renderStatsPage();
expect(await screen.findByText('42')).toBeInTheDocument();
expect(global.fetch).toHaveBeenCalledTimes(1);
@@ -138,7 +147,7 @@ describe('StatsPage', () => {
jest.useFakeTimers().setSystemTime(new Date('2026-07-22T12:00:00.000Z'));
mockFetch({ totalPairs: 42, paused: false });
- render( );
+ renderStatsPage();
const timestamp = await screen.findByText('just now');
expect(timestamp.tagName).toBe('TIME');
@@ -159,7 +168,7 @@ describe('StatsPage', () => {
jest.useFakeTimers().setSystemTime(new Date('2026-07-22T12:00:00.000Z'));
mockFetch({ totalPairs: 42, paused: false });
- render( );
+ renderStatsPage();
expect(await screen.findByText('just now')).toBeInTheDocument();
await act(async () => {
@@ -174,7 +183,7 @@ describe('StatsPage', () => {
jest.useFakeTimers();
mockFetch({ totalPairs: 42, paused: false });
- const { unmount } = render( );
+ const { unmount } = renderStatsPage();
expect(await screen.findByText('just now')).toBeInTheDocument();
expect(jest.getTimerCount()).toBe(2);
@@ -245,6 +254,24 @@ describe('statsSnapshotToJson', () => {
});
});
+describe('statsSnapshotToText', () => {
+ it('serialises the displayed metric values into a concise snapshot', () => {
+ const snapshot = buildStatsSnapshot(
+ { totalPairs: 1234567, paused: true },
+ '2026-07-23T05:30:00.000Z'
+ );
+
+ expect(statsSnapshotToText(snapshot)).toBe(
+ [
+ 'StableRoute stats snapshot',
+ 'Pairs: 1,234,567',
+ 'Status: Paused',
+ 'Captured: 2026-07-23T05:30:00.000Z',
+ ].join('\n')
+ );
+ });
+});
+
describe('statsSnapshotToCsv', () => {
it('emits a header row followed by one row per metric', () => {
const snapshot = buildStatsSnapshot(
@@ -353,7 +380,7 @@ describe('StatsPage download controls', () => {
it('renders Download JSON and Download CSV controls once stats load', async () => {
mockFetch({ totalPairs: 7, paused: false });
- render( );
+ renderStatsPage();
expect(
await screen.findByRole('button', { name: /download json/i })
@@ -365,17 +392,20 @@ describe('StatsPage download controls', () => {
it('does not render download controls while loading or on error', async () => {
global.fetch = jest.fn().mockRejectedValue(new Error('Network error'));
- render( );
+ renderStatsPage();
await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument());
expect(
screen.queryByRole('button', { name: /download json/i })
).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: 'Copy stats snapshot' })
+ ).not.toBeInTheDocument();
});
it('triggers a JSON blob download when Download JSON is clicked', async () => {
mockFetch({ totalPairs: 7, paused: true });
- render( );
+ renderStatsPage();
const button = await screen.findByRole('button', {
name: /download json/i,
@@ -389,7 +419,7 @@ describe('StatsPage download controls', () => {
it('triggers a CSV blob download when Download CSV is clicked', async () => {
mockFetch({ totalPairs: 7, paused: false });
- render( );
+ renderStatsPage();
const button = await screen.findByRole('button', { name: /download csv/i });
fireEvent.click(button);
@@ -399,3 +429,127 @@ describe('StatsPage download controls', () => {
expect(blob.type).toBe('text/csv');
});
});
+
+describe('StatsPage copy control', () => {
+ const originalSecureContext = window.isSecureContext;
+ const originalClipboard = navigator.clipboard;
+
+ function setClipboard(value: unknown) {
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value,
+ });
+ }
+
+ function setSecureContext(value: boolean) {
+ Object.defineProperty(window, 'isSecureContext', {
+ configurable: true,
+ value,
+ });
+ }
+
+ afterEach(() => {
+ setSecureContext(originalSecureContext);
+ setClipboard(originalClipboard);
+ });
+
+ it('copies a snapshot using the current displayed values and confirms it', async () => {
+ const writeText = jest.fn().mockResolvedValue(undefined);
+ setSecureContext(true);
+ setClipboard({ writeText });
+ mockFetch({ totalPairs: 1234567, paused: true });
+
+ renderStatsPage();
+
+ expect(await screen.findByText('1,234,567')).toBeInTheDocument();
+ expect(screen.getByText('Paused')).toBeInTheDocument();
+
+ fireEvent.click(
+ screen.getByRole('button', { name: 'Copy stats snapshot' })
+ );
+
+ await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1));
+ const copiedText = writeText.mock.calls[0][0] as string;
+ expect(copiedText).toContain('Pairs: 1,234,567');
+ expect(copiedText).toContain('Status: Paused');
+ expect(copiedText).toMatch(
+ /Captured: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/
+ );
+ expect(await screen.findByRole('status')).toHaveTextContent(
+ 'Stats snapshot copied.'
+ );
+ });
+
+ it('shows a selectable fallback with empty stats when clipboard writing fails', async () => {
+ setSecureContext(true);
+ setClipboard({
+ writeText: jest.fn().mockRejectedValue(new Error('denied')),
+ });
+ mockFetch({ totalPairs: 0, paused: false });
+
+ renderStatsPage();
+
+ fireEvent.click(
+ await screen.findByRole('button', { name: 'Copy stats snapshot' })
+ );
+
+ const fallback = await screen.findByRole('textbox', {
+ name: 'Stats snapshot text',
+ });
+ expect((fallback as HTMLTextAreaElement).value).toContain('Pairs: 0');
+ expect((fallback as HTMLTextAreaElement).value).toContain('Status: Live');
+ expect(fallback).toHaveAttribute('readonly');
+ expect(await screen.findByRole('alert')).toHaveTextContent(
+ "Couldn't copy automatically. Select the snapshot below to copy it."
+ );
+
+ const select = jest.spyOn(fallback, 'select');
+ fireEvent.focus(fallback);
+ expect(select).toHaveBeenCalledTimes(1);
+ });
+
+ it('blocks repeated clicks while a clipboard write is pending', async () => {
+ let resolveWrite!: () => void;
+ const writeText = jest.fn(
+ () =>
+ new Promise((resolve) => {
+ resolveWrite = resolve;
+ })
+ );
+ setSecureContext(true);
+ setClipboard({ writeText });
+ mockFetch({ totalPairs: 7, paused: false });
+
+ renderStatsPage();
+
+ const button = await screen.findByRole('button', {
+ name: 'Copy stats snapshot',
+ });
+ fireEvent.click(button);
+ fireEvent.click(button);
+
+ expect(writeText).toHaveBeenCalledTimes(1);
+ expect(button).toBeDisabled();
+
+ resolveWrite();
+ await waitFor(() => expect(button).not.toBeDisabled());
+ });
+
+ it('uses the fallback when the Clipboard API is unavailable', async () => {
+ setSecureContext(true);
+ setClipboard(undefined);
+ mockFetch({ totalPairs: 42, paused: false });
+
+ renderStatsPage();
+
+ fireEvent.click(
+ await screen.findByRole('button', { name: 'Copy stats snapshot' })
+ );
+
+ const fallback = await screen.findByRole('textbox', {
+ name: 'Stats snapshot text',
+ });
+ expect((fallback as HTMLTextAreaElement).value).toContain('Pairs: 42');
+ expect(await screen.findByRole('alert')).toBeInTheDocument();
+ });
+});