Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/clipboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<pre>` 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
Expand Down
39 changes: 32 additions & 7 deletions docs/stats-export.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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"`)
Expand All @@ -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.
65 changes: 63 additions & 2 deletions src/app/stats/Client.tsx
Original file line number Diff line number Diff line change
@@ -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 };

Expand Down Expand Up @@ -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, '""')}"`;
Expand Down Expand Up @@ -152,8 +163,12 @@ export function downloadStatsSnapshot(
}

export default function StatsClient() {
const { push } = useToast();
const result = useApi<Stats>('/api/v1/stats');
const [lastUpdatedAt, setLastUpdatedAt] = useState<number | null>(null);
const [isCopying, setIsCopying] = useState(false);
const [copyFallback, setCopyFallback] = useState<string | null>(null);
const copyInFlightRef = useRef(false);
const { refetch } = result;
const status = result.status;
const error = status === 'error' ? result.error : null;
Expand All @@ -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 (
<main
id="main-content"
Expand Down Expand Up @@ -196,7 +234,15 @@ export default function StatsClient() {
<StatTile label="Status" value={data.paused ? 'Paused' : 'Live'} />
</dl>
{lastUpdatedAt !== null && <LastUpdated timestamp={lastUpdatedAt} />}
<div className="mt-4 flex gap-2">
<div className="mt-4 flex flex-wrap gap-2">
<Button
type="button"
variant="secondary"
disabled={isCopying}
onClick={() => void copyStatsSnapshot(data)}
>
Copy stats snapshot
</Button>
<Button
type="button"
variant="secondary"
Expand All @@ -212,6 +258,21 @@ export default function StatsClient() {
Download CSV
</Button>
</div>
{copyFallback !== null && (
<label className="mt-4 block text-xs">
<span className="mb-1 block">
Select and copy the stats snapshot:
</span>
<textarea
aria-label="Stats snapshot text"
readOnly
rows={4}
value={copyFallback}
onFocus={(event) => event.currentTarget.select()}
className="w-full resize-y rounded border border-neutral-300 px-2 py-1 font-mono dark:border-neutral-700 dark:bg-neutral-900"
/>
</label>
)}
</section>
)}
{status === 'success' && data && data.totalPairs === 0 && (
Expand Down
Loading