--- title: Building a dashboard sidebar_position: 2 --- # Building a dashboard A guide for React developers who want to ingest a stream of events, derive statistics over a moving window, and render the result as a live chart — without writing the data layer from scratch every time. ![Reference dashboard: per-host CPU with ±σ baseline bands, EMA trend, and 15s anomaly buckets](/img/dashboard.png) A working reference dashboard (per-host CPU/request metrics, rolling baselines, ±σ anomaly detection, live charts) lives at [**pjm17971/pond-ts-dashboard**](https://github.com/pjm17971/pond-ts-dashboard). The full app is ~600 lines of TypeScript including markup, layout, and CSS. This guide is adapted from that repo's README; file paths in square brackets point at the source. The library doing the heavy lifting is [`pond-ts`](https://github.com/pjm17971/pond-ts) (data) and `@pond-ts/react` (React bindings). Charts are rendered with Recharts, but the patterns work with any chart library that takes flat row arrays. --- ## What pond-ts is for **The problem:** real-time dashboards are a collision of three concerns that don't naturally compose: 1. A **push-based data source** — WebSocket, SSE, polling fetch — that doesn't care about React's render cycle. 2. **Stateful transformations** — rolling averages, percentiles, windowed counts — that need the data in time order with bounded memory. 3. **Pull-based rendering** — React reads at its own cadence, which is decoupled from the rate of incoming data. **The model:** pond-ts splits the data layer into two shapes: - **`LiveSeries`** — a mutable, append-only buffer with a retention policy. Push events into it from anywhere. It enforces ordering and bounds memory. - **`TimeSeries`** — an immutable snapshot of a series at a point in time. You query, transform, and render off a snapshot. The React bindings turn that split into hooks: `useLiveSeries` creates and owns a `LiveSeries`; `useWindow` produces a throttled `TimeSeries` snapshot that updates at most every N milliseconds; everything downstream renders off the snapshot. Push happens on one schedule; render happens on another; the throttle guarantees they don't fight. If you've used `@tanstack/react-query`, the analogue is roughly: a `LiveSeries` is the cache; a `TimeSeries` is what you get out via `useQuery`; `useWindow` is the subscription. --- ## A two-minute setup ```bash npm install pond-ts @pond-ts/react ``` Declare your event schema as a const so types narrow end-to-end: ```ts // dashboardSchema.ts export const schema = [ { name: 'time', kind: 'time' }, { name: 'cpu', kind: 'number' }, { name: 'requests', kind: 'number' }, { name: 'host', kind: 'string' }, ] as const; ``` Mount a `LiveSeries` and push events: ```tsx import { useLiveSeries } from '@pond-ts/react'; import { schema } from './dashboardSchema'; function MyDashboard() { const [live, snapshot] = useLiveSeries( { name: 'metrics', schema, retention: { maxAge: '6m' }, // bound the buffer to 6 minutes }, { throttle: 200 }, // snapshot at most every 200ms ); // Push events from anywhere — a WebSocket, an interval, a fetch loop. useEffect(() => { const id = setInterval(() => { live.push([new Date(), Math.random(), 100, 'api-1']); }, 500); return () => clearInterval(id); }, [live]); return
events seen: {snapshot?.length ?? 0}
; } ``` That's the whole "ingest" half. `live.push(row)` is type-checked against the schema. `snapshot` is a `TimeSeries` (or `null` until the first event arrives) that re-renders the component at most every 200ms regardless of how fast events come in. --- ## Reading: snapshots and current values Once events are flowing, you have three reading patterns, in increasing order of "I just need a number": ### Windowed snapshot — the chart's source of truth `useWindow(live, '5m')` returns a `TimeSeries` snapshot of the last 5 minutes, throttled. This is what every chart in the reference dashboard reads from: ```tsx const timeSeries = useWindow(live, '5m', { throttle: 200 }); // timeSeries: TimeSeries | null ``` `TimeSeries` is immutable. You can call `.filter`, `.smooth`, `.aggregate`, `.partitionBy`, etc. on it without affecting the live buffer. Every chart in the dashboard is a chain of pure transforms off this snapshot. ### `useCurrent` — one value at a time When you only want the _current_ value of a reduction (a stat card, a header number), `useCurrent` is the lighter primitive: ```tsx // Last-minute average CPU across all hosts: const { cpu: rollingCpu } = useCurrent( live, { cpu: 'avg' }, { tail: '1m', throttle: 200 }, ); // rollingCpu: number | undefined ``` The `tail` option restricts the reduction to a trailing window; omit it for whole-buffer roll-ups. The return type is narrowed per reducer — `'avg'` gives `number`, `'unique'` gives `ReadonlyArray` — so no casts. `useCurrent` is also reference-stable: when the underlying values don't change between snapshots, you get back the same object reference. Downstream `useMemo` keyed on the result only re-runs when the values actually change. ### Direct queries on a snapshot For arbitrary work, just operate on the `TimeSeries`: ```tsx const lastEventCpu = timeSeries?.last()?.get('cpu'); const recentArray = timeSeries?.toArray().slice(-20); // last 20 events const filtered = timeSeries?.filter((e) => e.get('cpu') > 0.7); ``` `event.get('cpu')` is typed by the schema — `number | undefined` here. `e.key().timestampMs()` gives the millisecond timestamp. --- ## Per-host (or per-anything) computation Almost every real dashboard has a categorical column — host, region, user, channel — and most stateful operations (`rolling`, `smooth`, `fill`, `baseline`) silently cross category boundaries unless you scope them. `pond-ts` handles this with `partitionBy`: ```ts // Per-host rolling 1-min average + standard deviation, in one pass: const perHostBaselines = timeSeries .partitionBy('host') .baseline('cpu', { window: '1m', sigma: 2, minSamples: 20 }) // avg/sd/upper/lower per row .toMap((g) => g.toPoints()); // Map> ``` This is the workhorse pattern in the reference dashboard's CPU section ([`useDashboardData.ts`](https://github.com/pjm17971/pond-ts-dashboard/blob/main/src/useDashboardData.ts) step 7). Four things to notice: 1. **`partitionBy` keeps the rolling baseline scoped per host.** Without it, `baseline('cpu', { window: '1m' })` would average across hosts in the window — silently wrong, easy to miss in dev, broken at the visualization layer. 2. **`baseline` is one rolling pass that produces four columns** (`avg`, `sd`, `upper`, `lower`) on every event. The bands and the outlier predicate read from the same row data; no second rolling pass. 3. **`minSamples: 20` hides the warm-up.** A 1-minute rolling stdev computed off 2–3 samples is meaningless; the band collapses tight around the first few values and false-flags the next normal event as anomalous. With `minSamples`, `avg`/`sd`/`upper`/`lower` stay `undefined` until each host has 20 samples in its window — meaning the chart and the outlier predicate both naturally abstain instead of producing junk for the opening seconds. 4. **`toMap(g => g.toPoints())` returns wide-row arrays** — `[{ ts, cpu, avg, upper, lower, ... }]` — one per host, ready for a chart library to consume. (Earlier pond versions used `.collect().groupBy(col, fn)` for the same shape; on v0.10+ prefer `toMap` — it skips the unified-buffer round-trip and is ~3.3× faster.) :::tip[Live dashboards: roll the baseline at ingest] The pattern above re-runs `baseline`'s rolling pass on each snapshot — ideal when renders are infrequent or the window is small. For a high-cadence live dashboard, moving the rolling work onto the ingest path (`live.partitionBy('host').rolling('1m', { … }).collect()`) turns each render into a gather instead of a window re-walk — ~16× cheaper at dashboard scale. See [Recipe: Incremental ±σ baselines](../recipes/streaming-baseline). ::: ### Anomaly detection falls out for free Once you have the baseline columns on each row, an outlier is just a comparison: ```ts for (const r of rows) { if ( r.cpu != null && r.upper != null && r.lower != null && (r.cpu > r.upper || r.cpu < r.lower) ) { anomalies.push({ ts: r.ts, value: r.cpu }); } } ``` The dashboard renders these as red dots overlaid on the chart and as 15-second buckets on a bar chart underneath. Both views use the same `anomalies` array — the bar chart goes through `TimeSeries.fromPoints` and `aggregate(Sequence.every('15s'), { value: 'count' })` to bucket them. ### Filter then derive vs. derive then filter You'll sometimes want to compute a transform across all events but display only some of them. Two rules: - **Filter on the snapshot when the predicate depends on React state** (a user toggle, a slider value). Filtering a `TimeSeries` is cheap — it's just an array walk — and avoids tearing down LiveView subscriptions. - **Filter on the live source when the predicate is stable and you want incremental maintenance.** A live `live.filter(...)` view processes each event once as it arrives instead of re-scanning the whole snapshot every render. The reference dashboard does both: enabled-host filtering happens on the snapshot (depends on state); per-host CPU-rate views in earlier versions of the code used `live.filter('host', cb).cumulative.rate` because the predicate was stable. --- ## Bridging to chart libraries `pond-ts` deliberately doesn't ship charts — the rendering is yours. The bridge surface is two methods: ### `toPoints()` — long → wide ```ts const data = timeSeries.toPoints(); // Array<{ ts: number, cpu: number | undefined, requests: number | undefined, host: string | undefined }> ``` This is the universal shape mainstream chart libraries accept directly. Recharts uses it as `data={data}`; Observable Plot accepts it as `marks=[Plot.line(data, {x: 'ts', y: 'cpu'})]`; visx uses `data` plus accessor functions. For per-column extraction, compose with `select`: ```ts const cpuPoints = timeSeries.select('cpu').toPoints(); // Array<{ ts, cpu }> ``` ### `fromPoints` — wide → TimeSeries The inverse, useful when you've built up an array of derived points and want to feed them back through pond's transforms: ```ts const anomalyTs = TimeSeries.fromPoints(anomalies, { name: 'anomalies', schema: [ { name: 'time', kind: 'time' }, { name: 'value', kind: 'number' }, ] as const, }); const buckets = anomalyTs.aggregate(Sequence.every('15s'), { value: 'count' }); ``` The dashboard uses this for the anomaly bar chart — collect points, round-trip through a tiny TimeSeries, bucket via pond rather than hand-rolling the bucket loop. ### A note on chart libraries and live data Most charting libraries default to animated transitions when their `data` prop changes. For live data ticking every 200ms, that's a constant flicker. You'll want to disable animations everywhere: ```tsx ``` Recharts defaults to wide-row data in a single `data` prop — exactly what `toPoints()` produces. For multi-series charts where each series comes from a different transform (e.g., per-host CPU lines + a baseline line + anomaly dots), you have two options: - **Wide rows** — merge per-host data into one wide array with prefixed keys (`api-1_cpu`, `api-2_cpu`, …). One `` per key. Works if all series share timestamps. - **Separate `data` props per `` / `` / ``** — Recharts allows each series to override `data`. Better when timestamps don't align (e.g., anomaly dots are sparse). The reference dashboard mixes both. See [`Chart.tsx`](https://github.com/pjm17971/pond-ts-dashboard/blob/main/src/Chart.tsx) in the dashboard repo for the merge-and-render logic. --- ## Other patterns the reference dashboard demonstrates Skim these by section to see how a specific bit is wired: - **Eviction tracking** — `live.on('evict', cb)` fires when retention kicks in. Used as a counter in the page summary. - **Static reference series** — `useTimeSeries({ schema, rows })` mounts a fixed two-row series for the 70% threshold line. Demonstrates the static-data path. - **Multi-reducer rollups** — `useCurrent(live, { requests: 'sum', cpu: 'avg' })` — a single subscription, multiple reductions, reference-stable return. - **Bucketed counts** — `aggregate(Sequence.every('15s'), { col: 'count' })` for the alert/anomaly bar chart. - **Smooth + slice for warmup** — EMA smoothing with `.slice(12)` to drop the warmup samples that haven't fully converged yet. - **`partitionBy(...).toMap(transform)`** — when you want per-host _snapshots_ rather than a fan-in, this returns `Map` directly. --- ## What you don't need pond-ts for To set expectations: pond's job ends at producing chart-ready data. You still pick: - **A chart library.** Recharts, Observable Plot, visx, ECharts, raw SVG — pond is agnostic. - **A wire format.** WebSocket / SSE / fetch / gRPC — pond is the receiver, not the transport. - **A layout / styling system.** `useLiveSeries` mounts in any React tree. - **State management.** Pond is the data layer for _time-series_ data; user preferences, route state, etc. stay in `useState`/`zustand`/whatever you're already using. If your "real-time" data is one number that updates every second, pond is overkill — `useState` + `setInterval` is fine. Pond starts paying off at the point where you have _a stream_, _transforms_, and _charts_: any two of those drag in a third. --- ## When to reach for pond, summarized | Situation | Use pond? | | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | | Single value, occasional update | No, `useState` is fine | | Polled fetch, render the response | No, `useQuery` is fine | | **Stream of events with transforms (rolling avg, percentiles, anomalies) feeding charts** | **Yes** | | Stream of events with no transforms (just plot raw values) | Optional — pond's snapshot+throttle still helps but you could roll your own | | You're already using `react-timeseries-charts` (the predecessor) | Yes, this is the rewrite | The library's whole pitch is the third row. If that's your shape, it'll save you most of the data-layer code you'd otherwise hand-roll, and the result will compose with whatever chart library you bring.