---
module: analytics.fleet-health
title: Fleet Health
tab: Architect
order: 30
audience: dev
---

> **How it's built.** Fleet Health is a **synchronous fan-out aggregation** — not a snapshot/Kafka read. Grounded in `FleetHealthController` + `FleetHealthService` (hiveops-analytics) and the frontend `FleetHealthTab.svelte`. Cross-link [platform.data-architecture], [platform.kafka], [platform.service-ownership].

## Services involved

| Service | Role in this feature |
|---------|----------------------|
| **hiveops-analytics** (owner, port 8089) | Serves `GET /api/analytics/fleet-health`; orchestrates + aggregates. Holds **no state** for this tab. |
| **hiveops-incident** | Source of ATM connectivity summary, recent activity events, and open incidents. |
| **hiveops-journal** (port 8087) | Source of open journal gaps. |

Cross-link [platform.service-ownership]: analytics owns the *aggregation shape*; incident/journal own the underlying records. Analytics reads no incident/journal DB tables directly — only their HTTP APIs.

## Data flow (request-time, no Kafka)

```
Browser (FleetHealthTab.svelte)
  └─ GET /api/analytics/fleet-health   (Bearer JWT)
       └─ FleetHealthService.buildFleetHealth(callerToken)   [forwards the SAME JWT]
            ├─ incidentClient GET /api/atms/summary                       → {total,connected,disconnected,neverConnected}
            ├─ incidentClient GET /api/journal-events/activity?hoursBack=24 → [{atmId,eventType,...}]
            ├─ incidentClient GET /api/incidents/status/open              → [ ...open incidents ]
            └─ journalClient  GET /api/journal/gaps?status=OPEN           → [{atmId,...}]
       └─ aggregate → FleetHealthResponse (JSON)
```

- **No executor→Kafka→record-store pattern here.** That pattern belongs to the analytics *snapshot* pipeline (`SnapshotCollectionService` → `analytics.snapshot-ready` → `analytics_snapshots`), which powers other tabs (Overview/Trends), **not** Fleet Health. This tab bypasses the DB entirely. Cross-link [platform.kafka].
- Each downstream call is independently try/catch-wrapped; a failure logs a warning and substitutes an empty result, so the response degrades gracefully to partial/zero data.

## Aggregation logic (in `FleetHealthService`)

- **Connectivity**: pulls `total/connected/disconnected/neverConnected` from `/api/atms/summary`; `connectivityPct = connected*100.0/total` (0 when total=0).
- **Event Breakdown**: activity events (last **24h**) grouped by category via `classifyEventType(eventType)`. Category map is **seeded** with `HARDWARE, CASH, NETWORK, POWER, AUTH, SYSTEM, OTHER` (all 0), then incremented.
  - Notable: there is **no `OPERATIONS` category in the backend** — the frontend `categoryColors` defines one, but `classifyEventType` never emits it; unmatched types fall to `OTHER`.
- **Top Problematic ATMs**: groups the same 24h events by `atmId`, counts, sorts desc, `limit(5)` → `ProblematicAtm{atmId,eventCount}`.
- **Open Incidents**: `openIncidents.size()` of `/api/incidents/status/open`.
- **ATMs with Journal Gaps**: distinct `atmId` count from `/api/journal/gaps?status=OPEN`.

### Event-type → category mapping (source of truth: `classifyEventType`)

| Category | Event types |
|----------|-------------|
| POWER | `POWER_OFF` |
| HARDWARE | `CARD_READER_FAIL`, `DISPENSER_JAM`, `SELF_TEST_FAILED`, `APPLICATION_ERROR_CARD`, `APPLICATION_ERROR_DISPENSER`, `APPLICATION_ERROR_ENCRYPTION`, `APPLICATION_ERROR_PRINTER`, `APPLICATION_ERROR_PINPAD`, `MIXED_MEDIA_ERROR`, `CASH_ACCEPTOR_ERROR`, `ENCRYPTOR_PINPAD_ERROR` |
| CASH | `CASSETTE_LOW`, `CASSETTE_EMPTY`, `CASSETTE_ABNORMAL` |
| NETWORK | `NETWORK_DISCONNECTED`, `APPLICATION_ERROR_NETWORK` |
| AUTH | `AUTHENTICATION_FAILURE` |
| SYSTEM | `ATM_OUT_OF_SERVICE`, `APPLICATION_ERROR`, `APPLICATION_ERROR_JOURNAL`, `AGENT_LOG_ERROR`, `EJOURNAL_DB_WARNING`, `EJOURNAL_DB_CRITICAL` |
| OTHER | anything unmatched / null |

## DB tables read/written

- **This feature: none.** No reads or writes to `hiveiq_analytics`. (Snapshot tables `analytics_snapshots` / `ai_insights` exist for other tabs — see [technical.analytics].)
- Underlying records live in incident and journal databases and are reached only via HTTP.

## Key API endpoints

| Method | Path | Service | Used for |
|--------|------|---------|----------|
| GET | `/api/analytics/fleet-health` | analytics | The tab's single data call |
| GET | `/api/atms/summary` | incident | Connectivity donut |
| GET | `/api/journal-events/activity?hoursBack=24` | incident | Event breakdown + top problematic |
| GET | `/api/incidents/status/open` | incident | Open incidents count |
| GET | `/api/journal/gaps?status=OPEN` | journal | ATMs with journal gaps |

## Frontend

- `FleetHealthTab.svelte` renders four cards (Connectivity donut, Event Breakdown bars, Top Problematic ATMs, Alerts & Gaps) from `FleetHealthData`.
- API base resolves from `window.__APP_CONFIG__.apiUrl` (fallback `http://localhost:8089/api/analytics`); call is `analyticsAPI.getFleetHealth()` → `GET {base}/fleet-health`.
- `FleetHealthData.topProblematicAtms` carries a `deviceId` "Phase 2 alias" alongside `atmId` — part of the ongoing ATM→device rename. Cross-link [platform.service-ownership].

## Auth

- JWT-authenticated; **no role restriction** (`SecurityConfig`: `.anyRequest().authenticated()`). The caller's token is forwarded verbatim to incident/journal (`WebClientConfig` clients, no service account). Cross-link [platform.auth].
