README.md

bluetop #

A btop-inspired live monitor for Bluesky: throughput graphs, collection activity, languages, hashtags, post composition, and connection health. Each browser connects directly to public Jetstream in one Web Worker and aggregates events locally, including while the page is hidden or unfocused. The backend stores compressed hourly aggregate history and serves bootstrap snapshots.

Run locally #

Requires Node.js 22+ and npm.

npm ci
npm run dev

Open http://localhost:5173. Vite proxies /api to the backend on 127.0.0.1:3001. On connect, resume, and reconnect, the browser fetches compressed history from /api/bootstrap, then opens a WebSocket to public Jetstream. Range changes are computed locally and do not make a request. If history bootstrap or Jetstream is unavailable, the view reports a connection problem and reconnects as appropriate. Demo controls and shortcuts are disabled.

Seven views use the same browser-local aggregates and history bootstrap:

  • /dashboard: the dashboard, with charts and summary panels.
  • / and /btop: the default compact terminal view inspired by btop, with character graphs, colored panel borders, and a process-style activity table. Use the navigation link in either view to switch.
  • /htop: a simpler htop-style terminal, with colored text meters, a green table header, cyan row selection, and clickable function-key controls.
  • /glances: a Glances-style overview with compact summary groups, a narrow stats sidebar, collection and activity tables, and current connection notices.
  • /bottom: a bottom-style layout with dotted time-series charts, an interactive rate legend, and compact language, storage, and activity tables.
  • /gotop: a dark teal terminal with multicolor dotted graphs, stacked network plots, and a blue selected table row.
  • /top: plain text summaries and a sortable activity table inspired by the original top command.

Each terminal has separate Collections, Hashtags, and Languages categories. Collection tables describe record operations; hashtag and language tables describe metadata on newly created posts. Category switches reset the filter and sorting, so unrelated units never compete in a single ranking. Only measured columns appear: hashtags have approximate post counts, languages add observation share, and collections add event rate and event share. The dashboard keeps these datasets in separate panels.

npm test          # Aggregation, storage bounds, HTTP and WebSocket lifecycle tests
npm run build    # Typecheck and production frontend build
npm start        # Backend serves both the API and built frontend on port 3001

Production needs a long-running process with outbound WebSocket access. Run one collector instance for a consistent view; multiple instances have independent counters. Place a TLS reverse proxy in front when exposing it publicly.

Configuration #

Set environment variables in the process environment:

Variable Default Purpose
HOST 127.0.0.1 Bind address; use 0.0.0.0 in containers
PORT 3001 API and production frontend port
JETSTREAM_URL wss://jetstream2.us-east.bsky.network/subscribe Compatible Jetstream JSON WebSocket endpoint
DATA_DIR ./data Directory for the compressed aggregate history checkpoint

The development proxy assumes backend port 3001. Change vite.config.ts if using a different backend port during development.

Docker #

docker build -t bluetop .
docker volume create bluetop-data
docker run --rm -p 3001:3001 -v bluetop-data:/app/data bluetop

Open http://localhost:3001. The image sets DATA_DIR=/app/data. Reuse the same named volume across redeploys to keep aggregate history:

docker run --rm -p 3001:3001 -v bluetop-data:/app/data bluetop

The container creates /app/data and gives it to the node user. A new container without the existing named volume starts with empty history. Replacing a local filesystem deployment also loses its data unless the same DATA_DIR directory is preserved. Run one backend process per data directory.

Data flow and retention #

Bluesky Jetstream → one browser Web Worker per visitor → bounded local aggregates → views
         backend → GET /api/bootstrap compressed hourly aggregates → browser history
         backend ↔ data/history.json.gz (persisted hourly aggregates)

Each browser subscribes only to app.bsky.feed.post, app.bsky.feed.like, app.bsky.feed.repost, app.bsky.graph.follow, and app.bsky.graph.block. Incoming messages are parsed and discarded after local aggregation and optional sampling. Aggregate history excludes post text, account DIDs, record IDs, handles, previews, and raw events. The live stream view can show a random sample of at most 10 matching events per second or all matching events. This limits displayed previews, not incoming WebSocket traffic. Raw bandwidth and CPU use are per visitor. Only compressed aggregate buckets are persisted by the backend; no raw events or previews are saved.

  • 3600 one-second buckets, retaining up to one hour of history and expiring even with no connected browsers. The checkpoint is history.json.gz under DATA_DIR and contains versioned aggregate buckets only. Bootstrap returns available compressed history. New browser observations use Jetstream source timestamps; legacy persisted buckets use arrival timestamps until they expire. Browser session totals and uptime begin when its worker starts and persist across reconnects.
  • The checkpoint is atomically replaced every 10 seconds and flushed again on SIGINT or SIGTERM. A crash can lose aggregate history since the last successful checkpoint. A corrupt checkpoint fails startup explicitly. A periodic checkpoint failure is logged while the service remains available.
  • Each bucket holds at most 48 language categories and 96 hashtag categories, including overflow categories. Category lengths and per-post inspection are capped too.
  • Session totals are a fixed set of counters held in each browser session. Each visitor opens its own Jetstream connection and computes all ranges locally from bounded aggregates.
  • WebSocket messages are capped at 1 MiB. Reconnect delay grows exponentially from 1 second to 30 seconds without jitter. A 30-second inactivity timeout detects stalled connections.
  • Temporary deduplication keys are kept for up to 60 seconds, capped at 100,000 entries per browser cache. Raw identifiers are never persisted or included in the bootstrap API. The footprint panel estimates local aggregate storage only, not total browser memory, worker runtime, socket buffers, transient parsing allocations, or deduplication keys. Each visitor also pays the CPU and bandwidth cost of its own Jetstream connection. Retained aggregate storage remains bounded regardless of runtime.

Live stream #

Each terminal console has a Live stream tab beside its aggregate categories. It replaces the activity table with time, operation, record type, and preview columns. It can show a local random sample or all matching events. The stream covers only the five monitored types listed above; record and action filters apply in either mode.

With sampling on, the browser shows at most 10 matching events per second, along with observed counts. Turn on No sampling to receive every matching event in the displayed stream. Posts are the default filter. Text search applies only to rows received by the browser, so it is not a search of the full archive. Historical replay is not shown. Sampling does not reduce Jetstream network traffic.

The browser retains at most 150 rows, with plain-text previews capped at 160 characters, and clears them when changing record/action filters or sampling mode, or leaving the stream tab. No account or record identifiers are included. Temporary samples are discarded after use.

Pause stream freezes the live table; the monitor's pause control freezes the display while aggregation continues. Closing or navigating away from the page suspends its Jetstream socket and aborts any bootstrap request. Returning through browser back/forward cache resumes the page and refreshes history. Network or browser suspension can also interrupt the stream; reconnects fetch history and replay from the cutoff, set five seconds before the snapshot's whole-second boundary, when possible. The backend live tail itself can have gaps, and the handoff is best effort: this is not an authoritative census. Storage estimates cover aggregate history, excluding temporary samples, deduplication keys, and network buffers.

What the numbers mean #

Rates average the previous five completed seconds. Graphs show completed one-second counts, omitting the filling current second to avoid false traffic drops. Up to 3600 one-second buckets are available, so an hour chart has up to 3599 completed samples. Pre-start seconds are displayed as zero; outage periods can also appear as zero observations. New events use Jetstream source time; older persisted legacy buckets use arrival time until their one-hour expiry. Lag compares source time with the browser clock calibrated to the server during bootstrap.

Collection events include creates, updates, and deletes. The posts/likes/reposts/follows cards count creates only, so their percentages need not sum to 100%. Blocks and deletes remain visible in the collection table. These are observed activity counts, not current account/post inventories or all AT Protocol traffic.

Post composition uses created posts. Top-level means no reply parent; quotes and media overlap top-level posts or replies. With media includes images, video, and gallery embeds.

Languages come from declared post language tags, not language detection. A multilingual post contributes to multiple categories; percentages use total language-tag observations. Missing language is shown as Unspecified.

Hashtags come from structured facets and explicit tags, normalized and counted at most once per tag per post. Capped maps can undercount a popular tag, so this is an approximate ranking of observed tags, not an exact network-wide trending algorithm. Overflow is excluded from the hashtag list. Links open Bluesky search.

Reconnects use a best-effort history handoff. After a network or browser suspension, the browser refreshes its snapshot, then replays from the cutoff, set five seconds before the snapshot's whole-second boundary, when possible. Upstream gaps and late events can still leave inaccuracies; this is not an authoritative census. History already received remains available for up to one hour. Browser session counters and uptime start with the worker and survive reconnects.

Controls #

The browser stays connected while the page is hidden or unfocused. Closing or navigating away suspends the socket; returning from browser back/forward cache resumes it and refreshes history. Network or browser suspension triggers reconnect and a history refresh. Pausing freezes the display while aggregation continues.

Use Last 5 / 10 / 15 minutes / 1 hour in any dashboard or console to select its aggregate window. The selection is also available through ?range=5m, ?range=10m, ?range=15m, or ?range=hour in the page URL. Range selection and graph zoom are computed locally with no network request. Click the legend or a metric card to select its graph. Click collection headings to sort, or use the filter.

  • Space: freeze/resume the display; browser aggregation continues.
  • 1 / 5: graph window.
  • /: focus the collection filter.
  • ? / Escape: open/close help.

API #

  • GET /api/bootstrap: compressed aggregate history used on connect, resume, and reconnect. The browser derives selected ranges, counters, and charts locally.
  • GET /api/stats: legacy aggregate snapshot endpoint. The browser views do not poll it.
  • GET /api/health: HTTP service liveness (ok: true) and backend status. It does not report each visitor's Jetstream connection.
  • GET /api/live: legacy SSE endpoint. Browser views connect directly to Jetstream and do not use this endpoint.

All routes support HEAD. Other methods return 405. The same process serves dist/ after building.

Layout #

  • server/aggregator.ts: bounded rolling aggregation.
  • server/jetstream.ts: filtered WebSocket connection and reconnect lifecycle.
  • server/live.ts: bounded random event sampling and shared SSE subscriptions.
  • server/http.ts: JSON API, snapshot cache, and static production serving.
  • src/App.tsx: responsive React dashboard and browser connection status.
  • src/demo.ts: synthetic fixtures for tests and isolated previews.

Built using the Jetstream JSON subscription contract and Bluesky post lexicon. Visual inspiration: btop, the supplied htop reference, and Glances.