src/stores/dataService.ts
All background HTTP communication flows through DataService. Components read reactive store state; they do not fetch data themselves except in the specific cases documented below.
Before DataService, every component managed its own fetches, polling intervals, and retry logic independently. This caused:
- Duplicate requests to the same endpoint from different components mounting simultaneously
- Competing HTTP and WebSocket traffic on the initial load, causing timeouts on marginal links
- No TTL awareness — components refetched on every mount regardless of cache freshness
- No retry logic on critical endpoints
- Staggered, inconsistent polling across
setIntervalcalls scattered through the codebase
DataService consolidates all of this into one place.
Live data is delivered via two paths:
-
WebSocket push (primary). The WebSocket store receives real-time messages from the backend.
statsupdates, packet events, and noise floor readings arrive this way on an active connection. The WS message handler updates the relevant Pinia stores directly, which triggers reactive re-renders in all consuming components. -
HTTP polling (fallback and supplement). DataService runs timed polling for each
DataKeyvia_startPolling(). Each poll callsensure(key), which checks the TTL and skips the HTTP request if a WS push arrived within the TTL window. On a healthy connection the polls rarely hit the network forstats— the WS pushes keep the cache fresh. Polling exists so that if the WS drops silently (network partition, idle timeout), the UI continues to receive updated data via HTTP without the user noticing.
Practical implication: the Configuration page reads systemStore.stats, which is updated by both WS pushes and HTTP polling. Do not check dataService._lastFetch on that page to determine data freshness — use systemStore.stats !== null instead, because WS pushes do not update _lastFetch.
useConnectionLifecycle.ts calls await dataService.bootstrap() before opening the WebSocket connection. The HTTP phases complete in full before the WS handshake begins — this prevents the initial HTTP burst and WS from competing on marginal links.
Bootstrap runs once per session (guarded by _bootstrapped). Re-connecting after a tab going hidden/offline does not re-run bootstrap; the WebSocket reconnects and onReconnect() handles a lightweight data refresh.
| Phase | Endpoints | Strategy |
|---|---|---|
| 1 — Critical | /stats |
Sequential, 2 retries (500 ms / 1 s backoff) |
| 2 — Secondary | /packet_stats, /noise_floor_history, /recent_packets |
Parallel (Promise.allSettled) |
| 3 — Background | /sparkline_history, /advert_rate_limit_stats, neighbor adverts |
Parallel (Promise.allSettled) |
Phase 2 and 3 run regardless of whether Phase 1 succeeded — the UI degrades gracefully.
| Key | Endpoint(s) | TTL | Poll interval |
|---|---|---|---|
stats |
/stats |
30 s | 30 s (skipped if WS pushed an update within 25 s) |
packetStats |
/packet_stats |
60 s | 60 s |
noiseFloor |
/noise_floor_history |
15 s | 15 s |
recentPackets |
/recent_packets |
30 s | not polled (WS push handles live updates) |
sparklines |
/sparkline_history |
300 s | 300 s |
advertTier |
/advert_rate_limit_stats |
60 s | 30 s |
neighbors |
/adverts (5 contact types) |
10 min | not polled (user-triggered via neighborStore.fetchAll) |
Polling starts after bootstrap completes (_startPolling()). Each interval calls ensure(key), which checks the TTL and skips the fetch if the cached data is still fresh.
The safe way for a component to request data without caring whether it is already loaded:
// Returns immediately if data was fetched within the TTL window.
// Only fetches if stale or never loaded.
void dataService.ensure('sparklines');Use ensure in onMounted as a safety net — bootstrap normally pre-loads everything, but ensure protects against edge cases (bootstrap failure, hot reload, deep-link navigation).
Do not call ensure to check freshness on the Configuration page. Check systemStore.stats !== null instead — WS pushes keep the data fresh without updating _lastFetch, so ensure would trigger unnecessary refetches on that page.
Called automatically by the WebSocket store when the connection re-opens after a drop. Waits 3 s (lets the WS flush buffered messages first) then refreshes stats, packetStats, and recentPackets.
_inFlight: Map<DataKey, Promise<void>> ensures that if two callers ask for the same key simultaneously, they share one in-flight request rather than issuing two. The second caller receives the same promise.
The global Axios timeout on apiClient is 10 seconds. Several backend queries (RRD history endpoints, hardware stats) regularly take longer than this on real hardware — a 7-day /packet_stats query takes ~10.5 s; a 2-day query ~15 s. Using ApiService.get for these endpoints causes silent timeouts: the error is caught and swallowed, the spinner is cleared by an unrelated faster fetch, and stale cached values from the previous state are left on screen.
src/utils/streamingFetch.ts wraps ApiService.get with a two-phase AbortController:
- Connect phase — if no bytes arrive within
connectTimeoutMs(default 15 s), abort. - Stream phase — once bytes start flowing, arm a rolling idle timer that resets on every
onDownloadProgresschunk. Only fires if data stops foridleTimeoutMs(default 5 s).
This means an actively-streaming response is never aborted regardless of total duration. Use streamingGet (not ApiService.get) for any endpoint that may take more than a few seconds on a marginal link — in practice, all per-page chart and history fetches.
/stats is the most expensive endpoint — it aggregates hardware state (SPI bus reads), config file parsing, and duty-cycle calculations on the embedded backend. It gets special treatment:
- Idle timeout instead of total timeout. The axios instance default of 10 s is overridden with
timeout: 0. AnAbortControllerfires after 15 s of silence on the wire. If data is flowing (even slowly), the timer resets on eachonDownloadProgresschunk — so slow transfers on high-latency links are not abandoned mid-stream. - Sub-status reporting.
dataService.statsSubStatustransitions'requesting' → 'reading' → nullas the first byte arrives.BootstrapModaldisplays this as "(Requesting)" / "(Receiving data)" next to the System Configuration step.
Not all HTTP calls go through DataService. The distinction is:
DataService manages all endpoints that multiple pages or components might need simultaneously, or that benefit from TTL caching and polling:
| Endpoint | DataKey |
|---|---|
/stats |
stats |
/packet_stats |
packetStats |
/noise_floor_history |
noiseFloor |
/recent_packets |
recentPackets |
/sparkline_history |
sparklines |
/advert_rate_limit_stats |
advertTier |
/adverts (5 contact types) |
neighbors |
Pages fetch directly (via ApiService or their store's fetch method) when the data is specific to that page, needs custom parameters, or must bypass the cache:
| Endpoint / action | Who calls it | Reason for direct fetch |
|---|---|---|
/identities |
RoomServers.vue, Companions.vue |
Page-specific list, no shared consumer |
/transport_keys |
Configuration transport-keys tab | Page-specific |
/logs |
Logs page | Streamed, no TTL caching makes sense |
/gps |
GPS Diagnostics | Polled by the page itself while open |
/room_messages |
Room Messages dialog | Paginated, query-specific |
/update/status, /update/install, etc. |
UpdateModal.vue |
Install flow with SSE progress stream |
/restart_service |
RestartModal.vue |
One-shot action, not cacheable |
systemStore.fetchStats() (direct) |
Configuration page after save | Intentional cache-bust after a write |
streamingGet('/packet_stats', { hours }) |
Statistics page | Custom time range not covered by the 24 h cache. Not routed through the store — packetStore.packetStats is also written by the WebSocket push handler, so using it as the delivery mechanism would allow WebSocket events to overwrite time-scoped results between user interactions. The Statistics page stores the result in a local ref snapshot instead. |
streamingGet('/metrics_graph_data'), /route_stats, /noise_floor_history, /crc_error_history |
Statistics page | Time-scoped historical queries parameterised by the page's time-range picker. No shared consumer; TTL caching would require keying by range parameter. |
| Scenario | Correct approach |
|---|---|
| Display data that DataService loads | Read from the Pinia store directly (systemStore.stats, packetStore.packetStats, etc.) |
| Ensure data is present on mount | void dataService.ensure(key) in onMounted |
| Force a refresh after a config save | Call the store fetch method directly (e.g. systemStore.fetchStats()) — this is an intentional cache-bust and should bypass ensure |
| Poll data on your own | Don't — DataService owns all polling. If the interval is wrong, change it in _startPolling() |
| Fetch data with a custom time range | Fetch directly from the store (e.g. packetStore.fetchPacketStats({ hours: 48 })) — DataService caches 24 h only |
src/components/modals/BootstrapModal.vue
A full-screen overlay shown during the initial bootstrap sequence. Mounted in App.vue with v-if="showLayout".
loading → connecting → connected → closed
- loading — DataService is running bootstrap HTTP phases
- connecting — bootstrap complete, waiting for WebSocket to open
- connected — WebSocket open; auto-closes after 2 s
- closed — modal hidden (
v-if="false")
initialPhase() is called once on mount. It reads loadProgress to distinguish "not started" (all 'pending') from "already done" — important because isBootstrapping is false in both states.
Each DataKey has a step row showing pending → loading → done/error. The stats step additionally shows a sub-status label ("Requesting" / "Receiving data") driven by dataService.statsSubStatus.