diff --git a/backend/src/main/resources/content/analytics/adoons/architect.md b/backend/src/main/resources/content/analytics/adoons/architect.md new file mode 100644 index 0000000..e1a63ff --- /dev/null +++ b/backend/src/main/resources/content/analytics/adoons/architect.md @@ -0,0 +1,71 @@ +--- +module: analytics.adoons +title: Adoons Insights +tab: Architect +order: 30 +audience: internal +--- + +> **Internal · architecture.** Written 2026-07-01 from `hiveops-analytics` source. Verify against code before relying on specifics. + +## Shape of the feature + +Adoons Insights is the simplest slice of `hiveops-analytics`: a **single-table CRUD store** (`ai_insights`) exposed over REST, rendered as a card list. Unlike the dashboard/snapshot side of analytics, the insight path is **not** event-driven and does **not** use the executor→Kafka→record-store pattern. It's a straight `Controller → Service → JpaRepository → Postgres` flow. + +``` +AdoonsTab.svelte ──GET /insights/active──▶ InsightController ──▶ AiInsightService ──▶ AiInsightRepository ──▶ ai_insights (hiveiq_analytics) +publisher (admin JWT) ──POST /insights──▶ InsightController (@PreAuthorize MSP_ADMIN/BCOS_ADMIN) ──▶ save() +``` + +## Services involved + +- **hiveops-analytics** — owns everything for this feature: controller, service, entity, table. Port **8089** (prod host **8012**). Cross-link [platform.service-ownership]. +- **Publisher of insights** — *external to this repo.* Analytics has **no insight generator, no Kafka consumer that creates insights, and no scheduled producer** for them. Insight rows arrive only via authenticated `POST`. The intended author is Adoons (`hiveops-ai`), but that producer is **not present in hiveops-analytics** (unverified where it lives / whether it's live). + +## Data flow + +There is no Kafka in the insight read/write path. Cross-link [platform.kafka] for the surrounding analytics topics (`analytics.snapshot-ready` produced by `SnapshotEventProducer`; `hiveops.incidents.*` and `journal.file-parsed` consumed for snapshots) — **none of these touch `ai_insights`.** + +The only coupling to the rest of analytics is a **nullable FK** `ai_insights.snapshot_id → analytics_snapshots.id`. On publish: +- If `snapshotId` is supplied, that snapshot is linked. +- Else `AiInsightService.publish()` links the **latest** snapshot (`findTopByOrderByCreatedAtDesc()`), or `null` if none exist. + +So an insight can *reference* the fleet metrics snapshot it was derived from, but the reference is optional and never enforced. + +## DB tables + +| Table | R/W here | Columns of interest | +|-------|----------|---------------------| +| `ai_insights` | read + write | `id`, `category`, `severity`, `title`, `body`, `snapshot_id` (FK), `published_at`, `expires_at`, `acknowledged`, `acknowledged_at`, `acknowledged_by_user_id` | +| `analytics_snapshots` | read-only (FK lookup on publish) | `id`, `created_at` (used by `findTop...`) | + +DB **`hiveiq_analytics`** (PostgreSQL prod / H2 dev). Cross-link [platform.data-architecture]. Flyway manages schema; `ai_insights` is created in the analytics migration set (V1 init + later columns). + +**Enums (JPA `EnumType.STRING`):** +- `Category` = `ANOMALY | TREND | RECOMMENDATION | SUMMARY` +- `Severity` = `INFO | WARNING | CRITICAL` (entity default `INFO`) + +## Key API endpoints (`InsightController`, base `/api/analytics/insights`) + +| Method | Path | Behaviour | Auth | +|--------|------|-----------|------| +| GET | `/api/analytics/insights` | Paged list, `findAllByOrderByPublishedAtDesc` → `Page` | authenticated | +| GET | `/api/analytics/insights/active` | `findActive(now)`: unack'd + unexpired, newest first | authenticated | +| POST | `/api/analytics/insights` | `publish(req)`, returns `201` | `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` | +| PATCH | `/api/analytics/insights/{id}/acknowledge` | `acknowledge(id, principal.userId())`; `404` if id unknown; returns `204` | authenticated | +| DELETE | `/api/analytics/insights/active` | `acknowledgeAll(now)`; returns `204` | authenticated | + +## Lifecycle / retention + +- `published_at` set on `@PrePersist` if unset. +- Active = `acknowledged = false AND (expires_at IS NULL OR expires_at > now)`. +- `acknowledge` is an atomic JPQL `UPDATE`; `acknowledgeAll` clears every unacknowledged row. +- **Nightly purge:** `AiInsightService.purgeOldAcknowledged()` — `@Scheduled(cron = "0 0 3 * * *")`, deletes rows where `acknowledged = true AND acknowledged_at < now-30d`. + +## Security model + +- `SecurityConfig`: stateless, CSRF disabled; `permitAll` only for `/actuator/health` + `/api/public/**`; `anyRequest().authenticated()`. +- `JwtAuthenticationFilter` reads the token from the **`Authorization: Bearer` header only** (no cookie fallback) and builds an `AnalyticsPrincipal(userId, email, role, institutionKey)`. +- Only publish is role-restricted; read + acknowledge are open to any authenticated principal (note: no institution scoping on insights — they are fleet-global). + +Cross-links: [technical.analytics], [platform.data-architecture], [platform.kafka], [platform.service-ownership]. diff --git a/backend/src/main/resources/content/analytics/adoons/claude.md b/backend/src/main/resources/content/analytics/adoons/claude.md new file mode 100644 index 0000000..b44e377 --- /dev/null +++ b/backend/src/main/resources/content/analytics/adoons/claude.md @@ -0,0 +1,68 @@ +--- +module: analytics.adoons +title: Adoons Insights +tab: Claude +order: 40 +audience: internal +--- + +> **Internal · agent reference.** Grounded in `hiveops-analytics` @ 2026-07-01. Only confirmed facts below. + +## Ownership +- **Service:** `hiveops-analytics` owns this feature end-to-end (controller, service, entity, table). +- **Port:** 8089 (prod host 8012). **NGINX:** `/api/analytics/`. External base e.g. `https://api.bcos.cloud/analytics/api/analytics/insights`. +- **No AI generation in-repo.** Insights exist only because someone `POST`ed them. Do not assume an in-service generator/Kafka consumer creates them. + +## Endpoints (`InsightController`, base `/api/analytics/insights`) +| Method | Path | Auth | +|--------|------|------| +| GET | `/api/analytics/insights` | authenticated (paged) | +| GET | `/api/analytics/insights/active` | authenticated | +| POST | `/api/analytics/insights` | `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` | +| PATCH | `/api/analytics/insights/{id}/acknowledge` | authenticated | +| DELETE | `/api/analytics/insights/active` | authenticated (ack all) | + +Frontend calls: `getActiveInsights` → `GET /insights/active`; `acknowledgeInsight(id)` → `PATCH /insights/{id}/acknowledge`. + +## Auth +- Header only: `Authorization: Bearer `. No cookie fallback. +- Open paths: `/actuator/health`, `/api/public/**`. Everything else authenticated. +- Principal: `AnalyticsPrincipal(userId, email, role, institutionKey)`. + +## Data +- **DB:** `hiveiq_analytics` (Postgres prod on 10.10.10.188 via ProxyJump; H2 dev). +- **Table:** `ai_insights` (cols: `id, category, severity, title, body, snapshot_id, published_at, expires_at, acknowledged, acknowledged_at, acknowledged_by_user_id`). +- **FK:** `ai_insights.snapshot_id → analytics_snapshots.id` (nullable). +- **Enums:** `category ∈ {ANOMALY,TREND,RECOMMENDATION,SUMMARY}`; `severity ∈ {INFO,WARNING,CRITICAL}`. + +## POST body (`PublishInsightRequest`) +```json +{"category":"TREND","severity":"WARNING","title":"...","body":"...","snapshotId":123,"expiresAt":"2026-08-01T00:00:00Z"} +``` +- `category,severity,title,body` = `@NotBlank`. `snapshotId,expiresAt` optional. +- Omit `snapshotId` → links latest snapshot (or null if none). + +## Kafka +- Insight path uses **no Kafka**. (Analytics produces `analytics.snapshot-ready`; consumes `hiveops.incidents.created/updated/resolved`, `journal.file-parsed` — snapshots only, NOT insights.) + +## Active-list rule +- `/insights/active` = `acknowledged=false AND (expires_at IS NULL OR expires_at > now)`, ORDER BY `published_at DESC`. +- Nightly purge: cron `0 0 3 * * *` deletes acknowledged rows older than 30d. + +## Gotchas +- Severity enum is `INFO/WARNING/CRITICAL`, but frontend colour map keys `CRITICAL/HIGH/MEDIUM/LOW/INFO` → `WARNING` renders default blue; `HIGH/MEDIUM/LOW` can never come from this backend. +- Customer `overview.md` lists CRITICAL/HIGH/MEDIUM/LOW/INFO — does NOT match backend enum. Trust the enum. +- Frontend `api.ts` sets NO Authorization header / no axios interceptor, yet backend requires JWT on all `/api/analytics/**`. Token injection is not in frontend code (mechanism unverified — likely NGINX/host shell). +- `AdoonsTab.dismiss()` swallows PATCH errors silently → failed acknowledge = card silently stays. +- `acknowledge` / `acknowledgeAll` are NOT role-gated — any authenticated user can dismiss fleet-global insights. +- POST with bad enum string → 400 (`valueOf` throws). PATCH unknown id → 404. + +## Do NOT +- Do NOT expect insights to auto-appear — none generate inside analytics; only `POST` creates them. +- Do NOT use `Authorization` bearer header assumptions for the frontend; it doesn't set one. +- Do NOT invent per-institution scoping — insights are fleet-global, no `institution_key` on the table. +- Do NOT route insight writes through Kafka; it's plain REST+JPA. +- Do NOT publish `HIGH/MEDIUM/LOW` severities — not valid enum values. +- Do NOT put insight reads under `/api/public/**` — they require auth by design. + +Cross-links: [technical.analytics], [platform.service-ownership]. diff --git a/backend/src/main/resources/content/analytics/adoons/internal.md b/backend/src/main/resources/content/analytics/adoons/internal.md new file mode 100644 index 0000000..ffb1d63 --- /dev/null +++ b/backend/src/main/resources/content/analytics/adoons/internal.md @@ -0,0 +1,73 @@ +--- +module: analytics.adoons +title: Adoons Insights +tab: Internal +order: 20 +audience: internal +--- + +> **Internal · ops/support/admin.** Written 2026-07-01 from `hiveops-analytics` source. Verify against code before relying on specifics. + +## What this screen actually is + +The **Adoons Insights** (a.k.a. "Fleet Insights") tab is a thin card list over a plain REST + JPA store. Each card is one row in the `ai_insights` table, served by **hiveops-analytics** (port **8089**, prod host port **8012**, NGINX `/api/analytics/`). There is **no AI/ML generation inside analytics** — insights only exist because something `POST`ed them. + +- **Owner service:** `hiveops-analytics` +- **Table:** `ai_insights` in DB `hiveiq_analytics` +- **Frontend:** `AdoonsTab.svelte` → `analyticsAPI.getActiveInsights()` on load and on **Refresh**. + +## Roles required + +| Action | Endpoint | Auth | +|--------|----------|------| +| View active insights | `GET /api/analytics/insights/active` | Any authenticated JWT | +| View all (paged) | `GET /api/analytics/insights` | Any authenticated JWT | +| **Publish an insight** | `POST /api/analytics/insights` | **`hasAnyRole('MSP_ADMIN','BCOS_ADMIN')`** | +| Dismiss one (Refresh ✕) | `PATCH /api/analytics/insights/{id}/acknowledge` | Any authenticated JWT | +| Dismiss all | `DELETE /api/analytics/insights/active` | Any authenticated JWT | + +**Admin-only action = publishing.** Only MSP_ADMIN / BCOS_ADMIN can create insight cards. Everyone else can read and dismiss. + +## How a card gets on the screen + +1. A publisher (Adoons / an operator with an admin JWT) calls `POST /api/analytics/insights` with `{category, severity, title, body, [snapshotId], [expiresAt]}`. +2. Row is written to `ai_insights`; `published_at` defaults to now. +3. It shows in `/insights/active` until it is **acknowledged** or its `expires_at` passes. +4. Acknowledged rows older than 30 days are purged nightly (`@Scheduled cron = "0 0 3 * * *"`). + +`GET /insights/active` returns only rows where `acknowledged = false AND (expires_at IS NULL OR expires_at > now)`, newest first. + +## First things to check when it misbehaves + +**"No active fleet insights" but there should be some** +- Confirm rows exist: query `ai_insights` in `hiveiq_analytics` (Postgres on **10.10.10.188** via ProxyJump). +- Check they aren't all `acknowledged = true` or past `expires_at` — the active query hides both. +- Remember: analytics does **not** self-generate insights. If nothing is `POST`ing them, the tab is *correctly* empty. Check whoever owns publishing (external Adoons/AI job — not in this repo). + +**Cards won't load / error bar shows** +- The SPA calls `/api/analytics/insights/active`. Hit it directly with a real JWT and expect `200` + a JSON array. +- All `/api/analytics/**` require an `Authorization: Bearer ` header (only `/api/public/**` and `/actuator/health` are open). A `401/403` here = missing/expired token or wrong `role`. +- Downstream summary endpoints (incident/journal/mgmt) do **not** feed this tab — insights are self-contained. A red insights panel is an analytics-only problem. + +**Publish returns 403** +- Caller's JWT `role` is not `MSP_ADMIN` or `BCOS_ADMIN`. Only publish is role-gated. + +**Publish returns 400** +- `category` must be one of `ANOMALY | TREND | RECOMMENDATION | SUMMARY`; `severity` must be one of `INFO | WARNING | CRITICAL`. Any other value fails `valueOf(...)`. `title`/`body`/`category`/`severity` are all `@NotBlank`. + +**Dismiss (✕) does nothing / card reappears on refresh** +- `AdoonsTab.dismiss()` **swallows errors silently** (empty `catch`). If the `PATCH .../acknowledge` fails (e.g. 404 for a stale id, or auth), the card stays and no error is shown. Test the PATCH directly to see the real status. + +**Severity colour looks wrong (card renders blue instead of orange)** +- Known mismatch: backend severities are `INFO | WARNING | CRITICAL`, but the frontend colour map only keys `CRITICAL/HIGH/MEDIUM/LOW/INFO`. A `WARNING` insight has **no colour entry** and falls back to the INFO blue. If you need a visible non-critical warning, that's a frontend gap, not bad data. + +## Manual publish (support/testing) + +```bash +curl -s -X POST https://api.bcos.cloud/analytics/api/analytics/insights \ + -H "Authorization: Bearer $JWT" \ + -H "Content-Type: application/json" \ + -d '{"category":"TREND","severity":"WARNING","title":"Test insight","body":"Manual test.","expiresAt":"2026-08-01T00:00:00Z"}' +``` + +Cross-links: [technical.analytics], [platform.service-ownership]. diff --git a/backend/src/main/resources/content/analytics/agents/architect.md b/backend/src/main/resources/content/analytics/agents/architect.md new file mode 100644 index 0000000..8fb13c6 --- /dev/null +++ b/backend/src/main/resources/content/analytics/agents/architect.md @@ -0,0 +1,82 @@ +--- +module: analytics.agents +title: Agents +tab: Architect +order: 30 +audience: internal +--- + +> **Internal · architecture.** Grounded in hiveops-analytics + hiveops-devices source (2026-07-01). Cross-link [platform.data-architecture], [platform.kafka], [platform.service-ownership], [technical.analytics]. + +## Shape of the feature + +The Agents tab is a **thin read-through aggregation**. No dedicated persistence, no Kafka, no snapshot. On each page load the analytics backend fans out **one** HTTP call to the devices backend, computes distributions in-memory, and returns a single DTO. This is the exception to the rest of the analytics app, which is snapshot/Kafka-driven (see [technical.analytics]). + +## Services involved + +| Service | Role for this feature | Port | +|---------|-----------------------|------| +| hiveops-analytics | Serves `GET /api/analytics/agents`; computes version distribution, connection counts, latest-semver, outdated list | 8089 | +| hiveops-devices | **Source of truth** for ATM/agent data; serves `GET /api/atms/paginated` | 8096 | + +Analytics reaches devices via a `WebClient` bean named **`incidentWebClient`** (`WebClientConfig`), base URL from `services.incident.url` (`INCIDENT_SERVICE_URL`, default `http://hiveops-incident-backend:8080`). Historically `/api/atms/*` lived in incident; those endpoints were **extracted into hiveops-devices ~2026-06-18** (`AtmController`, `@RequestMapping({"/api/atms","/api/devices"})`). The analytics bean name and default URL still say "incident" — in a correct deploy that URL must resolve to the backend actually serving `/api/atms/paginated` (devices). See [platform.service-ownership]. + +## Data flow + +``` +Browser (AgentsTab.svelte) + │ GET /api/analytics/agents (Authorization: Bearer ) + ▼ +hiveops-analytics + AgentInsightController.agents() + → extracts raw token from Authorization header + AgentInsightService.buildAgentInsight(token) + → incidentWebClient GET /api/atms/paginated?size=500&page=0 + (forwards "Bearer ") + ▼ +hiveops-devices + AtmController.getAllAtmsPaginated(...) + → AtmService.getAllAtmsPaginated(...) → Spring Data Page + → reads atms + atm_properties (agentVersion, lastHeartbeat) + → agentConnectionStatus derived by calculateConnectionStatus(lastHeartbeat) + ◀ Page JSON: { content: [AtmDTO...], page: {...} } + ▼ +hiveops-analytics (in-memory aggregation) + - versionDistribution = groupingBy(agentVersion), desc by count + - latestVersion = findLatestSemver(distinct versions) [highest x.y.z present] + - connected/disconnected/neverConnectedCount = filter on agentConnectionStatus + - outdatedAtms = every ATM where version blank or != latestVersion + ◀ AgentInsightResponse + ▼ +Browser renders bars / status counts / outdated table +``` + +### Fields consumed from each `AtmDTO` +`atmId`, `agentVersion`, `lastHeartbeat` (ISO string after JSON serialization of `LocalDateTime`), `agentConnectionStatus` (`CONNECTED` | `DISCONNECTED` | `NEVER_CONNECTED`). Analytics reads them as loosely-typed `Map` and defensively coerces to String. + +## Kafka + +**None for this feature.** The Agents tab is a synchronous read-through — it does **not** consume or produce any topic, and does **not** read the analytics `analytics_snapshots` table. Contrast with the rest of analytics, which consumes `hiveops.incidents.*` / `journal.file-parsed` and produces `analytics.snapshot-ready` (see [technical.analytics], [platform.kafka]). Device changes propagate to *other* services via devices' `hiveops.device.events`, but analytics' Agents tab bypasses that and hits devices' REST API live. + +## Databases + +| DB | Owner | Read/written by this feature | +|----|-------|------------------------------| +| `hiveiq_devices` → `atms`, `atm_properties` | hiveops-devices | **Read** (indirectly, via devices REST). `agentVersion`/`lastHeartbeat` live on `atm_properties`. | +| `hiveiq_analytics` | hiveops-analytics | **Not touched** by the Agents tab. | + +Connection status is computed, not stored: `calculateConnectionStatus(lastHeartbeat)` → `NEVER_CONNECTED` (null), `CONNECTED` (≤15 min), else `DISCONNECTED`. + +## Key endpoints + +| Method | Path | Service | Auth | +|--------|------|---------|------| +| GET | `/api/analytics/agents` | hiveops-analytics | authenticated JWT (no role gate) | +| GET | `/api/atms/paginated?size=500&page=0` | hiveops-devices | authenticated JWT (no role gate); JWT forwarded from analytics | + +## Design notes & consequences + +- **Read-through, not executor→Kafka→record-store.** Unlike the snapshot pipeline in [technical.analytics], this tab has no scheduler, no producer, no record store — it trades freshness-guarantees for simplicity and is always live-consistent with devices at request time. +- **Fail-soft aggregation:** `fetchAtmsPaginated` catches everything and returns `List.of()`, so upstream failures surface as an empty (not errored) tab. +- **Single page, size 500:** the aggregation is bounded to the first 500 ATMs — a scaling limit as fleets grow. +- **"Latest" is fleet-relative:** `findLatestSemver` = max semver present among devices, independent of the CDN `latest.json`. Strict `\d+\.\d+\.\d+` regex means suffixed versions collapse "latest" to `"unknown"`. diff --git a/backend/src/main/resources/content/analytics/agents/claude.md b/backend/src/main/resources/content/analytics/agents/claude.md new file mode 100644 index 0000000..8cb8bb8 --- /dev/null +++ b/backend/src/main/resources/content/analytics/agents/claude.md @@ -0,0 +1,55 @@ +--- +module: analytics.agents +title: Agents +tab: Claude +order: 40 +audience: internal +--- + +> **Internal · AI agent quick-ref.** Confirmed against source 2026-07-01. Act on confirmed facts only. + +## Ownership +- **Tab served by:** `hiveops-analytics` (port 8089), `AgentInsightController` + `AgentInsightService`. +- **Data owned by:** `hiveops-devices` (port 8096) — source of truth for ATM/agent fields. +- **DB behind the tab:** `hiveiq_devices` only. `hiveiq_analytics` is **not** used by this tab. + +## Endpoints (METHOD path) +| Method | Path | Service | Auth | +|--------|------|---------|------| +| GET | `/api/analytics/agents` | hiveops-analytics | authenticated JWT, **no role** | +| GET | `/api/atms/paginated?size=500&page=0` | hiveops-devices | authenticated JWT, **no role**; caller JWT forwarded by analytics | + +## Response DTO (`AgentInsightResponse`) +- `versionDistribution: Map` +- `latestVersion: String` — highest `x.y.z` present in fleet, else `"unknown"` +- `connectedCount` / `disconnectedCount` / `neverConnectedCount: int` +- `outdatedAtms: [{ atmId, agentVersion, lastHeartbeat, connectionStatus }]` + +## Tables +- `atms`, `atm_properties` (in `hiveiq_devices`) — `atm_properties` holds `agentVersion`, `lastHeartbeat`. + +## Kafka topics +- **None** produced or consumed by this tab. (Analytics-wide topics `hiveops.incidents.*`, `journal.file-parsed`, `analytics.snapshot-ready` are unrelated to Agents. Devices emits `hiveops.device.events` but this tab reads devices via REST, not that topic.) + +## Connection status rule (computed in devices, not stored) +- `lastHeartbeat == null` → `NEVER_CONNECTED` +- heartbeat ≤ **15 min** → `CONNECTED` +- else → `DISCONNECTED` + +## Wiring gotcha +- Analytics calls devices through a bean named `incidentWebClient`, base URL `services.incident.url` / `INCIDENT_SERVICE_URL` (default `http://hiveops-incident-backend:8080`). `/api/atms/paginated` is a **devices** endpoint (moved out of incident ~2026-06-18). That env var must resolve to the backend serving `/api/atms/paginated`, or the tab goes empty. + +## Gotchas +- Empty tab = upstream failure. `fetchAtmsPaginated` **catches all exceptions → returns `[]`**; logs `AgentInsight: failed to fetch ATMs paginated` (Loki, service `hiveiq-analytics`). +- Only **first 500 ATMs** (`size=500,page=0`) are aggregated. Larger fleets under-count. +- `latestVersion` = max semver **present in fleet**, NOT the CDN release. Regex is strict `\d+\.\d+\.\d+`; suffixed/prefixed versions (`v4.4.3`, `4.4.3-patch`) → `"unknown"` → every ATM flagged outdated. +- `lastHeartbeat` serialized from `LocalDateTime` → ISO string; frontend renders relative ("2h ago"). + +## Do NOT +- Do NOT tell users the Agents tab pushes/schedules updates — it is **read-only**. +- Do NOT look for an analytics DB table for this tab — there isn't one; it's a live read-through. +- Do NOT claim "latest" reflects the newest released agent — it's fleet-relative. +- Do NOT expect a Kafka topic for this feature. +- Do NOT trust totals on fleets > 500 devices. +- Do NOT require an admin role to view — no `@PreAuthorize` on either endpoint. +- Do NOT hit incident for this data — devices owns `/api/atms/paginated`. diff --git a/backend/src/main/resources/content/analytics/agents/internal.md b/backend/src/main/resources/content/analytics/agents/internal.md new file mode 100644 index 0000000..fc812a4 --- /dev/null +++ b/backend/src/main/resources/content/analytics/agents/internal.md @@ -0,0 +1,62 @@ +--- +module: analytics.agents +title: Agents +tab: Internal +order: 20 +audience: internal +--- + +> **Internal · ops/support/admin.** Grounded in hiveops-analytics + hiveops-devices source (2026-07-01). This is a **read-only** dashboard tab — there is nothing to push, edit, or approve here. + +## What this tab actually is + +The **Agents** tab (analytics SPA, `analytics.bcos.cloud`) is a single GET against the analytics backend that aggregates agent-software health across the fleet. It shows: + +- **Version Distribution** — count of ATMs per `agentVersion`. +- **Connection Status** — Connected / Disconnected / Never Connected counts + percentages. +- **Outdated ATMs** — every ATM not on the computed "latest" version. + +All three panels are derived from **one** upstream call. There is no analytics DB table behind this tab — it is computed live on each request. + +## Access / roles + +- **Viewing:** any authenticated JWT. The analytics `/api/analytics/agents` endpoint is **not** role-gated — analytics `SecurityConfig` only requires `.authenticated()` (permitAll is limited to `/actuator/health` and `/api/public/**`). +- **Upstream `GET /api/atms/paginated`** (hiveops-devices) is likewise authenticated-only, **no** `@PreAuthorize` — the `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` gates on that controller apply to *mutating* device endpoints, not the paginated list. +- There are **no admin-only actions on this tab.** (The admin-gated action in the analytics app is `POST /api/analytics/insights`, which belongs to the Adoons Insights panel, not Agents.) + +## Data path (one line) + +`Agents tab → GET /api/analytics/agents (hiveops-analytics :8089) → GET /api/atms/paginated?size=500&page=0 (hiveops-devices :8096) → atms/atm_properties tables` + +The analytics service **forwards the caller's Bearer token** downstream (no service account). If the caller's token can't read devices, the tab degrades silently (see below). + +## First things to check when it misbehaves + +**Tab is empty / "No version data" / everything shows "unknown":** +1. `AgentInsightService.fetchAtmsPaginated()` **swallows all exceptions and returns an empty list** (`log.warn("AgentInsight: failed to fetch ATMs paginated: ...")`). So an empty tab = the upstream call failed or returned nothing. Check analytics logs (Loki, service `hiveiq-analytics`) for that warn line. +2. **Verify `INCIDENT_SERVICE_URL` points at a backend that serves `/api/atms/paginated`.** This endpoint is owned by **hiveops-devices** (extracted from incident ~2026-06-18). Analytics calls it through its `incidentWebClient` (default `http://hiveops-incident-backend:8080`). If that URL still points at plain incident (which no longer serves `/api/atms/paginated`), the call 404s → empty tab. This is the #1 thing to confirm on a fresh/mis-wired deploy. +3. Confirm the caller's JWT is valid and reaches devices (401/403 downstream also yields an empty list, not an error to the user). + +**"Latest" version looks wrong / all ATMs flagged outdated:** +- "Latest" is **not** the CDN release version. `findLatestSemver()` picks the **highest semver already present in the fleet**. If no ATM is on the newest release yet, "latest" lags reality. +- The regex is strict: `\d+\.\d+\.\d+` only. Any version with a suffix/prefix (`v4.4.3`, `4.4.3-patch`, `4.4.3-standard`) **fails to parse** → `latestVersion` becomes `"unknown"` → **every** ATM is flagged outdated. If the whole fleet suddenly shows outdated, check whether agent version strings picked up a suffix. + +**Counts look short on a large fleet:** +- The upstream call is hard-coded to `size=500, page=0`. **Only the first 500 ATMs are considered.** Fleets over 500 devices under-report version distribution, connection counts, and outdated lists. This is a code limitation, not a config knob. + +**Connection status disagrees with the Devices app:** +- Status is computed in **hiveops-devices** (`calculateConnectionStatus`): `NEVER_CONNECTED` if `lastHeartbeat` is null; `CONNECTED` if last heartbeat ≤ **15 minutes** ago; else `DISCONNECTED`. (Note: older docs say "5 min" — the code uses **15**.) The Agents tab just reflects whatever devices returns, so reconcile at the Devices source, not here. + +## Common failure modes → fix + +| Symptom | Likely cause | Fix | +|---------|--------------|-----| +| Empty tab, `AgentInsight: failed to fetch` in logs | `INCIDENT_SERVICE_URL` not pointing at a backend serving `/api/atms/paginated` (devices), or downstream 401/403 | Repoint env to devices backend; verify JWT reaches devices | +| All ATMs "outdated", latest = `unknown` | Agent version strings not bare `x.y.z` semver | Confirm agent version format; the semver regex is strict | +| Version/connection counts too low | Fleet > 500 devices; only page 0 fetched | Known limitation (see uncertainties) — do not trust totals on large fleets | +| Status differs from Devices app | Different heartbeat window / stale mirror | Devices is source of truth; this tab is downstream-only | + +## Related + +- Customer-facing description: [analytics.agents] Overview tab. +- Service internals: [technical.analytics]. diff --git a/backend/src/main/resources/content/analytics/cash/architect.md b/backend/src/main/resources/content/analytics/cash/architect.md new file mode 100644 index 0000000..fbfa396 --- /dev/null +++ b/backend/src/main/resources/content/analytics/cash/architect.md @@ -0,0 +1,85 @@ +--- +module: analytics.cash +title: Cash Intelligence +tab: Architect +order: 30 +audience: internal +--- + +> **Internal · architecture.** Grounded in `hiveops-analytics` + `hiveops-recon` source, 2026-07-01. Key point: Cash Intelligence is a **synchronous fan-out**, not the usual executor→Kafka→record-store pattern. Analytics stores nothing for this feature and consumes no Kafka topic to build it. + +## Services involved +| Service | Role in this feature | +|---------|----------------------| +| **hiveops-analytics** (port 8089, `analytics.bcos.cloud`) | Serves the tab. Owns the frontend + a thin transform. **No cash data of its own.** | +| **hiveops-recon** (port 8091, prod ext 8013, `recon.bcos.cloud`) | Source of truth for all cash/reconciliation data. Owns DB `hiveiq_recon`. | +| **hiveops-incident** | Called *by recon* (not analytics) to enumerate all fleet ATM IDs for admin/full-fleet views. | +| **hiveops-journal** | Upstream of recon's cash math (withdrawals/deposits/replenishments); not touched at cash-intelligence request time. | + +## Data flow (request time, fully synchronous) +``` +Browser (CashIntelligenceTab.svelte) + │ GET /api/analytics/cash-intelligence (Bearer JWT) + ▼ +hiveops-analytics · CashIntelligenceController + │ extracts raw bearer token from Authorization header + ▼ +CashIntelligenceService.buildCashIntelligence(token) + │ reconWebClient (baseUrl services.recon.url = http://hiveiq-recon:8091) + │ GET /api/recon/fleet/summary (forwards caller's "Bearer ") + ▼ +hiveops-recon · SummaryController.fleetSummary() + │ InstitutionContext.resolveScope() → institution list or null (full fleet) + │ reads atm_cash_positions (scoped) + reconciliation_cycles (per position) + │ admin/full-fleet only: incidentFetchService.fetchAllAtmIds() → merge all ATMs + │ applies recon device-selection filter (recon_device_selection) + │ counts replenishment_sessions in PENDING_CONFIRMATION + ▼ FleetSummaryResponse{totalAtms, balancedCycles, varianceCycles, openCycles, + │ pendingConfirmations, atms[AtmSummaryResponse]} + ▲ +CashIntelligenceService (transform, in-memory, no persistence) + │ near-empty = atms where projectedBalanceCents < 1_000_000, asc + │ variance = atms where abs(varianceCents) > 0, desc by |variance| + ▼ CashIntelligenceResponse +Browser renders 4 stat cards + 2 tables +``` + +## No Kafka on this path +- The **cash-intelligence request itself uses no Kafka topic and no consumer.** Do not look for an `analytics.*` cash topic — there isn't one. +- The broader analytics service *does* run Kafka (snapshot/insight pipeline: `analytics.snapshot-ready`, consumers of `hiveops.incidents.*` / `journal.file-parsed`), but **Cash Intelligence bypasses all of it** and reads recon live. See [technical.analytics] for that separate machinery. Cross-link [platform.kafka]. +- Recon's own cash figures are built asynchronously (journal sync + replenishment cycles) *before* this request; by read time they are already materialised in `hiveiq_recon`. + +## DB tables +Analytics reads/writes **no table** for this feature. All reads happen in recon against **`hiveiq_recon`**: + +| Table | Read here for | Key columns used | +|-------|---------------|------------------| +| `atm_cash_positions` | per-ATM balances + cycle pointer | `atm_id`, `institution`, `current_cycle_id`, `projected_balance_cents`, `last_replenishment_at`, `journal_format` | +| `reconciliation_cycles` | cycle status + variance | `status` (OPEN / PENDING_RECONCILIATION / BALANCED / VARIANCE / UNRECONCILED), `variance_cents`, `synced_at`, `cycle_start` | +| `replenishment_sessions` | pending-confirmation count | `status` = `PENDING_CONFIRMATION` | + +Only `BALANCED`, `VARIANCE`, `OPEN` cycle statuses contribute to the three cycle counts; other statuses fall through `default -> {}`. Cross-link [platform.data-architecture]. + +## Key API endpoints +| Method | Path | Service | Auth | Notes | +|--------|------|---------|------|-------| +| GET | `/api/analytics/cash-intelligence` | analytics | authenticated JWT (no role gate) | The tab's only call | +| GET | `/api/recon/fleet/summary` | recon | authenticated JWT | Upstream source; institution-scoped internally | +| GET | `/api/recon/fleet/pending` | recon | authenticated JWT | Related, not used by this tab | +| GET | `/api/recon/atm/{atmId}/summary` | recon | authenticated JWT | Per-ATM detail, not used by this tab | + +## Auth & scoping model +- Analytics has **no service account**: it forwards the **caller's** bearer JWT verbatim to recon (`Authorization: Bearer `). If the user's token is invalid/expired at recon, the tab empties. +- Scoping is entirely a **recon** concern via `InstitutionContext.resolveScope()`: + - `ROLE_CUSTOMER` / `ROLE_MSP_ADMIN` → scoped to JWT credentials (their institution ATMs only, existing recon positions only). + - anything else (e.g. `BCOS_ADMIN`) → `null` → full fleet, with all ATM IDs merged from incident. +- Cross-link [platform.service-ownership]: cash reconciliation is recon's domain; analytics is a presentation layer that must not persist or mutate cash state. + +## Configuration surface +| Property / env | Default | Effect | +|----------------|---------|--------| +| `services.recon.url` (Spring; override via `SERVICES_RECON_URL`) | `http://hiveiq-recon:8091` | recon base URL. **Not wired to `RECON_SERVICE_URL`** in analytics `application.yml`. | +| WebClient `maxInMemorySize` | 16 MiB | caps the recon response the analytics WebClient will buffer | + +## Failure semantics (by design) +- Recon error / timeout → `CashIntelligenceService` catches, logs a warn, returns `Map.of()` → **degrades to all-zero response** rather than surfacing an error to the UI. Freshness/health of this tab therefore depends entirely on recon being reachable and the JWT being accepted. diff --git a/backend/src/main/resources/content/analytics/cash/claude.md b/backend/src/main/resources/content/analytics/cash/claude.md new file mode 100644 index 0000000..349d3cc --- /dev/null +++ b/backend/src/main/resources/content/analytics/cash/claude.md @@ -0,0 +1,65 @@ +--- +module: analytics.cash +title: Cash Intelligence +tab: Claude +order: 40 +audience: internal +--- + +> Terse agent reference. Cash Intelligence = **read-only pass-through**: analytics re-shapes one recon call. Analytics owns NO cash data. + +## Ownership +- **UI + endpoint owner:** `hiveops-analytics` (port 8089, `analytics.bcos.cloud`). +- **Data owner (source of truth):** `hiveops-recon` (port 8091, prod ext 8013, DB `hiveiq_recon`). +- Analytics persists nothing and consumes no Kafka for this feature. + +## Endpoints (METHOD + path) +| Method | Path | Service | Auth | +|--------|------|---------|------| +| GET | `/api/analytics/cash-intelligence` | analytics | JWT authenticated, **no role gate** | +| GET | `/api/recon/fleet/summary` | recon (upstream) | JWT authenticated; institution-scoped internally | + +- Frontend base: `window.__APP_CONFIG__.apiUrl` else `http://localhost:8089/api/analytics`. +- Analytics → recon base URL: property `services.recon.url`, default `http://hiveiq-recon:8091`. Caller JWT forwarded verbatim. + +## Response shape +- Analytics `CashIntelligenceResponse`: `totalAtms, balancedCount, varianceCount, openCount, pendingConfirmationsCount, atmsNearEmpty[], atmsWithVariance[]`. +- `atmsNearEmpty` item: `{atmId, projectedBalanceCents, lastReplenishmentAt}`. +- `atmsWithVariance` item: `{atmId, varianceCents, cycleStatus}`. +- Recon `FleetSummaryResponse`: `totalAtms, balancedCycles, varianceCycles, openCycles, pendingConfirmations, atms[]`. + +## Transform rules (in analytics) +- Near-empty filter: `projectedBalanceCents < 1_000_000` (< $10,000), sort asc. +- Variance filter: `abs(varianceCents) > 0`, sort desc by `|variance|`. +- `cycleStatus` maps from recon field `currentCycleStatus`. +- Frontend red-row highlight (visual only): `< 300_000` cents (< $3,000). + +## Tables (all in `hiveiq_recon`, read by recon only) +| Table | Purpose | +|-------|---------| +| `atm_cash_positions` | balances, `current_cycle_id`, `institution`, `projected_balance_cents`, `last_replenishment_at` | +| `reconciliation_cycles` | `status`, `variance_cents`, `synced_at` | +| `replenishment_sessions` | pending count (`status = PENDING_CONFIRMATION`) | + +- Cycle `Status` enum: `OPEN, PENDING_RECONCILIATION, BALANCED, VARIANCE, UNRECONCILED`. Only `BALANCED/VARIANCE/OPEN` counted. + +## Topics +- **None** for cash-intelligence. (Analytics' snapshot/insight Kafka is unrelated — do not attribute it to this feature.) + +## Scoping (in recon `InstitutionContext.resolveScope()`) +- `ROLE_CUSTOMER` / `ROLE_MSP_ADMIN` → scoped to JWT institutions; only ATMs with existing recon positions. +- other roles (e.g. `BCOS_ADMIN`) → null → full fleet; recon merges all ATM IDs from hiveops-incident. +- recon device-selection filter (`recon_device_selection`) can further narrow results. + +## Gotchas +- Recon fetch failure → analytics catches, logs `CashIntelligence: failed to fetch fleet summary from recon:`, returns empty map → **all-zero tab, no error surfaced**. Empty tab == recon problem, not analytics. +- recon URL override is `SERVICES_RECON_URL` (Spring relaxed binding), **NOT** `RECON_SERVICE_URL` (that env is unmapped in analytics `application.yml`). +- Null `projectedBalanceCents` coerced to `0` → ATM appears in Near Empty at `$0.00` (admin full-fleet view includes position-less ATMs). +- Cents are integers; frontend divides by 100. Negative variance = short cash. + +## Do NOT +- Do NOT add cash endpoints/tables to analytics — cash domain belongs to recon ([platform.service-ownership]). +- Do NOT look for a Kafka topic feeding this tab; it's a synchronous recon call. +- Do NOT assume a role gate on `/api/analytics/cash-intelligence` — any authenticated JWT reaches it (scoping happens downstream in recon). +- Do NOT set `RECON_SERVICE_URL` expecting it to change the recon target; use `SERVICES_RECON_URL`. +- Do NOT expect data for ATMs never onboarded in Recon (scoped users won't see them). diff --git a/backend/src/main/resources/content/analytics/cash/internal.md b/backend/src/main/resources/content/analytics/cash/internal.md new file mode 100644 index 0000000..10c20e1 --- /dev/null +++ b/backend/src/main/resources/content/analytics/cash/internal.md @@ -0,0 +1,62 @@ +--- +module: analytics.cash +title: Cash Intelligence +tab: Internal +order: 20 +audience: internal +--- + +> **Internal · ops/support.** Written 2026-07-01, grounded in `hiveops-analytics` + `hiveops-recon` source. The Cash Intelligence tab is a **read-only pass-through**: analytics owns no cash data — it re-shapes one call to **hiveops-recon**. When it looks wrong, the fix is almost always in recon or the analytics→recon link, not in analytics itself. + +## What it actually is +- Frontend `CashIntelligenceTab.svelte` (in `hiveops-analytics/frontend`) calls **`GET /api/analytics/cash-intelligence`**. +- `CashIntelligenceService` forwards the caller's JWT and calls **`GET /api/recon/fleet/summary`** on **hiveops-recon**. +- The four counts (Balanced / Variance / Open Cycle / Pending Conf.) and both tables (Near Empty, With Variance) are derived entirely from recon's `FleetSummaryResponse`. No analytics DB, no Kafka on this path. + +## Roles required +- **Cash Intelligence endpoint (`/api/analytics/cash-intelligence`): authenticated only — no role gate.** `SecurityConfig` permits `/actuator/health` + `/api/public/**`, everything else just needs a valid JWT. +- **Recon `/api/recon/fleet/summary`: authenticated only** as well. +- **Institution scoping is enforced in recon, by role:** + - `CUSTOMER` and `MSP_ADMIN` → **scoped** to the institutions in their JWT credentials. They see only ATMs that already have a recon cash position in their institution(s). + - Non-scoped roles (e.g. `BCOS_ADMIN`) → **full fleet**: recon merges the complete ATM list from hiveops-incident so every ATM appears, even those with no recon position yet. +- There is **no admin-only mutating action** on this tab — it is view-only. Cash data is created/edited in the **Recon** app (custodian replenishments), not here. + +## First things to check when it misbehaves + +**Tab is all zeros / both tables empty** +1. This is the signature of a **recon fetch failure**. `CashIntelligenceService.fetchFleetSummary()` swallows any error and returns an empty map → every count is 0 and both lists empty. +2. Check analytics logs for: `CashIntelligence: failed to fetch fleet summary from recon:` (Loki, service `hiveiq-analytics`). The message tail is the real cause. +3. Common causes: recon container down, wrong recon URL (see below), or the caller's JWT rejected by recon. +4. Confirm recon directly: `GET /api/recon/fleet/summary` with the same JWT should return `{totalAtms, balancedCycles, varianceCycles, openCycles, pendingConfirmations, atms[]}`. + +**Wrong recon hostname (silent empty tab)** +- Analytics resolves recon via property `services.recon.url`, default **`http://hiveiq-recon:8091`**. +- The analytics `application.yml` `services:` block only wires `incident`, `journal`, `mgmt` — **recon is NOT mapped to an env var there.** So a `RECON_SERVICE_URL` env var does nothing; only Spring relaxed binding **`SERVICES_RECON_URL`** overrides it. +- If recon runs under a different container name than `hiveiq-recon:8091`, the tab silently empties. Verify container name and this property first. + +**A customer/MSP admin sees fewer ATMs than expected** +- Expected: scoped users only see ATMs with an existing recon position in their institution. An ATM that has never had a replenishment recorded in Recon will not appear for them. +- A **device selection filter** in recon further narrows the list: if a non-empty saved selection exists for that scope, only selected ATMs are returned. + +**An ATM shows in "Near Empty" at $0.00 that shouldn't** +- For full-fleet (admin) views, recon includes ATMs with **no cash position** (`null`). Analytics coerces a null `projectedBalanceCents` to `0`, so such ATMs land in Near Empty at `$0.00`. This is expected given the current code, not a data-loss bug — it means "no recon cycle has ever run for this ATM." (See uncertainties.) + +**Counts don't match the tables** +- Counts come straight from recon (`balancedCycles`, etc.); the tables are re-filtered/re-sorted in analytics. Thresholds differ intentionally: + - **Near Empty** list = ATMs with projected balance **< $10,000** (`< 1,000,000` cents), lowest first. + - Frontend **red-row** highlight = **< $3,000** (`< 300,000` cents) — a subset, purely visual. + - **With Variance** list = any ATM with a non-zero variance, largest absolute first. + +## Common failure modes → fix +| Symptom | Likely cause | Fix | +|--------|--------------|-----| +| All counts 0, both tables empty | recon down / unreachable / auth-rejected | Check analytics log warn line; restart/verify `hiveiq-recon`; test recon endpoint with the JWT | +| Empty tab only in prod, fine in dev | recon URL not resolving to the prod container | Set `SERVICES_RECON_URL` (not `RECON_SERVICE_URL`) or fix container name to `hiveiq-recon:8091` | +| Scoped user sees too few ATMs | no recon position for their ATMs, or device selection filter active | Confirm replenishments exist in Recon; clear the recon device selection | +| Ghost `$0.00` ATMs in Near Empty | fleet ATMs without a recon cycle (admin view) | Expected; onboard those ATMs into Recon or ignore | +| Variance/Balanced numbers stale | recon cycle not synced | Trigger recon `POST /api/recon/atm/{atmId}/sync`; cash positions update on replenishment/sync only | + +## Cross-links +- Recon service internals: see `hiveops-recon/CLAUDE.md`. +- Analytics service overview: [technical.analytics]. +- [platform.service-ownership] — cash reconciliation domain is owned by **recon**, surfaced by **analytics**. diff --git a/backend/src/main/resources/content/analytics/fleet-health/architect.md b/backend/src/main/resources/content/analytics/fleet-health/architect.md new file mode 100644 index 0000000..2749f0b --- /dev/null +++ b/backend/src/main/resources/content/analytics/fleet-health/architect.md @@ -0,0 +1,81 @@ +--- +module: analytics.fleet-health +title: Fleet Health +tab: Architect +order: 30 +audience: internal +--- + +> **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]. diff --git a/backend/src/main/resources/content/analytics/fleet-health/claude.md b/backend/src/main/resources/content/analytics/fleet-health/claude.md new file mode 100644 index 0000000..4467e98 --- /dev/null +++ b/backend/src/main/resources/content/analytics/fleet-health/claude.md @@ -0,0 +1,61 @@ +--- +module: analytics.fleet-health +title: Fleet Health +tab: Claude +order: 40 +audience: internal +--- + +> Act-without-guessing reference. All facts from `FleetHealthController`/`FleetHealthService`/`SecurityConfig` (hiveops-analytics) + `FleetHealthTab.svelte`/`api.ts`. + +## Owner +- **hiveops-analytics** owns `GET /api/analytics/fleet-health` (port **8089**, DB `hiveiq_analytics` but **not used by this endpoint**). +- Live synchronous aggregation. No snapshot, no cache, no Kafka, no DB read/write for this tab. + +## Endpoints +| Method | Path | Service | Auth | +|--------|------|---------|------| +| GET | `/api/analytics/fleet-health` | analytics | JWT, any authenticated role | +| GET | `/api/atms/summary` | incident | JWT (forwarded) | +| GET | `/api/journal-events/activity?hoursBack=24` | incident | JWT (forwarded) | +| GET | `/api/incidents/status/open` | incident | JWT (forwarded) | +| GET | `/api/journal/gaps?status=OPEN` | journal | JWT (forwarded) | + +Frontend call: `analyticsAPI.getFleetHealth()` → `GET {apiBase}/fleet-health`; `apiBase` = `window.__APP_CONFIG__.apiUrl` || `http://localhost:8089/api/analytics`. + +## Roles +- Endpoint gate: `.anyRequest().authenticated()` — **no `hasAnyRole`**. Not admin-only. +- `permitAll()`: only `/actuator/health`, `/api/public/**`. +- Role-gated in analytics = **only** `POST /api/analytics/insights` (`hasAnyRole('MSP_ADMIN','BCOS_ADMIN')`) — a different feature. + +## Tables / topics +- Tables read/written by this feature: **none**. +- Kafka topics in this feature's path: **none**. +- (Context only, other tabs: DB `analytics_snapshots`, `ai_insights`; topic `analytics.snapshot-ready`.) + +## Response fields (`FleetHealthResponse`) +- `totalAtms, connectedAtms, disconnectedAtms, neverConnectedAtms, connectivityPct` +- `openIncidents` (= size of open-incidents list) +- `eventsByCategory: Map` (24h window, always seeded) +- `topProblematicAtms: [{atmId, eventCount}]` (top 5 by 24h event count; `deviceId` alias in frontend) +- `atmsWithOpenGaps` (distinct atmId count, status=OPEN) + +## Categories (from `classifyEventType`) +- Emitted set: `HARDWARE, CASH, NETWORK, POWER, AUTH, SYSTEM, OTHER`. +- `POWER_OFF`→POWER · cassette*→CASH · network*→NETWORK · `AUTHENTICATION_FAILURE`→AUTH · reader/dispenser/app-error-hw→HARDWARE · out-of-service/app-error/ejournal-db→SYSTEM · unmatched→OTHER. + +## Gotchas +- Downstream failures are **swallowed** (try/catch → log `FleetHealth: failed to fetch …` + empty). **200 with all-zeros = broken dependency, not healthy fleet.** +- Event breakdown + top problematic are a **fixed 24h window** (`hoursBack=24`, hardcoded). Not configurable via request. +- `eventsByCategory` is **always seeded with 7 keys at 0**, so the map is never empty → frontend "No events recorded" empty-state effectively never triggers. +- **No `OPERATIONS` backend category** despite the frontend color map / customer copy mentioning "Operations"; such events land in `OTHER`. +- Journal gaps come from **hiveops-journal** (`/api/journal/gaps`), everything else from **hiveops-incident**. +- Downstream hosts are env-driven: `INCIDENT_SERVICE_URL` (def `http://hiveops-incident-backend:8080`), `JOURNAL_SERVICE_URL` (def `http://hiveops-journal:8087`). + +## Do NOT +- Do NOT claim this endpoint needs MSP_ADMIN/BCOS_ADMIN — it only needs a valid JWT. +- Do NOT say Fleet Health reads snapshots / Kafka / the analytics DB — it calls incident+journal live. +- Do NOT add an incident/gap/device write here — analytics owns aggregation only; incident/journal own the records ([platform.service-ownership]). +- Do NOT treat a 200 all-zeros response as "fleet is fine" — check analytics logs first. +- Do NOT assume events older than 24h are counted — they are not. +- Do NOT invent an `OPERATIONS` category in backend logic; unmatched → `OTHER`. diff --git a/backend/src/main/resources/content/analytics/fleet-health/internal.md b/backend/src/main/resources/content/analytics/fleet-health/internal.md new file mode 100644 index 0000000..7e5de28 --- /dev/null +++ b/backend/src/main/resources/content/analytics/fleet-health/internal.md @@ -0,0 +1,56 @@ +--- +module: analytics.fleet-health +title: Fleet Health +tab: Internal +order: 20 +audience: internal +--- + +> **Ops / Support / Admin view.** How to keep the Fleet Health screen honest and what to check when it lies. Grounded in `hiveops-analytics` source (`FleetHealthController`, `FleetHealthService`, `SecurityConfig`). + +## What actually serves this screen + +- Endpoint: **`GET /api/analytics/fleet-health`** on **hiveops-analytics** (port **8089**). +- It is **computed live on every request** — no analytics DB, no cache, no snapshot for this tab. Each load fans out to hiveops-incident and hiveops-journal and aggregates in-memory. +- The user's **JWT is forwarded** to those downstream services. If the caller's token can't see a device/incident, neither can this screen. Institution scoping is inherited from downstream, not enforced here. + +## Roles required + +- **Any authenticated JWT** can read fleet-health. `SecurityConfig` only has `permitAll()` on `/actuator/health` + `/api/public/**`; everything else is `.anyRequest().authenticated()`. +- There is **no `@PreAuthorize` / role gate** on this endpoint — it is *not* admin-only. Do not tell a customer "you need MSP_ADMIN to see it." (Role gates in analytics apply only to `POST /api/analytics/insights`.) + +## First things to check when it misbehaves + +1. **Whole screen empty / zeros everywhere** → the caller's JWT likely can't reach downstream, or a downstream service is down. Every fetch in `FleetHealthService` is wrapped in try/catch that **logs a warning and returns empty** — so a dead dependency produces silent zeros, not an error. Check analytics logs for: + - `FleetHealth: failed to fetch ATM summary` + - `FleetHealth: failed to fetch activity events` + - `FleetHealth: failed to fetch open incidents` + - `FleetHealth: failed to fetch open gaps` +2. **Connectivity donut wrong / total = 0** → `GET /api/atms/summary` on hiveops-incident is failing or returning `{}`. Curl it directly with the same JWT. +3. **Event Breakdown / Top Problematic empty** → `GET /api/journal-events/activity?hoursBack=24` on hiveops-incident. Note the **fixed 24-hour window** — anything older than 24h never appears here, by design. +4. **"ATMs with Journal Gaps" = 0 but you expect gaps** → `GET /api/journal/gaps?status=OPEN` on hiveops-journal. Only gaps with status `OPEN` are counted; distinct `atmId` values. +5. **Open Incidents count off** → `GET /api/incidents/status/open` on hiveops-incident. The number shown is simply `openIncidents.size()` of that list. + +## Common failure modes → fixes + +| Symptom | Likely cause | Fix | +|---------|-------------|-----| +| All cards zero, no error toast | A downstream call threw and was swallowed | Check analytics logs for the four `FleetHealth: failed to fetch…` warnings; restart/repair the named service | +| Donut total looks stale | incident `/api/atms/summary` cached/stale upstream | Investigate hiveops-incident, not analytics — analytics holds no state here | +| Event breakdown shows categories with **0** events | Backend seeds HARDWARE/CASH/NETWORK/POWER/AUTH/SYSTEM/OTHER at 0 (see architect tab) | Expected; not a data loss. The "No events recorded" empty-state effectively never fires | +| An event type shows under **OTHER** unexpectedly | `classifyEventType` switch has no case for it | Add the mapping in `FleetHealthService.classifyEventType` (see architect tab) | +| 401/403 on the endpoint | Missing/expired JWT | Re-auth; confirm `Authorization: Bearer` reaches analytics through NGINX | +| Downstream URL wrong after infra change | `INCIDENT_SERVICE_URL` / `JOURNAL_SERVICE_URL` env misconfigured | Fix env on the analytics container; defaults are `http://hiveops-incident-backend:8080` and `http://hiveops-journal:8087` | + +## Admin actions + +- There are **no admin mutation actions** on this screen. It is read-only. Nothing to "reset," acknowledge, or re-run — a browser refresh re-computes from live sources. +- To change what counts as Hardware/Cash/Network/etc., that is a **code change** in `classifyEventType`, not a config toggle. + +## Smoke test + +```bash +curl -s https://analytics.bcos.cloud/api/analytics/fleet-health \ + -H "Authorization: Bearer $JWT" | jq '{totalAtms,connectedAtms,openIncidents,atmsWithOpenGaps,eventsByCategory}' +``` +Expect HTTP 200 and a parseable body. Zeros with 200 = a swallowed downstream failure, not success — check logs. diff --git a/backend/src/main/resources/content/analytics/incidents/architect.md b/backend/src/main/resources/content/analytics/incidents/architect.md new file mode 100644 index 0000000..129327c --- /dev/null +++ b/backend/src/main/resources/content/analytics/incidents/architect.md @@ -0,0 +1,72 @@ +--- +module: analytics.incidents +title: Incident Trends +tab: Architect +order: 30 +audience: internal +--- + +> How the **Incident Trends** tab is built. Grounded in `hiveops-analytics` + `hiveops-incident` source, 2026-07-01. Cross-link [platform.data-architecture], [platform.kafka], [platform.service-ownership]. + +## Services involved + +| Service | Role for this feature | +|---------|-----------------------| +| `hiveops-analytics` (port 8089) | Owns the tab endpoint; aggregates/shapes the 4 charts in-memory. No DB write for this feature. | +| `hiveops-incident` (port 8080) | Owns the data (`journal_events`) and applies institution scoping. | +| Frontend (`hiveops-analytics/frontend`) | Svelte SPA renders `IncidentTrendsTab.svelte` from one JSON payload. | + +Ownership boundary (see [platform.service-ownership]): analytics **computes**, incident **owns the journal-event data**. Analytics holds no incident/journal tables of its own for this tab. + +## Data flow (synchronous, per request) + +``` +IncidentTrendsTab.svelte + └─ analyticsAPI.getIncidentTrends() + └─ GET {apiBase}/incident-trends → IncidentTrendsController (analytics) + └─ IncidentTrendsService.buildIncidentTrends(token) + └─ incidentWebClient.get() + GET /api/journal-events/activity?hoursBack=24 → JournalEventController (incident) + └─ JournalEventService.getActivityEvents(24, InstitutionContext.resolveScope()) + └─ JournalEventRepository.findActivityEvents / findActivityEventsByInstitutionIn + └─ SELECT from journal_events (joined to atms) +``` + +- **Auth propagation:** the caller's `Authorization: Bearer ` is extracted in the controller and re-sent on the WebClient call. No service account; downstream scoping keys off that token. +- **`incidentWebClient`** is the `@Qualifier("incidentWebClient")` bean; base URL from `INCIDENT_SERVICE_URL` (default `http://hiveops-incident-backend:8080`). + +## No Kafka / no snapshot store on this path + +This is the key architectural fact: **Incident Trends bypasses the analytics executor→Kafka→record-store pattern entirely.** It does *not* read `analytics_snapshots` or `ai_insights`, and it does *not* rely on the `IncidentEventConsumer` (`hiveops.incidents.created/updated/resolved`) or `JournalFileParsedConsumer` (`journal.file-parsed`) that feed the snapshot scheduler. Those exist in the service for the dashboard **Overview** snapshots, but the trends tab is a fresh live pull each time. Cross-link [platform.kafka] for the topic catalog used elsewhere in analytics. + +## DB tables + +| Table | Service | Access | +|-------|---------|--------| +| `journal_events` | hiveops-incident (`hiveiq_incident`) | **read** — `findActivityEvents(ACTIVITY_TYPES, since, institution)` filtered to a curated `ACTIVITY_TYPES` set and `event_time >= now-24h` | +| `atms` | hiveops-incident | **read** — joined for `atmName`/`location`, and drives institution scoping | + +Analytics writes nothing for this tab. (`analytics_snapshots`, `ai_insights` in `hiveiq_analytics` are unused here.) See [platform.data-architecture]. + +## In-memory aggregation (analytics) + +From the single `List`: +- **dailyCounts** — bucketed by date; seeded with **today only** (single bucket). +- **byCategory** — `FleetHealthService.classifyEventType(eventType)` → `HARDWARE | CASH | NETWORK | POWER | AUTH | SYSTEM | OTHER`. +- **recurringFailures** — group by `atmId|eventType` over last 24h, keep `count ≥ 3`, sort desc, top 20. +- **peakHours** — `int[7][24]`, Mon=row 0, by `eventTime` day-of-week × hour (UTC). + +## Key API endpoints + +| Method | Path | Service | Auth | +|--------|------|---------|------| +| GET | `/api/analytics/incident-trends` | analytics | authenticated JWT (no role gate) | +| GET | `/api/journal-events/activity?hoursBack=24` | incident | authenticated JWT; institution-scoped | + +## Config knobs + +- `INCIDENT_SERVICE_URL` (analytics) — downstream base URL. +- `JWT_SECRET` — must match across services for the forwarded token to validate. +- `hoursBack` is **hard-coded to 24** in `IncidentTrendsService`; not env-configurable. + +See the Internal tab for failure modes and the Overview tab for the customer-facing description. diff --git a/backend/src/main/resources/content/analytics/incidents/claude.md b/backend/src/main/resources/content/analytics/incidents/claude.md new file mode 100644 index 0000000..137443f --- /dev/null +++ b/backend/src/main/resources/content/analytics/incidents/claude.md @@ -0,0 +1,60 @@ +--- +module: analytics.incidents +title: Incident Trends +tab: Claude +order: 40 +audience: internal +--- + +> Terse agent reference. All facts confirmed in source 2026-07-01. `hiveops-ai` = "Adoons". + +## Ownership +- **Endpoint owner:** `hiveops-analytics` (port 8089). +- **Data owner:** `hiveops-incident` (port 8080), table `journal_events`. +- This tab is a **live synchronous pull**, not a snapshot/Kafka read. + +## Endpoints (METHOD + path) +| Method | Path | Service | Auth | +|--------|------|---------|------| +| GET | `/api/analytics/incident-trends` | analytics | JWT, **no role gate** | +| GET | `/api/journal-events/activity?hoursBack=24` | incident | JWT, institution-scoped | + +- Frontend call: `analyticsAPI.getIncidentTrends()` → `GET {apiBase}/incident-trends`. +- Analytics forwards caller `Bearer` token verbatim to incident. No service account. + +## Tables +- `journal_events` (read) — `hiveiq_incident` DB. +- `atms` (read, join + institution scope) — `hiveiq_incident` DB. +- **Not used by this tab:** `analytics_snapshots`, `ai_insights` (`hiveiq_analytics`). + +## Kafka +- **None on this path.** (`hiveops.incidents.*`, `journal.file-parsed`, `analytics.snapshot-ready` feed the snapshot tab, not this one.) + +## Response shape (`IncidentTrendsResponse`) +- `dailyCounts: [{date, count}]` — seeded today-only. +- `byCategory: {HARDWARE,CASH,NETWORK,POWER,AUTH,SYSTEM,OTHER: n}`. +- `recurringFailures: [{atmId, eventType, count}]` — 24h, count≥3, top 20. +- `peakHours: int[7][24]` — Mon=0, UTC hour. + +## Roles / scoping +- `CUSTOMER` / `MSP_ADMIN` → scoped to granted institution keys (downstream `InstitutionContext.resolveScope()`). +- `BCOS_ADMIN` / `ADMIN` / `QDS_ADMIN` / `USER` → unrestricted. + +## Config +- `INCIDENT_SERVICE_URL` default `http://hiveops-incident-backend:8080`. +- `hoursBack` hard-coded = 24 (not env-tunable). + +## Gotchas (confirmed) +- Window is **24h**, despite chart titled "30-DAY INCIDENT TREND". +- `dailyCounts` always length 1 → frontend renders "Not enough data" (needs ≥2). +- `recurringFailures` always empty: service reads `atmId` as String but DTO serializes it as numeric `Long` → skipped. +- No "OPERATIONS" category emitted; operational types → `OTHER`. +- Downstream failure is swallowed → whole tab silently empties (grep analytics log `IncidentTrends: failed to fetch activity events`). + +## Do NOT +- Do NOT query `analytics_snapshots`/`ai_insights` for this tab — wrong source. +- Do NOT expect this tab to read Kafka — it's a per-request HTTP pull. +- Do NOT assume role-gating on `/api/analytics/incident-trends` — any authenticated JWT passes. +- Do NOT add incident/journal endpoints to analytics — analytics computes, incident owns the data ([platform.service-ownership]). +- Do NOT trust "0 recurring failures" as fleet health — it's the atmId-type bug. +- Do NOT hand analytics a service token expecting broader data — scope follows the caller's JWT. diff --git a/backend/src/main/resources/content/analytics/incidents/internal.md b/backend/src/main/resources/content/analytics/incidents/internal.md new file mode 100644 index 0000000..440b209 --- /dev/null +++ b/backend/src/main/resources/content/analytics/incidents/internal.md @@ -0,0 +1,55 @@ +--- +module: analytics.incidents +title: Incident Trends +tab: Internal +order: 20 +audience: internal +--- + +> Ops/support view for the **Incident Trends** tab (analytics.incidents). Grounded in `hiveops-analytics` + `hiveops-incident` source, 2026-07-01. + +## What actually powers this screen + +The tab is **not** backed by the analytics snapshot store. It is a **live, synchronous pass-through**: + +``` +Browser → GET /api/analytics/incident-trends (hiveops-analytics) + → GET /api/journal-events/activity?hoursBack=24 (hiveops-incident) + → journal_events table +``` + +`IncidentTrendsService` forwards the **caller's own JWT** downstream and computes the 4 charts in-memory on every request. No caching, no DB write, no Kafka for this feature. If the incident service is down or slow, this tab is empty — nothing here reads analytics-local tables. + +## Roles / access + +- The endpoint has **no `@PreAuthorize`** — any authenticated JWT can call it. There is no MSP_ADMIN/BCOS_ADMIN gate on Incident Trends. +- **Data is institution-scoped downstream.** `hiveops-incident` calls `InstitutionContext.resolveScope()` on the forwarded token: + - `ROLE_CUSTOMER` / `ROLE_MSP_ADMIN` → scoped to their granted institution keys (sees only their ATMs). + - `BCOS_ADMIN` / `ADMIN` / `QDS_ADMIN` / `USER` → unrestricted (all institutions). +- So a support user seeing "less data than expected" is usually a **scoped role**, not a bug. + +## First things to check when it misbehaves + +1. **Whole tab empty / "No data" everywhere** → the downstream call failed. `IncidentTrendsService.fetchActivityEvents` swallows exceptions and returns an empty list, logging `IncidentTrends: failed to fetch activity events: ...`. Check `hiveops-analytics` logs (Loki, service VM) for that line, then check `hiveops-incident` health. +2. **Analytics can't reach incident** → verify `INCIDENT_SERVICE_URL` (default `http://hiveops-incident-backend:8080`). Wrong/stale hostname = every trends call empties out. +3. **401/403 from downstream** → the forwarded JWT lacks incident access, or is expired. The user's token is passed verbatim; there is no service account. +4. **Customer sees "Not enough data" on the line chart** → see Known quirks; this is expected with current code, not an outage. +5. **Only recent stuff shows** → the window is **hard-coded to the last 24 hours** (`hoursBack=24`), despite the "30-Day" chart title. Older events are simply not fetched. + +## Known quirks (expected behavior of current build) + +- **30-Day Incident Trend line chart always shows "Not enough data."** `dailyCounts` is seeded with **today only**, so it always has exactly one point; the frontend requires ≥2 points to draw a line. See uncertainties — this is a real defect, not a config issue. +- **Recurring Failures table is effectively always empty.** The service reads `atmId` as a String, but the incident DTO serializes `atmId` as a numeric `Long`, so every event is skipped. Do not tell users "no recurring failures = healthy fleet" from this tab. (real bug — noted for engineering.) +- **Event Category Breakdown never shows an "Operations" bar.** Operational event types (`OPERATOR_ACTION`, etc.) classify to **OTHER**, not OPERATIONS, even though the customer copy lists Operations. Categories the backend actually emits: `HARDWARE`, `CASH`, `NETWORK`, `POWER`, `AUTH`, `SYSTEM`, `OTHER`. +- **Peak Hours Heatmap and Category bars are the reliable panels** — they only depend on `eventTime`/`eventType` (both strings) and work as intended over the 24h window. + +## Where to look + +| Concern | Location | +|---------|----------| +| Trends endpoint | `hiveops-analytics` → `controller/IncidentTrendsController.java`, `service/IncidentTrendsService.java` | +| Upstream data | `hiveops-incident` → `controller/JournalEventController.java` (`/activity`), `service/JournalEventService.java#getActivityEvents` | +| Category mapping | `hiveops-analytics` → `service/FleetHealthService.classifyEventType` | +| Frontend | `hiveops-analytics/frontend/src/components/tabs/IncidentTrendsTab.svelte` | + +See also [analytics.incidents] Overview (customer copy) and the Architect tab for data-flow detail. diff --git a/backend/src/main/resources/content/analytics/transactions/architect.md b/backend/src/main/resources/content/analytics/transactions/architect.md new file mode 100644 index 0000000..2388751 --- /dev/null +++ b/backend/src/main/resources/content/analytics/transactions/architect.md @@ -0,0 +1,76 @@ +--- +module: analytics.transactions +title: Transactions +tab: Architect +order: 30 +audience: internal +--- + +> How the **Transactions** tab is built. It is a **synchronous pull-through aggregation**, not an event-driven or snapshot-backed view. Cross-link [platform.service-ownership], [platform.data-architecture], [platform.kafka]. + +## Services involved + +| Service | Role for this feature | +|---------|----------------------| +| `hiveops-analytics-frontend` | Serves the dashboard SPA (`analytics.bcos.cloud`, port 5174). The Transactions tab = `JournalCoverageTab.svelte`. | +| `hiveops-analytics` (port 8089) | Aggregator. Exposes `GET /api/analytics/journal-coverage`, forwards the caller JWT to journal, reshapes 3 journal responses into one `JournalCoverageResponse`. **Owns no data for this tab.** | +| `hiveops-journal` (port 8087) | **Source of truth.** Owns `journal_transactions` and `journal_gaps` in DB `hiveiq_journal`. | + +Ownership boundary: transaction + gap data belongs exclusively to **hiveops-journal**; analytics is a read-only consumer. See [platform.service-ownership]. + +## Data flow (request path) + +``` +Browser (Transactions tab) + → GET /api/analytics/journal-coverage [hiveops-analytics : JournalCoverageController] + JournalCoverageService.buildJournalCoverage(callerToken) + via journalWebClient (JOURNAL_SERVICE_URL), forwarding "Bearer ": + ├─ GET /api/journal/transactions/summary?dateFrom=today&dateTo=today → {total, successful, failed} + ├─ GET /api/journal/gaps?status=OPEN → [gaps...] + └─ GET /api/journal/transactions/top-atms?dateFrom=today&dateTo=today&limit=10 → [{atmId, transactionCount}] + ← JournalCoverageResponse { dailyVolume[today], openGapsTotal, atmsWithGaps[≤20], parseSuccessRateToday, topAtmsByVolume } +``` + +Notes on the aggregation logic (`JournalCoverageService`): +- `dailyVolume` is built with **today only** (single call; the multi-bar chart therefore renders one bar). +- `parseSuccessRateToday = successful * 100.0 / total` (0 when `total == 0`). +- `atmsWithGaps` = open gaps grouped by `atmId`, counted, sorted desc, capped at 20 (UI slices to 10). +- `topAtmsByVolume` = journal's top-ATMs list (limit 10; journal hard-caps page size at 50). + +## Kafka — NOT in this request path + +This tab is a live synchronous pull; **no Kafka topic is read or written to serve `journal-coverage`.** For context on the surrounding platform (relevant to *other* analytics tabs, not this one): + +- hiveops-analytics consumes `journal.file-parsed` (`JournalFileParsedConsumer`), `hiveops.incidents.created/updated/resolved` (`IncidentEventConsumer`) to drive **snapshots** (`analytics_snapshots`) and produces `analytics.snapshot-ready`. +- hiveops-journal uses `journal.parse-requests.{INSTITUTION}` (worker parse) and produces `journal.file-parsed`. +- The Transactions tab does **not** read snapshots (`analytics_snapshots`) — it queries journal live every load. See [platform.kafka]. + +## DB tables + +Read (in `hiveiq_journal`, owned by hiveops-journal): + +| Table | Used for | Via | +|-------|----------|-----| +| `journal_transactions` | today's total / APPROVED / DECLINED counts; top ATMs by tx count | `JournalTransactionRepository.countByDateRange...`, `findTopAtmsByTransactionCount` | +| `journal_gaps` | open gap count + gaps-per-ATM | `/api/journal/gaps?status=OPEN` | + +Written: **none by this feature.** `journal_gaps` rows are created by journal's own scheduled **gap detection** job (daily 06:00 UTC): for each ATM, any date between its first journal and yesterday with no `journal_files` row becomes an OPEN gap row. `analytics_snapshots` / `ai_insights` in `hiveiq_analytics` are unrelated to this tab. + +See [platform.data-architecture] for the hot/cold split (`journal_transactions` vs `journal_transactions_archive`, 90-day boundary) — the today-only queries here always hit the hot table. + +## Key endpoints + +| Method | Path | Service | Auth | +|--------|------|---------|------| +| GET | `/api/analytics/journal-coverage` | analytics | authenticated JWT (no role gate) | +| GET | `/api/journal/transactions/summary?dateFrom=&dateTo=` | journal | JWT; institution-scoped | +| GET | `/api/journal/gaps?status=OPEN` | journal | `isAuthenticated()`; institution-scoped | +| GET | `/api/journal/transactions/top-atms?dateFrom=&dateTo=&limit=` | journal | `isAuthenticated()`; page size capped 50 | +| PUT | `/api/journal/gaps/{id}/request-fetch` | journal | `isAuthenticated()` — marks a gap `FETCH_REQUESTED` (agent re-upload) | + +## Design characteristics + +- **No executor→Kafka→record-store pattern here.** That pattern applies to snapshot/insight generation, not to this pull-through tab. +- **Auth is caller-forwarded**, not a service account: analytics extracts the `Authorization: Bearer` header and re-sends it to journal (`WebClientConfig` / `journalWebClient`). Downstream scope = caller scope. +- **Fault tolerance is silent degradation**: each downstream call is wrapped in try/catch that logs a warning and returns empty on failure, so the tab renders zeros rather than an error. +- **Caching**: journal's summary is `@Cacheable("txSummary")` keyed `institutions:dateFrom:dateTo`; gaps and top-ATMs are uncached. diff --git a/backend/src/main/resources/content/analytics/transactions/claude.md b/backend/src/main/resources/content/analytics/transactions/claude.md new file mode 100644 index 0000000..78a7772 --- /dev/null +++ b/backend/src/main/resources/content/analytics/transactions/claude.md @@ -0,0 +1,72 @@ +--- +module: analytics.transactions +title: Transactions +tab: Claude +order: 40 +audience: internal +--- + +> Act-without-guessing reference. Transactions tab = analytics UI tab `id: 'journal', label: 'Transactions'` → `JournalCoverageTab.svelte` → `GET /api/analytics/journal-coverage`. Data source of truth is **hiveops-journal**, not analytics. + +## Ownership + +- **hiveops-journal** OWNS the data (`journal_transactions`, `journal_gaps`, DB `hiveiq_journal`). +- **hiveops-analytics** (port 8089) is a read-only aggregator/proxy; forwards caller JWT to journal. +- Frontend: `hiveops-analytics/frontend`, `analytics.bcos.cloud` (port 5174). + +## Endpoints (METHOD path → service) + +| Method | Path | Service | Auth | +|--------|------|---------|------| +| GET | `/api/analytics/journal-coverage` | analytics | authenticated JWT, **no role gate** | +| GET | `/api/journal/transactions/summary?dateFrom=&dateTo=` | journal | JWT (no method-level `@PreAuthorize`; filter chain enforces) | +| GET | `/api/journal/gaps?status=OPEN` | journal | `@PreAuthorize("isAuthenticated()")` | +| GET | `/api/journal/transactions/top-atms?dateFrom=&dateTo=&limit=10` | journal | `@PreAuthorize("isAuthenticated()")`, size cap 50 | +| PUT | `/api/journal/gaps/{id}/request-fetch` | journal | `isAuthenticated()` (marks gap `FETCH_REQUESTED`) | + +- Analytics frontend `apiBase` default (dev): `http://localhost:8089/api/analytics`; prod from `window.__APP_CONFIG__.apiUrl`. NGINX path `/api/analytics/`. +- Analytics `journalWebClient` base = `JOURNAL_SERVICE_URL` (default `http://hiveops-journal:8087`). + +## Tables (DB `hiveiq_journal`) + +| Table | Use | +|-------|-----| +| `journal_transactions` | total / APPROVED / DECLINED counts, top-ATMs | +| `journal_gaps` | open gaps (status `OPEN`) | + +- No table is WRITTEN to serve this tab. +- `analytics_snapshots` / `ai_insights` (DB `hiveiq_analytics`) are NOT used by this tab. + +## Kafka + +- **None in this request path.** `journal-coverage` is a synchronous WebClient pull. +- (Context only, other tabs) analytics consumes `journal.file-parsed`, `hiveops.incidents.{created,updated,resolved}`; produces `analytics.snapshot-ready`. Journal produces `journal.file-parsed`, consumes `journal.parse-requests.{INSTITUTION}`. + +## Response shape (`JournalCoverageResponse`) + +- `dailyVolume: [{date, total, successful, failed}]` — **today only, single element**. +- `parseSuccessRateToday = successful*100/total` (0 if total 0). +- `openGapsTotal`, `atmsWithGaps: [{atmId, gapCount}]` (≤20, UI shows 10). +- `topAtmsByVolume: [{atmId, transactionCount}]` (≤10). + +## Field semantics (critical) + +- journal `successful` = `COUNT(host_result='APPROVED')`; `failed` = `COUNT(host_result='DECLINED')`; `total` = all rows. +- Therefore "Parse Success Rate" and the "Successful/Failed" bars = transaction **approval** stats, NOT journal-parse health. +- `transactions/summary` is `@Cacheable("txSummary")` key `institutions:dateFrom:dateTo` → today's numbers can be stale. +- All scoped by `InstitutionContext.resolveScope()` in journal — results are per caller's institution. + +## Gotchas + +- Downstream failures are caught and return empty → tab shows **0 / "No data"** with no user error. Grep analytics logs: `JournalCoverage: failed to fetch`. +- Everything except open-gaps is `LocalDate.now()` (today) — early-day emptiness is expected. +- Gap rows only created by journal's daily 06:00 UTC gap-detection job. + +## Do NOT + +- Do NOT claim analytics stores transaction/gap data — it does not; query journal. +- Do NOT treat "Parse Success Rate" as an ingest/parse metric — it is approval rate. +- Do NOT expect multi-day history on this tab — `dailyVolume` is today-only. +- Do NOT assume `MSP_ADMIN`/`BCOS_ADMIN` is required — `journal-coverage` is any-authenticated. +- Do NOT look for Kafka in the read path — it is a synchronous pull. +- Do NOT add write endpoints or resolve gaps from analytics — gap fetch is `PUT /api/journal/gaps/{id}/request-fetch` on journal. diff --git a/backend/src/main/resources/content/analytics/transactions/internal.md b/backend/src/main/resources/content/analytics/transactions/internal.md new file mode 100644 index 0000000..a16f401 --- /dev/null +++ b/backend/src/main/resources/content/analytics/transactions/internal.md @@ -0,0 +1,50 @@ +--- +module: analytics.transactions +title: Transactions +tab: Internal +order: 20 +audience: internal +--- + +> Ops / support / admin view for the **Transactions** tab (analytics dashboard, tab id `journal`, label "Transactions"). This tab is a **read-only aggregation** — there are no write actions or admin buttons on it. + +## What this tab actually is + +- UI: `hiveops-analytics/frontend` → `App.svelte` tab `{ id: 'journal', label: 'Transactions' }` → renders `JournalCoverageTab.svelte`. +- Data: one call, `analyticsAPI.getJournalCoverage()` → `GET /api/analytics/journal-coverage` on **hiveops-analytics** (port 8089). +- Source of truth is **hiveops-journal**, not analytics. Analytics is a pass-through aggregator that forwards the caller's JWT to journal and reshapes the result. + +## Roles required + +- **`GET /api/analytics/journal-coverage`** — authenticated JWT only. No role gate: analytics `SecurityConfig` is `.requestMatchers("/actuator/health","/api/public/**").permitAll().anyRequest().authenticated()`. There is **no** `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` on this endpoint. +- Downstream journal endpoints (`/api/journal/transactions/summary`, `/gaps`, `/transactions/top-atms`) require an authenticated JWT (`@PreAuthorize("isAuthenticated()")`; the summary endpoint has no method annotation and relies on the journal filter chain). +- Results are **institution-scoped** in journal via `InstitutionContext.resolveScope()` — a user sees only their own institution's transactions/gaps. MSP/BCOS admins with multi-institution scope see aggregated counts. + +## First things to check when it misbehaves + +1. **All tiles show 0 / empty ("No data available", "No ATMs with open gaps").** + - `JournalCoverageService` **swallows downstream errors** and returns empty maps/lists (→ zeros). There is no user-facing error. Check analytics logs for the warnings it logs instead: + - `JournalCoverage: failed to fetch summary for {date}: ...` + - `JournalCoverage: failed to fetch open gaps: ...` + - `JournalCoverage: failed to fetch top ATMs: ...` + - Loki (services VM, `localhost:3100`) or `docker compose logs hiveiq-analytics`. +2. **Is hiveops-journal up and reachable?** Analytics calls it via `journalWebClient` (`JOURNAL_SERVICE_URL`, default `http://hiveops-journal:8087`). If journal is down or the URL is wrong, every tile zeros out. +3. **JWT / scope.** Analytics forwards the *caller's* Bearer token to journal — there is no service account. If the user's token is expired or lacks institution scope, journal returns empty/filtered data and the tab looks broken for that user only. +4. **No data for today is normal early in the day.** Everything on this tab is **today-only** (`LocalDate.now()`): daily volume, parse rate, and top-ATMs all use `dateFrom=dateTo=today`. Open gaps is the only cumulative number. Empty early-morning numbers usually mean "no journals ingested yet," not a fault. +5. **Numbers look stale.** Journal caches the summary: `JournalSummaryService.getTransactionSummary` is `@Cacheable("txSummary")` keyed on `institutions:dateFrom:dateTo`. Today's total/approved/declined can lag until the cache entry turns over. + +## Common failure modes → fix + +| Symptom | Likely cause | Fix | +|---------|-------------|-----| +| Whole tab zeros, warnings in analytics log | journal down / wrong `JOURNAL_SERVICE_URL` / journal 5xx | Restore journal, verify env, re-check `docker compose ps hiveiq-journal` | +| Tab zeros for one user only | that user's JWT expired or institution scope empty | Re-login; confirm the account has an institution and journal data exists for it | +| "Today's Journal Volume" shows a single bar | **by design** — service builds `dailyVolume` with today only ("Single call — today only") | Not a bug; multi-day history is not wired for this tab | +| Parse Success Rate looks like an approval rate | it *is* — see gotcha below | Interpret accordingly | +| Open gaps stuck high | gap detection runs daily 06:00 UTC; gaps clear only when the missing `journal_files` row appears | Use per-ATM "Request Fetch" (`PUT /api/journal/gaps/{id}/request-fetch`) to prompt the agent to re-upload | + +## Gotchas that bite support + +- **"Parse Success Rate" is not a parse rate.** It is computed as `successful / total * 100`, where journal defines `successful = COUNT(host_result='APPROVED')` and `total = all transactions`. So it reflects **transaction approval**, not journal-parse health. The green/red "Successful/Failed" bars are likewise APPROVED vs DECLINED host results — declines are normal customer behavior, not ingest failures. (See uncertainties — this labeling is misleading.) +- **"Other" transactions** (visible on the separate overview `TransactionCard`, not this tab) = `total − approved − declined` (PIN verifications, timeouts, etc.). +- Analytics never writes anything for this tab. There is nothing to reset in `hiveiq_analytics` for a Transactions problem — always look at **hiveops-journal**. diff --git a/backend/src/main/resources/content/analytics/uptime/architect.md b/backend/src/main/resources/content/analytics/uptime/architect.md new file mode 100644 index 0000000..3b1ea1e --- /dev/null +++ b/backend/src/main/resources/content/analytics/uptime/architect.md @@ -0,0 +1,99 @@ +--- +module: analytics.uptime +title: Uptime +tab: Architect +order: 30 +audience: internal +--- + +> **How Uptime is built.** Grounded in `hiveops-analytics` source (2026-07-01). Cross-link [platform.data-architecture], [platform.kafka], [platform.service-ownership]. + +## Shape of the feature + +Uptime is a **read-over-snapshots + one live overlay** feature. `hiveops-analytics` owns the endpoint and the persisted history; the "currently down" list is a live pass-through to the incident service. Nothing about Uptime is computed on the ATM or by any other frontend. + +``` +UptimeTab.svelte ──GET /api/analytics/uptime──▶ UptimeController ──▶ UptimeService + │ + ┌──────────────────────────────────────────┤ + ▼ ▼ + analytics_snapshots (DB, today) incident: GET /api/atms/paginated + → NOW / 7d / 30d / trend ?agentStatus=DISCONNECTED,NEVER_CONNECTED + → currentlyDown + neverConnected + table +``` + +## Services involved + +| Service | Role for Uptime | +|---|---| +| **hiveops-analytics** (8089) | Owns `GET /api/analytics/uptime`, owns `analytics_snapshots`, runs the snapshot collector. | +| **hiveops-incident** (8080) | Serves the **live** down/never-connected ATM list via `/api/atms/paginated`. Token forwarded from caller. | +| **hiveops-devices** (8096) | Source of ATM health **counts** written into snapshots (via `/api/atms/summary`). | +| **hiveops-fleet** (8097) | Task counts in the same snapshot (not shown on Uptime, collected together). | +| **hiveops-journal** (8087) | Transaction counts in the same snapshot; `journal.file-parsed` also triggers snapshot collection. | + +Downstream base URLs are env-configured in `WebClientConfig` (`services.incident.url`, `services.devices.url`, `services.fleet.url`, `services.journal.url`, `services.mgmt.url`) with in-cluster defaults. + +## Data flow — write path (snapshots) + +`SnapshotCollectionService.collect()` is the executor. It runs on two triggers: + +- **Scheduled fallback:** `@Scheduled(cron = "0 0 */6 * * *")` — every 6 hours. +- **Event-driven (real-time):** Kafka consumers call `collect()` on each relevant event. + +On each run it: +1. Mints an **internal service JWT** (`InternalTokenGenerator` — `sub=0`, `role=ADMIN`, 300 s TTL). +2. Calls `FleetOverviewService.buildOverview(token, "internal")`, which fans out to devices `/api/atms/summary`, incident `/api/incident-management/stats/summary`, fleet `/api/fleet/tasks/stats`, journal `/api/journal/transactions/summary` (each failure is caught → zeros for that section; `@Cacheable("fleetOverview")`). +3. **Upserts** one row into `analytics_snapshots` keyed by `(snapshot_date, snapshot_hour)` — same hour replaces, else insert. +4. Emits `analytics.snapshot-ready` via `SnapshotEventProducer`. + +This is the platform **executor → Kafka → record-store** pattern: events fan in to trigger a collect, the collect persists a record, and a "ready" event fans back out. See [platform.kafka]. + +## Data flow — read path (the Uptime endpoint) + +`UptimeService.buildUptime(callerToken)`: +1. Loads `analytics_snapshots` for **today** (`findBySnapshotDateBetween...`, `from = to = today`). +2. `currentUptimePct` = latest row's `connectedAtms/totalAtms`. +3. `last24Hours` = last 24 snapshot rows (hourly points). +4. `dailyTrend` = today's rows averaged into a single daily point. +5. `sevenDayAvgPct` and `thirtyDayAvgPct` = `average(dailyTrend)` (see Caveats — both are today's average today). +6. Overlays the **live** incident call `/api/atms/paginated?size=500&agentStatus=DISCONNECTED,NEVER_CONNECTED` (caller's token) for `currentlyDownCount`, `neverConnectedCount`, and the `atmsCurrentlyDown` table (sorted longest-down-first, capped at 50). + +## Kafka + +Cross-link [platform.kafka]. + +| Direction | Topic | Class / group | +|---|---|---| +| Consume | `hiveops.incidents.created`, `hiveops.incidents.updated`, `hiveops.incidents.resolved` | `IncidentEventConsumer`, group `hiveops-analytics-incidents` → `collect()` | +| Consume | `journal.file-parsed` | `JournalFileParsedConsumer`, group `hiveops-analytics-journal` → `collect()` | +| Produce | `analytics.snapshot-ready` | `SnapshotEventProducer` (`SnapshotReadyEvent{snapshotDate, snapshotHour}`) | + +Uptime freshness therefore degrades gracefully: if incident/journal events stop flowing, the NOW/trend data falls back to the 6-hour cron cadence; the live down-list is unaffected (it doesn't read snapshots). + +## DB tables + +Cross-link [platform.data-architecture]. + +| Table | R/W for Uptime | Notes | +|---|---|---| +| `analytics_snapshots` | **Read** by `UptimeService`; **written** by `SnapshotCollectionService` | Unique `(snapshot_date, snapshot_hour)`; `snapshot_hour` INT (V3 fix). Fields used: `total_atms`, `connected_atms`, `disconnected_atms`, `never_connected_atms`. | + +No Uptime-specific table; it reuses the shared fleet snapshot. `ai_insights` is unrelated to Uptime. + +## Key endpoints + +| Method | Path | Service | Purpose | +|---|---|---|---| +| GET | `/api/analytics/uptime` | analytics | The Uptime screen payload | +| GET | `/api/atms/paginated?size=500&agentStatus=DISCONNECTED,NEVER_CONNECTED` | incident | Live down/never-connected list | +| GET | `/api/atms/summary` | devices | ATM counts written into snapshots | +| GET | `/api/incident-management/stats/summary` | incident | Incident counts in snapshot | +| GET | `/api/fleet/tasks/stats` | fleet | Task counts in snapshot | +| GET | `/api/journal/transactions/summary?dateFrom=&dateTo=` | journal | Transaction counts in snapshot | + +## Caveats / architectural notes + +- **Snapshot vs live split:** NOW/7d/30d/trend are snapshot-derived (≤6 h stale possible); the down-list is live. Intentional but can look inconsistent. +- **Today-only window:** the read path only loads today's snapshots, so 7-day and 30-day averages resolve to the same value and the trend chart has a single point. See [analytics.uptime] Internal tab and the module's overview for the intended (advertised) behaviour vs. what ships today. +- **Cross-service ownership drift:** snapshot ATM **counts** come from devices, but the live down-list still calls **incident** `/api/atms/paginated`. If ATM detail ownership fully moves to devices, this call is the thing to re-point. See [platform.service-ownership]. diff --git a/backend/src/main/resources/content/analytics/uptime/claude.md b/backend/src/main/resources/content/analytics/uptime/claude.md new file mode 100644 index 0000000..248d200 --- /dev/null +++ b/backend/src/main/resources/content/analytics/uptime/claude.md @@ -0,0 +1,68 @@ +--- +module: analytics.uptime +title: Uptime +tab: Claude +order: 40 +audience: internal +--- + +> Terse agent reference for `analytics.uptime`. Only confirmed facts. Act, don't guess. + +## Owner + +- **Service:** `hiveops-analytics` — port **8089**, DB **`hiveiq_analytics`**. +- **Endpoint class:** `UptimeController` → `UptimeService`. +- **Frontend:** `hiveops-analytics/frontend/src/components/tabs/UptimeTab.svelte`, `api.getUptime()`. + +## Endpoints (METHOD + path) + +| Method | Path | Service | Auth | +|---|---|---|---| +| GET | `/api/analytics/uptime` | analytics | JWT (any authenticated; **no role gate**) | +| GET | `/api/atms/paginated?size=500&agentStatus=DISCONNECTED,NEVER_CONNECTED` | incident | caller's Bearer forwarded | +| GET | `/api/atms/summary` | devices | snapshot collector (internal token) | +| GET | `/api/incident-management/stats/summary` | incident | snapshot collector | +| GET | `/api/fleet/tasks/stats` | fleet | snapshot collector | +| GET | `/api/journal/transactions/summary?dateFrom=&dateTo=` | journal | snapshot collector | + +- Frontend `apiBase` default: `http://localhost:8089/api/analytics`; prod via `window.__APP_CONFIG__.apiUrl`. + +## Tables + +| Table | DB | Access | +|---|---|---| +| `analytics_snapshots` | `hiveiq_analytics` | read by `UptimeService`, written by `SnapshotCollectionService`; unique `(snapshot_date, snapshot_hour)` | + +## Kafka topics + +| Direction | Topic | Group | +|---|---|---| +| consume | `hiveops.incidents.created` / `.updated` / `.resolved` | `hiveops-analytics-incidents` | +| consume | `journal.file-parsed` | `hiveops-analytics-journal` | +| produce | `analytics.snapshot-ready` | — | + +- Any consumed event → `SnapshotCollectionService.collect()`. Cron fallback: `0 0 */6 * * *`. + +## Roles + +- `SecurityConfig`: `permitAll` on `/actuator/health` + `/api/public/**`; everything else `.anyRequest().authenticated()`. +- **No `@PreAuthorize`, no `hasAnyRole(...)` on Uptime.** MSP_ADMIN/BCOS_ADMIN not required to read. +- Snapshot collector uses `InternalTokenGenerator` (`sub=0`, `role=ADMIN`, 300s). + +## Gotchas + +- `UptimeService` queries **today only** (`from = to = LocalDate.now()`). Therefore `sevenDayAvgPct == thirtyDayAvgPct == today's avg`, and `dailyTrend` has **1 point** → frontend shows **"Not enough data"** (`trend.length < 2`). Not corruption — code limitation. +- **NOW/7d/30d/trend = snapshot data** (≤6 h stale). **CURRENTLY DOWN count/table = live incident call.** They can disagree. +- Down-list failure is swallowed (`catch → log.warn("failed to fetch down ATMs")`) → empty list, count 0. Check analytics logs, not an error response. +- Down-list still calls **incident** (`services.incident.url`, default `http://hiveops-incident-backend:8080`) even though ATM counts come from **devices**. +- Prod DB uses split `DB_HOST/DB_PORT/DB_NAME/DB_USERNAME/DB_PASSWORD`, **not** `DB_URL`. +- Snapshot upsert is per `(date, hour)` — a re-run in the same hour overwrites, not appends. + +## Do NOT + +- Do NOT assume a role check exists — Uptime is readable by any authenticated JWT. +- Do NOT expect the 30-day trend / distinct 7d vs 30d numbers to render — code only loads today. +- Do NOT look for Uptime data on the ATM, devices, or incident DB — history lives in `analytics_snapshots` (`hiveiq_analytics`). +- Do NOT trust the NOW ring as live truth; only the "Currently Down" list is live. +- Do NOT invent an admin/write endpoint for Uptime — it is read-only (`GET /api/analytics/uptime`). +- Do NOT confuse consumer groups: incidents = `hiveops-analytics-incidents`, journal = `hiveops-analytics-journal`. diff --git a/backend/src/main/resources/content/analytics/uptime/internal.md b/backend/src/main/resources/content/analytics/uptime/internal.md new file mode 100644 index 0000000..d994954 --- /dev/null +++ b/backend/src/main/resources/content/analytics/uptime/internal.md @@ -0,0 +1,66 @@ +--- +module: analytics.uptime +title: Uptime +tab: Internal +order: 20 +audience: internal +--- + +> **Internal ops/support.** Grounded in `hiveops-analytics` source (2026-07-01). The Uptime screen is **read-only** — there are no admin buttons, no writes, no fleet actions. When it looks wrong, you are almost always debugging **stale snapshots** or a **failing downstream call**, not user input. + +## Who owns it + +- **Service:** `hiveops-analytics` (port **8089**, DB **`hiveiq_analytics`**). +- **Endpoint the screen hits:** `GET /api/analytics/uptime` (`UptimeController`). +- **Frontend:** `UptimeTab.svelte`, called via `api.getUptime()` → `${apiBase}/uptime`. + +## Roles / access + +- **No role gate.** `SecurityConfig` = `.anyRequest().authenticated()`; only `/actuator/health` and `/api/public/**` are `permitAll()`. Any valid JWT can read Uptime — MSP_ADMIN is **not** required. +- The endpoint **forwards the caller's Bearer token** downstream to the incident service. If the caller's token can't read ATMs, the "Currently Down" list comes back empty (the call is caught and logged, not surfaced). + +## What the numbers actually come from + +| Card / panel | Real source | +|---|---| +| **NOW** ring | Latest `analytics_snapshots` row for **today** (`connectedAtms / totalAtms`). Can be up to **6 h stale**. | +| **7-DAY AVG** / **30-DAY AVG** | Both computed from **today's snapshots only** (see caveat below). | +| **30-Day Trend** chart | `dailyTrend` — currently **today only** (1 point). | +| **CURRENTLY DOWN** count + table + **never connected** badge | **Live** call to incident: `GET /api/atms/paginated?size=500&agentStatus=DISCONNECTED,NEVER_CONNECTED`. Capped at 50 rows, sorted longest-down first. | + +So the top-left ring is **snapshot-derived (may lag)** while the down-list is **live**. They can legitimately disagree for a few hours. + +## First things to check when it misbehaves + +1. **"Not enough data" on the trend / flat 7-day = 30-day numbers** → expected with current code (only today's snapshots are queried). Not a data-loss bug per se — see Common failure modes. +2. **All zeros / NOW ring empty** → no `analytics_snapshots` row for today. Check the snapshot pipeline: + - `docker compose logs hiveiq-analytics | grep -i snapshot` — look for `Analytics snapshot saved for /`. + - Snapshots are written by `SnapshotCollectionService.collect()`: cron **every 6 h** (`0 0 */6 * * *`) **plus** Kafka triggers (incident + journal events). If Kafka is quiet and the cron hasn't fired, today may have no snapshot yet. + - Force one indirectly by generating an incident/journal event, or wait for the 6-hour cron. +3. **CURRENTLY DOWN empty but you know ATMs are offline** → the live incident call failed or returned empty. `grep "failed to fetch down ATMs"` in analytics logs. Verify incident-backend is up and still serves `/api/atms/paginated`. +4. **Numbers look right in analytics but the snapshot is old** → the collector calls devices/incident/fleet/journal with an **internal service token** (`InternalTokenGenerator`, role `ADMIN`). If a downstream is down, `FleetOverviewService` logs `Failed to fetch ATM summary` and the snapshot stores zeros for that section. +5. **Wrong DB / no rows at all** → confirm prod split env vars `DB_HOST/DB_PORT/DB_NAME/DB_USERNAME/DB_PASSWORD` (analytics does **not** use a single `DB_URL`). Query: + `SELECT snapshot_date, snapshot_hour, total_atms, connected_atms FROM analytics_snapshots ORDER BY snapshot_date DESC, snapshot_hour DESC LIMIT 5;` + +## Common failure modes + fixes + +| Symptom | Likely cause | Fix | +|---|---|---| +| Trend chart always says "Not enough data" | `UptimeService` only loads **today's** snapshots → `dailyTrend` has 1 point; frontend needs ≥2 | Product/code issue (see Architect); not a config fix | +| 7-day and 30-day rings show the **same** number | Both = today's average in code | Same — code limitation, not corruption | +| NOW ring stale vs. down-list | NOW from snapshot (≤6 h old), down-list is live | Expected; force a fresh snapshot via a Kafka event if needed | +| Down-list empty / count 0 despite offline ATMs | Incident `/api/atms/paginated` failed or caller token lacks ATM read | Check analytics logs + incident-backend health | +| Whole screen zeros | No snapshot row for today | Check snapshot collector logs, Kafka consumers, cron | +| 401/403 loading tab | Caller JWT expired/invalid (no role issue — any auth works) | Re-login / refresh token | + +## Handy commands + +```bash +# analytics health + recent snapshot activity +docker compose logs --tail=200 hiveiq-analytics | grep -iE "snapshot|down ATMs|Failed to fetch" + +# latest snapshots (DB VM via ProxyJump) +ssh -i ~/.ssh/id_ed25519_hiveiq -J bcosadmin@173.231.195.250 bcosadmin@10.10.10.188 \ + "docker exec hiveiq-postgres psql -U hiveiq -d hiveiq_analytics -c \ + 'SELECT snapshot_date,snapshot_hour,total_atms,connected_atms,disconnected_atms FROM analytics_snapshots ORDER BY snapshot_date DESC, snapshot_hour DESC LIMIT 10;'" +``` diff --git a/backend/src/main/resources/content/aria/dashboard/architect.md b/backend/src/main/resources/content/aria/dashboard/architect.md new file mode 100644 index 0000000..cf067f0 --- /dev/null +++ b/backend/src/main/resources/content/aria/dashboard/architect.md @@ -0,0 +1,72 @@ +--- +module: aria.dashboard +title: Threat Dashboard +tab: Architect +order: 30 +audience: internal +--- + +How the ARIA Threat Dashboard is built. The dashboard is a **thin read-only aggregation view** — it does not own data, run detection, or consume Kafka. It renders one snapshot from `hiveops-aria`'s tables. All the interesting machinery (ingestion, IOC matching, sequence detection) runs upstream and only *populates* the tables this view reads. + +## Services involved + +- **`hiveops-aria`** (Spring Boot, `spring.application.name=hiveops-aria`, port **8095**, DB `hiveiq_aria`) — owns everything on this screen. Serves the stats, owns all four tables. See [platform.service-ownership]. +- **`hiveops-auth`** — only for the header's `GET /auth/api/users/me` name/role lookup; not part of the stats path. +- No other service participates in the *dashboard read path*. (Ingestion into the tables comes from agents via the ARIA ingest endpoints; auto-response — disabled by default — would reach out to `hiveops-incident`.) + +## Data flow for THIS feature + +``` +Browser (Dashboard.svelte, onMount) + → GET /api/aria/stats [JWT Bearer, ROLE_BCOS_ADMIN] + → ThreatEventController.getStats() + ├─ eventRepository.count() → threat_event + ├─ eventRepository.countBySeverityAndDetectedAtAfter(CRIT,-24h) → threat_event + ├─ eventRepository.countBySeverityAndDetectedAtAfter(HIGH,-24h) → threat_event + ├─ iocRepository.findByActiveTrue().size() → threat_ioc + ├─ sequenceAlertRepository.countByResolvedAtIsNull() → precursor_sequence_alert + ├─ postureRepository.countByPostureScoreLessThan(50) → device_security_posture + └─ postureRepository.countByOsEolStatus(EOL) → device_security_posture + ← Map of 7 counts + → renders 8 cards (Non-Critical Events computed client-side) +``` + +Clicking a card is pure **client-side navigation** (Svelte `createEventDispatcher` → `App.svelte handleDashboardNav`) to the Events / Sequences / IOC / Posture views. No stats-specific endpoint backs the click. + +## Tables read (never written by this view) + +| Table | Read for | Written by (upstream) | +|-------|----------|------------------------| +| `threat_event` | Total / Critical / High counts | event ingestion service (`POST /api/aria/ingest/events`) | +| `threat_ioc` | Active IOC count | Flyway seed (FBI FLASH) + IOC feed refresh + manual CRUD | +| `precursor_sequence_alert` | Active (unresolved) sequence count | sequence-detection service | +| `device_security_posture` | Low-posture + EOL device counts | posture report ingestion (`POST /api/aria/posture/report`) | + +The dashboard issues **zero writes**. See [platform.data-architecture]. + +## Kafka role (indirect only) + +The dashboard has **no Kafka consumer and no producer** — it is a synchronous DB read. Kafka appears one hop upstream, populating the tables it later reads: + +| Topic | Direction (in aria) | Producer | Note | +|-------|--------------------|----------|------| +| `hiveops.aria.threats` | produced | `AriaThreatProducer` from `ThreatEventIngestionService` | fan-out of ingested threat events; not consumed within aria | +| `hiveops.aria.sequences` | produced | `AriaSequenceProducer` from `SequenceDetectionService` | fan-out when a precursor sequence trips | + +There are **no `@KafkaListener`s** in `hiveops-aria`. This means the executor→Kafka→record-store pattern used elsewhere on the platform does **not** drive this dashboard: writes land directly in Postgres via the ingest/detection services, and the dashboard reads Postgres. Kafka here is purely an outbound notification channel for other consumers. See [platform.kafka]. + +## Key API endpoint + +| Method | Path | Auth | Returns | +|--------|------|------|---------| +| GET | `/api/aria/stats` | `hasRole('BCOS_ADMIN')` | `{ totalEvents, criticalCount, highCount, activeIocCount, activeSequenceCount, lowPostureCount, eolDeviceCount }` | + +Auth chain: `SecurityConfig` (stateless, `@EnableMethodSecurity`) → `JwtAuthenticationFilter` validates the Bearer JWT via shared `JwtTokenValidator`, maps the single `role` claim to `ROLE_`. `/stats` is **not** in the `permitAll` list, so it requires an authenticated BCOS_ADMIN. + +## Design notes / semantics + +- **Windowing is mixed on purpose (or by oversight):** `criticalCount`/`highCount` are **24h** rolling counts (`...DetectedAtAfter(now-24h)`), while `totalEvents` and everything else are **point-in-time totals**. The client's "Non-Critical Events = Total − Critical − High" therefore subtracts a 24h slice from an all-time total. Worth confirming this is intended before building anything else on those numbers. +- **No caching / no auto-poll:** the stat map is recomputed on every `GET /stats`, and the SPA calls it exactly once on mount. Live dashboards would need a poll loop or push. +- **Counting shape:** `activeIocCount` materializes the active-IOC list and takes `.size()` rather than a `count(*)` query — fine at current scale, a candidate for a dedicated count query later. + +Related: [aria.dashboard.internal], [technical.aria], [platform.data-architecture], [platform.kafka], [platform.service-ownership]. diff --git a/backend/src/main/resources/content/aria/dashboard/claude.md b/backend/src/main/resources/content/aria/dashboard/claude.md new file mode 100644 index 0000000..4555e59 --- /dev/null +++ b/backend/src/main/resources/content/aria/dashboard/claude.md @@ -0,0 +1,67 @@ +--- +module: aria.dashboard +title: Threat Dashboard +tab: Claude +order: 40 +audience: internal +--- + +Terse act-without-guessing reference. Owner service: **`hiveops-aria`** (port **8095**, DB **`hiveiq_aria`**). Frontend: `hiveops-aria/frontend/src/components/Dashboard.svelte`. + +## Endpoints (all this view uses) + +| Method | Path | Auth | Notes | +|--------|------|------|-------| +| GET | `/api/aria/stats` | `hasRole('BCOS_ADMIN')` | THE only data call. Returns 7 counts. | +| GET | `/auth/api/users/me` | authenticated | header name/role only; not aria; via gateway URL | + +Response keys of `/api/aria/stats`: `totalEvents`, `criticalCount`, `highCount`, `activeIocCount`, `activeSequenceCount`, `lowPostureCount`, `eolDeviceCount`. + +Card-click navigation is client-side only (no endpoints): Critical→events(sev=CRITICAL), High→events(sev=HIGH), Active Sequences→sequences, Active IOCs→ioc, Low Posture/EOL→posture. + +## Count sources (exact) + +| Key | Query | Table | Window | +|-----|-------|-------|--------| +| totalEvents | `count()` | `threat_event` | all-time | +| criticalCount | `countBySeverityAndDetectedAtAfter(CRITICAL, now-24h)` | `threat_event` | **24h** | +| highCount | `countBySeverityAndDetectedAtAfter(HIGH, now-24h)` | `threat_event` | **24h** | +| activeIocCount | `findByActiveTrue().size()` | `threat_ioc` | current | +| activeSequenceCount | `countByResolvedAtIsNull()` | `precursor_sequence_alert` | current | +| lowPostureCount | `countByPostureScoreLessThan(50)` | `device_security_posture` | current | +| eolDeviceCount | `countByOsEolStatus(EOL)` | `device_security_posture` | current | + +## Tables (owned by hiveops-aria, schema `hiveiq_aria`) + +- `threat_event` · `threat_ioc` · `precursor_sequence_alert` · `device_security_posture` +- Dashboard = **read-only**. Zero writes from this view. + +## Kafka + +- Topics produced by aria (NOT by the dashboard): `hiveops.aria.threats`, `hiveops.aria.sequences`. +- **No `@KafkaListener` in hiveops-aria.** No consumer. Dashboard reads Postgres directly. + +## Auth facts + +- JWT Bearer, header `Authorization: Bearer `. Single `role` claim → `ROLE_`. +- `/stats` requires role **exactly `BCOS_ADMIN`**. `MSP_ADMIN` → 403. +- `permitAll` in SecurityConfig: `/api/aria/ingest/**`, `/api/aria/posture/report`, actuator health/info. `/stats` is NOT public. + +## Gotchas + +- Critical/High cards are **24h counts**, Total is all-time → "Non-Critical Events" (`total − crit24h − high24h`, computed in browser) mixes windows. Don't treat it as a clean category. +- Dashboard loads once on mount — **no auto-poll, no refresh button**. Stale = reload. +- Total Events + Non-Critical Events cards are non-clickable by design. +- `activeIocCount` loads full active-IOC list then `.size()` (not a count query). +- `frontend/src/lib/api.ts` dev fallback `apiUrl` is `http://localhost:8017`; real port is 8095 via injected `window.__APP_CONFIG__.apiUrl`. Don't hardcode 8017. + +## Do NOT + +- Do NOT assume `MSP_ADMIN` can load this — it's `BCOS_ADMIN` only. +- Do NOT expect Kafka to drive the dashboard — it's a synchronous DB read. +- Do NOT add write/mutation logic here — mutations live in IOC Management / Precursor Alerts views. +- Do NOT read `criticalCount`/`highCount` as all-time totals — they are 24h. +- Do NOT invent per-card endpoints — only `/api/aria/stats` backs this page. +- Do NOT route stats through incident/journal/devices — hiveops-aria owns all of it. + +Related: [aria.dashboard.internal], [aria.dashboard.architect], [technical.aria]. diff --git a/backend/src/main/resources/content/aria/dashboard/internal.md b/backend/src/main/resources/content/aria/dashboard/internal.md new file mode 100644 index 0000000..99ca586 --- /dev/null +++ b/backend/src/main/resources/content/aria/dashboard/internal.md @@ -0,0 +1,60 @@ +--- +module: aria.dashboard +title: Threat Dashboard +tab: Internal +order: 20 +audience: internal +--- + +Ops/support view for the ARIA Threat Dashboard — the landing screen of the ARIA (`hiveops-aria`) SPA. The whole page is driven by **one call**: `GET /api/aria/stats`. If a card is wrong or blank, that endpoint (or its underlying tables) is where to look — there is no per-card fetch. + +## Who can see it + +- **Role required: `BCOS_ADMIN` only.** `/api/aria/stats` (and `/api/aria/events`) is gated by `@PreAuthorize("hasRole('BCOS_ADMIN')")`. +- This is **not** the usual `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` pattern. An `MSP_ADMIN` JWT gets **403** and the dashboard shows the "Could not load ARIA stats" toast with empty cards. +- Auth is a stateless JWT Bearer token. The filter reads the single `role` claim and maps it to `ROLE_`, so the token's `role` must be exactly `BCOS_ADMIN`. +- The sidebar/header also calls `GET /auth/api/users/me` (via the gateway URL) purely to show the user's name/role — a failure there is silent and does not block the dashboard. + +## What each card actually counts + +| Card | Source (repo query) | Window | +|------|--------------------|--------| +| Total Events | `threat_event` row count | **all-time** | +| Critical | `threat_event` where severity=CRITICAL | **last 24h only** | +| High | `threat_event` where severity=HIGH | **last 24h only** | +| Active Sequences | `precursor_sequence_alert` where `resolved_at IS NULL` | current | +| Active IOCs | `threat_ioc` where `active = true` | current | +| Low Posture | `device_security_posture` where `posture_score < 50` | current | +| EOL Devices | `device_security_posture` where `os_eol_status = EOL` | current | +| Non-Critical Events | `Total − Critical − High` (computed in the browser) | mixed | + +> **Gotcha — Critical/High are 24-hour rolling counts, not all-time.** The card label just says "Critical"/"High" (matching the customer Overview), but the backend uses `countBySeverityAndDetectedAtAfter(..., now-24h)`. "Non-Critical Events" subtracts 24h counts from an all-time total, so it is effectively "all events minus the last 24h of critical/high" — not a clean category. If someone asks "why does Critical show 0 when there are old critical events?", this is why. + +## First things to check when it misbehaves + +1. **All cards blank + "Load Failed" toast** → the `/api/aria/stats` call failed. Almost always a **403 (wrong role — not BCOS_ADMIN)** or a **401 (missing/expired JWT)**. Check the browser network tab for the status on `/api/aria/stats`. +2. **Page loads but numbers are all 0** → the tables are genuinely empty. ARIA data arrives only via event ingestion (`POST /api/aria/ingest/**`) and posture reports (`POST /api/aria/posture/report`). No ingestion = no events, IOCs still seeded from Flyway. +3. **Critical/High are 0 but you expect events** → confirm the events are within the last 24h (`detected_at`). Older events count toward Total but not Critical/High. +4. **Active IOCs unexpectedly low/zero** → `threat_ioc` seed (FBI FLASH, Flyway V2) may not have run, or IOCs were deactivated. Check `active = true` rows. +5. **502/timeout on the SPA** → `hiveops-aria` service (port 8095) is down or the gateway/nginx route to it is stale. Verify `curl localhost:8095/actuator/health`. +6. **A card doesn't navigate** → Total Events and Non-Critical Events are **intentionally not clickable**; the rest dispatch client-side navigation to Events/Sequences/IOC/Posture views (no extra API calls on click). + +## Admin-only actions reachable from here + +The dashboard itself is **read-only** (no buttons, no refresh trigger — it loads once on mount). The clickable cards jump to admin views that do have actions: + +- **Active IOCs → IOC Management** — create/update/deactivate IOCs (`POST/PUT/DELETE /api/aria/ioc`). +- **Active Sequences → Precursor Alerts** — resolve a sequence (`POST /api/aria/sequences/{id}/resolve`). +- **Low Posture / EOL → Device Posture** — read-only posture inspection. + +## Common failure modes → fix + +| Symptom | Likely cause | Fix | +|---------|-------------|-----| +| 403 on `/api/aria/stats` | JWT role ≠ BCOS_ADMIN | Re-issue token with `role: BCOS_ADMIN` | +| 401 on `/api/aria/stats` | Expired/absent Bearer token | Re-login to the SPA | +| All zeros | No ingested events/postures yet | Confirm agents are posting to `/api/aria/ingest/**` | +| Stats slow | `activeIocCount` loads all active IOC rows into memory to size the list | Cosmetic; only bites with very large IOC tables | +| Card counts stale | Dashboard loads once per page open — no auto-poll | Reload the page | + +Related: [aria.dashboard.architect], [technical.aria]. diff --git a/backend/src/main/resources/content/aria/device-posture/architect.md b/backend/src/main/resources/content/aria/device-posture/architect.md new file mode 100644 index 0000000..365c5c3 --- /dev/null +++ b/backend/src/main/resources/content/aria/device-posture/architect.md @@ -0,0 +1,90 @@ +--- +module: aria.device-posture +title: Device Posture +tab: Architect +order: 30 +audience: internal +--- + +> **How Device Posture is built.** Grounded in `hiveops-aria` backend + `hiveops-agent/hiveops-module-aria-signals`. See also [technical.aria] for the wider ARIA service. Cross-links: [platform.data-architecture], [platform.kafka], [platform.service-ownership]. + +## Services involved + +Device Posture is entirely **`hiveops-aria`** on the backend + the ATM agent on the collection side. It does **not** touch incident/journal/devices/mgmt for this feature. Cross-link [platform.service-ownership] — ARIA owns the `device_security_posture` table exclusively. + +| Layer | Component | Role | +|-------|-----------|------| +| Agent (ATM) | `hiveops-module-aria-signals` → `AriaPostureReporter` + `AriaHttpClient` | Collects posture facts on-box and POSTs them | +| Backend API | `DevicePostureController` (`/api/aria/posture`) | Ingest (report) + admin read endpoints | +| Backend logic | `DevicePostureService` | Upsert + score calculation | +| Persistence | `DeviceSecurityPostureRepository` → `device_security_posture` | Stores one row per device (keyed by agent id) | +| Frontend | `DevicePosture.svelte` (`aria.bcos.dev`, `ariaApi.getPostures`) | Institution-grouped table + detail panel | + +## Data flow (no Kafka on this path) + +Unlike ARIA's threat pipeline, posture is a **synchronous HTTP upsert**, not an executor→Kafka→record-store pattern. Kafka is used elsewhere in ARIA (`hiveops.aria.threats`, `hiveops.aria.sequences`) for threat events/sequences only — **posture is not published or consumed on Kafka**. Cross-link [platform.kafka]. + +``` +[ATM agent: aria-signals] + AriaPostureReporter.run() (scheduleAtFixedRate, immediate + every 86400s default) + → collects: OS ver/EOL (all OS); BitLocker/auditpol/Solidcore (Windows only); + SHA-256 image hash vs gold (only if aria.gold.image.hash + aria.image.path set) + → AriaHttpClient POST /api/aria/posture/report + header: X-Service-Secret: + body: PostureReportRequest (JSON) + │ + ▼ +[hiveops-aria: DevicePostureController.report()] + permitAll at filter level; controller verifies X-Service-Secret == aria.internal.service-secret + (rejects 401 if header mismatches OR secret is blank) + │ + ▼ +[DevicePostureService.report()] @Transactional + upsert by deviceAgentId (findByDeviceAgentId → else build new) + partial merge (only non-null fields overwrite) + set lastPostureCheckAt = now; postureScore = calculateScore(); updatedAt = now + → repository.save() → device_security_posture + │ + (read side) ▼ +[DevicePosture.svelte] GET /api/aria/posture?page&size&scoreFilter|osEolStatus + BCOS_ADMIN JWT → grouped by institutionKey, avg score, <80 flags +``` + +## Scoring model (`DevicePostureService.calculateScore`) + +Server-authoritative. Starts at **100**, floored at **0**: + +| Condition | Penalty | +|-----------|---------| +| `diskEncryptionEnabled == false` | −25 | +| `auditPolicyCompliant == false` | −20 | +| `osEolStatus == EOL` | −20 | +| `softwareWhitelistEnabled == false` | −10 | +| `goldImageHash != currentImageHash` (both non-null) | −20 | +| `lastPostureCheckAt` older than 7 days | −5 per extra day, capped at −15 | + +Null booleans are **not** penalized (a missing check ≠ a failed check). The frontend detail panel renders the first five penalties but not the staleness penalty. + +## DB tables + +| Table | R/W | Notes | +|-------|-----|-------| +| `device_security_posture` | read + write | One row per device. PK `id`; business key `device_agent_id`. Columns: `device_id`, `institution_key`, `disk_encryption_enabled`, `audit_policy_compliant`, `software_whitelist_enabled`, `gold_image_hash`, `current_image_hash`, `gold_image_validated_at`, `os_version`, `os_eol_status` (enum `SUPPORTED\|EOL\|UNKNOWN`), `last_posture_check_at`, `posture_score`, `updated_at`. | + +Schema is Flyway-managed in `hiveiq_aria` with `ddl-auto=validate`. Cross-link [platform.data-architecture]. No other service mirrors this table (contrast with `device_summary` mirrors used by incident/fleet/reports/recon). + +## Key API endpoints + +| Method | Path | Auth | Purpose | +|--------|------|------|---------| +| POST | `/api/aria/posture/report` | `X-Service-Secret` header (permitAll at filter) | Agent/internal ingest — upsert + rescore | +| GET | `/api/aria/posture` | `hasRole('BCOS_ADMIN')` | Paged list; `osEolStatus` OR `scoreFilter` (low<50/medium<80); `page`/`size` (default 25) | +| GET | `/api/aria/posture/{deviceId}` | `hasRole('BCOS_ADMIN')` | Single record by `device_id` column | + +Repository queries always sort **`OrderByPostureScoreAsc`** (worst devices first). `osEolStatus` filter takes precedence over `scoreFilter` in the controller. + +## Agent collection detail + +- Module: `hiveops-module-aria-signals`, active only when `aria.endpoint` is set. Config keys: `aria.endpoint`, `aria.service.secret`, `aria.posture.report.interval.sec` (default 86400). +- Windows checks shell out to `manage-bde -status C:`, `auditpol /get /subcategory:"Audit Policy Change"`, `sadmin status` (McAfee/Trellix Solidcore) with AppLocker registry fallback. +- OS EOL is classified **client-side** (Win7/XP/Vista/8 → EOL; other Windows/Linux → SUPPORTED). The backend stores whatever the agent sends. diff --git a/backend/src/main/resources/content/aria/device-posture/claude.md b/backend/src/main/resources/content/aria/device-posture/claude.md new file mode 100644 index 0000000..6f1b07f --- /dev/null +++ b/backend/src/main/resources/content/aria/device-posture/claude.md @@ -0,0 +1,55 @@ +--- +module: aria.device-posture +title: Device Posture +tab: Claude +order: 40 +audience: internal +--- + +> Terse agent reference. Every item below confirmed in `hiveops-aria` + `hiveops-agent/hiveops-module-aria-signals`. Act without guessing. + +## Ownership +- **Service:** `hiveops-aria` — port **8095**, DB **`hiveiq_aria`**, `spring.application.name=hiveops-aria`. +- **Frontend:** `hiveops-aria/frontend` → `DevicePosture.svelte`, host `aria.bcos.dev` / `aria.bcos.cloud`. +- No incident/journal/devices/mgmt involvement. No Kafka on the posture path. + +## Endpoints (METHOD path → auth) +| Method | Path | Auth | +|--------|------|------| +| GET | `/api/aria/posture` | `hasRole('BCOS_ADMIN')` | +| GET | `/api/aria/posture/{deviceId}` | `hasRole('BCOS_ADMIN')` — matches **`device_id`** column, not PK | +| POST | `/api/aria/posture/report` | header `X-Service-Secret` == `aria.internal.service-secret` | + +- `GET /api/aria/posture` params: `osEolStatus` (`SUPPORTED\|EOL\|UNKNOWN`), `scoreFilter` (`low`=<50, `medium`=<80), `page` (0), `size` (25; UI uses 500). `osEolStatus` wins if both set. Always sorted score ASC. +- Response: Spring `Page` (`content[]`, `page.totalElements`). + +## Storage +- **Table:** `device_security_posture` (only table for this feature). PK `id`; business key `device_agent_id`. +- Key columns: `device_id`, `institution_key`, `disk_encryption_enabled`, `audit_policy_compliant`, `software_whitelist_enabled`, `gold_image_hash`, `current_image_hash`, `os_version`, `os_eol_status`, `last_posture_check_at`, `posture_score`, `updated_at`. +- Flyway-managed, `ddl-auto=validate`. + +## Kafka +- **None for posture.** (ARIA's `hiveops.aria.threats` / `hiveops.aria.sequences` are threat-event topics only — do not attribute posture to them.) + +## Scoring (server-side, `DevicePostureService.calculateScore`) +- base 100, floor 0. diskEnc=false −25 · audit=false −20 · osEol=EOL −20 · whitelist=false −10 · gold≠current −20 · stale >7d −5/day cap −15. Null booleans not penalized. + +## Ingest source +- Agent module `aria-signals` (`AriaPostureReporter`), `POST /api/aria/posture/report`, immediate on start + every `aria.posture.report.interval.sec` (default **86400s / 24h**). +- Windows-only booleans (BitLocker/auditpol/Solidcore). Linux → OS fields only. + +## Gotchas +- Role gate is **BCOS_ADMIN only**, not `MSP_ADMIN,BCOS_ADMIN`. MSP admin → 403. +- If `aria.internal.service-secret` (`ARIA_SERVICE_SECRET`) is **blank**, `report()` returns **401 for every request** (`serviceSecret.isBlank() ||` short-circuit) → table silently stays empty. (Ingest events endpoint behaves differently — do not assume symmetry.) +- Upsert key is `deviceAgentId`; re-imaged device w/ new agent id = new row, old one orphaned. +- Blank `institutionKey` groups under `"Unknown"` in UI. +- Detail panel penalties omit the staleness penalty → displayed penalties may not sum to the score. +- `report()` merges only non-null fields; a report omitting a field keeps the prior stored value. + +## Do NOT +- Do NOT expect `MSP_ADMIN` to read these endpoints. +- Do NOT add a Kafka producer/consumer for posture — it is synchronous HTTP. +- Do NOT look for posture data in incident/journal/devices/mgmt DBs — only `hiveiq_aria.device_security_posture`. +- Do NOT treat null booleans as failed checks (they are unreported, not non-compliant). +- Do NOT assume `/api/aria/posture/{deviceId}` takes the PK — it queries the `device_id` column. +- Do NOT deploy `hiveops-aria` without a non-blank `ARIA_SERVICE_SECRET` or ingest breaks. diff --git a/backend/src/main/resources/content/aria/device-posture/internal.md b/backend/src/main/resources/content/aria/device-posture/internal.md new file mode 100644 index 0000000..5bd0f26 --- /dev/null +++ b/backend/src/main/resources/content/aria/device-posture/internal.md @@ -0,0 +1,57 @@ +--- +module: aria.device-posture +title: Device Posture +tab: Internal +order: 20 +audience: internal +--- + +> **Internal ops/support view.** Grounded in `hiveops-aria` (`DevicePostureController`, `DevicePostureService`) and the agent `hiveops-module-aria-signals`. Verify against code before acting on production. + +## Who owns it + +- **Service:** `hiveops-aria` (port **8095**, DB `hiveiq_aria`, table `device_security_posture`). This screen reads/writes ARIA only — no incident/journal/devices involvement. +- **Data source:** the ATM agent's **`aria-signals`** module POSTs a posture report on a slow schedule (default every **24h**, fires once immediately on module start). There is no Kafka in this path — it is a direct HTTP upsert. + +## Roles required + +- **Viewing the screen** (`GET /api/aria/posture`, `GET /api/aria/posture/{deviceId}`) requires **`hasRole('BCOS_ADMIN')`**. +- ⚠ Note this is **BCOS_ADMIN only** — NOT the usual `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` you see elsewhere. An `MSP_ADMIN` who can see other HiveIQ screens will get **403** here. This is by design in ARIA, not a bug — do not "fix" it without a ticket. +- **Posture ingest** (`POST /api/aria/posture/report`) is `permitAll()` at the security-filter level but gated inside the controller by the **`X-Service-Secret`** header matching `aria.internal.service-secret` (`ARIA_SERVICE_SECRET`). + +## First things to check when it misbehaves + +**"Empty table / No posture data yet"** +1. Confirm the caller is a **BCOS_ADMIN** — an MSP admin gets 403 and the UI shows a load error, not empty data. +2. Confirm **`ARIA_SERVICE_SECRET` is set (non-blank)** on the `hiveops-aria` service. If it is blank, **every** posture report is rejected `401` (see failure modes) — the table stays empty forever with no obvious error. +3. Confirm agents actually run `aria-signals`: the module is inactive unless `aria.endpoint` is configured in `hiveops.properties`, and it needs `aria.service.secret` to match the backend secret. +4. Remember the report cadence is **24h by default** (`aria.posture.report.interval.sec=86400`). A freshly deployed fleet can take a day to populate. It does fire once on agent start, so a restart is a fast way to force a report. + +**"Device shows no Disk Enc / Audit / Whitelist (all `—`)"** +- Those three checks are **Windows-only** (BitLocker `manage-bde`, `auditpol`, McAfee Solidcore `sadmin` / AppLocker fallback). Linux ATMs report only `osVersion` + `osEolStatus`; the boolean columns stay null and render as `—`. Expected. + +**"Image always shows `—` (never Match/Drift)"** +- Image drift only populates when the agent has **both** `aria.gold.image.hash` and `aria.image.path` set. Without them, `goldImageHash` is null and the Image column is `—`. + +**"Score looks lower than the penalties shown in the detail panel"** +- The detail panel lists penalties for encryption (−25), audit (−20), whitelist (−10), OS EOL (−20) and image drift (−20). The backend **also** subtracts a **staleness penalty** (−5/day beyond 7 days stale, capped at −15) that the panel does **not** display. A device that stopped reporting will keep dropping in score without a visible reason on the panel. See uncertainties. + +**"Device I expect is missing / duplicated"** +- Records are keyed by **`deviceAgentId`** (upsert via `findByDeviceAgentId`). A device re-imaged with a different agent ID creates a **new** posture row; the old one lingers until manually cleaned. Institution grouping uses `institutionKey` from the report — a blank key groups under **"Unknown"**. + +## Common failure modes + fixes + +| Symptom | Likely cause | Fix | +|---------|-------------|-----| +| Table empty, no agents landing | `ARIA_SERVICE_SECRET` blank on backend → all reports `401` | Set the secret to a non-blank value on `hiveops-aria`, redeploy, and set matching `aria.service.secret` on agents | +| Reports `401` from one ATM only | Agent's `aria.service.secret` mismatches backend | Push a `CONFIG_UPDATE` with the correct secret | +| MSP admin sees load error | BCOS_ADMIN-only gate | Expected — use a BCOS_ADMIN JWT | +| Bool checks all `—` on many devices | Linux fleet (checks are Windows-only) | Expected, not a defect | +| Stale `Last Check` timestamps fleet-wide | `aria-signals` disabled or `aria.endpoint` unset | Verify agent config; restart forces an immediate report | +| Score drifting down with no failing check | Hidden staleness penalty for stopped reporters | Investigate why the device stopped reporting | + +## Manual data checks (BCOS_ADMIN JWT) + +- List: `GET /api/aria/posture?page=0&size=500` (UI pulls up to 500 at once). +- Filters: `scoreFilter=low` (<50) or `scoreFilter=medium` (<80); `osEolStatus=EOL|SUPPORTED|UNKNOWN`. `osEolStatus` takes precedence over `scoreFilter` in the controller (only one is applied). +- Single device: `GET /api/aria/posture/{deviceId}` — note `{deviceId}` is matched against the **`device_id`** column, not the row primary key. diff --git a/backend/src/main/resources/content/aria/ioc-feeds/architect.md b/backend/src/main/resources/content/aria/ioc-feeds/architect.md new file mode 100644 index 0000000..f122552 --- /dev/null +++ b/backend/src/main/resources/content/aria/ioc-feeds/architect.md @@ -0,0 +1,76 @@ +--- +module: aria.ioc-feeds +title: IOC Feeds +tab: Architect +order: 30 +audience: internal +--- + +How the **IOC Feeds** feature is built. Everything for this feature lives inside **`hiveops-aria`** — it does not call any other HiveIQ service. Cross-link [platform.service-ownership]. + +## Component map + +| Layer | Class | Role | +|-------|-------|------| +| Frontend | `frontend/src/components/IocFeeds.svelte` | cards + run-history table; polls `/status` every 30s | +| Frontend API | `frontend/src/lib/api.ts` (`ariaApi`) | `getFeedStatus`, `getFeedRuns`, `refreshAllFeeds`, `refreshFeed` | +| Controller | `controller/IocFeedController.java` | `/api/aria/feeds/*`; spawns background threads for refresh | +| Service | `service/IocFeedService.java` | orchestrates fetch → upsert → run record; owns `@Scheduled` cron | +| Feed clients | `feed/OtxFeedClient`, `feed/MalwareBazaarFeedClient`, `feed/ThreatFoxFeedClient` | call external APIs, map raw indicators → `ThreatIoc` | +| Repos | `IocFeedRunRepository`, `ThreatIocRepository` | run history + IOC catalog | +| Entities | `entity/IocFeedRun`, `entity/ThreatIoc` | `ioc_feed_run`, `threat_ioc` tables | + +## Data flow (one feed run) + +``` +[BCOS_ADMIN] Run Feed / Run All [cron 0 0 3 * * *] + │ POST /api/aria/feeds/refresh(/{feed}) │ scheduledRefresh() + ▼ ▼ +IocFeedController ── new Thread() ──► IocFeedService.refreshFeed(feedName) + (202 accepted) │ + ├─ 1. countRunning guard (skip if RUNNING) + ├─ 2. save ioc_feed_run row (status=RUNNING, started_at) + ├─ 3. fetchFromFeed → Client.fetch() ──► external HTTPS + ├─ 4. upsertIocs → threat_ioc (add / update) + └─ 5. run row → SUCCESS|ERROR, counts, completed_at +``` + +- **Controller offloads to a raw `Thread`** (named `ioc-feed-manual-all` / `ioc-feed-manual-`) with an uncaught-exception handler that logs, and returns `202` immediately. There is **no executor/queue and no Kafka** in this path — it's a synchronous fetch running on a plain thread. +- **Upsert** (`ThreatIocRepository.findByActiveTrueAndIocTypeAndValueIgnoreCase`): match on active + `ioc_type` + case-insensitive `value`. Hit → update `description/source_ref/confidence/reported_at/expires_at` (**updated++**); miss → insert (**added++**). Blank values are skipped. + +## External sources (feed clients) + +| Client | Endpoint | Auth header | Query strategy | IOC types mapped | expires_at | +|--------|----------|-------------|----------------|------------------|-----------| +| OTX | `https://otx.alienvault.com/api/v1` | `X-OTX-API-KEY` | subscribed pulses (8d) + searches `atm+malware`, `jackpotting`, `atm+skimming` | MD5, FILE_PATH, IP, REGISTRY_KEY | +90d | +| MalwareBazaar | `https://mb-api.abuse.ch/api/v1/` | `Auth-Key` | `get_taginfo` per tag: ATM, jackpotting, NCR, Diebold, GreenDispenser, Tyupkin | MD5, FILENAME | +365d | +| ThreatFox | `https://threatfox-api.abuse.ch/api/v1/` | `Auth-Key` | `iocs_by_malware_family` (ATMii, GreenDispenser, Tyupkin, Ploutus, SUCEFUL, Ripper) + `get_iocs` 7d filtered for ATM relevance | MD5, IP (port stripped), FILE_PATH, FILENAME | +180d | + +- All three short-circuit to an empty list when their API key is blank (`isConfigured()` false) — that becomes a SUCCESS run with 0/0, not an error. +- Each client swallows per-request errors internally (`log.warn`) and returns whatever it collected; a run only flips to ERROR if the whole `fetchFromFeed`/`upsertIocs` throws. + +## DB tables ([platform.data-architecture]) + +Database **`hiveiq_aria`** (PostgreSQL), Flyway-managed, `ddl-auto=validate`. + +| Table | Access here | Notes | +|-------|-------------|-------| +| `ioc_feed_run` | read (status, runs) + write (each run) | added in Flyway **V6**; columns: `feed_name`, `started_at`, `completed_at`, `status`, `iocs_added`, `iocs_updated`, `error_message`; indexes on `feed_name` and `started_at DESC` | +| `threat_ioc` | read (dedup lookup) + write (upsert) | IOC catalog; feed rows carry `source` = `OTX`/`MALWARE_BAZAAR`/`THREAT_FOX` | + +- `/status` = for each feed name, `findTopByFeedNameOrderByStartedAtDesc` → latest run summary + `configured` flag. +- `/runs` = `findAllByOrderByStartedAtDesc` as a Spring `Page` (default size 20 from the UI), so the frontend gets `content` + page metadata. + +## Kafka ([platform.kafka]) + +- **This feature produces and consumes nothing.** ARIA's Kafka producers (`hiveops.aria.threats`, `hiveops.aria.sequences`) belong to the event-ingestion / sequence-detection paths, not IOC feeds. The catalog these feeds populate is later read synchronously by the classifier when matching device events — no topic sits between the feed and the catalog. + +## Scheduling & config + +- `@Scheduled(cron = "${aria.feed.refresh.cron:0 0 3 * * *}")` on `IocFeedService.scheduledRefresh()` → `refreshAll()` (iterates the three feeds sequentially). +- Keys: `aria.feed.otx.api-key`, `aria.feed.malware-bazaar.api-key`, `aria.feed.threat-fox.api-key` (env `ARIA_FEED_*_API_KEY`). +- Note: `aria.feed.enabled` is declared but **unused** in code (see Internal tab / uncertainties). + +## Security + +- `SecurityConfig`: stateless, `@EnableMethodSecurity`. `/api/aria/feeds/**` is **not** in the `permitAll` list, so it falls under `anyRequest().authenticated()` **plus** each method's `@PreAuthorize("hasRole('BCOS_ADMIN')")`. Cross-link [platform.service-ownership]. diff --git a/backend/src/main/resources/content/aria/ioc-feeds/claude.md b/backend/src/main/resources/content/aria/ioc-feeds/claude.md new file mode 100644 index 0000000..5c901b4 --- /dev/null +++ b/backend/src/main/resources/content/aria/ioc-feeds/claude.md @@ -0,0 +1,55 @@ +--- +module: aria.ioc-feeds +title: IOC Feeds +tab: Claude +order: 40 +audience: internal +--- + +Terse agent reference. Everything below is confirmed in `hiveops-aria` source. + +## Ownership +- **Owner service:** `hiveops-aria` (port **8095**, `spring.application.name=hiveops-aria`). Self-contained — no other service involved for this feature. +- **DB:** `hiveiq_aria` (PostgreSQL, Flyway `ddl-auto=validate`). + +## Endpoints (all `@PreAuthorize("hasRole('BCOS_ADMIN')")`) +| Method | Path | Returns | +|--------|------|---------| +| GET | `/api/aria/feeds/status` | `List` one per feed: `feedName`, `configured`, `enabled`(always true), `lastRunAt`, `lastRunStatus`, `lastIocsAdded`, `lastIocsUpdated` | +| GET | `/api/aria/feeds/runs?page=&size=` | Spring `Page` (UI size=20) | +| POST | `/api/aria/feeds/refresh` | `202 {"status":"refresh_started"}` — all feeds, background thread | +| POST | `/api/aria/feeds/refresh/{feedName}` | `202 {"status":"refresh_started","feed":}`; `400` if feedName unknown | + +## Feed names (exact, case-sensitive in service map) +- `OTX`, `MALWARE_BAZAAR`, `THREAT_FOX` +- `refresh/{feedName}` upper-cases input before validating. + +## Tables +- `ioc_feed_run` — run history (`feed_name`, `started_at`, `completed_at`, `status`, `iocs_added`, `iocs_updated`, `error_message`). Added in Flyway **V6**. +- `threat_ioc` — IOC catalog; feeds upsert here. Match key = active + `ioc_type` + case-insensitive `value`. + +## Status values +- `RUNNING` (default on insert), `SUCCESS`, `ERROR`. Frontend also renders "Never run" when no row exists. + +## Config / env +- API keys: `ARIA_FEED_OTX_API_KEY`, `ARIA_FEED_MALWARE_BAZAAR_API_KEY`, `ARIA_FEED_THREAT_FOX_API_KEY`. Blank key ⇒ `configured=false`, run skips, SUCCESS with 0/0. +- Cron: `ARIA_FEED_REFRESH_CRON` (default `0 0 3 * * *`). + +## Kafka +- **None** for this feature — no producer, no consumer. (ARIA topics `hiveops.aria.threats` / `hiveops.aria.sequences` are unrelated paths.) + +## Gotchas +- Refresh is **async fire-and-forget** on a raw `Thread`; `202` ≠ success. Read `/status` or `/runs` for the real result. +- Role is **`BCOS_ADMIN` only** — NOT `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')`. `MSP_ADMIN` ⇒ 403. +- `enabled` in `/status` is **hardcoded true**; `ARIA_FEED_ENABLED` env is declared but **unused** — does not gate anything. +- `countRunning` concurrency guard is soft: single-feed refresh is `@Transactional` (RUNNING row uncommitted until done) → double-click can double-run; `refreshAll`/cron reach `refreshFeed` via self-invocation → **`@Transactional` bypassed**. +- Feed clients swallow per-request errors (`log.warn`); a run only goes ERROR if `fetchFromFeed`/`upsertIocs` throws. +- Both abuse.ch clients hard-filter for ATM relevance ⇒ 0 results is normal, not a bug. + +## Do NOT +- Do NOT treat a `202` as "feed succeeded." +- Do NOT expect `MSP_ADMIN` to work — feeds are BCOS_ADMIN-gated. +- Do NOT set `ARIA_FEED_ENABLED=false` to stop auto-runs (no effect); change the cron or remove keys. +- Do NOT look for a Kafka topic between feed and catalog — the upsert is a direct DB write. +- Do NOT invent other feed names — only `OTX`, `MALWARE_BAZAAR`, `THREAT_FOX` exist. +- Do NOT expect any other service (incident/journal/mgmt/devices) to serve this data. diff --git a/backend/src/main/resources/content/aria/ioc-feeds/internal.md b/backend/src/main/resources/content/aria/ioc-feeds/internal.md new file mode 100644 index 0000000..fe1ac93 --- /dev/null +++ b/backend/src/main/resources/content/aria/ioc-feeds/internal.md @@ -0,0 +1,65 @@ +--- +module: aria.ioc-feeds +title: IOC Feeds +tab: Internal +order: 20 +audience: internal +--- + +Ops/support view for the **IOC Feeds** screen in ARIA (HiveIQ threat-intelligence service, `hiveops-aria`, port **8095**). This screen pulls known-bad indicators (IOCs) from three external sources and upserts them into ARIA's IOC catalog. + +## Who can use it + +- **Every feed endpoint requires `hasRole('BCOS_ADMIN')`** — not the usual `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')`. A plain `MSP_ADMIN` JWT gets **403** on status, run history, and every refresh button. +- Auth is stateless JWT (`JwtAuthenticationFilter`), shared `JWT_SECRET`. No session. +- The screen self-polls the **status** endpoint every 30s. It never auto-triggers a refresh; refreshes are always manual (buttons) or the nightly cron. + +## What "Run" actually does + +- **Run All Feeds** → `POST /api/aria/feeds/refresh` → controller spawns a background thread `ioc-feed-manual-all` and returns `202 accepted` immediately with `{"status":"refresh_started"}`. The UI does not wait; it reloads status/history after ~3s. +- **Run Feed** (single card) → `POST /api/aria/feeds/refresh/{feedName}` → thread `ioc-feed-manual-`, same 202. +- Because it's fire-and-forget, a **202 does not mean the feed succeeded**. Always confirm the result in the run-history table or the card's status badge, not in the button response. +- The single-card **Run Feed** button is disabled when the feed is not `configured` (no API key) — the card shows the amber *API key required* tag. + +## Feeds & what makes them "configured" + +| Card | feedName | Configured when set | +|------|----------|---------------------| +| AlienVault OTX | `OTX` | `ARIA_FEED_OTX_API_KEY` non-blank | +| MalwareBazaar | `MALWARE_BAZAAR` | `ARIA_FEED_MALWARE_BAZAAR_API_KEY` non-blank | +| ThreatFox | `THREAT_FOX` | `ARIA_FEED_THREAT_FOX_API_KEY` non-blank | + +All three require an API key. MalwareBazaar and ThreatFox keys come from `https://auth.abuse.ch/`. With no key, a run is **not** an error — the client logs "no API key configured, skipping" and returns 0 IOCs (a SUCCESS run with 0 added / 0 updated). + +## First things to check when it misbehaves + +1. **Buttons/tables return 403** → the caller's JWT is not `BCOS_ADMIN`. Confirm the role claim, not just that the user is logged in. +2. **Card stuck on "API key required" / Run Feed greyed out** → the matching `ARIA_FEED_*_API_KEY` env var is unset or blank on the ARIA container. Check the deployed `.env`, redeploy. +3. **Run finishes SUCCESS but 0 added / 0 updated** → almost always a missing/blank API key (feed skipped silently) OR the source returned no ATM-relevant indicators. Both feeds filter hard for ATM relevance (see below), so 0 is common and not automatically a fault. +4. **Status ERROR with a message in the Error column** → hover the cell for the full text. It's the exception message from the fetch/upsert. Usually an external-source outage, bad/expired API key, or an HTTP/parse error. Retry with the single-card **Run Feed**. +5. **Card stuck on "Running"** → a prior run row was left with `status='RUNNING'` (never completed — e.g. container killed mid-run). New runs of that same feed will short-circuit and just return the last row (see concurrency note). Inspect `ioc_feed_run` for that `feed_name` and clear/complete the stale RUNNING row. +6. **Nothing ever runs on its own** → the scheduled refresh cron is `ARIA_FEED_REFRESH_CRON` (default `0 0 3 * * *`, 03:00 daily). Check ARIA logs at that time for `IOC feed scheduled refresh starting`. +7. **No data at all / screen empty** → check ARIA is up (`/actuator/health`) and DB `hiveiq_aria` reachable; the table reads `ioc_feed_run`, cards read the same via `/status`. + +## Common failure modes → fix + +| Symptom | Likely cause | Fix | +|---------|-------------|-----| +| 403 on every action | non-`BCOS_ADMIN` JWT | grant BCOS_ADMIN / use an admin token | +| "API key required" tag, Run disabled | `ARIA_FEED_*_API_KEY` blank | set env var, redeploy ARIA | +| SUCCESS, 0/0 | key missing (skipped) or no ATM-relevant hits | verify key present; 0 hits can be normal | +| ERROR row | source outage / bad key / parse error | read Error column, retry single feed | +| Card wedged "Running" | stale RUNNING row after a crash | fix the `ioc_feed_run` row for that feed | +| Feeds never auto-run | cron disabled/changed, or ARIA down at 03:00 | check `ARIA_FEED_REFRESH_CRON`, logs | + +## Concurrency gotcha (know before you spam the button) + +`refreshFeed` guards against overlap with `countRunning(feedName)` (counts rows where `status='RUNNING'`). If a run is already RUNNING it skips and returns the existing row. Two caveats: +- A single manual **Run Feed** runs inside one `@Transactional` method, so the RUNNING row is **not committed until the whole fetch finishes** — a second concurrent click may not see it yet and can double-run. Don't rely on the guard as a hard lock. +- **Run All** and the nightly cron call `refreshFeed` via internal self-invocation, which **bypasses the `@Transactional` proxy** entirely (only the single-card path is transactional). Behaviour differs between "Run Feed" and "Run All" — flagged in uncertainties. + +## `ARIA_FEED_ENABLED` does nothing + +`aria.feed.enabled` / `ARIA_FEED_ENABLED` (default true) is defined in `application.properties` but **not referenced anywhere in Java** and the `/status` endpoint hardcodes `enabled: true`. Setting it to false will **not** stop the scheduled refresh. To actually stop automatic runs, change the cron or remove the API keys. Flagged in uncertainties. + +Cross-links: the customer-facing feature is on the **Overview** tab; build/data-flow detail is on the **Architect** tab. diff --git a/backend/src/main/resources/content/aria/ioc-management/architect.md b/backend/src/main/resources/content/aria/ioc-management/architect.md new file mode 100644 index 0000000..bceae19 --- /dev/null +++ b/backend/src/main/resources/content/aria/ioc-management/architect.md @@ -0,0 +1,101 @@ +--- +module: aria.ioc-management +title: IOC Management +tab: Architect +order: 30 +audience: internal +--- + +How the IOC watchlist is built and how it plugs into ARIA's threat pipeline. Cross-link [platform.service-ownership], [platform.data-architecture], [platform.kafka]. + +## Ownership + +- **Single service: `hiveops-aria`** (`spring.application.name=hiveops-aria`, port **8095**, DB `hiveiq_aria`). The IOC catalog, its CRUD API, the external-feed importer, and the classifier that consumes it all live in the same service. No cross-service call is involved in IOC management itself. +- Frontend: `hiveops-aria/frontend` — `IocManagement.svelte`, calling `ariaApi` (`src/lib/api.ts`) at base `/api/aria`. + +## Components for this feature + +| Layer | Class | Role | +|-------|-------|------| +| Controller | `ThreatIocController` (`/api/aria/ioc`) | CRUD over the watchlist. | +| Repository | `ThreatIocRepository` (Spring Data JPA) | `findByActiveTrue`, `findByActiveTrueAndIocType`, `findByActiveTrueAndIocTypeAndValueIgnoreCase`, `findActiveExecutableIocs`. | +| Entity | `ThreatIoc` → table `threat_ioc` | The IOC record. | +| Feed importer | `IocFeedService` (+ `ioc_feed_run`) | Populates/refreshes IOCs from external feeds. | +| Consumer | `ThreatEventClassifier` | Reads active IOCs to match incoming device events. | + +## Data model + +Table **`threat_ioc`** (Flyway-managed, `db/migration` V1–V8; `ddl-auto=validate`): + +| Column | Notes | +|--------|-------| +| `id` | PK, identity. | +| `ioc_type` | enum `FILENAME, MD5, REGISTRY_KEY, DIRECTORY, SERVICE_NAME, IP, FILE_PATH`. Immutable after create. | +| `value` | the thing being watched for (required). | +| `description` | free text. | +| `source` | enum `FBI_FLASH, FS_ISAC, MANUAL, OTX, MALWARE_BAZAAR, THREAT_FOX`. CRUD-created rows are always `MANUAL`. | +| `source_ref` | e.g. FBI FLASH number. Widened to TEXT in V8. | +| `confidence` | enum `HIGH, MEDIUM, LOW`. | +| `reported_at` | added V7; when the source reported it. | +| `added_at` | insert time (server default `Instant.now()`). | +| `expires_at` | optional expiry. | +| `active` | soft-delete flag; deactivate flips this to false. | + +> Note: V1 defined Postgres enum types (`ioc_type`, `ioc_source`, `confidence_level`) but V3 (`convert_enums_to_varchar`) converted them to VARCHAR; the entity still declares `columnDefinition = "ioc_type"` etc. + +## Data flow + +**Write path (managing the watchlist):** + +``` +IocManagement.svelte ──HTTP──▶ ThreatIocController ──▶ ThreatIocRepository ──▶ threat_ioc + (INSERT / UPDATE / active=false) +``` + +- No Kafka on the IOC CRUD path — it is synchronous JPA. +- Parallel write path: `IocFeedService` runs on a schedule (cron `aria.feed.refresh.cron`, default `0 0 3 * * *`) pulling OTX / MalwareBazaar / ThreatFox, upserting into `threat_ioc` (dedup via `findByActiveTrueAndIocTypeAndValueIgnoreCase`) and recording each run in `ioc_feed_run`. + +**Read path (how the watchlist is used):** the catalog is the matching table for ARIA's detection pipeline. + +``` +agent event ─▶ POST /api/aria/ingest/events ─▶ ThreatEventClassifier + │ (permitAll + X-Service-Secret) + │ + ├─ classifier looks up active IOCs by type+value (case-insensitive): + │ FILENAME / SERVICE_NAME / DIRECTORY / FILE_PATH / REGISTRY_KEY / IP + │ + ├─ match ─▶ threat_event (matched_ioc_id, severity, attack_phase) ─▶ Kafka hiveops.aria.threats + │ + └─ SequenceDetectionService correlates phases within a window + ─▶ precursor_sequence_alert ─▶ Kafka hiveops.aria.sequences +``` + +- IOC matching is **exact value, case-insensitive**, per `findByActiveTrueAndIocTypeAndValueIgnoreCase`. No wildcard/substring matching. `findActiveExecutableIocs` narrows to `FILENAME, MD5, FILE_PATH`. +- Matching applies only to **newly ingested** events; editing the watchlist is not retroactive. + +## Kafka (context, not on the CRUD path) + +Both topics are produced by ARIA (`AriaKafkaConfig`, JSON), downstream of an IOC match — see [platform.kafka]: + +| Topic | Producer | Trigger | +|-------|----------|---------| +| `hiveops.aria.threats` | `AriaThreatProducer` | classified/matched threat event (key = deviceAgentId). | +| `hiveops.aria.sequences` | `AriaSequenceProducer` | a precursor sequence trips. | + +ARIA has **no `@KafkaListener`** — it only produces. Auto-response fleet actions are off by default (`aria.autoresponse.shutdown.enabled=false`); when enabled they call `hiveops-incident`. + +## Key API endpoints (this feature) + +| Method | Path | Auth | Purpose | +|--------|------|------|---------| +| GET | `/api/aria/ioc?type=&activeOnly=` | `BCOS_ADMIN` | List (default `activeOnly=true`). | +| GET | `/api/aria/ioc/active?type=` | authenticated | Active IOCs, no role gate. | +| POST | `/api/aria/ioc` | `BCOS_ADMIN` | Create (`source` forced MANUAL). | +| PUT | `/api/aria/ioc/{id}` | `BCOS_ADMIN` | Update value/description/confidence/expires only. | +| DELETE | `/api/aria/ioc/{id}` | `BCOS_ADMIN` | Soft-deactivate (`active=false`). | + +## Design notes / caveats + +- **Soft-delete by design** — HTTP DELETE flips `active`, preserving audit/history; the classifier only reads `active = true`. +- **BCOS_ADMIN-only** across the board here — divergent from the platform-wide `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')`. See [platform.service-ownership]. +- **PUT drops `sourceRef`** — the update handler never persists `request.getSourceRef()` (create does). Flagged as a real bug; noted in uncertainties. diff --git a/backend/src/main/resources/content/aria/ioc-management/claude.md b/backend/src/main/resources/content/aria/ioc-management/claude.md new file mode 100644 index 0000000..7cb331b --- /dev/null +++ b/backend/src/main/resources/content/aria/ioc-management/claude.md @@ -0,0 +1,59 @@ +--- +module: aria.ioc-management +title: IOC Management +tab: Claude +order: 40 +audience: internal +--- + +Terse act-without-guessing reference. Owning service: **`hiveops-aria`** (port 8095, DB `hiveiq_aria`). Frontend: `hiveops-aria/frontend/src/components/IocManagement.svelte` via `ariaApi` → base `/api/aria`. + +## Endpoints (`ThreatIocController`) + +| METHOD | Path | Auth | Notes | +|--------|------|------|-------| +| GET | `/api/aria/ioc` | `hasRole('BCOS_ADMIN')` | params `type` (enum), `activeOnly` (default `true`). | +| GET | `/api/aria/ioc/active` | authenticated (no role) | param `type`; only `active=true`. | +| POST | `/api/aria/ioc` | `hasRole('BCOS_ADMIN')` | body `CreateIocRequest`; `source` forced `MANUAL`. | +| PUT | `/api/aria/ioc/{id}` | `hasRole('BCOS_ADMIN')` | updates value/description/confidence/expiresAt ONLY. 404 if id missing. | +| DELETE | `/api/aria/ioc/{id}` | `hasRole('BCOS_ADMIN')` | soft-deactivate (`active=false`), returns 204. NOT a hard delete. | + +`CreateIocRequest`: `iocType`*(NotNull), `value`*(NotBlank), `description`, `sourceRef`, `confidence`, `reportedAt`, `expiresAt`. (* = required) + +## Storage + +- **Table:** `threat_ioc` (only table for this feature). +- **Columns:** `id, ioc_type, value, description, source, source_ref, confidence, reported_at, added_at, expires_at, active`. +- **Enums:** `ioc_type` = FILENAME, MD5, REGISTRY_KEY, DIRECTORY, SERVICE_NAME, IP, FILE_PATH · `source` = FBI_FLASH, FS_ISAC, MANUAL, OTX, MALWARE_BAZAAR, THREAT_FOX · `confidence` = HIGH, MEDIUM, LOW. +- Flyway V1–V8; `ddl-auto=validate`. + +## Kafka (NOT on CRUD path — downstream of IOC match only) + +- Produces `hiveops.aria.threats` (`AriaThreatProducer`), `hiveops.aria.sequences` (`AriaSequenceProducer`). +- No consumers in ARIA (no `@KafkaListener`). + +## Consumers of the catalog + +- `ThreatEventClassifier` — matches ingested events to active IOCs via `findByActiveTrueAndIocTypeAndValueIgnoreCase` (exact value, case-insensitive) for FILENAME/SERVICE_NAME/DIRECTORY/FILE_PATH/REGISTRY_KEY/IP. +- `IocFeedService` — writes feed-sourced IOCs; dedups by active type+value; logs to `ioc_feed_run`. + +## Gotchas + +- Role is `BCOS_ADMIN` ONLY here — NOT `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')`. MSP_ADMIN → 403. +- DELETE = soft deactivate; no hard-delete endpoint exists. +- `ioc_type` immutable after create (UI disables it; backend update ignores it). +- **BUG:** `update()` never reads `request.getSourceRef()` → editing Source Reference is a silent no-op. Create persists it; PUT does not. +- POST always sets `source=MANUAL` regardless of payload. +- `confidence` defaults to `MEDIUM` server-side if null on create (UI sends `HIGH`). +- Matching is exact + case-insensitive; no wildcard/substring. Not retroactive to past events. +- Frontend `getIocs` filters by type client-side; it sends only `activeOnly` to the API. +- DB is on `10.10.10.188` (`hiveiq_aria`), not the services VM. Health: `GET /actuator/health` on 8095. + +## Do NOT + +- Do NOT expect DELETE to remove a row — it only sets `active=false`. +- Do NOT expect PUT to change `sourceRef` or `iocType`. +- Do NOT assume MSP_ADMIN can access — BCOS_ADMIN required. +- Do NOT add Kafka handling for IOC CRUD — it is synchronous JPA. +- Do NOT hand-write IOC rows into feed sources (`FBI_FLASH`/`FS_ISAC`/`OTX`/`MALWARE_BAZAAR`/`THREAT_FOX`) via this API — POST forces `MANUAL`; feed rows come from `IocFeedService`/seed migrations. +- Do NOT invent other IOC endpoints/tables/topics — the above is the complete surface for this module. diff --git a/backend/src/main/resources/content/aria/ioc-management/internal.md b/backend/src/main/resources/content/aria/ioc-management/internal.md new file mode 100644 index 0000000..97bea54 --- /dev/null +++ b/backend/src/main/resources/content/aria/ioc-management/internal.md @@ -0,0 +1,49 @@ +--- +module: aria.ioc-management +title: IOC Management +tab: Internal +order: 20 +audience: internal +--- + +Ops/support/admin view of the ARIA IOC watchlist. The screen is a thin CRUD over the `threat_ioc` table in `hiveiq_aria`, served by `hiveops-aria` (port 8095) via `ThreatIocController` (`/api/aria/ioc`). Editing the watchlist directly changes what the threat-classification pipeline matches device events against. + +## Who can use it + +- **`BCOS_ADMIN` only.** Every list/create/update/deactivate endpoint is gated `@PreAuthorize("hasRole('BCOS_ADMIN')")`. This is **not** the usual `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` pattern — an MSP_ADMIN JWT will get **403** on this screen. If a customer/admin reports "IOC page is empty / won't load," check their role first. +- `GET /api/aria/ioc/active` is the one exception — no role gate, any authenticated caller can read active IOCs. It is used by internal consumers, not this UI. + +## What the buttons actually do + +| UI action | Endpoint | Effect | +|-----------|----------|--------| +| **+ Add IOC** | `POST /api/aria/ioc` | Inserts a row with `source = MANUAL` (always forced server-side, regardless of what you type). | +| **Edit → Update** | `PUT /api/aria/ioc/{id}` | Updates **value, description, confidence, expires_at only**. | +| **Deactivate** | `DELETE /api/aria/ioc/{id}` | **Soft** delete — sets `active = false`, returns 204. Row is never removed. | + +- **Deactivate is not delete.** There is no hard-delete endpoint. Deactivated IOCs stay in the table and reappear under **Show inactive**. To "remove" a bad entry, deactivate it. +- IOC **Type cannot be changed** after creation — the form disables the select on edit, and the backend `update` ignores type. If the type is wrong, deactivate and re-add. + +## First things to check when it misbehaves + +1. **403 / blank list** → caller lacks `BCOS_ADMIN`. Decode the JWT `role` claim. +2. **"Save Failed" toast on add/edit** → validation: `iocType` (`@NotNull`) and `value` (`@NotBlank`) are required. The UI also blocks empty type/value client-side, so a server-side failure usually means a bad enum value for `iocType`/`confidence` or the service being down. +3. **Edited "Source Reference" didn't stick** → **known bug** (see below), not user error. +4. **New IOC not triggering detections** → confirm it is `active = true` and the `value` matches exactly (matching is case-insensitive but otherwise exact — see [architect]). Matching only happens for incoming events processed by `ThreatEventClassifier`; it is not retroactive to past events. +5. **Service reachability** → `curl http://localhost:8095/actuator/health` on the services VM; DB is on `10.10.10.188` (`hiveiq_aria`), not the services VM. + +## Common failure modes + fixes + +- **Duplicate-looking IOCs** — the CRUD endpoints do **not** de-duplicate. Only the external-feed importer (`IocFeedService`) checks for an existing active IOC of the same type+value. Two identical MANUAL entries are allowed; deactivate the extra. +- **Wrong confidence default** — if `confidence` is omitted on create the server defaults it to `MEDIUM`; the UI always sends `HIGH`. If IOCs show up as MEDIUM unexpectedly, something is calling the API directly without the field. +- **Feed-sourced IOCs you can't explain** — rows with `source` of `FBI_FLASH`, `FS_ISAC`, `OTX`, `MALWARE_BAZAAR`, or `THREAT_FOX` came from the seed migration or the scheduled feed refresh, not a person. Manage feed behavior on the **IOC Feeds** screen, not here. +- **Seed data** — `FILENAME` IOCs like the Ploutus jackpotting set were seeded by Flyway `V2` (`FLASH-20260219-001`). Deactivating them is safe and reversible. + +## Known code bug (grounded, unfixed) + +- `ThreatIocController.update()` sets value/description/confidence/expiresAt but **never reads `request.getSourceRef()`**. The Edit panel exposes a **Source Reference** field and the frontend sends it, but PUT silently ignores it. Editing Source Reference on an existing IOC is a no-op. Workaround: deactivate and re-create with the correct source reference. (Create honors `sourceRef`; update does not.) + +## Related internal screens + +- **IOC Feeds** — external feed status/refresh (`IocFeedService`, `ioc_feed_run` table). +- See [architect] for how the watchlist feeds the classification + precursor-sequence pipeline, and [claude] for the terse endpoint/table reference. diff --git a/backend/src/main/resources/content/aria/precursor-alerts/architect.md b/backend/src/main/resources/content/aria/precursor-alerts/architect.md new file mode 100644 index 0000000..6ca25ba --- /dev/null +++ b/backend/src/main/resources/content/aria/precursor-alerts/architect.md @@ -0,0 +1,71 @@ +--- +module: aria.precursor-alerts +title: Precursor Alerts +tab: Architect +order: 30 +audience: internal +--- + +How the **Precursor Alerts** feature is built. Everything lives in **hiveops-aria** (port 8095, DB `hiveiq_aria`). ARIA owns detection end-to-end — it does not read another service's tables to build alerts. See [platform.service-ownership]. + +## Services involved + +- **hiveops-aria** — the only service in the write path. Ingests threat events, classifies them, detects multi-phase sequences, persists alerts, serves this screen, and emits a Kafka event downstream. +- **Agents / internal callers** — POST threat events into ARIA ingestion (out of scope of this screen but the source of all data). +- **Downstream consumers** (potential) — anything subscribing to `hiveops.aria.sequences`. ARIA itself has **no Kafka consumers** for this feature; the topic is fire-and-forget. + +## Data flow + +``` +agent/internal → POST /api/aria/ingest/events + → ThreatEventIngestionService.ingest() [@Transactional] + → ThreatEventClassifier.classify() (severity + attack_phase + IOC match) + → save ThreatEvent (threat_event) + → if severity HIGH/CRITICAL: AriaThreatProducer → topic hiveops.aria.threats + → SequenceDetectionService.detectAndAlert(event) + → query recent events for device within window + → detect JACKPOTTING / SKIMMING / (null) + → dedup guard (one active alert per device) + → stamp events with sequence UUID, save + → save PrecursorSequenceAlert (precursor_sequence_alert) + → AriaSequenceProducer → topic hiveops.aria.sequences +``` + +This is the **ingest → executor(detect) → persist → Kafka** pattern: the ingestion service is the executor, detection persists the record, then publishes an event to the record-store topic for downstream fan-out. See [platform.kafka], [platform.data-architecture]. + +### Detection logic (`SequenceDetectionService`) + +- Window: `aria.sequence.window.minutes` (`ARIA_SEQUENCE_WINDOW_MINUTES`, default **15**). Pulls all `threat_event` rows for the device with `detected_at` after `now - window`. +- Requires **≥2 recent events** and a **`PHYSICAL_ACCESS`** attack phase, else returns null (no alert). +- **JACKPOTTING** if `MALWARE_STAGING` or `MALWARE_EXECUTION` phase present. Confidence = weighted sum of phases (`PHYSICAL_ACCESS` .25, `MALWARE_STAGING` .30, `MALWARE_EXECUTION` .25, `PERSISTENCE` .10, `CLEANUP` .05) + .10 if any CRITICAL event + .05 if any IOC match, capped at 1.0, 3-dp. +- **SKIMMING** if signal `CARD_READER_TAMPER` or `USB_INSERTION` present. Base .35 + .40 (tamper) + .20 (USB) + .05 (critical), capped 1.0. +- **Dedup:** `existsByDeviceAgentIdAndResolvedAtIsNull` — a device with an unresolved alert gets no new alert until the existing one is resolved. +- On detect: all correlated events get the same `sequence_id` (UUID) written back, then the alert row is saved with `phasesDetected[]`, `confidence`, `institutionKey`, `sequenceId`. + +## Kafka + +| Topic | Direction | Producer | Key | When | +|-------|-----------|----------|-----|------| +| `hiveops.aria.sequences` | produced | `AriaSequenceProducer.publishSequenceAlert` | `deviceAgentId` | on every raised sequence alert | +| `hiveops.aria.threats` | produced | `AriaThreatProducer.publishThreatEvent` | `deviceAgentId` | per HIGH/CRITICAL ingested event (feeds context, not alerts) | + +No `@KafkaListener` consumes these in ARIA. Payloads are JSON (`AriaSequenceEvent`, `AriaThreatEvent`). `AriaSequenceEvent.alertId` is a freshly generated UUID (not the DB row id). See [platform.kafka]. + +## Database (`hiveiq_aria`, Flyway-managed, `ddl-auto=validate`) + +| Table | Role in this feature | Read | Write | +|-------|----------------------|------|-------| +| `threat_event` | source signals; also linked to a sequence via `sequence_id` | detection query; events panel | `sequence_id` stamped on correlate | +| `precursor_sequence_alert` | the alert record shown on this screen | list / resolve | created on detect; `resolved_at`/`resolved_by` on resolve | + +`precursor_sequence_alert` columns: `device_id`, `device_agent_id`, `sequence_type`, `phases_detected[]`, `confidence` (precision 4 scale 3), `triggered_at`, `auto_response_taken`, `resolved_at`, `resolved_by`, `institution_key`, `sequence_id`. See [platform.data-architecture]. + +## API endpoints (`PrecursorSequenceAlertController`, all `hasRole('BCOS_ADMIN')`) + +| Method | Path | Purpose | +|--------|------|---------| +| GET | `/api/aria/sequences` | paged list; params `resolved`, `sequenceType`, `deviceAgentId`, `page`, `size` (default size 25); sorted `triggeredAt` desc | +| GET | `/api/aria/sequences/{id}/events` | threat events sharing the alert's `sequence_id` | +| POST | `/api/aria/sequences/{id}/resolve` | idempotent resolve; stamps `resolved_at` + `resolved_by` (JWT email) | + +Note the repository query precedence in `getSequences`: a non-blank `deviceAgentId` **overrides** the `resolved` and `sequenceType` filters (device query returns all statuses for that device). Only when no device filter is set do the resolved/type branches apply. diff --git a/backend/src/main/resources/content/aria/precursor-alerts/claude.md b/backend/src/main/resources/content/aria/precursor-alerts/claude.md new file mode 100644 index 0000000..546ec35 --- /dev/null +++ b/backend/src/main/resources/content/aria/precursor-alerts/claude.md @@ -0,0 +1,53 @@ +--- +module: aria.precursor-alerts +title: Precursor Alerts +tab: Claude +order: 40 +audience: internal +--- + +Terse agent reference. Owned by **hiveops-aria** (port 8095, DB `hiveiq_aria`). All facts below confirmed in code. + +## Ownership +- Service: `hiveops-aria` — sole owner of detection + storage + API for this feature. +- Frontend: `hiveops-aria/frontend/src/components/PrecursorAlerts.svelte`, view id `sequences`. + +## Endpoints (all `@PreAuthorize("hasRole('BCOS_ADMIN')")`) +| METHOD | Path | Notes | +|--------|------|-------| +| GET | `/api/aria/sequences` | params: `resolved` (bool), `sequenceType` (JACKPOTTING\|SKIMMING\|UNKNOWN), `deviceAgentId`, `page` (0), `size` (25); Page<> sorted `triggeredAt` desc | +| GET | `/api/aria/sequences/{id}/events` | `{id}` = alert Long id; returns `List` by `sequence_id`; 404 if alert missing | +| POST | `/api/aria/sequences/{id}/resolve` | idempotent; sets `resolved_at`+`resolved_by`(JWT email); no un-resolve endpoint | +| POST | `/api/aria/ingest/events` | upstream source; `permitAll`, gated by header `X-Service-Secret` vs `ARIA_SERVICE_SECRET` (open if blank) | + +## Tables (`hiveiq_aria`) +- `precursor_sequence_alert` — the alert record. +- `threat_event` — source signals; `sequence_id` links events to an alert. + +## Kafka (produced only; NO consumers in aria) +- `hiveops.aria.sequences` — one per raised alert, key=`deviceAgentId`, payload `AriaSequenceEvent` (JSON). +- `hiveops.aria.threats` — per HIGH/CRITICAL ingested event, key=`deviceAgentId`. + +## Roles +- BCOS_ADMIN **only** for all sequence endpoints. NOT `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')`. MSP_ADMIN → 403. + +## Detection facts +- Window `ARIA_SEQUENCE_WINDOW_MINUTES` default 15. +- Needs ≥2 device events in window AND `PHYSICAL_ACCESS` phase, else no alert. +- JACKPOTTING = has `MALWARE_STAGING` or `MALWARE_EXECUTION`. SKIMMING = signal `CARD_READER_TAMPER` or `USB_INSERTION`. +- One active (unresolved) alert per device — dedup guard blocks new alerts until resolved. + +## Gotchas +- `deviceAgentId` filter overrides `resolved`+`sequenceType` (returns all statuses for that device). +- `AriaSequenceEvent.alertId` = random UUID, NOT the DB alert id; use `sequenceId`/`deviceAgentId` to correlate. +- Alerts are auto-generated on ingestion; no manual create endpoint. +- `resolved_by` = `"unknown"` if JWT principal email absent. +- Auto-refresh is client-side 60s poll (`localStorage aria_alerts_autoRefresh`); does not affect backend. + +## Do NOT +- Do NOT call these with an MSP_ADMIN token expecting data (403). +- Do NOT expect a re-open/un-resolve endpoint — none exists. +- Do NOT assume alerts appear without a `PHYSICAL_ACCESS` phase and ≥2 correlated events. +- Do NOT look for these tables/topics in incident/journal/devices — everything is in `hiveops-aria` / `hiveiq_aria`. +- Do NOT treat `AriaSequenceEvent.alertId` as a foreign key to `precursor_sequence_alert`. +- Do NOT invent an admin "force alert" or "auto-response" action from this screen (auto-response shutdown is off by default). diff --git a/backend/src/main/resources/content/aria/precursor-alerts/internal.md b/backend/src/main/resources/content/aria/precursor-alerts/internal.md new file mode 100644 index 0000000..b1e78f4 --- /dev/null +++ b/backend/src/main/resources/content/aria/precursor-alerts/internal.md @@ -0,0 +1,51 @@ +--- +module: aria.precursor-alerts +title: Precursor Alerts +tab: Internal +order: 20 +audience: internal +--- + +Ops/support view of the ARIA **Precursor Alerts** screen (multi-phase attack-sequence detection: jackpotting / skimming precursors). Served by **hiveops-aria** only. Nothing here touches incident/journal/devices data directly. + +## Who can use it + +- **All three endpoints require `hasRole('BCOS_ADMIN')`** — not the usual `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')`. An MSP_ADMIN JWT will get **403** on this screen even though they can use other apps. This is the #1 "I can't see any alerts / it's empty for me" cause. +- Auth is a stateless JWT (ARIA's own `JwtAuthenticationFilter`). `resolvedBy` is stamped from the caller's JWT email; if the principal is missing it records `"unknown"`. + +## What an operator does here + +- **Review active alerts** — list defaults to Active (`resolved=false`), newest-triggered first. +- **Filter** by Device ID, Status (Active / Resolved / All), Type (Jackpotting / Skimming / Unknown). +- **Open a row** → side panel lists the individual threat events that make up the sequence (calls `/sequences/{id}/events`). +- **Resolve** a row or hit Mark Resolved in the panel. Resolve is idempotent — resolving an already-resolved alert just returns it unchanged. +- Auto-refresh polls the list every **60s** (client-side toggle, persisted in `localStorage` key `aria_alerts_autoRefresh`). + +## Admin-only actions + +- **Resolve alert:** `POST /api/aria/sequences/{id}/resolve` (BCOS_ADMIN). Sets `resolved_at`/`resolved_by`. There is **no un-resolve / re-open endpoint** — once resolved it stays resolved. +- Alerts themselves are **generated automatically** by the detection engine on event ingestion. There is no manual "create alert" action and no admin knob in the UI to force one. + +## First things to check when it misbehaves + +1. **"Empty list / 403"** → confirm the JWT role is **BCOS_ADMIN**. MSP_ADMIN is not enough here. +2. **"No alerts are ever raised despite threat events"** — walk the pipeline: + - Are events actually arriving? Check `POST /api/aria/ingest/events` (ThreatEvent ingestion). If ingestion is gated, the `X-Service-Secret` header must match `ARIA_SERVICE_SECRET`; **if that env var is blank, ingestion accepts anything** — so a silent-drop is more likely an empty request than auth. + - A sequence needs **≥2 threat events on the same device within the detection window** (`ARIA_SEQUENCE_WINDOW_MINUTES`, default **15 min**). Sparse/slow events won't correlate. + - Detection **requires a `PHYSICAL_ACCESS` attack phase** to be present. Without it, no jackpotting/skimming sequence is ever raised — this is by design, not a bug. + - Classification assigns the `attack_phase` and `severity`; if the classifier isn't tagging phases (bad/missing IOC catalog), sequences won't form. Check the IOC catalog (`GET /api/aria/ioc`). +3. **"Only one alert per device, second attack not showing"** — expected. There is a dedup guard: while a device has an **unresolved** alert, no new alert is created for it. Resolve the existing one to allow the next. +4. **"Resolved-by shows `unknown`"** → JWT reached the endpoint without a populated principal email; check the token/claims. +5. **"Alerts stopped updating on screen"** → auto-refresh toggle is off, or the 60s poll is failing silently; hit the manual ↻ button and watch the network call to `GET /api/aria/sequences`. +6. **Events panel empty ("No events tagged to this sequence yet")** → the alert's `sequence_id` has no matching rows in `threat_event`. Events are stamped with the sequence UUID at detection time; if they were purged/re-ingested the linkage is lost. + +## Data locations (for support triage) + +- DB: **`hiveiq_aria`** (Postgres on the database VM, ProxyJump). Tables: `precursor_sequence_alert`, `threat_event`. +- Logs: search for `ARIA SEQUENCE ALERT` (warn on every raised sequence) and `ARIA event ingested` in Loki. Sequence-detection failures log `Sequence detection failed for device ...`. + +## Config that changes behavior + +- `ARIA_SEQUENCE_WINDOW_MINUTES` (default 15) — correlation window. +- `ARIA_SERVICE_SECRET` — ingestion gate (blank = open). +- Auto-response (shutdown) is **off by default** (`aria.autoresponse.shutdown.enabled=false`); resolving here is manual triage, not an automated containment action. diff --git a/backend/src/main/resources/content/aria/threat-events/architect.md b/backend/src/main/resources/content/aria/threat-events/architect.md new file mode 100644 index 0000000..5e7b36e --- /dev/null +++ b/backend/src/main/resources/content/aria/threat-events/architect.md @@ -0,0 +1,88 @@ +--- +module: aria.threat-events +title: Threat Events +tab: Architect +order: 30 +audience: internal +--- + +How the **Threat Events** feature is built. It is owned end-to-end by **`hiveops-aria`** (the threat-intelligence service, `spring.application.name=hiveops-aria`, DB `hiveiq_aria`). The screen is a thin read view; the interesting work happens in the ingest → classify → store → (optionally) publish pipeline. Cross-link [platform.service-ownership], [platform.data-architecture], [platform.kafka]. + +## Services involved + +- **`hiveops-aria` (owner)** — serves the read API the screen calls, owns ingestion, classification, persistence, and Kafka production for this feature. No other service is required to render Threat Events. +- **Agents / internal callers (producers of data)** — POST batched signals to ARIA's ingest endpoint. They are the *source* of `threat_event` rows. +- **hiveops-incident (downstream consumer, optional)** — a potential consumer of the `hiveops.aria.threats` topic for auto-response; ARIA itself has **no Kafka consumers** for this feature. + +## Data flow + +``` +Agent/service ──POST /api/aria/ingest/events──▶ ThreatEventIngestionController + (X-Service-Secret header, permitAll) │ + ▼ + ThreatEventIngestionService.ingest() + per item: + ThreatEventClassifier.classify() + → severity, matchedIoc, attackPhase + build ThreatEvent + eventRepository.save() ──▶ threat_event (hiveiq_aria) + if severity ∈ {CRITICAL, HIGH}: + AriaThreatProducer.publishThreatEvent() + ──▶ Kafka topic hiveops.aria.threats + (key = deviceAgentId) + SequenceDetectionService.detectAndAlert(event) + → may emit hiveops.aria.sequences + +Browser (BCOS_ADMIN) ──GET /api/aria/events──▶ ThreatEventController.getEvents() + → threat_event (paged, detectedAt DESC) +``` + +This is the standard **executor → record-store → Kafka** pattern: the ingestion service is the executor, `threat_event` is the record store, and `hiveops.aria.threats` is the fan-out for downstream reaction. Cross-link [platform.data-architecture]. + +## Classification + +`ThreatEventClassifier.classify(item)` returns a `ClassificationResult(severity, matchedIoc, attackPhase)`: +- **IOC match** — signal is matched against active rows in `threat_ioc`; a hit sets `matched_ioc_id` (the **IOC Match** column). +- **severity** — one of `CRITICAL / HIGH / MEDIUM / LOW / INFO`. +- **attackPhase** — one of `PHYSICAL_ACCESS`, `MALWARE_STAGING`, `MALWARE_EXECUTION`, `PERSISTENCE`, `CLEANUP` (the UI relabels these to Physical Access / Staging / Execution / Persistence / Cleanup). +- `eventType` is parsed from the request; unknown values fall back to `OS_EVENT`. +- Classification happens **once, at ingest**. Rows are not re-classified later. + +## DB tables (in `hiveiq_aria`, Flyway-managed, `ddl-auto=validate`) + +| Table | Read/Written by this feature | +|-------|------------------------------| +| `threat_event` | **Written** by ingestion (`eventRepository.save`); **read** by the screen (`GET /api/aria/events`, `/stats`). Columns include `device_agent_id`, `device_id`, `signal_key`, `event_type`, `severity`, `matched_ioc_id`, `attack_phase`, `sequence_id`, `institution_key`, `raw_payload` (JSONB), `detected_at`. | +| `threat_ioc` | **Read** during classification to resolve `matched_ioc_id`; surfaced as the IOC Match badge (type + value). | +| `precursor_sequence_alert` | **Written** by `SequenceDetectionService` when an event completes a multi-phase sequence (adjacent feature, not this screen). | + +Repository queries used by the read path (`ThreatEventRepository`): `findAllByOrderByDetectedAtDesc`, `findBySeverityOrderByDetectedAtDesc`, `findByDeviceAgentIdOrderByDetectedAtDesc` — all paged, sort `detectedAt DESC`. + +## Kafka (produced only) + +Cross-link [platform.kafka]. Topics defined in `AriaKafkaConfig` (JSON serializer): + +| Topic | Producer | When | Key | Payload | +|-------|----------|------|-----|---------| +| `hiveops.aria.threats` | `AriaThreatProducer.publishThreatEvent` | ingest of a **CRITICAL or HIGH** event only | `deviceAgentId` (or `"unknown"`) | `AriaThreatEvent` (eventId, deviceId, deviceAgentId, institutionKey, signalKey, eventType, severity, description, matchedIocValue/Type, attackPhase, sequenceId, detectedAt, sourceService=`hiveops-aria`) | +| `hiveops.aria.sequences` | `AriaSequenceProducer` (via `SequenceDetectionService`) | a precursor sequence trips | — | sequence alert | + +**MEDIUM / LOW / INFO events are stored but never published** (`shouldPublish` = CRITICAL||HIGH). ARIA has **no `@KafkaListener`** for this feature — it is a pure producer here. + +## Key API endpoints (this feature) + +| Method | Path | Auth | Role | +|--------|------|------|------| +| GET | `/api/aria/events` | JWT | `hasRole('BCOS_ADMIN')` | +| GET | `/api/aria/stats` | JWT | `hasRole('BCOS_ADMIN')` | +| POST | `/api/aria/ingest/events` | `X-Service-Secret` header | `permitAll` (secret checked only if `ARIA_SERVICE_SECRET` non-blank) | + +`GET /api/aria/events` params: `severity?`, `deviceAgentId?`, `page` (0), `size` (50). Returns a Spring `Page`. + +## Frontend + +`hiveops-aria/frontend/src/components/ThreatEvents.svelte` → `ariaApi.getEvents` in `src/lib/api.ts`. Base URL from `window.__APP_CONFIG__.apiUrl` (dev default `http://localhost:8017`, `/aria` suffix trimmed for the gateway var). Client-side 60s auto-refresh; filters map straight to the query params above. + +## Design note (verified) + +`getEvents` evaluates `severity` before `deviceAgentId` and returns early — so the two filters are **mutually exclusive** at the backend even though the UI can send both. If combined severity+device filtering is a requirement, the controller needs a combined repository query (`findBySeverityAndDeviceAgentId…`). See Internal/Claude tabs. diff --git a/backend/src/main/resources/content/aria/threat-events/claude.md b/backend/src/main/resources/content/aria/threat-events/claude.md new file mode 100644 index 0000000..3a45d8a --- /dev/null +++ b/backend/src/main/resources/content/aria/threat-events/claude.md @@ -0,0 +1,56 @@ +--- +module: aria.threat-events +title: Threat Events +tab: Claude +order: 40 +audience: internal +--- + +Terse act-without-guessing reference. Owner service: **`hiveops-aria`** (`hiveiq_aria` DB, port 8095). All facts below confirmed in `hiveops-aria` source. + +## Ownership +- **Read + ingest + classify + store + produce: `hiveops-aria` only.** No other service needed to render this screen. +- No Kafka **consumers** in aria for this feature — pure producer. + +## Endpoints (METHOD path — role) +| Method | Path | Auth | +|--------|------|------| +| GET | `/api/aria/events` | `hasRole('BCOS_ADMIN')` | +| GET | `/api/aria/stats` | `hasRole('BCOS_ADMIN')` | +| POST | `/api/aria/ingest/events` | `permitAll` + `X-Service-Secret` header | +| GET | `/api/aria/ioc` | `hasRole('BCOS_ADMIN')` (IOC catalog behind the IOC Match column) | + +- `GET /api/aria/events` params: `severity?` (CRITICAL/HIGH/MEDIUM/LOW/INFO), `deviceAgentId?`, `page` (def 0), `size` (def 50). Returns `Page`, sort `detectedAt DESC`. +- Ingest body: `ThreatEventIngestionRequest` (deviceId, deviceAgentId, institutionKey, `events[]` of `ThreatEventItem{signalKey, eventType, rawPayload, detectedAt}`). Returns 202. + +## Tables (DB `hiveiq_aria`) +- `threat_event` — the rows this screen lists. Cols: `device_agent_id`, `device_id`, `signal_key`, `event_type`, `severity`, `matched_ioc_id`→`threat_ioc`, `attack_phase`, `sequence_id`, `institution_key`, `raw_payload` (JSONB), `detected_at`. +- `threat_ioc` — IOC catalog; source of the IOC Match badge. +- `precursor_sequence_alert` — sequence alerts (sibling feature). + +## Kafka topics +- Produce `hiveops.aria.threats` — key = `deviceAgentId` (else `"unknown"`). **Only CRITICAL/HIGH events published.** Payload `AriaThreatEvent`, `sourceService="hiveops-aria"`. +- Produce `hiveops.aria.sequences` — sequence alerts. +- Consume: **none.** + +## Roles +- Read path is **`BCOS_ADMIN` only** — NOT `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')`. MSP_ADMIN → 403. +- Ingest gated by `X-Service-Secret` == `ARIA_SERVICE_SECRET`, **only when that env var is non-blank**; blank ⇒ open. + +## Gotchas +- MSP_ADMIN gets 403 on this screen — by design. +- `getEvents` checks `severity` first and returns early → **`deviceAgentId` is ignored whenever `severity` is also set.** Filters are effectively mutually exclusive server-side. +- MEDIUM/LOW/INFO are stored but never hit Kafka — downstream services will not see them. +- Classification (severity/IOC/phase) is done once at ingest by `ThreatEventClassifier`; rows are never re-classified. +- Unknown `eventType` on ingest silently falls back to `OS_EVENT`. +- Attack-phase enums: `PHYSICAL_ACCESS`, `MALWARE_STAGING`, `MALWARE_EXECUTION`, `PERSISTENCE`, `CLEANUP` (UI relabels). +- Auto-refresh is client-side (60s), `localStorage` key `aria_threats_autoRefresh`. Staleness ⇒ ingest problem, not UI. +- Blank `ARIA_SERVICE_SECRET` ⇒ ingest accepts anything (no auth). + +## Do NOT +- Do NOT tell an MSP_ADMIN they should see this screen — they can't (BCOS_ADMIN gate). +- Do NOT expect a MEDIUM/LOW/INFO event on `hiveops.aria.threats`. +- Do NOT assume filtering by device + severity together works — it doesn't. +- Do NOT look for the data in another service — `hiveops-aria` / `threat_event` owns it exclusively. +- Do NOT re-classify or mutate historical rows to "fix" a label; fix the classifier/IOC catalog and re-ingest. +- Do NOT confuse `/api/aria/ingest/events` (write, service-secret) with `/api/aria/events` (read, BCOS_ADMIN JWT). diff --git a/backend/src/main/resources/content/aria/threat-events/internal.md b/backend/src/main/resources/content/aria/threat-events/internal.md new file mode 100644 index 0000000..97b26f4 --- /dev/null +++ b/backend/src/main/resources/content/aria/threat-events/internal.md @@ -0,0 +1,56 @@ +--- +module: aria.threat-events +title: Threat Events +tab: Internal +order: 20 +audience: internal +--- + +Ops/support/admin view of the ARIA **Threat Events** screen. This is a read-only list rendered from the `hiveops-aria` service (`ThreatEvents.svelte`); there are no per-row admin actions on this screen — the only controls are filters, pagination, and auto-refresh. Grounded in `hiveops-aria` source. + +## Who can see it + +- The screen calls **`GET /api/aria/events`**, which is gated **`@PreAuthorize("hasRole('BCOS_ADMIN')")`**. +- ⚠️ **BCOS_ADMIN only** — this is *not* the usual `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')`. An MSP_ADMIN JWT will get **403** and the list will show the "Could not load threat events" toast. If a customer/MSP admin reports an empty or failing screen, this is expected — they are not authorized. Same gate applies to the stats endpoint (`GET /api/aria/stats`). + +## What feeds the list + +- Rows come from the `threat_event` table in the **`hiveiq_aria`** database, newest first (`ORDER BY detectedAt DESC`). +- Rows are written by the **ingestion pipeline**, not by anyone using this screen. Agents/services POST batches to **`POST /api/aria/ingest/events`**; `ThreatEventIngestionService` classifies each item (severity, matched IOC, attack phase) and saves a `threat_event` row. +- The **IOC Match** column is populated only when the classifier matched the signal against an active row in `threat_ioc`. No match → `—`. + +## First things to check when it misbehaves + +1. **403 / "Could not load threat events"** — confirm the caller's JWT role is `BCOS_ADMIN`. MSP_ADMIN is rejected. +2. **Screen loads but is empty** — check whether events are actually being ingested: + - Are agents/services POSTing to `/api/aria/ingest/events`? Look for `ARIA event ingested: device=… signal=… severity=… phase=…` INFO logs in `hiveops-aria`. + - `401` on ingest means the `X-Service-Secret` header did not match `aria.internal.service-secret` (`ARIA_SERVICE_SECRET`). Look for `Rejected ingestion request from … — invalid service secret` WARN logs. + - ⚠️ If `ARIA_SERVICE_SECRET` is **blank/unset**, the ingest endpoint accepts *any* request (no auth). A blank secret means the header is never checked. +3. **Device filter seems ignored** — see "Known quirk" below. Searching a Device (Agent) ID **while a severity is also selected** returns severity-filtered results ignoring the device. Clear the severity filter to search by device. +4. **IOC Match always empty** — the IOC catalog may be empty/inactive. IOCs live in `threat_ioc`; check via `GET /api/aria/ioc` (also BCOS_ADMIN). If the feed refresh hasn't run or seed migration (V2, FBI FLASH) is missing, nothing will match. +5. **List looks stale** — auto-refresh runs client-side every **60s** (toggle persisted in `localStorage` key `aria_threats_autoRefresh`). The `↻` button forces a reload. Staleness is a data/ingest problem, not a refresh problem — the UI just re-calls `GET /api/aria/events`. +6. **Wrong severity/phase on a row** — classification is done at ingest time by `ThreatEventClassifier`; rows are not re-classified. A bad label means the classifier or the IOC catalog was wrong when the event arrived. + +## Common failure modes + fixes + +| Symptom | Likely cause | Fix | +|---------|-------------|-----| +| 403 loading events | Caller is MSP_ADMIN, not BCOS_ADMIN | Use a BCOS_ADMIN account/JWT | +| Empty list, no ingest logs | Agents not posting, or wrong service URL | Verify agent/service target + `ARIA_SERVICE_SECRET` match | +| Ingest returns 401 | `X-Service-Secret` mismatch | Align caller header with `ARIA_SERVICE_SECRET` | +| Ingest accepts anything | `ARIA_SERVICE_SECRET` unset/blank | Set the secret in the ARIA env | +| IOC Match column all `—` | No active IOCs matched | Check `threat_ioc` / feed refresh (`GET /api/aria/feeds/status`) | +| Device search returns severity results | Severity + device combined (see quirk) | Clear severity, then search device | +| Downstream (incident, etc.) missing an event | Only CRITICAL/HIGH are published to Kafka | Expected — MEDIUM/LOW/INFO are stored but not published | + +## Known quirk (verified in code) + +`ThreatEventController.getEvents` checks `severity` **before** `deviceAgentId` and returns early. So when the UI sends both (severity dropdown + device search active at once), the **device filter is silently ignored**. Workaround: filter by only one at a time. Track/report as a backend bug if it causes support confusion. + +## Related admin surfaces (same service, BCOS_ADMIN) + +- `GET /api/aria/stats` — dashboard counters (total events, critical/high 24h, active IOCs, active sequences, low-posture/EOL devices). +- `GET /api/aria/ioc`, `POST/PUT/DELETE /api/aria/ioc` — IOC catalog management. +- `GET /api/aria/sequences` — precursor-sequence alerts (multi-phase attack chains). + +See the **Architect** and **Claude** tabs for data flow and exact endpoint/table/topic references. diff --git a/backend/src/main/resources/content/claims/claim-detail/architect.md b/backend/src/main/resources/content/claims/claim-detail/architect.md new file mode 100644 index 0000000..d33b871 --- /dev/null +++ b/backend/src/main/resources/content/claims/claim-detail/architect.md @@ -0,0 +1,71 @@ +--- +module: claims.claim-detail +title: Claim Detail +tab: Architect +order: 30 +audience: internal +--- + +How the **Claim Detail** panel is built end-to-end. See [platform.service-ownership] — this feature is owned entirely by **hiveops-claims**; no other HiveOps service is called for claim, message, note, attachment, or payment data. The only external dependency is the device mirror (below). + +## Services involved + +| Piece | What it is | Role for this feature | +|-------|-----------|-----------------------| +| `hiveiq-claims-frontend` | Svelte SPA (port 5179, `claims.bcos.cloud`) | Renders `ClaimPanel.svelte`; one component, five tabs | +| `hiveiq-claims` | Spring Boot backend (port 8092, DB `hiveiq_claims`) | Serves all panel data + mutations | +| SMTP (`JavaMailSender`) | outbound mail | Vendor notifications on message send / attachment upload | +| devices service (upstream) | publishes `hiveops.device.events` | Feeds `device_summary` read-model (terminal/location/ATM list) | + +There is **no** executor→Kafka→record-store pattern here (that's the fleet-task shape). Claims is a straight synchronous REST + JPA CRUD service; the only Kafka involvement is the inbound device mirror. See [platform.kafka]. + +## Component → controller wiring + +`ClaimPanel.svelte` takes a `claimUuid` prop and: + +1. **On mount** — `Promise.all([ claimsAPI.get(uuid), vendorAPI.list() ])` + - `GET /api/claims/{uuid}` → `ClaimController.getClaim` → `ClaimService.getClaim` → returns `ClaimSummaryResponse` with `payments` **embedded** (so the Payment tab needs no extra call). + - `GET /api/claims/vendors` → `VendorController` (used for Edit dropdowns + Messages recipient list). +2. **Tabs lazy-load on first open** (via `ClaimActivityController`, base `/api/claims/{claimUuid}`): + - Messages → `GET /messages?vendorUuid=` (optional filter) + - Notes → `GET /notes` + - Files → `GET /attachments` + +## Data flow — mutations + +All mutations are synchronous REST → `ClaimService` / `ClaimActivityService` → JPA repositories. + +- **Overview → Save** — `PUT /api/claims/{uuid}` body `{status, claimType, citVendorUuid|clearCitVendor, spVendorUuid|clearSpVendor}` → `ClaimService.updateClaim`. Sets status/type; assigning/clearing a vendor adds or deletes the matching `claim_payments` row (no re-split). +- **Messages → Send** — `POST /messages` `{messageText, vendorUuid?, additionalEmail?}` → `ClaimActivityService.sendMessage`: persists `claim_messages` row then calls `EmailService.sendMessageNotification` (recipient = vendor `contactEmail`, else `additionalEmail`; silently skipped if neither resolves or SMTP unconfigured). Sender party is derived server-side; UI renders `fromParty == INSTITUTION` on the right. +- **Notes → Add** — `POST /notes` `{noteText}` → `claim_notes`. Institution-only, no email, never shown to vendors. +- **Files → Upload** — `POST /attachments` multipart `file` → writes bytes to `UPLOADS_DIR` (default `/tmp/claims-uploads`), persists `claim_attachments` row, then `EmailService.sendAttachmentNotification` to assigned CIT/SP vendors. **Download** streams back via `GET /attachments/{attachmentUuid}/download`. +- **Payment → Save card** — `PUT /payments/{party}` `{amountOwed, amountPaid}` (`party` ∈ `INSTITUTION|CIT|SP`) → updates one `claim_payments` row. + +## DB tables (read/written by this feature) — DB `hiveiq_claims` + +| Table | Read | Written | By | +|-------|------|---------|-----| +| `claims` | ✔ | ✔ (PUT) | get/update claim | +| `claim_payments` | ✔ (embedded) | ✔ | payment edit, vendor assign/clear | +| `claim_messages` | ✔ | ✔ | Messages tab | +| `claim_notes` | ✔ | ✔ | Notes tab | +| `claim_attachments` | ✔ | ✔ | Files tab (+ bytes on local disk) | +| `vendors` | ✔ | — | Edit + Messages dropdowns | +| `email_templates` | ✔ | — | resolves vendor-notification subject/body | +| `device_summary` | ✔ | — (Kafka-fed) | ATM/terminal metadata | + +See [platform.data-architecture]. `dev` profile uses H2 (`create-drop`, Flyway off); `prod` uses PostgreSQL with Flyway `validate`. + +## Kafka topics + consumers + +- **Consumed:** `hiveops.device.events` — `DeviceEventConsumer` (`@KafkaListener`, group `hiveops-claims-device-sync`, constants in `DeviceEventKafkaConfig`). Upserts/deletes `device_summary`; `DEVICE_DELETED` removes the row; missing `institutionKey` defaults to `"BCOS"`. This is what backs the terminal/location shown on the panel. +- **Produced:** **none.** Claim state changes do not emit events. Downstream services do not learn about claim/payment updates. + +Claims is one of the `device_summary` mirror consumers (incident/fleet/reports/recon/claims); there is **no reconciler**, so a missed publish leaves the mirror stale silently. See [platform.kafka]. + +## Security model (this feature) + +- JWT-authentication-only; `ClaimsPrincipal(userId, email, role, institutionKey)`. No role checks. +- **List** scopes by `institutionKey`; **detail + activity endpoints resolve by UUID only** (no institution scoping) — see the Internal tab and uncertainties for the resulting cross-institution exposure. + +For the terse endpoint/table/topic map, see [claims.claim-detail] Claude tab. diff --git a/backend/src/main/resources/content/claims/claim-detail/claude.md b/backend/src/main/resources/content/claims/claim-detail/claude.md new file mode 100644 index 0000000..d87ed94 --- /dev/null +++ b/backend/src/main/resources/content/claims/claim-detail/claude.md @@ -0,0 +1,67 @@ +--- +module: claims.claim-detail +title: Claim Detail +tab: Claude +order: 40 +audience: internal +--- + +Terse agent reference for the **Claim Detail** panel (`ClaimPanel.svelte`). + +## Ownership +- **Service:** `hiveops-claims` OWNS all of this — claims, payments, messages, notes, attachments. No other service serves panel data. +- **Backend:** `hiveiq-claims`, port 8092, base path `/api/claims`. +- **External route:** `api.bcos.cloud/claims/` → `hiveiq-claims:8092/api/claims/`. +- **Frontend:** `hiveiq-claims-frontend`, `claims.bcos.cloud`, port 5179, component `ClaimPanel.svelte`. +- **DB:** `hiveiq_claims` (Postgres prod on `10.10.10.188`; H2 in `dev`). + +## Endpoints (METHOD path) — all require JWT; `{uuid}`/`{claimUuid}` = claim UUID + +| Method | Path | Use | +|--------|------|-----| +| GET | `/api/claims/{uuid}` | Load claim + embedded `payments` | +| PUT | `/api/claims/{uuid}` | Body `{status,claimType,citVendorUuid\|clearCitVendor,spVendorUuid\|clearSpVendor}` | +| GET | `/api/claims/vendors` | Edit + message dropdowns (`?type=CIT\|SP`) | +| GET | `/api/claims/{claimUuid}/messages` | `?vendorUuid=` optional filter | +| POST | `/api/claims/{claimUuid}/messages` | `{messageText,vendorUuid?,additionalEmail?}` → sends SMTP email | +| GET | `/api/claims/{claimUuid}/notes` | internal notes | +| POST | `/api/claims/{claimUuid}/notes` | `{noteText}` | +| GET | `/api/claims/{claimUuid}/attachments` | list | +| POST | `/api/claims/{claimUuid}/attachments` | multipart field `file` → sends SMTP email | +| GET | `/api/claims/{claimUuid}/attachments/{attachmentUuid}/download` | stream file | +| GET | `/api/claims/{claimUuid}/payments` | (panel uses embedded instead) | +| PUT | `/api/claims/{claimUuid}/payments/{party}` | `party`∈`INSTITUTION\|CIT\|SP`; body `{amountOwed,amountPaid}` | + +## Tables (`hiveiq_claims`) +- `claims`, `claim_payments`, `claim_messages`, `claim_notes`, `claim_attachments`, `vendors`, `email_templates`, `device_summary`. + +## Kafka +- **Consume:** `hiveops.device.events` (group `hiveops-claims-device-sync`) → `device_summary`. +- **Produce:** none. Claim/payment changes emit no events. + +## Enums +- `ClaimStatus`: `OPEN`, `WAITING_CIT`, `WAITING_SP`, `WAITING_INSTITUTION`, `PENDING_PAYMENT`, `RESOLVED_PAID`, `RESOLVED_OFFAGE_FOUND`, `RESOLVED_DENIED`. +- `ClaimType`: `WITHDRAW`, `DEPOSIT`. Payment `Party`: `INSTITUTION`, `CIT`, `SP`. + +## Roles +- **None.** `SecurityConfig` = `anyRequest().authenticated()`. No `hasAnyRole`. Isolation = `institutionKey` from JWT (`ClaimsPrincipal`), enforced in service layer for LIST only. + +## Gotchas +- `GET/PUT /api/claims/{uuid}` and every `/{claimUuid}/…` activity endpoint resolve by UUID with **no `institutionKey` check** → cross-institution read/write (IDOR). Only `GET /api/claims` (list) is scoped. +- Panel `PUT` sends `clearCitVendor/clearSpVendor=true` when a vendor select is blank → saving Edit can delete a vendor + its payment row. +- Assigning a vendor later adds its `claim_payments` row at `amountOwed=0` — no re-split. +- Message/attachment emails go via **SMTP (`JavaMailSender`)**, not Kafka; silently skipped if no recipient resolves or SMTP unconfigured (record still saves). +- Message/notes/attachments tabs swallow load errors (empty `catch {}`) — no toast; check logs. +- Attachments stored on local disk `UPLOADS_DIR` (default `/tmp/claims-uploads`); lost if not a volume. Cap 50MB/file, 52MB/request. +- List filter params are `status`,`claimNumber`,`terminalId` (NOT `search`). +- `dev` = H2 create-drop, Flyway off; `prod` = Postgres validate. + +## Do NOT +- Do NOT assume role gates exist — there are none; do not tell users an action is "admin-only." +- Do NOT expose claim UUIDs as safe identifiers — they bypass institution scoping. +- Do NOT expect a Kafka event after a claim/payment change — none is produced. +- Do NOT look in incident/mgmt/devices for claim data — it's all in `hiveops-claims`. +- Do NOT trust `device_summary` freshness — no reconciler; verify against the devices service. +- Do NOT call `/api/internal/**` for panel work — it's email-template only, `X-Internal-Secret`-gated. + +See [claims.claim-detail] Internal + Architect tabs. [platform.service-ownership] · [platform.kafka] · [platform.data-architecture] diff --git a/backend/src/main/resources/content/claims/claim-detail/internal.md b/backend/src/main/resources/content/claims/claim-detail/internal.md new file mode 100644 index 0000000..7785350 --- /dev/null +++ b/backend/src/main/resources/content/claims/claim-detail/internal.md @@ -0,0 +1,53 @@ +--- +module: claims.claim-detail +title: Claim Detail +tab: Internal +order: 20 +audience: internal +--- + +Ops/support reference for the **Claim Detail** panel (`ClaimPanel.svelte`, opens right-side over the claims list). Backend is **hiveops-claims** (`hiveiq-claims`, port 8092, DB `hiveiq_claims`). Frontend `claims.bcos.cloud`; API `api.bcos.cloud/claims/` → `hiveiq-claims:8092/api/claims/`. + +## Who can do what + +- **No role guards.** `SecurityConfig` is `anyRequest().authenticated()` — there is **no** `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` anywhere in this service. Any user with a valid HiveIQ JWT can open, edit, message, note, upload, and change payments on the panel. +- **Isolation is by `institutionKey`** carried in the JWT (`ClaimsPrincipal`). The *list* (`GET /api/claims`) filters to your institution — but the *detail* endpoints below do **not** (see failure modes). There is no admin-vs-user distinction inside claims. +- **Internal email-template endpoints** (`/api/internal/**`) are `permitAll()` at security level but gated by an `X-Internal-Secret` header (env `INTERNAL_SERVICE_SECRET`, default empty). Not used by this panel. + +## What each tab calls (first things to check) + +| Tab | Action | Call | +|-----|--------|------| +| (open) | Load claim + payments + vendor list | `GET /api/claims/{uuid}`, `GET /api/claims/vendors` | +| Overview | Save Edit | `PUT /api/claims/{uuid}` | +| Messages | Load / Send | `GET /api/claims/{uuid}/messages?vendorUuid=`, `POST …/messages` | +| Files | List / Upload / Download | `GET/POST …/attachments`, `GET …/attachments/{aUuid}/download` | +| 🔒 Notes | List / Add | `GET/POST …/notes` | +| Payment | (embedded) / Save card | payments come inside the claim; `PUT …/payments/{party}` | + +`{party}` is `INSTITUTION` / `CIT` / `SP`. + +## First things to check when it misbehaves + +1. **Panel opens blank / "Could not load claim details"** — the open path fires `GET /api/claims/{uuid}` **and** `GET /api/claims/vendors` in parallel; if either 401/500s the whole panel shows the load-failed toast. Check the JWT (expired → 401) and that the vendors endpoint is healthy first. +2. **Messages/Files/Notes empty but you expect data** — these tabs lazy-load only on first open and only when the local array is empty. A failed load is swallowed silently (empty `catch {}`) — no toast. Check the network tab / `docker logs hiveiq-claims`, don't trust the empty state. +3. **Vendor email didn't arrive after sending a message or uploading a file** — messages and attachments trigger `EmailService` (SMTP `JavaMailSender`), **not** Kafka. If `mailSender` is null / SMTP unconfigured, or no recipient can be resolved, the email is **silently skipped** and the message/note/file still saves. Check `mail.from` / SMTP env and that the vendor has a `contactEmail` (or that an additional recipient email was supplied). +4. **CIT/SP vendor dropdown is empty in Edit** — depends on `GET /api/claims/vendors` returning vendors of that `type`; register vendors first (Vendors page). The Messages "to vendor" dropdown only lists vendors **assigned to this claim** (`citVendorUuid`/`spVendorUuid`). +5. **Download does nothing** — `GET …/attachments/{attachmentUuid}/download` streams from the local uploads dir (`UPLOADS_DIR`, default `/tmp/claims-uploads`). If the container was recreated and that dir isn't a persistent volume, the file rows exist but the bytes are gone. +6. **Terminal/location/ATM list looks stale or wrong** — device data (`device_summary`) is a Kafka mirror fed by `hiveops.device.events`. There is **no reconciler**; if the devices service didn't publish a change, claims shows stale data. Cross-check the devices service. + +## Common failure modes + fixes + +- **Cross-institution data visible / editable (IDOR).** `GET/PUT /api/claims/{uuid}` and all `/{claimUuid}/…` activity endpoints resolve by UUID with **no** `institutionKey` check. Anyone with a claim UUID from any institution can read/modify it. Treat claim UUIDs as sensitive; flagged as a real bug (see uncertainties). No config toggle — needs a code fix. +- **Save Edit clears a vendor unexpectedly.** The panel sends `clearCitVendor: true` / `clearSpVendor: true` whenever its dropdown is blank. Opening Edit and saving with a vendor field showing "— None —" **removes** that party (and deletes its payment row). Don't save the Overview edit unless the vendor selects are correct. +- **Payment balance looks wrong after assigning a vendor.** Assigning a CIT/SP vendor later adds that party's payment row at `amountOwed = 0` — it does **not** re-split the shortage. Adjust owed amounts manually on the Payment tab. +- **50MB upload rejected.** Multipart cap is 50MB/file, 52MB/request. Larger files 413. +- **Attachments lost after redeploy** — confirm `UPLOADS_DIR` is a mounted volume, not the container-local default. + +## Where to look + +- Logs: `docker logs hiveiq-claims` (or Loki, `localhost:3100` on services VM). +- Health: `GET /actuator/health` (permitAll). +- DB (separate VM `10.10.10.188` via ProxyJump): tables `claims`, `claim_payments`, `claim_messages`, `claim_notes`, `claim_attachments`, `vendors`, `device_summary`. + +See [claims.claim-detail] Architect tab for how the pieces fit together. diff --git a/backend/src/main/resources/content/claims/dashboard/architect.md b/backend/src/main/resources/content/claims/dashboard/architect.md new file mode 100644 index 0000000..922288c --- /dev/null +++ b/backend/src/main/resources/content/claims/dashboard/architect.md @@ -0,0 +1,68 @@ +--- +module: claims.dashboard +title: Claims Dashboard +tab: Architect +order: 30 +audience: internal +--- + +How the **Claims Dashboard** is built. It is a thin read view over one service — **hiveops-claims** owns all the data it shows. See [platform.service-ownership]. + +## Components involved + +| Component | Role for this view | +|-----------|-------------------| +| `hiveiq-claims-frontend` (Svelte SPA, port 5179, `claims.bcos.cloud`) | `Dashboard.svelte` renders stat cards + table; polls every 60s | +| `hiveiq-claims` backend (Spring Boot, port 8092) | `ClaimController` → `ClaimService` → JPA repositories | +| PostgreSQL `hiveiq_claims` | Source of truth for claims + payments | +| Kafka `hiveops.device.events` | Feeds the `device_summary` mirror used by the *File New Claim* ATM picker (not the dashboard table itself) | + +The dashboard does **not** call any other microservice. Unlike the `device_summary` mirror consumers, its own table/stat data is fully local to hiveops-claims. + +## Data flow + +``` +Dashboard.svelte ──GET /api/claims/stats──▶ ClaimController.getStats + └─GET /api/claims?…──────▶ ClaimController.listClaims + │ + ▼ + ClaimService (@Transactional readOnly) + │ + ▼ + ClaimRepository (JPA) ──▶ hiveiq_claims.claims (+ claim_payments) +``` + +- `JwtAuthenticationFilter` populates a `ClaimsPrincipal` record (`userId, email, role, institutionKey`) from the Bearer token. Both controller methods read `principal.institutionKey()` and pass it down as the scope — data isolation is enforced in the service/repo layer, not by role annotations. +- **List** (`GET /api/claims`): `ClaimService.listClaims` picks one of two `@Query` repository methods: + - no status filter → `findFilteredNoStatus(institutionKey, claimNumber, terminalId, pageable)` + - status(es) present → parses the CSV into `List` and calls `findFilteredWithStatuses(...)`. + Both apply case-insensitive `LIKE %…%` on `claimNumber` and `terminalId`, return a `Page`, and map to `ClaimSummaryResponse` (includes CIT/SP vendor uuid+name, `hasUpdate`, `submittedAt`, `updatedAt`). +- **Stats** (`GET /api/claims/stats`): `ClaimService.getStats` loads `findByInstitutionKey(institutionKey)` (all claims) and computes in Java: active count (any non-`RESOLVED_*`), deposit/withdraw exposure (sum of `shortageAmount` over active), awaiting (`OPEN`/`WAITING_*`), deposit/withdraw recovered (sum of `claim_payments.amount_paid`), and `recoveryPercent = totalRecovered / totalShortage * 100` (`HALF_UP`). Returned as `ClaimStatsResponse`. + +## DB tables + +| Table | Read | Written | Notes | +|-------|:----:|:-------:|-------| +| `claims` | ✓ | — | Dashboard is read-only; writes happen via *File New Claim* (`POST /api/claims`) | +| `claim_payments` | ✓ | — | `amount_paid` drives the Recovered cards; `amount_owed` set at claim creation | +| `vendors` | ✓ | — | Joined for CIT/SP vendor names in the table | +| `device_summary` | ✓ | — | Only for the ATM picker (`GET /api/claims/atms`), not the dashboard grid | + +No table is written by the dashboard itself. See [platform.data-architecture]. + +## Kafka + +The dashboard read path produces and consumes **nothing**. The service as a whole consumes `hiveops.device.events` (`DeviceEventConsumer`, group `hiveops-claims-device-sync`) to keep `device_summary` in sync — this is the standard mirror pattern shared by incident/fleet/reports/recon/claims and only surfaces in the claim-creation ATM dropdown, not this view. There is no executor→Kafka→record-store write pattern here; claim/stat state is committed synchronously to Postgres. See [platform.kafka]. + +## Key API endpoints (this view) + +| METHOD | Path | Returns | +|--------|------|---------| +| GET | `/api/claims/stats` | `ClaimStatsResponse` | +| GET | `/api/claims?status=&claimNumber=&terminalId=&page=&size=&sort=` | `Page` | + +Externally routed as `api.bcos.cloud/claims/…` → `hiveiq-claims:8092/api/claims/…`. + +## Refresh model + +Client-side polling only: `Dashboard.svelte` runs a 1s ticker; every 60s (when Auto-refresh is on) it fires `loadStats()` + `loadClaims()` in parallel. Toggle state persists in `localStorage['claims_autoRefresh']`. No websockets, no SSE, no server push. diff --git a/backend/src/main/resources/content/claims/dashboard/claude.md b/backend/src/main/resources/content/claims/dashboard/claude.md new file mode 100644 index 0000000..67b0707 --- /dev/null +++ b/backend/src/main/resources/content/claims/dashboard/claude.md @@ -0,0 +1,61 @@ +--- +module: claims.dashboard +title: Claims Dashboard +tab: Claude +order: 40 +audience: internal +--- + +Terse agent reference for the Claims Dashboard view. Owner service: **hiveops-claims** (`hiveiq-claims`, port 8092, image `hiveiq-claims`). Frontend: `Dashboard.svelte` in `hiveops-claims/frontend`. + +## Owns this view +- Backend: `hiveops-claims` only. No cross-service call in the dashboard read path. +- DB: `hiveiq_claims` (PostgreSQL prod; H2 in dev profile). +- External route: `api.bcos.cloud/claims/…` → `hiveiq-claims:8092/api/claims/…`. + +## Endpoints (this view) +| METHOD | Path | Notes | +|--------|------|-------| +| GET | `/api/claims/stats` | → `ClaimStatsResponse`; scoped by JWT `institutionKey` | +| GET | `/api/claims` | `Page`; params below | + +Query params for `GET /api/claims`: +- `status` — CSV of enum values (`OPEN,WAITING_CIT,…`); unknown value = 500 +- `claimNumber` — case-insensitive substring +- `terminalId` — case-insensitive substring +- `page`, `size` (default 25), `sort` (default `createdAt`; e.g. `shortageAmount,asc`) + +Sortable fields: `claimNumber`, `terminalId`, `lossStart`, `shortageAmount`, `status`, `submittedAt`, `updatedAt`, `createdAt`. + +## Enums +- `ClaimStatus`: `OPEN`, `WAITING_CIT`, `WAITING_SP`, `WAITING_INSTITUTION`, `PENDING_PAYMENT`, `RESOLVED_PAID`, `RESOLVED_OFFAGE_FOUND`, `RESOLVED_DENIED` +- `ClaimType`: `DEPOSIT`, `WITHDRAW` + +## Tables +- `claims` (read) · `claim_payments` (read; `amount_paid`, `amount_owed`) · `vendors` (read, join for names) · `device_summary` (read, only for `GET /api/claims/atms`, not the grid) + +## Topics +- Dashboard read path: **none produced, none consumed.** +- Service-wide: consumes `hiveops.device.events` (group `hiveops-claims-device-sync`) → `device_summary`. Affects ATM picker only. + +## Auth / roles +- JWT Bearer required; `anyRequest().authenticated()`. +- **No `hasAnyRole`** anywhere. Scope = `institutionKey` from `ClaimsPrincipal`. +- `permitAll`: `/actuator/health`, `/actuator/info`, swagger, `/h2-console/**`, `/error`, `/api/internal/**`. + +## Gotchas +- `depositRecoveredYtd` / `withdrawRecoveredYtd` are **all-time**, not year-scoped (name is misleading — no year filter in `getStats`). +- `awaitingResponse` counts `OPEN` + all `WAITING_*` active claims (includes fresh OPEN). +- Exposure cards sum full `shortageAmount` of active claims — not remaining owed. +- `getStats` loads all institution claims and computes in-memory (no SQL aggregate). +- Filtering is two params (`claimNumber`, `terminalId`), **not** a single `search` param (older docs are stale). +- Stats errors are swallowed client-side (`catch {}`) → cards silently vanish; curl to see real error. +- DB env is split form `DB_HOST`/`DB_PORT`/`DB_NAME`/`DB_USERNAME`/`DB_PASSWORD` — **not** `DB_URL`. + +## Do NOT +- Do NOT assume role gates protect claims data — only `institutionKey` scoping does. +- Do NOT add a claims endpoint/table to another service; hiveops-claims owns claims. See [platform.service-ownership]. +- Do NOT trust "YTD" stat labels as year-bounded. +- Do NOT use a `search=` param on `/api/claims` — it is ignored. +- Do NOT expect Kafka/websocket push for the dashboard; refresh is 60s client polling. +- Do NOT pass unlisted `sort` fields or unknown `status` values — the latter 500s. diff --git a/backend/src/main/resources/content/claims/dashboard/internal.md b/backend/src/main/resources/content/claims/dashboard/internal.md new file mode 100644 index 0000000..9687709 --- /dev/null +++ b/backend/src/main/resources/content/claims/dashboard/internal.md @@ -0,0 +1,48 @@ +--- +module: claims.dashboard +title: Claims Dashboard +tab: Internal +order: 20 +audience: internal +--- + +Ops/support view for the HiveIQ **Claims Dashboard** — the landing view of the `claims.bcos.cloud` SPA. It renders six stat cards plus a filtered, paginated, sortable claims table. All data comes from **hiveops-claims** (`hiveiq-claims`, port 8092) via two calls: `GET /api/claims/stats` and `GET /api/claims`. + +## Who can see what + +- **No role guards.** `SecurityConfig` ends in `anyRequest().authenticated()` — there is **no** `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` check anywhere in this service. Any authenticated user with a valid HiveIQ JWT can load the dashboard. +- **Isolation is by `institutionKey`, not role.** Both endpoints read `principal.institutionKey()` from the JWT and scope every query to it. A user only ever sees their own institution's claims. There is no "see all institutions" admin path in the dashboard (a null `institutionKey` would return everything, but real tokens always carry one). +- No admin-only buttons on this view. The only mutating action reachable from the dashboard is **+ File New Claim** (opens a separate form → `POST /api/claims`). + +## First things to check when it misbehaves + +1. **Blank table / "Load failed" toast** — `GET /api/claims` failed. Check the JWT is present and valid (401 = auth filter rejected it), then check `hiveiq-claims` is up (`docker compose ps`, `curl localhost:8092/actuator/health`). +2. **Stat cards missing entirely** — `GET /api/claims/stats` failed silently. The frontend swallows stats errors (`catch {}`), so the cards just don't render while the table may still load. Curl `/api/claims/stats` directly with a real token to see the error. +3. **"Wrong institution's claims" / empty for a user who should have data** — check the `institutionKey` claim in their JWT. Wrong or missing key = wrong scope. This is a token problem, not a claims-service problem. +4. **ATM dropdown empty in File New Claim** (adjacent to dashboard) — that list comes from the `device_summary` mirror table, kept in sync by the `hiveops.device.events` Kafka consumer. If devices didn't publish, the mirror is stale. Not a dashboard-table issue. +5. **Numbers look wrong** — see "Known gotchas" below before filing a bug; several are by-design or naming quirks. + +## Common failure modes + fixes + +| Symptom | Likely cause | Fix | +|---------|-------------|-----| +| Table empty, no error | Filters still applied (status pills / Claim # / Terminal ID) | Click **✕ Clear all** in the Filters sidebar | +| 401 on both calls | Expired/absent JWT | Re-login; confirm `VITE_AUTH_URL` reachable | +| 403 / CORS error in console | Origin not in `CORS_ALLOWED_ORIGINS` | Ensure `claims.bcos.cloud` (or `localhost:5179` in dev) is allowed | +| Cards render but table 500s | `hiveiq_claims` DB unreachable / schema drift | Check `DB_HOST/DB_PORT/DB_NAME` env; prod uses Flyway `validate` | +| Stale/short claim list for whole institution | DB connectivity, not mirror | Mirror only affects the ATM picker, not the claims table | + +## Search & filter behavior (so you can reproduce a report) + +- **Status pills** are multi-select; the frontend joins them CSV and sends `?status=OPEN,WAITING_CIT,…`. Values must match the enum exactly (`OPEN`, `WAITING_CIT`, `WAITING_SP`, `WAITING_INSTITUTION`, `PENDING_PAYMENT`, `RESOLVED_PAID`, `RESOLVED_OFFAGE_FOUND`, `RESOLVED_DENIED`) — an unknown value throws `IllegalArgumentException` (500). +- **Claim #** and **Terminal ID** are separate params (`?claimNumber=`, `?terminalId=`), each a case-insensitive substring `LIKE %…%` match. There is **no** single free-text `search` param despite older docs mentioning one. +- **Sorting** is server-side via Spring `sort=field,dir`. Default `createdAt,desc`. Sortable columns: `claimNumber`, `terminalId`, `lossStart`, `shortageAmount`, `status`, `submittedAt`, `updatedAt` (and default `createdAt`). Location, Type, and the two Vendor columns are **not** sortable. +- **Pagination** default page size is 25 (backend `@PageableDefault`); the frontend overrides with the user's saved page size. +- **Amber left stripe** on a row = `hasUpdate = true` on that claim (`Claim.getHasUpdate()`), surfacing a new vendor/party update. + +## Known gotchas (verify before filing a "bug") + +- **"Recovered YTD" is not year-scoped.** `depositRecoveredYtd` / `withdrawRecoveredYtd` sum `amountPaid` across **all** of the institution's claims with no year filter — the "YTD" label is a misnomer, the value is all-time recovered. (See uncertainties.) +- **"Awaiting Response" counts more than truly-waiting claims.** It counts active claims in `OPEN`, `WAITING_CIT`, `WAITING_SP`, or `WAITING_INSTITUTION` — including brand-new `OPEN` claims nobody has been asked to respond to yet. +- **Exposure = shortage, not remaining owed.** `depositExposure` / `withdrawExposure` sum the full `shortageAmount` of active (non-resolved) claims; they do not subtract amounts already paid. +- **Stats are computed in-memory.** `getStats` loads every claim for the institution and streams them in Java — fine at current volumes, but it is O(all claims) per dashboard load, not a SQL aggregate. diff --git a/backend/src/main/resources/content/claims/settings/architect.md b/backend/src/main/resources/content/claims/settings/architect.md new file mode 100644 index 0000000..1536c75 --- /dev/null +++ b/backend/src/main/resources/content/claims/settings/architect.md @@ -0,0 +1,75 @@ +--- +module: claims.settings +title: Settings +tab: Architect +order: 30 +audience: internal +--- + +How the Claims **Settings** (vendor email templates) feature is built. Single-service, synchronous, DB-backed — **no Kafka, no executor/record-store pattern** in this feature. See [platform.service-ownership]. + +## Services involved + +- **`hiveops-claims`** owns this feature end to end (backend image `hiveiq-claims:8092`, frontend `hiveiq-claims-frontend:5179`). No other service participates. +- No cross-service calls, no Kafka producer/consumer for templates. (The wider claims service *does* consume `hiveops.device.events` into `device_summary`, but that is unrelated to Settings — see [platform.kafka].) + +## Data flow — reading the effective template + +``` +SettingsPage.svelte + └─ EmailTemplatesSection.svelte (onMount → emailTemplateAPI.effective()) + └─ GET api.bcos.cloud/claims/settings/email-templates/effective [JWT] + └─ EmailTemplateController.effective(ClaimsPrincipal) + └─ EmailTemplateRepository.findByInstitutionKeyAndIsDefaultTrue(institutionKey) + .or( findByInstitutionKeyAndIsDefaultTrue("__GLOBAL__") ) + └─ 200 {name, subject, global} or 204 No Content → UI fallback +``` + +The `global` boolean in the response is computed as `"__GLOBAL__".equals(template.institutionKey)` so the UI can label an inherited default. + +## Data flow — using the template (send path) + +The template is consumed at message-send time, not just displayed: + +``` +POST /api/claims/{claimUuid}/messages + └─ ClaimActivityService.addMessage(...) (persists claim_messages row) + └─ EmailService.sendMessageNotification(claim, vendor, text, sender, additionalEmail) + └─ EmailTemplateRepository.findByInstitutionKeyAndIsDefaultTrue(claim.institutionKey) + ├─ present → resolvePlaceholders(subject/bodyHtml) + └─ empty → __GLOBAL__ default → else hardcoded fallback body + └─ JavaMailSender.send(MimeMessage) (skipped if mail bean not configured) +``` + +Placeholders substituted in subject + body (HTML-escaped): `{{claimNumber}}`, `{{terminalId}}`, `{{location}}`, `{{shortageAmount}}`, `{{claimStatus}}`, `{{vendorName}}`, `{{messageText}}`, `{{senderEmail}}`. Attachment-notification emails (`sendAttachmentNotification`) use a **hardcoded** body, not the template. + +## Write path & the "single default" invariant + +- `EmailTemplateService.create/update`: when the request sets `isDefault=true`, it first runs the `@Modifying` query `clearDefaultForInstitution(institutionKey)` (UPDATE ... SET is_default=false WHERE institution_key=:key), then saves the new/updated row as default. This keeps at most one default per institution, which `findByInstitutionKeyAndIsDefaultTrue` (returns `Optional`) relies on. +- `update`/`delete` enforce ownership: `institutionKey.equals(template.institutionKey)` or throw 403; missing `uuid` → 404. + +## Tables read/written + +| Table | R/W | By | +|-------|-----|----| +| `email_templates` (DB `hiveiq_claims`) | R | `effective`, `list`, send path | +| `email_templates` | W | `create`, `update`, `delete`, `clearDefaultForInstitution` | + +Schema from `V2__email_templates.sql`: `id BIGSERIAL`, `uuid UUID UNIQUE`, `institution_key VARCHAR(255)` (indexed), `name`, `subject VARCHAR(500)`, `body_html TEXT`, `is_default BOOLEAN`, `created_at`, `updated_at`. The special `institution_key = '__GLOBAL__'` row is the shared inherited default. See [platform.data-architecture]. + +## API endpoints (all served by hiveops-claims) + +| METHOD | Path | Auth | Notes | +|--------|------|------|-------| +| GET | `/api/claims/settings/email-templates/effective` | JWT | 200 `{name,subject,global}` or 204 | +| GET | `/api/claims/settings/email-templates` | JWT | list for caller's institution | +| POST | `/api/claims/settings/email-templates` | JWT | 201; body `{name,subject,bodyHtml,isDefault}` | +| PUT | `/api/claims/settings/email-templates/{uuid}` | JWT | ownership-checked | +| DELETE | `/api/claims/settings/email-templates/{uuid}` | JWT | 204; ownership-checked | +| GET/POST/PUT/DELETE | `/api/internal/email-templates` | `X-Internal-Secret` + `?institutionKey=` | `permitAll` at security layer, secret-gated in controller; used for `__GLOBAL__` / cross-institution | + +## Security model + +`SecurityConfig` permits `/actuator/health|info`, swagger, `/h2-console/**`, `/error`, `/api/internal/**`, then `anyRequest().authenticated()`. **No role-based guards** — isolation is by `institutionKey` from the JWT (`JwtAuthenticationFilter` → `ClaimsPrincipal`). The `/api/internal/**` path is publicly routable but every method rejects unless `X-Internal-Secret` matches the non-blank `internal.service-secret` (`INTERNAL_SERVICE_SECRET`). + +Cross-links: [platform.data-architecture] · [platform.kafka] · [platform.service-ownership] diff --git a/backend/src/main/resources/content/claims/settings/claude.md b/backend/src/main/resources/content/claims/settings/claude.md new file mode 100644 index 0000000..0266fd6 --- /dev/null +++ b/backend/src/main/resources/content/claims/settings/claude.md @@ -0,0 +1,61 @@ +--- +module: claims.settings +title: Settings +tab: Claude +order: 40 +audience: internal +--- + +Terse reference. Feature = **vendor email templates** shown on the Claims Settings screen (read-only display of the *effective* template). + +## Ownership +- **Owner service:** `hiveops-claims` (image `hiveiq-claims`, port 8092, DB `hiveiq_claims`). No other service. No Kafka in this feature. +- External route: `api.bcos.cloud/claims/...` → `hiveiq-claims:8092/api/claims/...` +- Frontend view: `hiveops-claims/frontend/src/components/Settings/EmailTemplatesSection.svelte` (calls `effective` only). + +## Endpoints (METHOD path) +| METHOD | Path | Auth | Result | +|--------|------|------|--------| +| GET | `/api/claims/settings/email-templates/effective` | JWT | 200 `{name,subject,global}` / **204** if none | +| GET | `/api/claims/settings/email-templates` | JWT | list (own institution) | +| POST | `/api/claims/settings/email-templates` | JWT | 201; `{name,subject,bodyHtml,isDefault}` | +| PUT | `/api/claims/settings/email-templates/{uuid}` | JWT | 403 if not owner, 404 if uuid unknown | +| DELETE | `/api/claims/settings/email-templates/{uuid}` | JWT | 204 | +| GET/POST/PUT/DELETE | `/api/internal/email-templates?institutionKey=` | header `X-Internal-Secret` | cross-institution / `__GLOBAL__` | + +## Table +- `email_templates` (DB `hiveiq_claims`, migration `V2__email_templates.sql`) +- Cols: `id`, `uuid`, `institution_key`, `name`, `subject`, `body_html`, `is_default`, `created_at`, `updated_at` +- Global inherited default row: `institution_key = '__GLOBAL__'`, `is_default = true` + +## Topics +- None for this feature. (Service consumes `hiveops.device.events` for `device_summary` only — unrelated.) + +## Roles / auth +- **No `hasAnyRole` anywhere.** `SecurityConfig` → `anyRequest().authenticated()`. Any valid JWT works. +- Scope = `institutionKey` from JWT (`ClaimsPrincipal`). Write ops enforce `institutionKey == template.institution_key` else 403. +- `/api/internal/**` = `permitAll` in security, but each method requires `X-Internal-Secret` == `INTERNAL_SERVICE_SECRET` (blank default ⇒ all rejected). + +## Resolution logic (effective + send path, identical) +1. own institution default (`is_default=true`) → +2. `__GLOBAL__` default → +3. none → 204 (UI) / hardcoded fallback body (email send) + +## Template placeholders (subject + body_html, HTML-escaped) +`{{claimNumber}}` `{{terminalId}}` `{{location}}` `{{shortageAmount}}` `{{claimStatus}}` `{{vendorName}}` `{{messageText}}` `{{senderEmail}}` + +## Gotchas +- `{uuid}` path var is the `uuid` column, NOT numeric `id`. +- Only ONE default per institution — setting `isDefault=true` auto-clears others (`clearDefaultForInstitution`). +- Settings SPA is **read-only**: `list/create/update/delete` exist in `api.ts` + backend but are NOT wired to any UI. Manage templates via API only. +- Template used by `EmailService.sendMessageNotification` (message emails). **Attachment** emails use a hardcoded body, not the template. +- Email send silently skips if `MAIL_USERNAME`/`MAIL_PASSWORD` unset (no `JavaMailSender` bean); look for DEBUG "Mail not configured". +- Dev profile = H2 create-drop, Flyway off → templates vanish on restart. +- `MAIL_FROM` default `noreply@bcos.cloud`; `EmailService` uses `noreply@hiveops.com` only when blank. + +## Do NOT +- Do NOT add/expect a role check (`MSP_ADMIN`/`BCOS_ADMIN`) — none exists here; don't document one. +- Do NOT edit another institution's template via the JWT endpoints (403); use `/api/internal/**` with the secret for `__GLOBAL__`/cross-institution. +- Do NOT call `/api/internal/**` without a non-blank `INTERNAL_SERVICE_SECRET` set on the container — every call 401s. +- Do NOT assume a Kafka topic or executor→record-store flow for this feature — it's synchronous DB CRUD. +- Do NOT reference "HiveOps" in user-facing copy — brand is **HiveIQ**; `hiveops-ai` = **Adoons**. diff --git a/backend/src/main/resources/content/claims/settings/internal.md b/backend/src/main/resources/content/claims/settings/internal.md new file mode 100644 index 0000000..b2138bb --- /dev/null +++ b/backend/src/main/resources/content/claims/settings/internal.md @@ -0,0 +1,60 @@ +--- +module: claims.settings +title: Settings +tab: Internal +order: 20 +audience: internal +--- + +Ops/support view of the Claims **Settings** screen. The screen is a **read-only** display of the *effective* vendor-email template for the logged-in user's institution. Template management is **API-only** — there is no create/edit UI wired into this page (the SPA calls only the `effective` endpoint). + +## What the screen actually does + +- Frontend: `hiveops-claims/frontend/src/components/Settings/EmailTemplatesSection.svelte` calls `emailTemplateAPI.effective()` on mount and renders one of three states. +- Backend: `GET /api/claims/settings/email-templates/effective` (owner service `hiveops-claims`, image `hiveiq-claims`, port 8092). +- Resolution order (from `EmailTemplateController.effective`): + 1. This institution's own default (`is_default = true`, `institution_key = `) + 2. Global default (`institution_key = '__GLOBAL__'`, `is_default = true`) → shown as **Global Template (inherited)** + 3. Neither → HTTP **204** → UI shows **No template configured** (built-in fallback body is used at send time) + +## Roles required + +- **None beyond authentication.** `SecurityConfig` ends in `anyRequest().authenticated()` — there is **no** `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` guard anywhere in this service. Any user with a valid HiveIQ JWT can read the effective template and (via API) manage their own institution's templates. +- Isolation is purely by `institutionKey` taken from the JWT principal (`ClaimsPrincipal`), not by role. A user only ever sees/edits templates where `institution_key` matches their own JWT. + +## Admin-only actions (how templates get created) + +There is no in-app editor, so templates are created one of two ways: + +1. **Authenticated API (per-institution)** — acting as a user of that institution: + - `POST /api/claims/settings/email-templates` `{ name, subject, bodyHtml, isDefault }` + - `PUT /api/claims/settings/email-templates/{uuid}` + - `DELETE /api/claims/settings/email-templates/{uuid}` +2. **Internal service API (cross-institution / global)** — `/api/internal/email-templates`, gated by an `X-Internal-Secret` header (see failure modes). Requires `?institutionKey=` query param. Use `institutionKey=__GLOBAL__` to set the shared default that every institution inherits. + +Setting `isDefault: true` on create/update runs `clearDefaultForInstitution` first, so **only one default per institution** is ever active — you don't have to clear the old one manually. + +## First things to check when it misbehaves + +- **User sees "No template configured" but a template exists** → the template's `is_default` is `false`. Only the default is resolved. Set one template's `isDefault=true`. +- **User sees the Global template instead of their own** → their institution has no row with `is_default=true`; only `__GLOBAL__` does. Create/mark an institution-specific default. +- **Wrong institution's template appears** → check the JWT's `institutionKey` claim; scoping is entirely JWT-driven. A mis-issued token = wrong template. +- **Vendor emails go out with the wrong wording** → the *send path* (`EmailService.sendMessageNotification`) re-resolves the default the same way (own → `__GLOBAL__` → hardcoded fallback). If the Settings screen shows template X, message emails use X. Attachment notification emails do **not** use templates (hardcoded body) — that's expected. +- **204/500 on the endpoint** → confirm the service is up: `curl http://localhost:8092/actuator/health` on the services VM; route externally as `api.bcos.cloud/claims/settings/email-templates/effective` with a real Bearer token. + +## Common failure modes + fixes + +| Symptom | Likely cause | Fix | +|---------|--------------|-----| +| Screen stuck on "Loading…" or shows fallback | `effective` request failed (network/401); the component catches errors and sets `template=null` | Check JWT validity + nginx `claims/` route; look at `hiveiq-claims` logs | +| `/api/internal/email-templates` → 401 "Invalid service secret" | `INTERNAL_SERVICE_SECRET` env unset (default blank) or header mismatch | Set `INTERNAL_SERVICE_SECRET` on the container **and** send matching `X-Internal-Secret`. If blank, ALL internal calls are rejected | +| PUT/DELETE → 403 | Template's `institution_key` ≠ caller's JWT institutionKey | Edit only your own institution's templates; use the internal API for cross-institution | +| PUT/DELETE → 404 "Template not found" | Wrong `uuid` (this uses the `uuid` column, not the numeric `id`) | Use the `uuid` from the list response | +| Vendor emails never arrive | `MAIL_USERNAME`/`MAIL_PASSWORD` unset → `JavaMailSender` bean absent → send silently skipped (logged at DEBUG) | Configure `MAIL_*` env; check logs for "Mail not configured — skipping" | + +## Where the data lives + +- Table `email_templates` in DB `hiveiq_claims` (PostgreSQL, prod). Columns: `id`, `uuid`, `institution_key`, `name`, `subject`, `body_html`, `is_default`, `created_at`, `updated_at` (migration `V2__email_templates.sql`). +- Dev profile is H2 in-memory (`create-drop`, Flyway disabled) — templates reset on restart. + +See the customer-facing Overview tab for the end-user description, and the Architect tab for the build. diff --git a/backend/src/main/resources/content/claims/vendors/architect.md b/backend/src/main/resources/content/claims/vendors/architect.md new file mode 100644 index 0000000..d36d291 --- /dev/null +++ b/backend/src/main/resources/content/claims/vendors/architect.md @@ -0,0 +1,80 @@ +--- +module: claims.vendors +title: Vendors +tab: Architect +order: 30 +audience: internal +--- + +> **Internal · architecture.** How the Vendor Registry feature is built. All facts grounded in hiveops-claims source (2026-07-01). See [platform.service-ownership], [platform.data-architecture], [platform.kafka]. + +## Shape of the feature + +Vendors is the simplest slice of hiveops-claims: a single-entity CRUD-lite (create + read only) registry, fully self-contained in the **hiveops-claims** service. There are **no other services, no Kafka, and no cross-service calls** in this feature — it is one Svelte page, one controller, one service, one repository, one table. + +``` +VendorsPage.svelte / AddVendorModal.svelte + │ vendorAPI.list() / vendorAPI.create() (frontend/src/lib/api.ts) + ▼ +GET|POST api.bcos.cloud/claims/api/claims/vendors (NGINX → hiveiq-claims:8092) + ▼ +VendorController ──► VendorService ──► VendorRepository (JPA) ──► Postgres: vendors +``` + +Unlike claims (which do the executor→Kafka→record-store and `device_summary` mirror dance), **vendors never touch Kafka**. hiveops-claims consumes `hiveops.device.events` only to keep its `device_summary` read-model current for claim creation — that path is unrelated to the vendor registry. + +## Services involved + +- **hiveops-claims** — owns everything for this feature. Backend `hiveiq-claims` (port 8092, `/api/claims/...`), frontend `hiveiq-claims-frontend` (`claims.bcos.cloud`, port 5179). +- No incident / journal / devices / mgmt involvement for vendor read or write. + +## Data flow + +**Read (`GET /api/claims/vendors`)** +1. `VendorController.listVendors` reads `principal.institutionKey()` (populated by `JwtAuthenticationFilter` from the JWT `institutionKey` claim; principal type `ClaimsPrincipal(userId, email, role, institutionKey)`). +2. Optional `?type=CIT|SP` binds to `Vendor.VendorType`. With a type → `listVendorsByType`; without → `listVendors`. +3. `VendorService` → `VendorRepository.findByInstitutionKeyOrderByNameAsc` (or `...AndTypeOrderByNameAsc`). +4. Rows map to `VendorResponse` (uuid, name, type, contactName/Email/Phone, createdAt). +5. Frontend `VendorsPage` receives the flat list and splits it **client-side** into CIT vs SP (`vendors.filter(v => v.type === 'CIT')`), rendering two tables. (Note: the page's `typeFilter` variable exists but is not bound to any UI control, so the server-side `?type=` filter is effectively unused today.) + +**Write (`POST /api/claims/vendors`)** +1. `AddVendorModal` posts `{ name, type, contactName?, contactEmail?, contactPhone? }` (empty optionals omitted). +2. `CreateVendorRequest` validates `@NotBlank name`, `@NotNull type`. Email/phone are free text server-side. +3. `VendorService.createVendor` builds a `Vendor`, stamps `institutionKey` from the principal, and saves. `uuid`, `created_at`, `updated_at` are DB/entity-generated (`@GeneratedValue`, `@CreationTimestamp`, `@UpdateTimestamp`). +4. Returns `201 CREATED` with the `VendorResponse`. Frontend re-runs `load()` on `onCreated`. + +## Persistence + +Single table, `hiveiq_claims` DB (Flyway `V1__init_schema.sql`): + +| Column | Type | Notes | +|--------|------|-------| +| `id` | BIGSERIAL PK | internal FK target | +| `uuid` | UUID UNIQUE | public identifier in API | +| `name` | VARCHAR(255) NOT NULL | required | +| `type` | VARCHAR(3) NOT NULL | CHECK IN ('CIT','SP') | +| `contact_name` / `contact_email` / `contact_phone` | VARCHAR | optional | +| `institution_key` | VARCHAR(255) | scoping key; **nullable, no index** | +| `created_at` / `updated_at` | TIMESTAMPTZ | defaults NOW() | + +- **Referenced by** `claims.cit_vendor_id` and `claims.sp_vendor_id` (`BIGINT REFERENCES vendors(id)`). This FK is why vendors have no delete endpoint — a referenced vendor can't be removed. Also referenced conceptually by `claim_messages.vendor_id` / `claim_attachments.vendor_id`. +- No unique constraint on `(institution_key, name)` — duplicates are allowed. +- `dev` profile = H2 in-memory (`create-drop`, Flyway off); `prod` = Postgres + Flyway `validate`. + +## API endpoints (this feature only) + +| METHOD | Path | Handler | +|--------|------|---------| +| GET | `/api/claims/vendors` (`?type=CIT|SP`) | `VendorController.listVendors` | +| POST | `/api/claims/vendors` | `VendorController.createVendor` | + +Externally routed as `api.bcos.cloud/claims/api/claims/vendors`. Both require a valid JWT; there is no role authority check (see [claims.vendors] Internal tab). + +## Kafka + +None for vendors. (The service-level `hiveops.device.events` consumer feeds `device_summary` for claim filing, not the registry.) See [platform.kafka]. + +## Design gaps worth knowing + +- Create/read only — no update, no delete, no dedup (all confirmed absent in `VendorController`/`VendorRepository`). +- Scoping relies entirely on a correctly-populated JWT `institutionKey`; a blank claim silently pools vendors under a null key. diff --git a/backend/src/main/resources/content/claims/vendors/claude.md b/backend/src/main/resources/content/claims/vendors/claude.md new file mode 100644 index 0000000..832d112 --- /dev/null +++ b/backend/src/main/resources/content/claims/vendors/claude.md @@ -0,0 +1,57 @@ +--- +module: claims.vendors +title: Vendors +tab: Claude +order: 40 +audience: internal +--- + +> Act-without-guessing reference. All items code-confirmed in hiveops-claims (2026-07-01) unless marked (unverified). + +## Ownership + +- **Service:** hiveops-claims (`hiveiq-claims`, port 8092). Owns vendor read + write end to end. +- **Frontend:** `hiveiq-claims-frontend`, `claims.bcos.cloud`, port 5179. Page: `frontend/src/components/Vendors/VendorsPage.svelte`; create panel: `components/modals/AddVendorModal.svelte`; api: `lib/api.ts` → `vendorAPI`. +- No other service, no Kafka, no cross-service call in this feature. + +## Endpoints (exact) + +| METHOD | Path | Notes | +|--------|------|-------| +| GET | `/api/claims/vendors` | optional `?type=CIT` or `?type=SP` (binds `Vendor.VendorType`); no type → all | +| POST | `/api/claims/vendors` | body `{name*, type*, contactName?, contactEmail?, contactPhone?}`; returns `201` | + +- External route: `https://api.bcos.cloud/claims/api/claims/vendors`. +- Auth: `Authorization: Bearer ` required. Scope key = JWT `institutionKey` claim. +- Required fields: `name` (`@NotBlank`), `type` (`@NotNull`, `CIT`|`SP`). Email validated **client-side only**. +- Response fields: `uuid, name, type, contactName, contactEmail, contactPhone, createdAt`. + +## Storage + +- **DB:** `hiveiq_claims` (Postgres, `10.10.10.188` via ProxyJump). **Table:** `vendors`. +- Columns: `id, uuid, name, type('CIT'|'SP'), contact_name, contact_email, contact_phone, institution_key(nullable, no index), created_at, updated_at`. +- FK in: `claims.cit_vendor_id`, `claims.sp_vendor_id` → `vendors(id)`. +- **Kafka topics:** none for this feature. + +## Gotchas + +- **No update / no delete endpoint** — `VendorController` has only GET + POST. Typos are fixable only via direct SQL `UPDATE vendors`. +- **No role gate** — `SecurityConfig` = `anyRequest().authenticated()`. Do NOT assume `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')`; isolation is `institutionKey` only. +- **No dedup** — `(institution_key, name)` is not unique; POST twice = two rows. +- **Blank `institutionKey` JWT** pools vendors under a null key (nullable column) → apparent "missing" or cross-user leakage. +- Frontend splits CIT/SP **client-side**; `typeFilter` in `VendorsPage` is declared but not bound to any UI control, so `?type=` is currently unused. +- Deleting a vendor referenced by a claim fails the FK — unassign on the claim first. +- `dev` profile = H2 (`create-drop`, Flyway off); `prod` = Postgres + Flyway `validate`. + +## Do NOT + +- Do NOT tell users to edit or delete a vendor in the UI — no such action exists. +- Do NOT claim vendor changes emit Kafka events — they don't. +- Do NOT invent an MSP/all-institutions vendor view — every query is `institutionKey`-scoped. +- Do NOT expect a role/permission error path — auth failures are 401 (missing/invalid token), not 403 by role. +- Do NOT run `docker exec hiveiq-postgres` on the services VM — Postgres is on `10.10.10.188`. +- Do NOT reference this AI assistant as anything but "Adoons" externally. + +## Cross-links + +[claims.vendors] Overview · Internal · Architect. Service facts: [technical.claims]. Platform: [platform.service-ownership], [platform.kafka], [platform.data-architecture]. diff --git a/backend/src/main/resources/content/claims/vendors/internal.md b/backend/src/main/resources/content/claims/vendors/internal.md new file mode 100644 index 0000000..d4c14d9 --- /dev/null +++ b/backend/src/main/resources/content/claims/vendors/internal.md @@ -0,0 +1,64 @@ +--- +module: claims.vendors +title: Vendors +tab: Internal +order: 20 +audience: internal +--- + +> **Internal · ops/support/admin.** The Vendor Registry (`VendorsPage.svelte`) is a per-institution list of CIT (Cash-in-Transit) and SP (Service Provider) companies used when filing a claim. Backend owner: **hiveops-claims** (`hiveiq-claims`, port 8092). Grounded in code 2026-07-01. + +## Who can do what + +- **No role gate.** hiveops-claims security is **JWT-authentication-only** (`SecurityConfig` → `.anyRequest().authenticated()`). There is **no** `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` check on vendor endpoints — unlike most HiveOps services. Any authenticated user with a valid JWT can list and create vendors. +- **Isolation is by `institutionKey`, not role.** The controller passes `principal.institutionKey()` (read from the JWT `institutionKey` claim in `JwtAuthenticationFilter`) into every query. A user only ever sees/creates vendors for their own institution. Vendors are private to the institution — there is no cross-institution or MSP "all vendors" view. + +## What actions exist (and what's missing) + +Only **two** operations are wired end to end: + +| Action | UI | API | +|--------|----|-----| +| List vendors (CIT + SP) | Vendor Registry tables, split by type client-side | `GET /api/claims/vendors` | +| Register a vendor | **+ Add Vendor** panel (`AddVendorModal.svelte`) | `POST /api/claims/vendors` | + +- **There is NO edit endpoint and NO delete endpoint.** `VendorController` exposes only `@GetMapping` and `@PostMapping`. Once a vendor is created it is permanent — a typo in name/email/phone **cannot be corrected through the app**. Fixing it requires a direct SQL `UPDATE` on the `vendors` table (see below). +- **No de-duplication.** Nothing enforces unique `(institution_key, name)`. Registering "Acme CIT" twice creates two rows; both show in the list and both are assignable to claims. + +## First things to check when it misbehaves + +1. **"I don't see any vendors" / empty tables** — the list is scoped to the caller's `institutionKey`. If a user logged in with a JWT whose `institutionKey` claim is missing/blank, the query runs against a null/blank key and returns only vendors saved under that same blank key. Confirm the JWT actually carries `institutionKey` (auth service issue, not claims). +2. **"Vendor I added is gone / in the wrong institution"** — same root cause: the vendor was saved with whatever `institutionKey` the JWT had at create time. `institution_key` is nullable and never re-derived. Check the row directly. +3. **"Can't add vendor" toast** — the create panel surfaces the backend message. `name` and `type` are required (`@NotBlank name`, `@NotNull type`); email is only format-validated **client-side** (there is no server email validation). A 400 usually means missing name or an invalid `type` value. +4. **Wrong "Added" date format** — cosmetic only; `createdAt` comes straight from the DB `created_at` (`@CreationTimestamp`). +5. **Vendor exists but not selectable when filing a claim** — that's the claim side, not the registry. The vendor must exist first; assignment happens in the claim (`PUT /api/claims/{uuid}`). + +## Direct DB access (for the edit/delete gap) + +DB `hiveiq_claims` on the database VM (`10.10.10.188`, via ProxyJump — never the services VM). Table `vendors`. + +```bash +# List an institution's vendors +ssh -i ~/.ssh/id_ed25519_hiveiq -J bcosadmin@173.231.195.250 bcosadmin@10.10.10.188 \ + "docker exec hiveiq-postgres psql -U hiveiq -d hiveiq_claims \ + -c \"SELECT uuid, name, type, contact_email, institution_key FROM vendors WHERE institution_key='PBNC' ORDER BY name;\"" +``` + +- **Fix a typo (no app path exists):** `UPDATE vendors SET contact_email='new@vendor.com' WHERE uuid='...';` +- **Remove a bad/duplicate vendor:** deleting is only safe if it is not referenced. `claims.cit_vendor_id` / `claims.sp_vendor_id` are `BIGINT REFERENCES vendors(id)` — a `DELETE` on a referenced vendor will fail the FK constraint. Clear the reference on the claim first, or leave the vendor in place. + +## Common failure modes → fix + +| Symptom | Likely cause | Fix | +|---------|--------------|-----| +| Vendor list empty for one user only | JWT missing/blank `institutionKey` | Fix at auth/mgmt; vendors are scoped to that claim | +| Duplicate vendor rows | No unique constraint; double-submit or re-registration | Delete the unreferenced duplicate via SQL | +| Can't edit vendor details | No update endpoint exists (by design/gap) | `UPDATE vendors ...` directly | +| Delete fails with FK error | Vendor assigned to a claim (`cit_vendor_id`/`sp_vendor_id`) | Unassign on the claim, then delete | +| 401 on `/api/claims/vendors` | Missing/expired Bearer token | Re-auth; endpoint requires authentication | + +## Related + +- User-facing behavior: [claims.vendors] Overview tab. +- Build-it view: [claims.vendors] Architect tab. +- Service-wide facts: [technical.claims] overview. diff --git a/backend/src/main/resources/content/dashboard/cash-stats/architect.md b/backend/src/main/resources/content/dashboard/cash-stats/architect.md new file mode 100644 index 0000000..6d06cef --- /dev/null +++ b/backend/src/main/resources/content/dashboard/cash-stats/architect.md @@ -0,0 +1,87 @@ +--- +module: dashboard.cash-stats +title: Cash Stats +tab: Architect +order: 30 +audience: internal +--- + +How the **Cash Stats** page is built end-to-end. It is a thin read view over data that another service derives — the interesting work happens in `hiveops-journal`, not in the dashboard. See [platform.service-ownership]. + +## Services involved + +| Service | Role for this feature | +|---------|----------------------| +| `hiveops-dashboard` (SPA, 5181) | Renders `CashStatus.svelte`; no backend | +| `hiveops-incident` (8080) | Owns `journal_events` + `atms`; serves the read API | +| `hiveops-journal` (8087, **upload** role) | Parses ATM journals, extracts cassette counts, **writes into incident's DB** | + +## Data flow + +``` +Agent → agent-proxy → journal (chunked upload) ─┐ + │ parse (per-institution worker) +journal.parse-requests.{INSTITUTION} ────────────┘ + │ worker parses file, persists JournalTransaction (STARTUP has rawLines) + ▼ +journal.file-parsed ──► CassetteInventoryService (upload role, groupId journal-cassette-inventory) + │ extracts [DENOMINATION] / RCY USD / MIX / RETRACT slots from the newest STARTUP + ▼ + incident DB: journal_events (event_type='CASSETTE_INVENTORY', event_source='JOURNAL') + ▲ + │ GET /api/journal-events/fleet/cassette-inventory (+ GET /api/atms) +CashStatus.svelte ── merges events with ATM list, colours pills, sorts empty→low→ok→config→nodata +``` + +Key point: this is the **executor→Kafka→record-store** pattern applied across a service boundary. `hiveops-journal` is the executor (parse), `journal.file-parsed` is the event, and the "record store" it writes to is **incident's `journal_events` table** via a second read/write JDBC datasource (`incidentJdbcTemplate`), not its own DB. See [platform.kafka], [platform.data-architecture]. + +## Kafka + +- **Topic:** `journal.file-parsed` (constant `JournalKafkaConfig.TOPIC_FILE_PARSED`), produced by `FileParsedEventProducer`. +- **Consumer for this feature:** `CassetteInventoryService.onFileParsed` — `@KafkaListener(groupId="journal-cassette-inventory", auto.offset.reset=latest)`. Runs **only in the upload role** (`@ConditionalOnProperty journal.role=upload`). +- Offset reset = **latest**: on a fresh consumer it ignores backlog; use the admin backfill to seed history. + +## Write path (journal → incident) + +`CassetteInventoryService.processFile(fileId, atmId)`: +1. Load STARTUP transactions for the file; take the most recent; read `rawLines`. +2. `extractSlots()` — regex the STARTUP text: + - `[DENOMINATION]` table (preferred) → per-slot denom + count, type `RECYCLER`. + - else `RCY USD` lines (older firmware). + - `MIX CASSETTE COUNT` → slot `E` (type MIX), `RETRACT CASSETTE COUNT` → slot `R` (type RETRACT). + - Slots sorted by denomination, lettered `A, B, C...`. +3. `resolveNumericId(atmId)` → `SELECT id FROM atms WHERE atm_id=?` (incident). Null → skip + warn. +4. **Freshness guard:** if incident already has a newer `CASSETTE_INVENTORY.event_time` for that ATM, skip. +5. Otherwise `DELETE` existing `CASSETTE_INVENTORY` rows for the ATM, then `INSERT` fresh slots (`event_source='JOURNAL'`, `cassette_currency='USD'`). + +`backfillAll()` does the same across every ATM with STARTUPs (admin sync endpoint). + +## Read path (incident → dashboard) + +`JournalEventController.getFleetCassetteInventory()` → `JournalEventService.getFleetCassetteInventory(scope)`: +- Scope from `InstitutionContext.resolveScope()` → resolved to institution *names* via `institutionKeyService`. +- Repo query returns the **latest event per (atm_id, cassettePosition)** using `MAX(id)` grouped by `(atm.id, COALESCE(cassettePosition,''))`, with `JOIN FETCH e.atm` (no N+1). Three variants: unrestricted / single-institution / `institution IN (...)`. +- For ATMs with **no** live inventory events, it synthesizes slots from `AtmProperties`: + - Source 1: agent-synced `cassetteConfigs` map (`position → "currency:denomination"`). + - Source 2: manually applied `CassetteConfiguration` template. + - These are flagged `event_source='CASSETTE_CONFIG'` → the UI renders them as "(no data)" config-only pills. + +The frontend (`CashStatus.svelte`) does the rest client-side: groups events by ATM, computes empty/low/ok (`LOW_BILL_THRESHOLD = 200` bills), sorts empty→low→ok→config→no-data, and merges in ATMs from `/api/atms` that returned no events at all as "No inventory reported." + +## DB tables + +| Table | Service / DB | Access | +|-------|--------------|--------| +| `journal_events` | incident (`hiveiq_incident`) | read by API; **written by journal** via `incidentJdbcTemplate` | +| `atms` | incident | read (both by API and by journal's `resolveNumericId`) | +| `journal_transactions` | journal (`hiveiq_journal`) | read by `CassetteInventoryService` (STARTUP rawLines) | + +## API endpoints (this feature) + +| Method | Path | Service | Notes | +|--------|------|---------|-------| +| GET | `/api/journal-events/fleet/cassette-inventory` | incident | institution-scoped fleet inventory | +| GET | `/api/atms` | incident | ATM list for merge/labels | +| POST | `/api/journal/admin/sync-cassette-inventory` | journal | admin backfill (`MSP_ADMIN`/`BCOS_ADMIN`) | + +Cross-links: [platform.data-architecture], [platform.kafka], [platform.service-ownership]. diff --git a/backend/src/main/resources/content/dashboard/cash-stats/claude.md b/backend/src/main/resources/content/dashboard/cash-stats/claude.md new file mode 100644 index 0000000..1c8b6e7 --- /dev/null +++ b/backend/src/main/resources/content/dashboard/cash-stats/claude.md @@ -0,0 +1,56 @@ +--- +module: dashboard.cash-stats +title: Cash Stats +tab: Claude +order: 40 +audience: internal +--- + +Terse agent reference for `dashboard.cash-stats`. Read view over cassette bill counts. + +## Ownership +- **Frontend:** `hiveops-dashboard` → `frontend/src/components/Dashboard/CashStatus.svelte` (no backend). +- **Read API owner:** `hiveops-incident` — `JournalEventController` / `JournalEventService`. +- **Data producer:** `hiveops-journal` (upload role) — `CassetteInventoryService` writes into incident's DB. +- Dashboard SPA has **no server**; all data is incident's. + +## Endpoints (METHOD path → service) +| Method | Path | Service | Auth | +|--------|------|---------|------| +| GET | `/api/journal-events/fleet/cassette-inventory` | incident | any app role, institution-scoped | +| GET | `/api/atms` | incident | any app role | +| POST | `/api/journal/admin/sync-cassette-inventory` | journal | `MSP_ADMIN`/`BCOS_ADMIN` | +| POST | `/api/journal/admin/reconcile` | journal | `MSP_ADMIN`/`BCOS_ADMIN` (run post-deploy) | + +- Frontend calls these via configured API base (`window.__APP_CONFIG__.apiUrl`); paths above are the incident controller mappings. + +## Tables +- `journal_events` — incident DB (`hiveiq_incident`). Filter `event_type='CASSETTE_INVENTORY'`. **Written by journal**, read by incident API. +- `atms` — incident DB. Journal resolves `atm_id` (string) → `id` (numeric) here. +- `journal_transactions` — journal DB (`hiveiq_journal`). Source STARTUP `rawLines`. + +## Topics +- `journal.file-parsed` — consumed by `CassetteInventoryService` (groupId `journal-cassette-inventory`, `auto.offset.reset=latest`). +- No topic is consumed/produced by the dashboard itself. + +## Roles +- Read endpoints: `USER, CUSTOMER, ADMIN, MSP_ADMIN, QDS_ADMIN, BCOS_ADMIN` (incident `/api/**`). +- Scope: `InstitutionContext.resolveScope()` → `CUSTOMER`/`MSP_ADMIN` scoped to JWT institution keys; others unrestricted. +- Journal admin: `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')`. + +## Gotchas +- `event_source='CASSETTE_CONFIG'` = **synthetic config-only** slot (no real count) — UI shows "(no data)". Real data = `event_source='JOURNAL'` (from journal) or agent ingest. +- Journal write **deletes then inserts** all `CASSETTE_INVENTORY` rows for an ATM per update; has a freshness guard (skips if existing `event_time` newer). +- Read query uses `MAX(id)` per `(atm_id, COALESCE(cassette_position,''))` as recency proxy — assumes monotonic ids. +- Currency hardcoded `USD`; type from STARTUP is `RECYCLER`/`MIX`/`RETRACT`. MIX/RETRACT have null denomination. +- Low threshold = **200 bills** (frontend `LOW_BILL_THRESHOLD`), empty = 0 bills, evaluated only on slots with denomination > 0. +- `CassetteInventoryService` runs **only in journal upload role** (`journal.role=upload`). +- ATMs whose `atm_id` isn't in incident `atms` get skipped (logged warn), so they show as "No Data." + +## Do NOT +- Do NOT add a cassette/cash endpoint to `hiveops-dashboard` — it has no backend; use incident. +- Do NOT write `CASSETTE_INVENTORY` rows from anywhere but journal's `CassetteInventoryService` (single writer owns the delete+insert). +- Do NOT expect Kafka backlog replay — consumer is `auto.offset.reset=latest`; use `sync-cassette-inventory` to backfill. +- Do NOT assume "last reported" = now; it's the STARTUP transaction time. +- Do NOT bypass institution scoping by querying the table directly for a customer context. +- Do NOT invent per-position history — read path keeps only the latest per slot (getCassetteInventory keeps latest + previous for single-ATM only). diff --git a/backend/src/main/resources/content/dashboard/cash-stats/internal.md b/backend/src/main/resources/content/dashboard/cash-stats/internal.md new file mode 100644 index 0000000..3c7cc69 --- /dev/null +++ b/backend/src/main/resources/content/dashboard/cash-stats/internal.md @@ -0,0 +1,54 @@ +--- +module: dashboard.cash-stats +title: Cash Stats +tab: Internal +order: 20 +audience: internal +--- + +Ops / support view for the **Cash Stats** page on `dashboard.bcos.cloud`. It shows the latest cassette bill counts per ATM across the fleet. The page is **read-only** — there are no admin actions on the page itself; all levers are on the backend that feeds it. + +## Who serves this + +- **Frontend:** `hiveops-dashboard` (SPA, port 5181) — `CashStatus.svelte`. No backend of its own. +- **Data API:** `hiveops-incident` backend (port 8080) — `JournalEventController`. +- **Data producer:** `hiveops-journal` (upload role) parses ATM STARTUP journals and writes `CASSETTE_INVENTORY` rows into the **incident** DB. + +The page makes exactly two calls on load and every 60 s: +- `GET /api/journal-events/fleet/cassette-inventory` (incident) +- `GET /api/atms` (incident) + +## Roles required + +- Both endpoints fall under incident's `/api/**` rule → **any authenticated app user**: `USER, CUSTOMER, ADMIN, MSP_ADMIN, QDS_ADMIN, BCOS_ADMIN`. (`/api/journal-events` POST also allows `AGENT`, but that's ingest, not this page.) +- **Institution scoping is automatic** (`InstitutionContext.resolveScope`): `CUSTOMER`/`MSP_ADMIN` see only their granted institution keys; `BCOS_ADMIN/ADMIN/QDS_ADMIN/USER` see the whole fleet. If a customer says "I only see some ATMs," that's correct scoping, not a bug. +- **Admin backfill/sync is `MSP_ADMIN` or `BCOS_ADMIN` only** (journal `AdminController`). + +## First things to check when it misbehaves + +1. **Whole page errors / red banner** → incident backend down or unreachable. `curl` the two endpoints with a real JWT; expect `200` + JSON array. +2. **Everything shows "No Data" / "No inventory reported"** → no `CASSETTE_INVENTORY` rows in incident `journal_events`. Usual cause: journal parsing stalled or the cassette-inventory Kafka consumer isn't running. Check `hiveops-journal` (upload role) is up and that files are parsing (`parse_status=PARSED`). +3. **One ATM stuck stale / never updates** → its STARTUP journals aren't parsing, or the ATM's `atm_id` string doesn't resolve to a numeric id in incident's `atms` table (`resolveNumericId` returns null → row skipped, logged as a warning). +4. **Counts look wrong for a recycler** → the journal parser reads the `[DENOMINATION]` table (or falls back to `RCY USD` lines) out of the STARTUP record; a firmware format it doesn't match yields missing/zero slots. +5. **Cassettes show but say "(no data)"** → those are **config-only** synthetic slots. The ATM has a cassette *configuration* (agent-synced or a manually applied template) but has reported no actual bill count yet. Not an error. + +## Admin actions (backend, not on the page) + +- **Force a fleet re-derive of cash inventory** from the latest STARTUP journal of every ATM: + `POST /api/journal/admin/sync-cassette-inventory` (journal service, `MSP_ADMIN`/`BCOS_ADMIN`). Runs `CassetteInventoryService.backfillAll()`. +- After any journal deploy, run the standard journal **reconcile** (`POST /api/journal/admin/reconcile`) so stranded files finish parsing — otherwise new inventory never lands. + +## Common failure modes → fix + +| Symptom | Likely cause | Fix | +|---------|--------------|-----| +| All "No Data" | journal upload role down / no parsed STARTUPs | Bring up `hiveiq-journal`; verify files reach `PARSED`; then `sync-cassette-inventory` | +| One ATM never updates | `atm_id` not in incident `atms`, or no STARTUP parsed | Confirm ATM exists in incident; check journal parse logs for that ATM | +| Newer count not showing | journal skips write because existing `event_time` is newer | Expected guard; a genuinely newer STARTUP will overwrite | +| Customer sees subset of ATMs | institution scoping from JWT | Working as intended | +| 401/403 on load | expired/invalid JWT, or user has none of the allowed roles | Re-auth; check role | + +## Data freshness + +- The "last reported" time per ATM is the `event_time` of its newest cassette event — for journal-derived data this is the **STARTUP transaction time**, not "now." A stale time means the ATM hasn't produced a fresh STARTUP journal, not that the page is broken. +- Page auto-refreshes every 60 s and on the **↻** button; there's no server-side push. diff --git a/backend/src/main/resources/content/dashboard/overview/architect.md b/backend/src/main/resources/content/dashboard/overview/architect.md new file mode 100644 index 0000000..4364710 --- /dev/null +++ b/backend/src/main/resources/content/dashboard/overview/architect.md @@ -0,0 +1,44 @@ +--- +module: dashboard.overview +title: Fleet Overview +tab: Architect +order: 30 +audience: internal +--- + +## Shape of the feature +**Fleet Overview** is a **read-only aggregation view with no backend of its own.** `hiveops-dashboard` is a static Svelte/Vite SPA (port 5181, `dashboard.bcos.cloud`). All state lives in three owning services; the SPA is a thin client that composes their responses into KPI cards, health bars, task stats, and a recent-incidents table. + +Component: `hiveops-dashboard/frontend/src/components/Dashboard/Dashboard.svelte` +Data layer: `frontend/src/lib/api.ts` (single axios instance) + `frontend/src/lib/stores.ts` (`atmSummary`, `dashboardStats`, `fleetStats` writables, `loadAll()`). + +## Services involved (three-way fan-out) +The SPA has **one** base URL (`window.__APP_CONFIG__.apiUrl` from `VITE_API_URL`, injected by `frontend/entrypoint.sh` into `config.js` at container start). It calls four relative paths that are **owned by three different services**, so the deployment relies on NGINX path-routing to fan a single origin out to the right backend (the exact nginx map is in the ops repo, not here — verify there if a path 404s): + +| Path | Service | Handler | Backing store | +|---|---|---|---| +| `GET /atms/summary` | hiveops-devices | `AtmController#getAtmSummary` (`@RequestMapping({"/api/atms","/api/devices"})`) | `hiveiq_devices.atms` (+ `atm_properties.last_heartbeat`) | +| `GET /atms/dashboard/stats` | hiveops-incident | `AtmDashboardController#getDashboardStats` (`/api/atms/dashboard/stats`) → `AtmService#getDashboardStats` | `hiveiq_incident.incidents`, `hiveiq_incident.atms` | +| `GET /fleet/tasks/stats` | hiveops-fleet | `FleetApiController#getStats` (`/api/fleet/{stats,tasks/stats}`) → `FleetTaskService#getStats` | `hiveiq_fleet.fleet_tasks`, `hiveiq_fleet.fleet_artifacts` | +| `GET /users/me` | hiveops-incident | `UserController#getCurrentUser` (`/api/users/me`) | JWT claims only (no DB) | + +## Data flow +1. `onMount` in `Dashboard.svelte` calls `loadAll()` → `Promise.all([loadAtmSummary(), loadDashboardStats(), loadFleetStats()])`, then repeats on a 60s tick (auto-refresh) or manual button. +2. Each loader wraps its call in an isolated `try/catch`; a failure zeroes only that store, so cards degrade independently. +3. `App.svelte` separately calls `GET /users/me` to resolve `userRole` (CUSTOMER hides the "View all" incidents link; Electron `electronAPI.getUserProfile` fallback). + +### Per-service internals +- **devices `/atms/summary`** — loads institution-scoped ATMs, computes `connected/disconnected/neverConnected` from `AtmDTO.agentConnectionStatus` (derived from `last_heartbeat`) and a `byModel` histogram. Pure read; no Kafka in the request path. +- **incident `/atms/dashboard/stats`** — `@Cacheable("dashboardStats")` keyed by institution scope. Reads `incidents` (open/critical/this-week counts + last 10 for the table) and `atms` (`totalAtms`, `operationalAtms` where `status == IN_SERVICE`). Institution names resolved via `institutionKeyService.resolveInstitutionNames`. +- **fleet `/fleet/tasks/stats`** — `taskRepository.count()` for total, `countByTaskKindAndStatus(AUTO_UPDATE, PENDING)` for pending, `findByStatus(RUNNING).size()` for running, `artifactRepository.count()` for artifacts. `completed`, `failed`, and `totalModulesReporting` are **not computed** (see uncertainties). No institution filter applied. + +## Kafka / mirrors (context, not request-path) +This feature reads each service's **own** primary tables, so Kafka is not in the live read path. It is relevant to correctness because device identity is mirrored fleet-wide: hiveops-devices publishes `hiveops.device.events`, which hiveops-fleet (and incident/reports/recon) consume into a local `device_summary` mirror. The dashboard's ATM counts come from the devices `atms` table directly; fleet task stats come from `fleet_tasks`. If device naming/institution on the dashboard disagrees with Fleet, suspect a stale `device_summary` mirror rather than the dashboard. See [platform.kafka], [platform.data-architecture]. + +## Ownership boundaries +- Device inventory & connection status → **hiveops-devices** (source of truth). +- Incidents & the recent-incident list → **hiveops-incident**. +- Fleet task/artifact aggregates → **hiveops-fleet**. +- The dashboard SPA owns **nothing** — it must never grow its own endpoints or tables; new numbers belong in the owning service and are surfaced here. See [platform.service-ownership]. + +Cross-links: [platform.data-architecture] · [platform.kafka] · [platform.service-ownership] · [dashboard.overview] diff --git a/backend/src/main/resources/content/dashboard/overview/claude.md b/backend/src/main/resources/content/dashboard/overview/claude.md new file mode 100644 index 0000000..1539ea1 --- /dev/null +++ b/backend/src/main/resources/content/dashboard/overview/claude.md @@ -0,0 +1,53 @@ +--- +module: dashboard.overview +title: Fleet Overview +tab: Claude +order: 40 +audience: internal +--- + +## Identity +- Frontend: `hiveops-dashboard` (Svelte/Vite SPA, port 5181, `dashboard.bcos.cloud`). **No backend, no DB, no Kafka of its own.** +- Component: `hiveops-dashboard/frontend/src/components/Dashboard/Dashboard.svelte`; data: `frontend/src/lib/{api.ts,stores.ts}`. +- Single axios base: `window.__APP_CONFIG__.apiUrl` (`VITE_API_URL`, default `http://localhost:8080`), set by `frontend/entrypoint.sh`. + +## Endpoints (METHOD + path, relative to `apiUrl`) +| Method | Path | Owner service | Backend mapping | Auth | +|---|---|---|---|---| +| GET | `/atms/summary` | hiveops-devices | `AtmController` `/api/atms` + `/api/devices` → `/summary` | JWT, institution-scoped, no `@PreAuthorize` | +| GET | `/atms/dashboard/stats` | hiveops-incident | `AtmDashboardController` `/api/atms/dashboard/stats` | JWT, institution-scoped, `@Cacheable("dashboardStats")` | +| GET | `/fleet/tasks/stats` | hiveops-fleet | `FleetApiController` `/api/fleet/{stats,tasks/stats}` | `hasAnyRole('USER','CUSTOMER','ADMIN','MSP_ADMIN','BCOS_ADMIN')` | +| GET | `/users/me` | hiveops-incident | `UserController` `/api/users/me` | JWT (claims only) | + +## Owning service per data element +- ATM totals / connected / disconnected / neverConnected / byModel → **devices** (`atms` table, status from `agentConnectionStatus`/`last_heartbeat`). +- openIncidents / criticalIncidents / incidentsThisWeek / recentIncidents / totalAtms / operationalAtms → **incident** (`incidents`, `atms`). +- totalTasks / pendingTasks / runningTasks / completedTasks / failedTasks / totalArtifacts / totalModulesReporting → **fleet** (`fleet_tasks`, `fleet_artifacts`). + +## Tables +- `hiveiq_devices.atms`, `hiveiq_devices.atm_properties` +- `hiveiq_incident.incidents`, `hiveiq_incident.atms` +- `hiveiq_fleet.fleet_tasks`, `hiveiq_fleet.fleet_artifacts` (also `fleet_task_history`, `device_summary` — NOT read by this feature's stats) + +## Kafka +- `hiveops.device.events` — published by devices, consumed into `device_summary` mirrors in fleet/incident/reports/recon. **Not in this feature's request path** (dashboard reads primary tables directly). + +## Gotchas +- `/atms/dashboard/stats` exists in **both** hiveops-incident AND hiveops-devices. Only the **incident** one populates incident fields. Routing determines which answers. +- `/atms/summary` (devices) and `/atms/dashboard/stats` (incident) share the `/atms/` prefix but land on **different services** — relies on nginx path routing (ops repo, not in tree). +- `FleetTaskService.getStats()` hard-codes `completedTasks=0`, `failedTasks=0`; never sets `totalModulesReporting` (→ always 0 on the page). Do NOT treat as an outage. +- `getStats()` `pendingTasks` = only `AUTO_UPDATE` PENDING (undercounts other kinds). +- `getStats()` ignores its `institutionKeys` arg → task/artifact totals are fleet-wide even for scoped users (inconsistent with scoped ATM/incident numbers). +- Each SPA loader `try/catch`es independently: one failing call zeroes only its card, silently (`console.error` only). +- CUSTOMER role only hides the "View all" incidents link; not an admin surface. +- Auto-refresh state in `localStorage['dashboard_autoRefresh']`; 60s tick. + +## Do NOT +- Do NOT add endpoints or tables to `hiveops-dashboard` — it is a pure client; put new data in the owning service. +- Do NOT assume `/atms/dashboard/stats` hits incident — verify routing before diagnosing wrong incident counts. +- Do NOT report Completed/Failed/Modules-Reporting = 0 as a bug in the dashboard; it originates in `FleetTaskService.getStats`. +- Do NOT expect fleet task/artifact totals to respect institution scope. +- Do NOT write to `device_summary` to "fix" dashboard ATM counts — those come from the devices `atms` table. +- Do NOT add role guards to `/atms/summary` or `/atms/dashboard/stats` assuming MSP_ADMIN-only; current auth is any valid JWT, institution-scoped. + +Refs: [platform.service-ownership] · [platform.kafka] · [platform.data-architecture] · [dashboard.overview] diff --git a/backend/src/main/resources/content/dashboard/overview/internal.md b/backend/src/main/resources/content/dashboard/overview/internal.md new file mode 100644 index 0000000..4077417 --- /dev/null +++ b/backend/src/main/resources/content/dashboard/overview/internal.md @@ -0,0 +1,42 @@ +--- +module: dashboard.overview +title: Fleet Overview +tab: Internal +order: 20 +audience: internal +--- + +Ops/support view of the **Fleet Overview** landing page (`dashboard.bcos.cloud`, hiveops-dashboard, port 5181). This is a **frontend-only SPA** — it holds no data of its own. Every number on the page is fetched live from three other backends. If a card is wrong, the bug is almost always in the owning backend, not the dashboard. + +## What the page actually calls +The SPA loads everything through one axios base URL (`window.__APP_CONFIG__.apiUrl` = `VITE_API_URL`, default `http://localhost:8080`) and fans out to four endpoints: + +| Card / section | Endpoint (relative to `apiUrl`) | Owning service | +|---|---|---| +| Total ATMs, Connected, Fleet Health bars, By Model | `GET /atms/summary` | **hiveops-devices** | +| Open Incidents, Critical, Incidents this week, Recent Incidents | `GET /atms/dashboard/stats` | **hiveops-incident** | +| Active Tasks, Fleet Operations counts, Artifacts, Modules Reporting | `GET /fleet/tasks/stats` | **hiveops-fleet** | +| Sidebar user / role (hides "View all" for CUSTOMER) | `GET /users/me` | **hiveops-incident** | + +`loadAll()` fires all three data calls in parallel every 60s (auto-refresh toggle, persisted in `localStorage['dashboard_autoRefresh']`) plus a manual refresh button. + +## Roles required +- **`/atms/summary`** and **`/atms/dashboard/stats`** — no method-level `@PreAuthorize`; access is a valid JWT enforced by each service's SecurityFilterChain. Results are **institution-scoped** via `InstitutionContext.resolveScope()` (a scoped user sees only their institutions' ATMs/incidents). +- **`/fleet/tasks/stats`** — `@PreAuthorize("hasAnyRole('USER','CUSTOMER','ADMIN','MSP_ADMIN','BCOS_ADMIN')")`. +- **CUSTOMER** role only changes one thing on this page: the **"View all →"** link on Recent Incidents is hidden. +- There are **no admin-only actions** on this page — it is read-only. Data mutations happen in the Incidents / Fleet / Devices apps. + +## First things to check when it misbehaves +1. **A whole card reads 0 / blank** → open DevTools Network. Each of the 3 data calls fails independently (each has its own `try/catch` that only `console.error`s). A single 401/403/500 silently zeroes just that card; the rest of the page still renders. +2. **"Completed" and "Failed" task counts are always 0, "Modules Reporting" always 0** → this is **expected / a known backend limitation**, not a data outage. `FleetTaskService.getStats()` hard-codes `completed=0`, `failed=0` and never sets `totalModulesReporting`. Do not chase it as a dashboard bug. +3. **"Pending" tasks look too low** → `getStats()` counts only `AUTO_UPDATE` PENDING tasks, not all pending kinds. Also a backend limitation. +4. **Task/Artifact totals ignore institution** → `getStats()` does **not** apply `institutionKeys`; totals are fleet-wide even for a scoped user, while the ATM and incident numbers above ARE scoped. Expect the "Active Tasks" number to look inconsistent with the scoped ATM counts. +5. **Recent Incidents / incident counts wrong** → suspect nginx routing of `/atms/dashboard/stats`. This exact path exists in **both** hiveops-incident and hiveops-devices; only the incident version populates `recentIncidents`, `openIncidents`, `criticalIncidents`, `incidentsThisWeek`. If those fields are empty but ATM counts are fine, the call is landing on devices instead of incident. +6. **Numbers stale for ~a while** → `getDashboardStats` in incident is `@Cacheable("dashboardStats")` keyed by institution scope. A cache TTL/eviction lag, not a broken query. +7. **Auto-refresh "stuck"** → check the toggle; state persists in `localStorage`. A user who turned it off sees a frozen page until manual refresh. +8. **Fleet Health "Never Connected" spike** → device-side status; a machine added but never checked in (`agentConnectionStatus == NEVER_CONNECTED`). Not a dashboard fault. + +## Connection-status definitions (as computed in devices) +`connected/disconnected/neverConnected` come from `AtmDTO.getAgentConnectionStatus()`, derived from each ATM's last heartbeat (`CONNECTED` < grace, `DISCONNECTED` beyond it, `NEVER_CONNECTED` = never checked in). If health bars disagree with the Devices app, both read the same `hiveiq_devices` source — reconcile there. + +See also: [dashboard.overview] (customer tab), [platform.service-ownership]. diff --git a/backend/src/main/resources/content/devices/agent-profile/architect.md b/backend/src/main/resources/content/devices/agent-profile/architect.md new file mode 100644 index 0000000..1869e4e --- /dev/null +++ b/backend/src/main/resources/content/devices/agent-profile/architect.md @@ -0,0 +1,80 @@ +--- +module: devices.agent-profile +title: Agent Profile +tab: Architect +order: 30 +audience: internal +--- + +How the **Agent Profile** feature is built end to end. Profiles are lightweight hardware-grouping records; the "which modules" logic is resolved at render time by joining profile records against a CDN-hosted module manifest. See [platform.service-ownership], [platform.data-architecture], [platform.kafka]. + +## Services involved + +| Component | Role | +|-----------|------| +| `hiveops-devices` frontend (Svelte, port 5177) | `components/Settings/AgentProfile.svelte` — CRUD table + modal, module-count expansion | +| `hiveops-devices` backend (Spring Boot, port 8096) | `AgentProfileController` / `AgentProfileService` / `AgentProfileRepository` — owns profile records and proxies the CDN manifest | +| CDN (`cdn.bcos.cloud`) | Hosts `downloads/agent/modules/modules.json` — the module→profile mapping | +| Agent (on-ATM) | Reads its own `atm.hardware.profile` property and pulls matching modules | + +There is **no cross-service call** for this feature: it is entirely within `hiveops-devices`, which is the source of truth for device/agent-config data. The only external dependency is the CDN fetch. + +## Data flow + +### Profile CRUD (admin write path) +``` +AgentProfile.svelte ──HTTP──▶ AgentProfileController ──▶ AgentProfileService ──▶ agent_profiles (hiveiq_devices) + getAll/create/update/delete (@PreAuthorize on mutations) (@Transactional) +``` +- `getAll()` → `repo.findAllByOrderBySortOrderAscProfileKeyAsc()` (sort_order asc, then key). +- `create()` → 409 if PK exists; key is trimmed + lowercased. +- `update()` → mutates label/description/enabled/sortOrder only; **key is immutable** (PK, disabled in the UI). +- `delete()` → guarded by `isProfileInUse(key)`: `SELECT COUNT(a) > 0 FROM Atm a WHERE a.hardwareProfile = :key`; returns 409 when any ATM still references it. +- `@PrePersist`/`@PreUpdate` set `created_at`/`updated_at`. + +### Module resolution (read path — the interesting part) +``` +AgentProfile.svelte ─GET /modules-manifest─▶ AgentProfileController + │ 60s in-memory cache (AtomicReference + AtomicLong expiry) + ▼ + HTTPS GET cdn.bcos.cloud/downloads/agent/modules/modules.json + │ adds X-CDN-Token header (agent.cdn.token) server-side + ▼ + {"modules":[{name,version,filename,profiles?[]}]} +``` +The SPA joins the manifest against each profile **client-side**: +- module with empty/absent `profiles[]` → installs on all ATMs (universal). +- module whose `profiles[]` contains the profileKey → **exclusive** to that profile (★ / "+N exclusive" badge). + +So a profile record itself stores no module list; membership is derived. This keeps module bundles versioned on the CDN independent of the DB. + +### Agent-side effect (out of scope of this screen, for context) +An ATM's `atm.hardware.profile` agent property is matched against `agent_profiles.profile_key` and against `modules.json`. The `enabled` flag pauses provisioning for that profile. + +## Kafka + +**None for this feature.** Profile CRUD does not produce or consume Kafka events. (The broader `hiveops-devices` service publishes `hiveops.device.events` for device rows via `DeviceEventPublisher`, and consumes `hiveops.fleet.task.events` / `journal.replenishments` — but agent-profile writes touch neither.) See [platform.kafka]. + +## DB tables + +| Table | Access | Notes | +|-------|--------|-------| +| `agent_profiles` (`hiveiq_devices`) | read/write | PK `profile_key` (varchar 40); `label`, `description` (TEXT), `enabled` (bool), `sort_order` (int), `created_at`, `updated_at`. Flyway `V14__agent_profiles_and_hardware_profile.sql`. | +| `atms` (`hiveiq_devices`) | read-only (from this feature) | `hardware_profile` (varchar 40) — the device→profile link, read by the delete-guard count query. | + +## Key API endpoints + +| Method + Path | Auth | Purpose | +|---------------|------|---------| +| GET `/api/agent-profiles` | authenticated | List profiles (sorted) | +| POST `/api/agent-profiles` | MSP_ADMIN/BCOS_ADMIN | Create | +| PUT `/api/agent-profiles/{key}` | MSP_ADMIN/BCOS_ADMIN | Update (no key change) | +| DELETE `/api/agent-profiles/{key}` | MSP_ADMIN/BCOS_ADMIN | Delete (guarded) | +| GET `/api/agent-profiles/modules-manifest` | authenticated | 60s-cached CDN manifest proxy, injects `X-CDN-Token` | + +## Design notes + +- **No executor→Kafka→record-store pattern here** — this is a plain CRUD + read-through-cache feature, not a long-running fleet action. (That pattern applies to fleet tasks, not profiles.) +- **CDN token stays server-side.** The token is only ever attached in `AgentProfileController.getModulesManifest`; the SPA never sees it. +- **Manifest cache is process-local** (`AtomicReference`), so each backend replica caches independently and CDN changes take up to 60s per replica to appear. +- See parent architect doc [technical.devices] for the full devices service map. diff --git a/backend/src/main/resources/content/devices/agent-profile/claude.md b/backend/src/main/resources/content/devices/agent-profile/claude.md new file mode 100644 index 0000000..41826a5 --- /dev/null +++ b/backend/src/main/resources/content/devices/agent-profile/claude.md @@ -0,0 +1,56 @@ +--- +module: devices.agent-profile +title: Agent Profile +tab: Claude +order: 40 +audience: internal +--- + +Terse act-without-guessing reference. Feature = hardware profiles controlling agent module provisioning. + +## Ownership +- **Service:** `hiveops-devices` backend (Spring Boot, port 8096), DB `hiveiq_devices`. Frontend: `hiveops-devices` SPA port 5177 (`devices.bcos.cloud`). +- **Component:** `frontend/src/components/Settings/AgentProfile.svelte` +- **Backend:** `AgentProfileController` / `AgentProfileService` / `AgentProfileRepository` / entity `AgentProfile`. + +## Endpoints (all under `/api/agent-profiles`) +| Method + Path | Auth | Notes | +|---|---|---| +| GET `/api/agent-profiles` | any JWT | list, sorted by sort_order asc, then key | +| POST `/api/agent-profiles` | `MSP_ADMIN`/`BCOS_ADMIN` | 400 if blank key/label; 409 if key exists; key lowercased+trimmed | +| PUT `/api/agent-profiles/{key}` | `MSP_ADMIN`/`BCOS_ADMIN` | 400 if blank label; key immutable | +| DELETE `/api/agent-profiles/{key}` | `MSP_ADMIN`/`BCOS_ADMIN` | 409 if any ATM uses it; 404 if missing | +| GET `/api/agent-profiles/modules-manifest` | any JWT | proxies CDN modules.json, 60s cache; 502 `{"modules":[]}` on failure | + +## Tables (DB `hiveiq_devices`) +| Table | Use | +|---|---| +| `agent_profiles` | PK `profile_key`(varchar40); `label`,`description`(TEXT),`enabled`(bool),`sort_order`(int),`created_at`,`updated_at`. Flyway V14. | +| `atms` | `hardware_profile`(varchar40) = device→profile link; read by delete-guard `COUNT(*) WHERE hardware_profile=:key`. | + +## Kafka topics +- **None** for profile CRUD. Do NOT expect `hiveops.device.events` on profile writes (that topic is device rows only). + +## External deps +- CDN manifest: `https://cdn.bcos.cloud/downloads/agent/modules/modules.json` (shape: `{"modules":[{name,version,filename,profiles?[]}]}`). +- Token: `agent.cdn.token` / env `CDN_TOKEN` → sent as header `X-CDN-Token`, server-side only. + +## Module→profile logic (client-side join) +- module `profiles` absent/empty ⇒ installs on ALL ATMs (universal). +- module `profiles` contains profileKey ⇒ exclusive (★) to that profile. + +## Gotchas +- Read endpoints (`GET list`, `GET modules-manifest`) have **no `@PreAuthorize`** — open to any authenticated user; only mutations are admin-gated. +- `profileKey` is the PK and **immutable**; UI disables the key field on edit. "Rename" = create new + reassign devices + delete old. +- Manifest cache is **per-process** (`AtomicReference`); CDN changes take up to 60s per replica. +- Manifest failure ⇒ HTTP **502** body `{"modules":[]}`; UI shows "Could not load modules manifest from CDN" and blanks the Modules column. +- `enabled=false` pauses provisioning for that profile; ATM match is on `atm.hardware.profile` agent property (lowercase, exact). +- Module membership is NOT in the DB — it lives in CDN `modules.json`. Never look for a module list on the profile row. + +## Do NOT +- Do NOT invent module-list columns on `agent_profiles` — modules come from CDN `modules.json`. +- Do NOT try to change a profile's key via PUT — it's ignored/immutable. +- Do NOT expect Kafka events from profile writes. +- Do NOT route these calls through incident/journal — `hiveops-devices` owns them. +- Do NOT expose `X-CDN-Token` to the SPA; only the backend attaches it. +- Do NOT delete a profile still referenced by any `atms.hardware_profile` — reassign first. diff --git a/backend/src/main/resources/content/devices/agent-profile/internal.md b/backend/src/main/resources/content/devices/agent-profile/internal.md new file mode 100644 index 0000000..6c0b118 --- /dev/null +++ b/backend/src/main/resources/content/devices/agent-profile/internal.md @@ -0,0 +1,44 @@ +--- +module: devices.agent-profile +title: Agent Profile +tab: Internal +order: 20 +audience: internal +--- + +Ops/support view for the **Agent Profile** screen (Devices › Settings). Profiles are hardware groupings (`profileKey` + label) that decide which agent modules an ATM receives. This tab covers who can change them, what breaks, and how to fix it fast. + +## Who can do what + +| Action | Endpoint | Role required | +|--------|----------|---------------| +| List profiles | GET `/api/agent-profiles` | any authenticated user (no `@PreAuthorize`) | +| View modules manifest | GET `/api/agent-profiles/modules-manifest` | any authenticated user | +| Add profile | POST `/api/agent-profiles` | `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` | +| Edit profile | PUT `/api/agent-profiles/{key}` | `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` | +| Delete profile | DELETE `/api/agent-profiles/{key}` | `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` | + +All served by `hiveops-devices` backend (port 8096). The read endpoints are open to any valid JWT; only mutations are admin-gated. + +## First things to check when it misbehaves + +1. **Modules column shows "—" for every profile.** The CDN modules manifest failed to load. The frontend shows the banner *"Could not load modules manifest from CDN."* The backend proxies `https://cdn.bcos.cloud/downloads/agent/modules/modules.json` and returns HTTP **502** with `{"modules":[]}` when the CDN is unreachable or non-200. Check: is the CDN up, and is `agent.cdn.token` / `CDN_TOKEN` set correctly on the devices backend? The token is sent as the `X-CDN-Token` header; if the CDN now requires it and it's missing/stale, the fetch returns non-200 and the column blanks out. Manifest is cached 60s, so allow up to a minute after a fix. +2. **Module counts look wrong / an "exclusive" module isn't showing.** Module-to-profile mapping lives entirely in the CDN `modules.json` (each module's optional `profiles: []` array), NOT in the devices DB. A module with no `profiles` (or an empty array) installs on **every** ATM; a module listing a profileKey installs **only** on ATMs with that profile (the ★ exclusive badge). If a module shows on the wrong profile, fix `modules.json` on the CDN, not the profile record. +3. **Profile save fails with 400.** Backend rejects blank `label` (create + update) and blank `profileKey` (create) with `400 Bad Request`. The form also enforces this client-side. +4. **"Profile key already exists" (409).** Create is refused when the key is already present — keys are the primary key of `agent_profiles`. Edit the existing one instead. +5. **Delete fails with 409 "assigned to one or more devices".** A profile can't be deleted while any ATM has it. See below. + +## Common failure modes + fixes + +- **Can't delete a profile.** `DELETE` returns **409 Conflict** if `SELECT COUNT(*) FROM atms WHERE hardware_profile = :key > 0`. Reassign or clear those devices' profile first (device detail page, or group bulk-assign), then retry. To find the blockers: query `atms` for `hardware_profile = ''`. +- **ATM not getting its modules.** Two independent gates: (a) the profile row must be **enabled** (Active badge) — a disabled profile pauses provisioning; (b) the ATM's `atm.hardware.profile` agent property must exactly match a `profileKey` (lowercase, no spaces). A mismatch means the ATM falls back to only the universal (non-exclusive) modules and never gets the exclusive ones. +- **Key case/format issues.** Backend lowercases and trims `profileKey` on create (`applyFields`), but the ATM-side `atm.hardware.profile` value is matched as-is against `modules.json` and the `atms` table. Keep everything lowercase. +- **Edit doesn't change the key.** The key field is disabled on edit — `profileKey` is immutable (it's the PK). To "rename" a key you must create a new profile, reassign devices, and delete the old one. + +## Data location + +- **DB:** `hiveiq_devices` › table `agent_profiles` (columns: `profile_key` PK, `label`, `description`, `enabled`, `sort_order`, `created_at`, `updated_at`). Created by Flyway `V14__agent_profiles_and_hardware_profile.sql`. +- **Device assignment:** `atms.hardware_profile` (varchar 40). +- **Module manifest:** CDN file, not DB — `https://cdn.bcos.cloud/downloads/agent/modules/modules.json`. + +No Kafka is involved in editing a profile. Profile writes do **not** publish `hiveops.device.events` (that topic tracks device rows, not profile rows). diff --git a/backend/src/main/resources/content/devices/agent-scripts/architect.md b/backend/src/main/resources/content/devices/agent-scripts/architect.md new file mode 100644 index 0000000..bdc73f2 --- /dev/null +++ b/backend/src/main/resources/content/devices/agent-scripts/architect.md @@ -0,0 +1,89 @@ +--- +module: devices.agent-scripts +title: Agent Scripts +tab: Architect +order: 30 +audience: internal +--- + +> **Architect · internal.** Written 2026-07-01 from source. Describes both what is **built** (a devices-owned CRUD store) and the **intended** signed-delivery pipeline that partly exists in hiveops-fleet + the agent but is **not yet connected** to this module's Push action. + +## Ownership & shape + +`devices.agent-scripts` is a thin CRUD feature fully owned by **hiveops-devices** (Spring Boot, port 8096, DB `hiveiq_devices`). No Kafka topics, no consumers, no cross-service calls, no agent contact originate from this module. See [platform.service-ownership]. + +``` +Devices SPA (AgentScripts.svelte, Settings) + │ agentScriptAPI → devicesApi (baseURL = window.__APP_CONFIG__.apiUrl, e.g. api.bcos.cloud/devices) + ▼ +hiveops-devices : AgentScriptController (/api/fleet/scripts) + ▼ +AgentScriptService → AgentScriptRepository (JPA) + ▼ +Postgres hiveiq_devices : agent_scripts +``` + +## Components (hiveops-devices) + +| Layer | Class | +|-------|-------| +| Controller | `com.hiveops.devices.controller.AgentScriptController` (`@RequestMapping("/api/fleet/scripts")`) | +| Service | `com.hiveops.devices.service.AgentScriptService` (`@Transactional`) | +| Repository | `com.hiveops.devices.repository.AgentScriptRepository extends JpaRepository` | +| Entity | `com.hiveops.devices.entity.AgentScript` (`@Table("agent_scripts")`) | +| DTO | `com.hiveops.devices.dto.AgentScriptRequest` (`name`, `description`, `content`, `institutionKey`) | + +## Data — `agent_scripts` (Flyway `V15__create_agent_scripts.sql`) + +| Column | Type | Notes | +|--------|------|-------| +| `id` | BIGSERIAL PK | `GenerationType.IDENTITY` | +| `name` | VARCHAR(200) NOT NULL | file name; extension implies type | +| `description` | TEXT | nullable | +| `content` | TEXT NOT NULL | raw script body | +| `institution_key` | VARCHAR(50) | nullable; indexed (`idx_agent_scripts_institution_key`) | +| `created_at` | TIMESTAMPTZ | `@CreationTimestamp` / `DEFAULT now()` | +| `updated_at` | TIMESTAMPTZ | `@UpdateTimestamp` / `DEFAULT now()` | + +Repository queries: `findAllByOrderByNameAsc()` and `findByInstitutionKeyOrderByNameAsc(String)`. This is the **only** persistence — no mirror tables, no `device_summary` involvement. See [platform.data-architecture]. + +## API surface + +| Method + Path | Behaviour | +|---------------|-----------| +| `GET /api/fleet/scripts?institutionKey=` | Lists all, or filtered by institution key. | +| `POST /api/fleet/scripts` | 400 if name/content blank; else 201 with saved entity. Sets `institutionKey` from body. | +| `PUT /api/fleet/scripts/{id}` | 404 if missing; updates name (if non-blank), description, content. | +| `DELETE /api/fleet/scripts/{id}` | 404 if missing; else 204. | +| `POST /api/fleet/scripts/{id}/push` | **Stub** — returns `200` with `List.of()`. Comment in code: "requires fleet service integration (not yet implemented)". | + +All gated by `@PreAuthorize("hasAnyRole('MSP_ADMIN','BCOS_ADMIN')")` at class level. + +## Kafka + +**None for this module.** It neither produces nor consumes. (The devices service overall publishes `hiveops.device.events` for device inventory, but agent-scripts writes never touch that path.) See [platform.kafka]. + +## Intended signed-delivery pipeline (NOT wired to this module) + +The Overview/SPA copy promises "signed server-side with RSA-SHA256, agent verifies before writing" and "SCRIPT_UPDATE fleet tasks." That machinery exists — but in **hiveops-fleet** and the **agent**, not behind the devices Push endpoint: + +``` +(intended) devices push ──X──▶ hiveops-fleet FleetTaskService (kind = UPDATE_SCRIPT) + │ ScriptSigningService.sign(scriptBytes) [SHA256withRSA, private key] + ▼ configPatch += signature + fleet_tasks ──▶ agent-proxy ──▶ ATM agent + │ + hiveops-agent hiveops-file-collection ProcessScriptUpdate + verifySignature(...) [SHA256withRSA public key] ──▶ writes to scripts dir +``` + +- **hiveops-fleet:** `FleetTaskKind.UPDATE_SCRIPT` (and `RUN_SCRIPT`); `ScriptSigningService` loads a PKCS8 RSA private key from `script.signing.private-key` (env `SCRIPT_SIGNING_PRIVATE_KEY`) and signs `SHA256withRSA`. Signed tasks flow through the standard fleet-task → agent-proxy path (executor→task-store pattern). +- **agent:** `com.hiveops.filemgmt.ProcessScriptUpdate` requires `scriptName`, `scriptContent` (base64), `scriptSignature`; rejects unsigned scripts; verifies `SHA256withRSA` against an embedded public key; only writes files with `.cmd`/`.sh`/`.ps1` extensions. + +The gap: the devices `push` endpoint is a no-op stub and never calls hiveops-fleet, so nothing in this module actually creates a task or signs anything today. There is also a **config-patch key mismatch** between the fleet signer and the agent consumer (see Claude tab / uncertainties) that would need reconciling if the two are ever wired together. + +## Cross-links + +- [platform.data-architecture] — where device-domain data lives. +- [platform.kafka] — device event bus (not used by this module). +- [platform.service-ownership] — devices owns `agent_scripts`; fleet owns task delivery + signing. diff --git a/backend/src/main/resources/content/devices/agent-scripts/claude.md b/backend/src/main/resources/content/devices/agent-scripts/claude.md new file mode 100644 index 0000000..cc0122f --- /dev/null +++ b/backend/src/main/resources/content/devices/agent-scripts/claude.md @@ -0,0 +1,50 @@ +--- +module: devices.agent-scripts +title: Agent Scripts +tab: Claude +order: 40 +audience: internal +--- + +> Act-without-guessing reference. Only confirmed facts (hiveops-devices source, 2026-07-01). + +## Owner +- **Service:** `hiveops-devices` (Spring Boot, port `8096`, DB `hiveiq_devices`). +- Served under `/api/fleet/scripts` **but owned by devices, NOT hiveops-fleet.** +- SPA: `hiveops-devices/frontend` → `components/Settings/AgentScripts.svelte`, `lib/api.ts` → `agentScriptAPI` on `devicesApi` (base `window.__APP_CONFIG__.apiUrl` else `http://localhost:8096`). + +## Endpoints (all `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')`) +| Method | Path | Result | +|--------|------|--------| +| GET | `/api/fleet/scripts` | list; optional `?institutionKey=` | +| POST | `/api/fleet/scripts` | 400 if name/content blank, else 201 | +| PUT | `/api/fleet/scripts/{id}` | 404 if missing | +| DELETE | `/api/fleet/scripts/{id}` | 204 (hard delete) | +| POST | `/api/fleet/scripts/{id}/push` | **STUB → 200 + `[]`, does nothing** | + +## Data +- **Table:** `agent_scripts` (Flyway `V15`). Cols: `id`, `name` VARCHAR(200), `description` TEXT, `content` TEXT, `institution_key` VARCHAR(50), `created_at`, `updated_at`. Index: `idx_agent_scripts_institution_key`. +- **Entity/Repo/Service/Controller:** `AgentScript` / `AgentScriptRepository` / `AgentScriptService` / `AgentScriptController` (pkg `com.hiveops.devices.*`). +- Repo methods: `findAllByOrderByNameAsc`, `findByInstitutionKeyOrderByNameAsc`. + +## Kafka / topics +- **None.** This module produces/consumes nothing. + +## Signed delivery (lives elsewhere, NOT wired here) +- hiveops-fleet: `FleetTaskKind.UPDATE_SCRIPT` + `ScriptSigningService` (`SHA256withRSA`, key `script.signing.private-key` / env `SCRIPT_SIGNING_PRIVATE_KEY`). +- agent: `com.hiveops.filemgmt.ProcessScriptUpdate` verifies `SHA256withRSA`, needs patch keys `scriptName` + `scriptContent` (base64) + `scriptSignature`. + +## Gotchas +- Devices Push button is a **no-op**; SPA still shows a success toast. Never treat a push as delivered. +- Path says `fleet` but the backend is **devices** — route to `api.bcos.cloud/devices`, not `/fleet`. +- Name extension check (`.cmd/.sh/.ps1`) is **client-side only**; backend accepts any non-blank name. +- SPA `getAll()` never sends `institutionKey` → lists ALL institutions; `create()` never sends it → rows saved with `institution_key = NULL`. +- Edit locks `name` (SPA sends only `{description, content}` on PUT). +- Fleet signer writes patch key `signature` over raw `script`; agent reads `scriptSignature` + base64 `scriptContent` — key/format mismatch (see uncertainties). + +## Do NOT +- Do NOT claim scripts reach ATMs via this module's Push endpoint — it's a stub. +- Do NOT look for a Kafka topic or `device_summary` mirror for scripts — none exist. +- Do NOT expect institution scoping from the UI — it lists/creates unscoped. +- Do NOT search hiveops-fleet for `/api/fleet/scripts` — that path is a devices controller. +- Do NOT rely on backend name-extension validation. diff --git a/backend/src/main/resources/content/devices/agent-scripts/internal.md b/backend/src/main/resources/content/devices/agent-scripts/internal.md new file mode 100644 index 0000000..88ec052 --- /dev/null +++ b/backend/src/main/resources/content/devices/agent-scripts/internal.md @@ -0,0 +1,63 @@ +--- +module: devices.agent-scripts +title: Agent Scripts +tab: Internal +order: 20 +audience: internal +--- + +> **Internal · ops/support.** Grounded in `hiveops-devices` source (2026-07-01). This is a CRUD store today; the "Push to fleet" delivery path is **not wired up** in the backend — read the failure notes below before telling a customer a script was delivered. + +## What this module actually is + +A per-fleet library of shell scripts (`.cmd` / `.ps1` / `.sh`) stored in the **hiveops-devices** backend. Screen lives under **Settings → Agent Scripts** in the Devices SPA (`AgentScripts.svelte`). Owned entirely by hiveops-devices — no other service, no Kafka, no agent contact from this module's own endpoints. + +- **Service:** `hiveops-devices` (Spring Boot, port 8096, DB `hiveiq_devices`) +- **Table:** `agent_scripts` +- **API base:** `/api/fleet/scripts` (yes — `fleet` in the path, but it is served by devices, not hiveops-fleet) + +## Roles required + +Every endpoint on `AgentScriptController` is gated at the class level: + +```java +@PreAuthorize("hasAnyRole('MSP_ADMIN','BCOS_ADMIN')") +``` + +So **all** actions (list, create, edit, delete, push) require `MSP_ADMIN` or `BCOS_ADMIN`. There is no read-only role. A user without one of these roles gets 403 on the initial `GET /api/fleet/scripts` and the list simply fails to load. + +## Admin actions + +| Action | Endpoint | Notes | +|--------|----------|-------| +| List scripts | `GET /api/fleet/scripts` | Optional `?institutionKey=`; the SPA never sends it, so the list shows **all** institutions' scripts. | +| Add | `POST /api/fleet/scripts` | Name + content required (blank → 400). Name must end `.cmd`/`.sh`/`.ps1` (validated **client-side only**). | +| Edit | `PUT /api/fleet/scripts/{id}` | SPA sends `{description, content}` only — name is locked after create. | +| Delete | `DELETE /api/fleet/scripts/{id}` | Hard delete, 204. No soft-delete, no audit row. | +| Push | `POST /api/fleet/scripts/{id}/push` | **No-op stub** — see below. | + +## First things to check when it misbehaves + +1. **"I pushed a script but nothing happened on the ATMs."** Expected. `POST /api/fleet/scripts/{id}/push` is a **stub** — it returns `200 OK` with an empty list and creates **no** fleet task. The SPA still shows a green "pushed to N ATMs" toast. Do not tell the customer a script was delivered based on that toast. (The real signed-delivery pipeline lives in **hiveops-fleet** as `UPDATE_SCRIPT` tasks, but this module's Push button is not connected to it.) +2. **List won't load / 403.** User lacks `MSP_ADMIN`/`BCOS_ADMIN`, or the JWT didn't reach the devices backend. Check the token role claim and that the request hit `api.bcos.cloud/devices` (devices backend), not the incident/fleet base. +3. **List empty but scripts exist.** Confirm you're pointed at the right DB — `agent_scripts` in `hiveiq_devices` (devices VM path via ProxyJump to `10.10.10.188`). Scripts created here have `institution_key = NULL` (SPA never sets it), so any institution-scoped query returns nothing. +4. **Can't rename a script.** By design — the name field is disabled on edit and the SPA doesn't send `name` on `PUT`. To rename, delete + recreate. +5. **Save rejected "Name must end with .cmd, .sh, or .ps1".** That check is **client-side only**. A direct `POST` with any name will be accepted by the backend as long as name+content are non-blank. + +## Common failure modes + fixes + +| Symptom | Cause | Fix | +|---------|-------|-----| +| Push toast succeeds, no ATM change | Backend push is a stub (returns `[]`) | Deliver via hiveops-fleet `UPDATE_SCRIPT` fleet task instead; treat this Push button as non-functional. | +| 403 on load | Missing admin role | Grant `MSP_ADMIN`/`BCOS_ADMIN`. | +| Scripts from other institutions visible | SPA lists unscoped (no `institutionKey`) | Known; filter is supported in the API but unused in the UI. | +| Edit blanks a description | `PUT` overwrites `description` with the request value each time | Always resend the full description on edit (SPA does this). | + +## Quick DB check + +```sql +-- on hiveiq_devices (database VM 10.10.10.188, via ProxyJump) +SELECT id, name, institution_key, updated_at FROM agent_scripts ORDER BY name; +``` + +See also the customer-facing **Overview** tab, the **Architect** tab for the intended signed-delivery design, and [platform.service-ownership]. diff --git a/backend/src/main/resources/content/devices/config-audit/architect.md b/backend/src/main/resources/content/devices/config-audit/architect.md new file mode 100644 index 0000000..fd45c49 --- /dev/null +++ b/backend/src/main/resources/content/devices/config-audit/architect.md @@ -0,0 +1,84 @@ +--- +module: devices.config-audit +title: Config Audit +tab: Architect +order: 30 +audience: internal +--- + +> **Internal · architecture view.** How Config Audit is built inside `hiveops-devices`. Grounded in source 2026-07-01. + +## Shape at a glance + +Config Audit is a **single-service, synchronous, DB-backed** feature. There is **no Kafka, no async executor, and no record-store fan-out** for this feature — the audit row is written in the same transaction as the config save it records. (Contrast with the executor→Kafka→record-store pattern used elsewhere in the platform; it does **not** apply here.) + +- **Service owner:** `hiveops-devices` (backend port **8096**, SPA `devices.bcos.cloud` port 5177). Devices owns all device/institution config data. See [platform.service-ownership]. +- **Database:** `hiveiq_devices`. See [platform.data-architecture] / [platform.data-stores]. +- **Messaging:** none for this feature. See [platform.kafka] for topics the rest of `hiveops-devices` produces/consumes (`hiveops.device.events`, etc.) — Config Audit touches none of them. + +## Components + +| Layer | Class | Role | +|-------|-------|------| +| Frontend | `Settings/ConfigAudit.svelte` | Table + institution filter + paging + per-row diff expander | +| Frontend API | `institutionKeyAPI.getConfigAudit()` (`lib/api.ts`) → `devicesApi` (base `VITE_API_URL`, else `http://localhost:8096`) | Calls `GET /api/institution-keys/config-audit` | +| Controller | `InstitutionKeyController` (`@RequestMapping("/api/institution-keys")`) | `getConfigAudit` (read), `saveAgentProperties` (write trigger) | +| Service | `InstitutionKeyService` | `getAuditLog()` (read + diff), `saveAgentProperties()` (write + audit insert) | +| Repository | `InstitutionConfigAuditRepository` (JPA) | `findAllByOrderByChangedAtDesc`, `findByInstitutionKeyOrderByChangedAtDesc` | +| Entity / table | `InstitutionConfigAudit` → `institution_config_audit` | Stored snapshots | +| Scoping | `InstitutionContext.resolveScope()` | Per-role institution key scope from JWT | + +## Write path (how a row is created) + +The audit row is a **side effect of saving agent properties** — there is no standalone "create audit" API. + +``` +Agent Properties save (PUT /api/institution-keys/{key}/agent-properties) + → InstitutionKeyController.saveAgentProperties() [@PreAuthorize MSP_ADMIN|BCOS_ADMIN, verifyKeyAccess] + → InstitutionKeyService.saveAgentProperties(key, dto) + 1. load existing InstitutionAgentProperties (institution_agent_properties) + 2. previousConfig = entity.hiveopsConfig (old JSON string) + 3. newConfig = serialize(dto.hiveopsConfig) + 4. save entity (updates institution_agent_properties) ← evicts agentProperties cache + 5. changedBy = SecurityContext principal name, else "system" + 6. auditRepository.save(InstitutionConfigAudit{key, changedBy, now, previousConfig, newConfig}) +``` + +- Snapshots stored are the **serialized `hiveopsConfig` JSON only** (the `ejConfig` half of the DTO is persisted to `institution_agent_properties` but is **not** captured in the audit snapshot). +- Both writes happen in the same service call; a serialization failure throws before either save (`RuntimeException("Failed to save agent properties")`). + +## Read path (how the diff is produced) + +``` +GET /api/institution-keys/config-audit?institutionKey&page&size (no @PreAuthorize) + → InstitutionKeyController.getConfigAudit() + scopedKeys = InstitutionContext.resolveScope() + effectiveKey = (scoped) ? scopedKeys.get(0) : institutionKey // param used only for unrestricted roles + → InstitutionKeyService.getAuditLog(effectiveKey, page, size) + - PageRequest.of(page, size) + - repo query: byInstitutionKey…Desc if key present, else findAll…Desc + - for each row: computeDiff(previous_config, new_config) → Map + - map to InstitutionConfigAuditDTO {id, institutionKey, institutionName, changedBy, changedAt, changes} +``` + +- **Diff is computed on read**, not stored. `computeDiff` parses both JSON snapshots with Jackson and walks fields: added/changed (`!newVal.equals(oldVal)`) then removed (present in prev, absent in next → `to = null`). Parse errors are swallowed (warn-logged), yielding a partial/empty diff. +- `institutionName` is resolved by joining every audit row against an in-memory map built from `institution_keys.findAll()` on each request (cheap; small table). +- Paging: server returns Spring `Page` (`content/totalPages/number`); default size 50, matching the frontend request. + +## DB tables + +| Table | Access | Notes | +|-------|--------|-------| +| `institution_config_audit` | **written** on save, **read** for the log | `id` (identity), `institution_key` (≤10), `changed_by`, `changed_at` (default `now()`), `previous_config` TEXT, `new_config` TEXT. Indexes: `(institution_key)`, `(changed_at DESC)`. Created in `V7__create_institution_config_audit.sql`. | +| `institution_agent_properties` | written on save, read for previous snapshot | Source of the config being audited (`hiveops_config`, `ej_config`) | +| `institution_keys` | read | Key → institution name resolution + filter dropdown | + +## Key endpoints + +| Method | Path | Purpose | Auth | +|--------|------|---------|------| +| `GET` | `/api/institution-keys/config-audit` | Paged audit log (+ computed diffs) | authenticated; scoped by role | +| `PUT` | `/api/institution-keys/{key}/agent-properties` | Save agent props → creates audit row | `MSP_ADMIN`/`BCOS_ADMIN` | +| `GET` | `/api/institution-keys` | Filter dropdown (delegates to `/accessible`) | authenticated; scoped | + +Cross-links: [platform.data-architecture], [platform.kafka], [platform.service-ownership], [technical.devices]. diff --git a/backend/src/main/resources/content/devices/config-audit/claude.md b/backend/src/main/resources/content/devices/config-audit/claude.md new file mode 100644 index 0000000..39e27c0 --- /dev/null +++ b/backend/src/main/resources/content/devices/config-audit/claude.md @@ -0,0 +1,55 @@ +--- +module: devices.config-audit +title: Config Audit +tab: Claude +order: 40 +audience: internal +--- + +> Terse agent reference. All facts below verified in `hiveops-devices` source (2026-07-01). + +## Ownership + +- **Service:** `hiveops-devices` (backend port **8096**; SPA `devices.bcos.cloud` port 5177). +- **DB:** `hiveiq_devices` (Postgres, separate DB VM — ProxyJump to reach). +- **Kafka:** none for this feature. Do not look for a topic. +- **Frontend:** `frontend/src/components/Settings/ConfigAudit.svelte`; API in `lib/api.ts` → `institutionKeyAPI.getConfigAudit`. + +## Endpoints (backend-relative; base = `VITE_API_URL` / `http://localhost:8096`) + +| Method | Path | Auth | Notes | +|--------|------|------|-------| +| `GET` | `/api/institution-keys/config-audit?institutionKey={k}&page={n}&size={s}` | authenticated, **no `@PreAuthorize`** | Paged log; diffs computed on read; default size 50 | +| `PUT` | `/api/institution-keys/{key}/agent-properties` | `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` | The ONLY thing that creates an audit row | +| `GET` | `/api/institution-keys` (→ `/accessible`) | authenticated, scoped | Dropdown source | + +## Tables + +- `institution_config_audit` — the log. Cols: `id, institution_key, changed_by, changed_at, previous_config(TEXT), new_config(TEXT)`. Indexes `(institution_key)`, `(changed_at DESC)`. Migration `V7`. +- `institution_agent_properties` — the audited config (`hiveops_config`, `ej_config`). +- `institution_keys` — key→name resolution. + +## Roles / scoping (`InstitutionContext.resolveScope()`) + +- `null` (unrestricted): `BCOS_ADMIN`, `ADMIN`, `QDS_ADMIN`, `USER` → `institutionKey` param honored (blank = all). +- scoped list: `CUSTOMER`, `MSP_ADMIN` → forced to `scopedKeys.get(0)`; **param ignored**. + +## Gotchas + +- Audit row is a **side effect of `PUT …/agent-properties`** — there is no create/delete audit endpoint. +- **Diffs are NOT stored.** Only full `previous_config`/`new_config` JSON snapshots are; `InstitutionKeyService.computeDiff` regenerates the field diff per read. +- Only `hiveopsConfig` is snapshotted in the audit; `ejConfig` changes are saved to props but **not** captured in the audit diff. +- First save → `previous_config = null` → UI badge "Initial save", all fields `from = null`. +- `changed_by` = SecurityContext principal name, else literal `"system"`. +- Scoped user (esp. multi-institution `MSP_ADMIN`) sees **only their first institution key**; dropdown filter is a no-op for them. +- `computeDiff` swallows JSON parse errors (warn log) → can return empty/partial diff silently. +- `institution_key` column is `VARCHAR(10)`. + +## Do NOT + +- Do **not** claim Config Audit publishes/consumes Kafka — it does not. +- Do **not** claim the diff is persisted — it is computed on read. +- Do **not** assume `MSP_ADMIN` can filter across institutions here — backend pins to the first scoped key. +- Do **not** look for this data in `hiveops-incident` — it lives in `hiveops-devices` / `hiveiq_devices`. +- Do **not** invent an "add/edit/delete audit" API — the log is append-only via config saves. +- Do **not** run `docker exec hiveiq-postgres` on the services VM — Postgres is on the separate DB VM. diff --git a/backend/src/main/resources/content/devices/config-audit/internal.md b/backend/src/main/resources/content/devices/config-audit/internal.md new file mode 100644 index 0000000..916e041 --- /dev/null +++ b/backend/src/main/resources/content/devices/config-audit/internal.md @@ -0,0 +1,58 @@ +--- +module: devices.config-audit +title: Config Audit +tab: Internal +order: 20 +audience: internal +--- + +> **Internal · ops/support view.** Grounded in `hiveops-devices` source (2026-07-01). Config Audit is the read-only change log of every save to an institution's **agent properties** (the per-institution `hiveops_config` blob edited on the Agent Properties screen). + +## What it actually is + +- A history table in the `hiveops-devices` backend. **One row per save** to an institution's agent properties. +- The row stores a full `previous_config` / `new_config` JSON snapshot; the field-level before/after diff shown in the UI is computed **on read**, not stored. +- **Read-only.** Viewing the audit never changes any config. Nothing here pushes config to devices — that is a separate CONFIG_UPDATE fleet action. + +## Who can do what (roles) + +| Action | Endpoint | Auth | +|--------|----------|------| +| **View** the audit log | `GET /api/institution-keys/config-audit` | Any authenticated user (no `@PreAuthorize`) — results scoped by role, see below | +| **Create** an audit row (side effect of saving agent props) | `PUT /api/institution-keys/{key}/agent-properties` | `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` | + +- There is **no admin-only action inside Config Audit itself** — it is purely a viewer. The only way to add a row is for an `MSP_ADMIN`/`BCOS_ADMIN` to save agent properties. +- `changedBy` is captured from the Spring `SecurityContext` principal name at save time (falls back to literal `"system"` if no auth present). + +## Scoping (who sees which rows) + +Scope comes from `InstitutionContext.resolveScope()` (institution keys are carried in the JWT): + +- **BCOS_ADMIN / ADMIN / QDS_ADMIN / USER** → unrestricted. The `institutionKey` query param is honored (blank = all institutions). +- **CUSTOMER / MSP_ADMIN** → scoped. The backend **ignores the query param** and forces the log to the caller's **first** scoped institution key only. + +## First things to check when it misbehaves + +1. **"Log is empty / No config changes recorded yet."** Expected until someone actually saves agent properties for that institution. Confirm a save has happened: check `institution_agent_properties.updated_at` for the key. No save → no audit row, by design. +2. **"An MSP_ADMIN can only see one institution's history."** This is current behavior, not a glitch — scoped callers are pinned to `scopedKeys.get(0)` and the institution dropdown filter is effectively a no-op for them. See uncertainties in the Architect tab. Reproduce/confirm by hitting the endpoint as an `MSP_ADMIN` with a multi-institution JWT. +3. **"Changed By shows `system`."** Save was made without an authenticated principal in context (e.g. an internal/service call path). Confirm the caller's JWT is present and being parsed by the auth filter. +4. **"Diff shows the whole config as changed on the first entry."** Correct — the first save has `previous_config = null`, so every field is rendered as an **Initial save** (badge `Initial save`, all fields `from = null`). +5. **Filter dropdown empty / institutions missing.** The dropdown is populated by `GET /api/institution-keys` (delegates to `/accessible`). If it's empty for a scoped user, their JWT institution-key claim is missing/empty — an auth/token issue, not a Config Audit issue. +6. **403 on save (no new audit rows appearing).** The saver lacks `MSP_ADMIN`/`BCOS_ADMIN`, or `verifyKeyAccess` rejected them (scoped user editing an institution outside their scope → `403 Access denied`). +7. **500 / nothing loads.** Backend down or DB unreachable. Service = `hiveops-devices` (port **8096**, DB `hiveiq_devices`). Check the container and Postgres connectivity, then Loki logs for `InstitutionKeyService`. + +## Common failure modes → fixes + +| Symptom | Likely cause | Fix | +|---------|--------------|-----| +| No rows for an institution | Nobody saved agent props yet | Save agent properties once (as admin); a row appears | +| Scoped user can't filter to another institution | By-design pinning to first scoped key | Use an unrestricted admin account to view other institutions | +| `Changed By = system` | Save with no auth principal | Ensure request carries a valid JWT | +| Save succeeds but no audit row | Serialization error thrown before audit save | Check `InstitutionKeyService` logs for `Failed to serialize agent properties` | +| Diff looks wrong / partial | `computeDiff` swallows parse errors (logs warn, returns partial) | Inspect `previous_config`/`new_config` TEXT columns directly in `institution_config_audit` | + +## Direct DB inspection (when the UI won't tell you) + +Table: `institution_config_audit` in **`hiveiq_devices`** (separate DB VM, reach via ProxyJump — see platform gotchas). Columns: `id, institution_key, changed_by, changed_at, previous_config, new_config`. Ordered newest-first by `changed_at` (indexed). + +See also: [devices.config-audit] Overview (customer view), [technical.devices], [platform.service-ownership]. diff --git a/backend/src/main/resources/content/devices/device-detail/architect.md b/backend/src/main/resources/content/devices/device-detail/architect.md new file mode 100644 index 0000000..959c5bd --- /dev/null +++ b/backend/src/main/resources/content/devices/device-detail/architect.md @@ -0,0 +1,80 @@ +--- +module: devices.device-detail +title: Device Detail +tab: Architect +order: 30 +audience: internal +--- + +How the single-device page is built. Component `AtmDetail.svelte` (~4.2k lines) is a tabbed shell; the "Details" surface is owned by **hiveops-devices**, but the page is a **multi-service aggregator** — most tabs fan out to other backends. See [platform.service-ownership] and [technical.devices]. + +## Services involved + +| Service | Port | Role for this page | +|---------|------|--------------------| +| hiveops-devices (backend) | 8096 | **Owner** of the device record; serves Details/Agent/Hardware/Software/Cassettes/Config-Changes + logs; publishes device events | +| hiveops-incident | 8081 | Journal events, cassette inventory, incident history | +| hiveops-fleet | — | CONFIG_UPDATE fleet task, packet captures, fitted module status, task history | +| hiveops-journal | 8087 | Transactions / replenishments | + +Frontend axios clients (`lib/api.ts`): `devicesApi` (base `apiUrl`, default `:8096`), `incidentApi` (`/incident`), `gatewayApi` (gateway root, used for `/fleet/*` and `/journal/*`). `gatewayUrl` is derived by stripping a trailing `/devices` from the configured `apiUrl` (`window.__APP_CONFIG__.apiUrl`). + +## Data flow + +``` +AtmDetail.svelte + ├─ devicesApi GET /api/atms/{id} (+ /properties) ← Atm + AtmProperties (source of truth) + ├─ devicesApi GET /api/atms/{id}/hardware|config/changes + ├─ devicesApi GET /api/atms/{id}/latest-log | log-collects (FileSystemResource off disk) + ├─ incidentApi GET /journal-events/atm/{id}/paginated | /cassette-inventory + ├─ incidentApi GET /incidents/atm/{id} + ├─ gatewayApi GET /fleet/captures?atmId | /fleet/modules/atm/{id} | /fleet/tasks/history?atmId + └─ gatewayApi GET /journal/transactions?atmId&type=REPLENISHMENT + +Auto-refresh: setInterval 60_000ms → loadAtms() + loadAtmProperties(id); skipped while editing. +``` + +## Write paths (all admin-gated) and the executor → Kafka pattern + +Every mutating device call runs through `AtmService`, persists to the `atms`/`atm_properties` tables, then publishes to Kafka so the four **device_summary mirrors** stay in sync. There is **no reconciler** — a write that skips `DeviceEventPublisher` silently desyncs mirrors. + +| Endpoint | Service method | Kafka | +|----------|----------------|-------| +| `PUT /api/atms/{id}` | `updateAtm` | `publishUpdated` → `DEVICE_UPDATED` | +| `PATCH /api/atms/{id}/institution` | `moveInstitution` (upper-cases key) | `publishUpdated` | +| `PATCH /api/atms/{id}/status` | `updateAtmStatus` | `publishUpdated` | +| `POST /api/atms/{id}/reset-state` | `resetAtmState` (IN_SERVICE, clears supervisor) | *(status write; see code)* | +| `PUT /api/atms/{id}/hardware-profile` | `setHardwareProfile` | `publishUpdated` | +| `DELETE /api/atms/{id}` | `deleteAtm` (soft if active, hard if INACTIVE) | `publishDeleted` → `DEVICE_DELETED` (even on soft-delete) | + +### Kafka (see [platform.kafka]) + +- **Produced:** `hiveops.device.events` (`KafkaProducerConfig.DEVICE_EVENTS_TOPIC`), **keyed by `atmId`**, payload `DeviceEventType ∈ {DEVICE_CREATED, DEVICE_UPDATED, DEVICE_DELETED}` via `DeviceEventPublisher`. +- **Consumers:** the `device_summary` mirrors in incident / fleet / reports / recon (and vault). This page's Details tab is the write origin; the other apps consume its events. See [platform.data-architecture]. + +## The hardware-profile CONFIG_UPDATE flow (frontend-orchestrated) + +Distinct from a pure backend action — the UI does two calls in sequence: + +``` +1. devicesApi PUT /api/atms/{id}/hardware-profile { profileKey } → persists profile, publishUpdated +2. gatewayApi POST /fleet/tasks → queues the machine update + { atmIds:[id], taskKind:'CONFIG_UPDATE', + configPatch:{ 'atm.hardware.profile': profileKey } } +``` + +Step 2 is what actually reaches the ATM; the fleet task executor delivers the config patch. The devices backend does **not** create the fleet task itself. + +## DB tables (in `hiveiq_devices`, read/written for this page) + +- `atms` (`Atm`) — core record; `atm_properties` (`AtmProperties`) — service state, supervisor flags, custom fields. +- `atm_hardware_changes` / `atm_config_changes` — change audit + review badges (acknowledged here). +- `atm_log_collects` (`AtmLogCollect`) — LOG_COLLECT bundle index; log/bundle bytes live on disk (`atmLogService`). +- `device_cassette_config` + `cassette_configurations`/`cassette_slots` — Cassettes tab. +- Cross-service reads hit incident/journal/fleet DBs, not `hiveiq_devices`. + +## Cross-links + +- [platform.service-ownership] — devices owns the device record; incident/journal/fleet own their tabs. +- [platform.kafka] — `hiveops.device.events` topic + mirror consumers. +- [platform.data-architecture] — device_summary mirror pattern (publish-on-every-change, no reconciler). diff --git a/backend/src/main/resources/content/devices/device-detail/claude.md b/backend/src/main/resources/content/devices/device-detail/claude.md new file mode 100644 index 0000000..bb6f2d8 --- /dev/null +++ b/backend/src/main/resources/content/devices/device-detail/claude.md @@ -0,0 +1,77 @@ +--- +module: devices.device-detail +title: Device Detail +tab: Claude +order: 40 +audience: internal +--- + +Act-without-guessing reference for `devices.device-detail` (`AtmDetail.svelte`). + +## Ownership + +- **Owner:** `hiveops-devices` backend, port **8096**, DB **`hiveiq_devices`**, controller `AtmController` (`@RequestMapping({"/api/atms","/api/devices"})`). +- Page is a multi-service aggregator: tabs also call **incident**, **fleet**, **journal**. + +## Endpoints — devices-backend (`AtmController` unless noted) + +| Method + Path | Purpose | Auth | +|---------------|---------|------| +| `GET /api/atms/{id}` | Device detail | authed | +| `GET /api/atms/{id}/properties` | AtmProperties | authed | +| `PUT /api/atms/{id}` | Update / rename | `MSP_ADMIN`\|`BCOS_ADMIN` | +| `PUT /api/atms/{id}/properties` | Update properties | admin | +| `PATCH /api/atms/{id}/institution` | Move institution (upper-cases key) | admin | +| `PATCH /api/atms/{id}/status` | Set status | admin | +| `POST /api/atms/{id}/reset-state` | → IN_SERVICE, clear supervisor | admin | +| `PUT /api/atms/{id}/hardware-profile` | Set profile (does NOT queue task) | admin | +| `DELETE /api/atms/{id}` | Soft-delete if active, hard if INACTIVE | admin | +| `GET /api/atms/{id}/hardware/changes` | Hardware changes | authed | +| `POST /api/atms/{id}/hardware/changes/{changeId}/acknowledge` | Clear badge | admin | +| `GET /api/atms/{id}/config/changes` | Config changes | authed | +| `POST /api/atms/{id}/config/changes/{changeId}/acknowledge` | Clear badge | admin | +| `POST /api/atms/{id}/cassette-configuration/{configId}` | Apply template | admin | +| `DELETE /api/atms/{id}/cassette-configuration` | Clear template | admin | +| `GET /api/atms/{id}/latest-log` | Text log (from disk) | authed | +| `GET /api/atms/{id}/latest-log-collect` | Latest LOG_COLLECT zip | authed | +| `GET /api/atms/{id}/log-collects[/{collectId}/download]` | Bundle list / download | authed | +| `GET /api/atms/{id}/fleet-tasks` (`DeviceFleetTaskController`) | Task history | admin | + +## Endpoints — other services (called from tabs) + +| Method + Path | Service | +|---------------|---------| +| `GET /incident/journal-events/atm/{id}/paginated` | incident | +| `GET /incident/journal-events/atm/{id}/cassette-inventory` | incident | +| `GET /incident/incidents/atm/{id}` | incident | +| `POST /fleet/tasks` (CONFIG_UPDATE for hw profile) | fleet | +| `GET /fleet/captures?atmId=` · `/fleet/modules/atm/{id}` · `/fleet/tasks/history?atmId=` | fleet | +| `GET /journal/transactions?atmId=&type=REPLENISHMENT` | journal | + +## Tables (`hiveiq_devices`) + +- `atms`, `atm_properties`, `atm_hardware_changes`, `atm_config_changes`, `atm_log_collects`, `device_cassette_config`, `cassette_configurations`, `cassette_slots`. + +## Kafka + +- **Produces:** `hiveops.device.events` — key = `atmId`, `DeviceEventType ∈ {DEVICE_CREATED, DEVICE_UPDATED, DEVICE_DELETED}` (`DeviceEventPublisher`). +- Consumed by device_summary mirrors in incident/fleet/reports/recon (no reconciler). + +## Gotchas + +- Hardware profile = **two calls**: `PUT .../hardware-profile` THEN `POST /fleet/tasks` (`configPatch:{'atm.hardware.profile':}`). Backend alone does not queue the task. +- `DELETE` on an active device is a **soft delete** (status→INACTIVE) yet still emits `DEVICE_DELETED`; call again to hard-delete. +- `moveInstitution` upper-cases + trims the key; blank is rejected. +- `latest-log*` are `FileSystemResource` reads → error/404 if no file uploaded yet (not a bug). +- Auto-refresh every **60s**, paused while `editing`. +- `server.error.include-message=never` → terse 5xx; get real errors from Loki (`localhost:3100`, services VM). +- Frontend base URL from `window.__APP_CONFIG__.apiUrl`; `gatewayUrl` = that minus trailing `/devices`. + +## Do NOT + +- Do NOT set a hardware profile without also queuing the CONFIG_UPDATE fleet task — the ATM won't get it. +- Do NOT add device-write endpoints that skip `DeviceEventPublisher` — mirrors silently desync. +- Do NOT expect journal/incident/transaction/capture tabs from devices — they belong to other services; debug the owner. +- Do NOT write incidents/fleet-tasks/journal data into `hiveiq_devices` — respect [platform.service-ownership]. +- Do NOT assume reads are admin-gated (they aren't); do NOT assume writes are open (all are `MSP_ADMIN`/`BCOS_ADMIN`). +- Do NOT call it "AI" — the assistant is **Adoons**; brand is **HiveIQ**. diff --git a/backend/src/main/resources/content/devices/device-detail/internal.md b/backend/src/main/resources/content/devices/device-detail/internal.md new file mode 100644 index 0000000..f1164a3 --- /dev/null +++ b/backend/src/main/resources/content/devices/device-detail/internal.md @@ -0,0 +1,57 @@ +--- +module: devices.device-detail +title: Device Detail +tab: Internal +order: 20 +audience: internal +--- + +Ops/support view of the single-device page (`AtmDetail.svelte`). The page is served by the **hiveops-devices** frontend (port 5177, `devices.bcos.cloud`) and reads/writes through the **hiveops-devices** backend (port 8096, DB `hiveiq_devices`). Several tabs pull from *other* services — see the failure table below. + +## Roles required + +- **Read** (device detail, tabs, logs): any authenticated HiveIQ user (institution scoping applies via `verifyAtmAccess`). +- **Every mutating action on this page** is gated by `@PreAuthorize("hasAnyRole('MSP_ADMIN','BCOS_ADMIN')")`. A non-admin sees the tabs but 403s on save/rename/move/reset/delete/acknowledge/apply-template. +- `GET /api/atms/{id}/fleet-tasks` (Task History tab) is also admin-gated — non-admins get 403 on that tab specifically. + +## Admin-only actions (Details tab) → what fires + +| UI action | Call | Backend effect | +|-----------|------|----------------| +| Edit → Save | `PUT /api/atms/{id}` | Updates the `atms` row, publishes `DEVICE_UPDATED` | +| Rename (ATM ID / display name) | `PUT /api/atms/{id}` | Same update path | +| Move Institution | `PATCH /api/atms/{id}/institution` | Key is **upper-cased + trimmed**; blank rejected; publishes `DEVICE_UPDATED` | +| Reset to In Service | `POST /api/atms/{id}/reset-state` | Sets status `IN_SERVICE`, `serviceState=IN_SERVICE`, `inSupervisor=false`, clears `supervisorEnteredAt` | +| Deactivate / Delete | `DELETE /api/atms/{id}` | **Soft-delete** if active (status → `INACTIVE`); **hard-delete** if already `INACTIVE` (bulk JPQL, DB `ON DELETE CASCADE`) | +| Set hardware profile | `PUT /api/atms/{id}/hardware-profile` **then** `POST /fleet/tasks` (CONFIG_UPDATE) | Two separate calls — see gotcha below | +| Lookup Coords | `fetch('/api/geocode?address=…')` | Geocode proxy fills lat/long | +| Acknowledge hardware change | `POST /api/atms/{id}/hardware/changes/{changeId}/acknowledge` | Clears the review badge | +| Acknowledge config change | `POST /api/atms/{id}/config/changes/{changeId}/acknowledge` | Clears the Config Changes badge | +| Apply / clear cassette template | `POST` / `DELETE /api/atms/{id}/cassette-configuration[/{configId}]` | Applies or clears the cassette template | + +## First things to check when it misbehaves + +| Symptom | Likely cause | Fix / where to look | +|---------|--------------|---------------------| +| Save/rename/move returns 403 | User lacks `MSP_ADMIN`/`BCOS_ADMIN`, or JWT expired | Confirm role in the JWT; re-login | +| "Delete" left the device visible but INACTIVE | Expected — first delete is a **soft delete** (status→INACTIVE). Delete again to hard-remove | Filter list by INACTIVE to confirm | +| Agent tab "latest log" 404s / errors | `GET /api/atms/{id}/latest-log` reads a file off disk (`atmLogService.getLatestLogPath`); no file yet if the agent never uploaded | Check the agent actually uploaded; nothing to serve = expected error | +| Log Collect download fails | `GET /api/atms/{id}/latest-log-collect` / `.../log-collects/{id}/download` are FileSystemResource reads | Verify the LOG_COLLECT bundle exists on the services VM | +| Journal / Incidents / Transactions / Captures / Modules tab empty or 5xx | **Those tabs are NOT devices** — they call incident, journal, or fleet backends | Check the owning service (table below), not devices | +| Status/institution shown in another app is wrong | Stale **device_summary mirror** — devices must publish on every change | Confirm `DEVICE_UPDATED` fired; no reconciler exists | +| Hardware/Config Changes red badge won't clear | Acknowledge call 403'd or hit wrong service | Re-run as admin; badge clears only after the acknowledge POST 200s | +| Page not refreshing | Auto-refresh (every 60s) is paused while `editing` is true | Cancel the edit form; polling resumes | +| Terse 500 with no message | `server.error.include-message=never` in devices | Pull the real stack from Loki (`localhost:3100` on services VM) | + +## Which backend serves each tab + +- **hiveops-devices** (`/api/atms/*`, `/api/institution-keys`, `/api/agent-profiles`, `/api/cassette-configurations`, `/api/atm-makes|models|field-definitions`): Details, System Info, Agent, Software, Hardware, Cassettes, Config Changes, Task History. +- **hiveops-incident** (`/incident/journal-events/*`, `/incident/incidents/*`): Journal events, cassette inventory, Incidents history. +- **hiveops-fleet** (`/fleet/*`): Captures, fitted Modules, fleet task creation (CONFIG_UPDATE), Task History artifacts. +- **hiveops-journal** (`/journal/transactions`): Transactions / replenishments. + +## Gotchas + +- **Hardware profile is a two-step, frontend-orchestrated flow.** The UI first calls `PUT /api/atms/{id}/hardware-profile`, then separately `POST /fleet/tasks` with `taskKind=CONFIG_UPDATE`, `configPatch={ "atm.hardware.profile": }`. Setting the profile via the backend endpoint alone (or any non-UI caller) persists the profile but does **not** queue the CONFIG_UPDATE — the machine won't be told. +- **Move Institution never blanks a key** and always upper-cases; a wrong-token/mis-imaged box keeps its sticky wrong institution until moved here. +- **Soft-delete still emits `DEVICE_DELETED`** — deactivating a device publishes a delete event, so it disappears from all mirror apps even though the `atms` row survives as INACTIVE. diff --git a/backend/src/main/resources/content/devices/device-groups/architect.md b/backend/src/main/resources/content/devices/device-groups/architect.md new file mode 100644 index 0000000..2a9ef2d --- /dev/null +++ b/backend/src/main/resources/content/devices/device-groups/architect.md @@ -0,0 +1,79 @@ +--- +module: devices.device-groups +title: Device Groups +tab: Architect +order: 30 +audience: internal +--- + +> **Internal · architecture.** Grounded in `hiveops-devices` backend + `AtmGroups.svelte` + `api.ts` (2026-07-01). + +## Ownership +Device Groups are owned end-to-end by **hiveops-devices** (Spring Boot, port **8096**, DB **`hiveiq_devices`**). No other service owns group state. See [platform.service-ownership]. The one cross-service interaction is the hardware-profile push, which reaches into **fleet** (via NGINX `/fleet/*`) and emits a device event on **Kafka**. + +## Components (this feature only) +- **Frontend:** `AtmManagement/AtmGroups.svelte` — table + slide-in panels for create/edit and Assign Devices, plus an Assign-Profile modal. Two-panel "shuttle" pickers manage membership client-side before saving. Calls `atmGroupAPI` (and `fleetAPI`, `agentProfileAPI`) in `lib/api.ts`. +- **Controller:** `AtmGroupController` — `@RequestMapping({"/api/atm-groups","/api/device-groups"})` (Phase-2 rename alias). +- **Service:** `AtmGroupService` (`@Transactional(readOnly=true)`, write methods `@Transactional`). +- **Repository:** `AtmGroupRepository` — uses `LEFT JOIN FETCH g.atms` queries (`findByIdWithAtms`, `findAllWithAtms`, `findAllWithAtmsByInstitutionKeyIn`) to avoid lazy-load blowups on the `@ManyToMany`. +- **Entity:** `AtmGroup` → table `atm_groups`; membership is a `@ManyToMany` to `Atm` via join table `atm_group_members` (`group_id`, `atm_id`). +- **DTO:** `AtmGroupDTO` (adds computed `atmIds` sorted + `atmCount`); request DTO `CreateAtmGroupRequest`. + +## Data stores (read/written) +| Table | Access | Notes | +|-------|--------|-------| +| `atm_groups` | R/W | group name (`UNIQUE`, global), description, `color`, `institution_key` (len 10), timestamps | +| `atm_group_members` | R/W | join table; membership replace/add/remove all mutate here | +| `atms` | R (membership resolve) / W (hardware-profile only) | `setGroupHardwareProfile` writes `atm.hardware_profile` per member | + +See [platform.data-architecture]. Schema is Flyway-managed; `ddl-auto=validate`. + +## Data flow — plain group CRUD +1. Frontend `atmGroupAPI.*` → devices backend controller → service → repository → `atm_groups` / `atm_group_members`. +2. Reads are institution-filtered by `InstitutionContext.resolveScope()`; single-institution callers have their `institution_key` forced on create. +3. **No Kafka is emitted for group create/update/delete/membership changes.** Groups are a devices-local concept and are *not* mirrored into the `device_summary` tables of incident/fleet/reports/recon. + +## Data flow — Assign Profile (the cross-service path) +This is the executor → Kafka → downstream pattern for this feature: + +``` +User picks profile in Assign-Profile modal + → POST /api/atm-groups/{id}/hardware-profile { profileKey } (devices backend) + AtmGroupService.setGroupHardwareProfile(): + for each member Atm: + atm.setHardwareProfile(profileKey.trim().toLowerCase()) // normalised + atmRepository.save(atm) + DeviceEventPublisher.publishUpdated(atm) ──► Kafka hiveops.device.events (DEVICE_UPDATED, keyed by atmId) + returns { atmIds: [...] } // sorted member ids + → Frontend, if atmIds non-empty: + POST /fleet/tasks { atmIds, taskKind: "CONFIG_UPDATE", + configPatch: { "atm.hardware.profile": profileKey } } (fleet backend, via /fleet/*) +``` + +So a single profile assign produces **two** effects: (a) N `DEVICE_UPDATED` Kafka events consumed by the device-summary mirrors ([platform.kafka]), and (b) one batch of `CONFIG_UPDATE` fleet tasks that the agents pull to actually apply `atm.hardware.profile`. The Kafka publish and the fleet task are independent — the fleet task is orchestrated by the **frontend**, not the backend, so a backend-only caller of `/hardware-profile` updates DB + emits events but does **not** queue the config push. + +## Key API endpoints +| Method + Path | Auth | Purpose | +|---------------|------|---------| +| `GET /api/atm-groups` | authed | List (institution-scoped) | +| `GET /api/atm-groups/{id}` | authed | One group | +| `POST /api/atm-groups` | admin | Create | +| `PUT /api/atm-groups/{id}` | admin | Update (partial; non-null fields only) | +| `DELETE /api/atm-groups/{id}` | admin | Delete (clears members first) | +| `PUT /api/atm-groups/{id}/atms` | admin | **Replace** all members (`List` body) | +| `POST /api/atm-groups/{id}/atms/{atmId}` | admin | Add one member | +| `DELETE /api/atm-groups/{id}/atms/{atmId}` | admin | Remove one member | +| `POST /api/atm-groups/{id}/hardware-profile` | admin | Bulk-set `atm.hardware_profile`, returns `{atmIds}` | +| `POST /fleet/tasks` | authed | (fleet) `CONFIG_UPDATE` push queued by frontend after profile assign | + +admin = `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')`. + +## Kafka +- **Produced (only via profile assign):** `hiveops.device.events`, type `DEVICE_UPDATED`, keyed by `atmId`, from `DeviceEventPublisher.publishUpdated`. Consumed by the device-summary mirrors in incident/fleet/reports/recon. See [platform.kafka]. +- **No group-specific topic exists.** Group identity/membership is never published. + +## Design notes / trade-offs +- **Default group is not persisted server-side** — it lives in browser `localStorage` (`hiveops_defaultGroupId`, `stores.ts`) and only drives the `groupId` filter on `/api/atms/paginated`. +- **Group name uniqueness is global**, not per-institution — a multi-tenant naming collision surfaces as a DB constraint error. +- **Membership save is a full-set replace** (`assignAtms` / `updateGroup` with `atmIds`), so the client must send the complete intended set. +- Cross-link: [platform.data-architecture], [platform.kafka], [platform.service-ownership], and the service-level [technical.devices]. diff --git a/backend/src/main/resources/content/devices/device-groups/claude.md b/backend/src/main/resources/content/devices/device-groups/claude.md new file mode 100644 index 0000000..ffcebb6 --- /dev/null +++ b/backend/src/main/resources/content/devices/device-groups/claude.md @@ -0,0 +1,56 @@ +--- +module: devices.device-groups +title: Device Groups +tab: Claude +order: 40 +audience: internal +--- + +## Owner +- Service: **hiveops-devices** (port 8096, DB `hiveiq_devices`). Groups live entirely here. +- Controller `AtmGroupController`, base `@RequestMapping({"/api/atm-groups","/api/device-groups"})` (Phase-2 alias — both route). +- External via NGINX: `https://api.bcos.cloud/devices/api/atm-groups`. + +## Endpoints (METHOD path — auth) +| Method | Path | Auth | Body / returns | +|--------|------|------|----------------| +| GET | `/api/atm-groups` | any authed | list, institution-scoped | +| GET | `/api/atm-groups/{id}` | any authed | one group | +| POST | `/api/atm-groups` | admin | `{name, description?, color?, institutionKey?, atmIds?[]}` | +| PUT | `/api/atm-groups/{id}` | admin | partial update; only non-null fields applied | +| DELETE | `/api/atm-groups/{id}` | admin | 204 | +| PUT | `/api/atm-groups/{id}/atms` | admin | body = `[atmId,...]` → **replaces** all members | +| POST | `/api/atm-groups/{id}/atms/{atmId}` | admin | add one | +| DELETE | `/api/atm-groups/{id}/atms/{atmId}` | admin | remove one | +| POST | `/api/atm-groups/{id}/hardware-profile` | admin | `{"profileKey":"..."}` → `{"atmIds":[...]}` | +| POST | `/fleet/tasks` | authed | fleet svc; frontend queues `CONFIG_UPDATE` after profile assign | + +- admin = `@PreAuthorize("hasAnyRole('MSP_ADMIN','BCOS_ADMIN')")`. +- GET endpoints have **no** `@PreAuthorize` (only institution scoping). + +## Tables +- `atm_groups` — id, name (**UNIQUE, global**), description, `color`, `institution_key` (len 10), createdAt, updatedAt. +- `atm_group_members` — join (`group_id`, `atm_id`); `@ManyToMany AtmGroup↔Atm`. +- `atms` — written only by `/hardware-profile` (sets `atm.hardware_profile`, lowercased+trimmed). + +## Kafka +- Topic: `hiveops.device.events`, type `DEVICE_UPDATED`, key = atmId. +- Emitted **only** by `/hardware-profile` (one event per member, via `DeviceEventPublisher.publishUpdated`). +- Group create/update/delete/membership changes emit **nothing**. + +## Gotchas +- **Default group is client-side only** — browser `localStorage` key `hiveops_defaultGroupId`. Not in DB, not per-account. Drives `groupId` filter on `/api/atms/paginated`. +- **Group name UNIQUE is global**, not per-institution → duplicate name = DB constraint error; `server.error.include-message=never` hides detail. +- **`PUT .../atms` replaces the whole set** — not additive. Must send full membership. +- Single-institution caller: create **forces** `institution_key = scope` regardless of request `institutionKey`. +- `verifyGroupAccess` only blocks when `institution_key != null`; **null ("All") groups are editable/deletable by any in-scope admin**. +- Profile assign = 2 effects: DB+Kafka from backend, `CONFIG_UPDATE` fleet task from **frontend** (skipped if `atmIds` empty). Calling `/hardware-profile` directly does NOT queue the fleet task. +- `configPatch` key used by the push = `atm.hardware.profile`. + +## Do NOT +- Do NOT expect `Set Default` to persist server-side or across browsers. +- Do NOT assume group changes propagate via Kafka / device_summary mirrors — they don't; only per-ATM hardware-profile updates do. +- Do NOT treat `PUT .../atms` as add — it overwrites membership. +- Do NOT invent a group Kafka topic or a `hiveiq_*` table other than `atm_groups` / `atm_group_members`. +- Do NOT queue a fleet task by calling only the backend `/hardware-profile`; the fleet push is a separate frontend call. +- Do NOT rely on error bodies — messages are suppressed in prod. diff --git a/backend/src/main/resources/content/devices/device-groups/internal.md b/backend/src/main/resources/content/devices/device-groups/internal.md new file mode 100644 index 0000000..fd6bbcc --- /dev/null +++ b/backend/src/main/resources/content/devices/device-groups/internal.md @@ -0,0 +1,63 @@ +--- +module: devices.device-groups +title: Device Groups +tab: Internal +order: 20 +audience: internal +--- + +> **Internal · ops/support/admin.** Grounded in `hiveops-devices` backend + `AtmGroups.svelte` (2026-07-01). Owner service: **hiveops-devices** (port 8096, DB `hiveiq_devices`). + +## What this module actually is +A CRUD screen over the `atm_groups` table plus its `atm_group_members` join table. Groups are named, colored collections of devices used for **filtered device lists** and **bulk hardware-profile pushes**. Deleting a group or dropping a device only touches the join table — no device is ever deleted. + +## Roles required +- **Read** (list groups, view a group): any authenticated JWT. `GET /api/atm-groups` and `GET /api/atm-groups/{id}` have **no `@PreAuthorize`** — they are readable by any logged-in user, scoped by institution (see below). +- **All mutations** (create, update, delete, assign ATMs, add/remove single ATM, assign hardware profile): `@PreAuthorize("hasAnyRole('MSP_ADMIN','BCOS_ADMIN')")`. A non-admin PUT/POST/DELETE returns **403**. + +## Institution scoping (why a user "can't see a group") +`AtmGroupService` calls `InstitutionContext.resolveScope()` on every read/write: +- scope `null` → full access (MSP/BCOS admin) — sees all groups. +- scope empty list → sees **zero** groups. +- scope non-empty → only groups whose `institution_key` is in scope (`findAllWithAtmsByInstitutionKeyIn`). +- On **create**, if the caller is scoped to exactly one institution, the group's `institution_key` is **forced** to that key — the Institution dropdown selection in the form is ignored for single-institution users. + +First thing to check when "my group vanished / I can't edit it": the caller's JWT institution scope vs the group's `institution_key`. + +## Admin-only actions on the screen +- **+ New Group** → `POST /api/atm-groups`. +- **Edit** → `PUT /api/atm-groups/{id}` (name/description/color/institution/membership; only non-null fields are applied). +- **Assign Devices** → `PUT /api/atm-groups/{id}/atms` with a full `[atmId, ...]` list — this **replaces** the whole membership set, it is not additive. +- **Assign Profile** → `POST /api/atm-groups/{id}/hardware-profile` `{ "profileKey": "..." }`, then the **frontend** fires a `POST /fleet/tasks` (`CONFIG_UPDATE`, patch `atm.hardware.profile`). +- **Delete** → `DELETE /api/atm-groups/{id}`. + +## Set Default / Clear Default — NOT server state +The **Set Default** / **✕ Clear** buttons are **purely client-side**. The chosen group id is stored in browser `localStorage` under key **`hiveops_defaultGroupId`** (`stores.ts`). It is: +- per-browser, per-machine — never synced to the account or DB; +- used only to pre-filter the device list (passes `groupId` to `/api/atms/paginated`). + +"My default group didn't follow me to another machine" is expected behaviour, not a bug. To reset it: clear site data / `localStorage`. + +## Common failure modes + fixes +| Symptom | Likely cause | Fix | +|--------|--------------|-----| +| **409 / 500 on create or edit** with terse message | `atm_groups.name` is `UNIQUE` **globally** (not per-institution). A duplicate name — even across institutions — violates the constraint. `server.error.include-message=never` hides the detail. | Pick a unique name; check DB for the colliding row. | +| **403 on any save** | Caller lacks `MSP_ADMIN`/`BCOS_ADMIN`. | Use an admin JWT. | +| **Group list empty for a real user** | Institution scope is empty, or all groups are tied to institutions outside scope. | Check JWT `institutionKey` claim / scope. | +| **"Assign Profile" says N devices but no config lands on ATMs** | Two steps: profile save succeeded but the follow-up fleet task failed, or ATMs are offline/haven't polled. | Check `/fleet/tasks` for the queued `CONFIG_UPDATE`; verify agents are polling. | +| **Assigning devices wiped others out** | `Assign Devices` is a full replace, not add. | Re-open, select the complete intended set. | +| **Removed a device but it's "back"** | Edit form / Assign panel was opened from stale data. | Reload groups (`GET /api/atm-groups`) and retry. | +| **Group shows "All" institution but a scoped admin can still edit it** | `verifyGroupAccess` only blocks when `institution_key` is non-null. Null (All) groups are editable by any in-scope admin. | Intended today; note it before blaming data. | + +## Quick checks +```bash +# List groups (any authed JWT) +curl -s https://api.bcos.cloud/devices/api/atm-groups -H "Authorization: Bearer $JWT" + +# DB: group + member counts +SELECT g.id, g.name, g.institution_key, count(m.atm_id) AS atms +FROM atm_groups g LEFT JOIN atm_group_members m ON m.group_id = g.id +GROUP BY g.id ORDER BY g.name; +``` + +See also [devices.device-groups] Overview (customer) and the Architect tab for data flow. diff --git a/backend/src/main/resources/content/devices/device-list/architect.md b/backend/src/main/resources/content/devices/device-list/architect.md new file mode 100644 index 0000000..885ec81 --- /dev/null +++ b/backend/src/main/resources/content/devices/device-list/architect.md @@ -0,0 +1,53 @@ +--- +module: devices.device-list +title: Device List +tab: Architect +order: 30 +audience: internal +--- + +How the Device List is built. Frontend: `hiveops-devices/frontend/src/components/AtmManagement/AtmList.svelte`. Backend of record: **hiveops-devices** (Spring Boot, port **8096**, DB `hiveiq_devices`). See [platform.service-ownership], [platform.data-architecture], [platform.kafka]. + +## Services involved (for this feature) +- **hiveops-devices** — owns the device inventory and serves every grid query. `AtmController` (`@RequestMapping({"/api/atms","/api/devices"})`) → `AtmService` → `AtmRepository` / `AtmPropertiesRepository` on `hiveiq_devices`. +- **hiveops-incident** — only for **📍 Lookup Coords**: `GeocodingController` (`@RequestMapping("/api/geocode")`) proxies a US Census geocoder. The Device List calls it as a relative `fetch('/api/geocode?address=...')`; nginx must route `/api/geocode` to incident. +- **auth/mgmt (JWT)** — the bearer token carries role + institution keys, consumed by `InstitutionContext` for row-level scoping. + +## Data flow — reading the grid +1. `AtmList.fetchAtms()` builds params (page, size, sort, search, status[], model[], agentStatus[], groupId, deviceType, agentVersion[]) and calls `loadAtmsPaginated()` in `stores.ts`. +2. `GET /api/atms/paginated` → `AtmController.getAllAtmsPaginated` → `InstitutionContext.resolveScope()` derives the institution scope from the JWT (scoped for `ROLE_CUSTOMER`/`ROLE_MSP_ADMIN`; null = whole fleet for BCOS_ADMIN) → `atmService.getAllAtmsPaginated(...)`. +3. `AtmService` reads `atms` + `atm_properties`, and per row derives `agentConnectionStatus` via `calculateConnectionStatus(lastHeartbeat)`: + - `null` → `NEVER_CONNECTED` + - ≤ 15 min → `CONNECTED` + - > 15 min → `DISCONNECTED` +4. Returns a Spring `Page`; the store maps `page.totalElements / totalPages / number / size`. +5. **Status badge** is computed client-side in the grid: `AGENT_OFFLINE` if disconnected/never-connected, else `IN_SUPERVISOR` if `inSupervisor`, else `serviceState || status`. The `Status` column sorts on backend field `effectiveStatus`. +6. Filter dropdowns are populated by `GET /api/atms/distinct-models` and `GET /api/atms/distinct-agent-versions`; groups by `GET /api/atm-groups`. + +## Data flow — Add Device (write) + Kafka mirrors +1. `POST /api/atms` (`@PreAuthorize("hasAnyRole('MSP_ADMIN','BCOS_ADMIN')")`) → `AtmService` persists to `atms` (+ `atm_properties`). +2. On create, `deviceEventPublisher.publishCreated(savedAtm)` sends a `DEVICE_CREATED` event to Kafka topic **`hiveops.device.events`** (`KafkaProducerConfig.DEVICE_EVENTS_TOPIC`), keyed by `atmId`. Updates/institution-moves/deletes publish `DEVICE_UPDATED` / `DEVICE_DELETED` the same way. +3. Mirror consumers in **incident / fleet / reports / recon** (and vault) keep their `device_summary` copies in sync from this topic. There is **no reconciler** — every write path must publish or the mirrors go stale. +4. `POST /api/atms/republish-all` (admin) re-emits events to rebuild mirrors. + +## DB tables (this feature) +| Table | Read/Write | Role | +|-------|-----------|------| +| `atms` | R (grid) / W (add) | Core device inventory, source of truth | +| `atm_properties` | R (heartbeat, version) / W | Per-device props incl. `lastHeartbeat`, agent version | +| `atm_groups` | R | Group filter | + +## Kafka +| Topic | Direction | Producer/Notes | +|-------|-----------|----------------| +| `hiveops.device.events` | produced | `DeviceEventPublisher`; `DEVICE_CREATED/UPDATED/DELETED`, keyed by atmId; consumed by incident/fleet/reports/recon mirrors | + +## Key API endpoints +- `GET /api/atms/paginated` — the grid (institution-scoped, server-side filter/sort/page) +- `GET /api/atms/distinct-models`, `GET /api/atms/distinct-agent-versions` — filter options +- `POST /api/atms` — Add Device (admin) → publishes `DEVICE_CREATED` +- `GET /api/geocode?address=` — coord lookup (**incident**, not devices) + +## Notes +- `/api/atms*` also answers on the `/api/devices*` Phase-2 alias (ATM→device rename); both must stay routable in nginx. +- Devices backend uses `DB_URL` (full JDBC URL), not split host/port/name vars. diff --git a/backend/src/main/resources/content/devices/device-list/claude.md b/backend/src/main/resources/content/devices/device-list/claude.md new file mode 100644 index 0000000..d8ea48d --- /dev/null +++ b/backend/src/main/resources/content/devices/device-list/claude.md @@ -0,0 +1,50 @@ +--- +module: devices.device-list +title: Device List +tab: Claude +order: 40 +audience: internal +--- + +Terse agent reference. Frontend: `hiveops-devices/frontend/src/components/AtmManagement/AtmList.svelte`. + +## Owner +- **hiveops-devices** backend (port 8096, DB `hiveiq_devices`) owns the grid data + all mutations. +- **hiveops-incident** owns ONLY `/api/geocode` (Lookup Coords). + +## Endpoints (METHOD path → purpose · role) +| Method + Path | Purpose | Role | +|---|---|---| +| GET `/api/atms/paginated` | Grid rows (params: page,size,sort,search,status[],model[],agentStatus[],groupId,deviceType,agentVersion[]) | any auth, institution-scoped | +| GET `/api/atms/distinct-models` | Model filter options | any auth | +| GET `/api/atms/distinct-agent-versions` | Agent version filter options | any auth | +| GET `/api/atm-groups` | Group filter | any auth | +| GET `/api/institution-keys/accessible` | Add-Device institution dropdown | any auth | +| POST `/api/atms` | Add Device | `MSP_ADMIN`/`BCOS_ADMIN` | +| PATCH `/api/atms/{id}/institution` | Reassign institution | `MSP_ADMIN`/`BCOS_ADMIN` | +| GET `/api/geocode?address=` | Lookup Coords (**incident**) | any auth | + +- Controller: `AtmController` `@RequestMapping({"/api/atms","/api/devices"})` → all paths also work under `/api/devices*`. + +## Tables +- `atms` (source of truth), `atm_properties` (`lastHeartbeat`, agent version), `atm_groups`. + +## Kafka +- Produces `hiveops.device.events` (`DeviceEventPublisher`; `DEVICE_CREATED/UPDATED/DELETED`, key=atmId) on every write. Consumed by incident/fleet/reports/recon mirrors. **No reconciler.** + +## Gotchas +- Connection status = heartbeat age: `null`→`NEVER_CONNECTED`, ≤15min→`CONNECTED`, >15min→`DISCONNECTED` (`calculateConnectionStatus`). NOTE: customer overview.md still says "5 min" — code uses **15**. +- Status badge is derived **client-side**: `AGENT_OFFLINE` if disconnected/never-connected overrides real `serviceState`; then `IN_SUPERVISOR`; else `serviceState||status`. Status column sorts on backend `effectiveStatus`. +- Grid filtering/sorting/paging is **server-side** (re-queries on every change). `AGENT OFFLINE` status-filter option is translated to `agentStatus=disconnected,never_connected` client-side. +- Institution scoping via `InstitutionContext.resolveScope()`: `ROLE_CUSTOMER`/`ROLE_MSP_ADMIN` scoped (keys from JWT credentials); other roles (BCOS_ADMIN) unscoped. Empty list for one user = scoping, not a bug. +- `/api/geocode` is a relative `fetch` → depends on nginx routing to incident, not devices. +- Backend: `server.error.include-message=never` → terse prod errors; read logs (Loki). +- Devices backend config uses `DB_URL` (full JDBC URL). + +## Do NOT +- Do NOT add device read/write endpoints to incident/fleet/etc — device domain is owned by **hiveops-devices** only. +- Do NOT assume `AGENT OFFLINE` means a service fault — it means no heartbeat in 15 min. +- Do NOT expect other apps to reflect a device edit unless `hiveops.device.events` was published (no reconciler); use `POST /api/atms/republish-all` (admin) to rebuild mirrors. +- Do NOT look for geocode in hiveops-devices — it lives in hiveops-incident. +- Do NOT hit `POST /api/atms` as a non-admin (403). +- Do NOT invent split DB host/port env vars — it's `DB_URL`. diff --git a/backend/src/main/resources/content/devices/device-list/internal.md b/backend/src/main/resources/content/devices/device-list/internal.md new file mode 100644 index 0000000..89ed87f --- /dev/null +++ b/backend/src/main/resources/content/devices/device-list/internal.md @@ -0,0 +1,41 @@ +--- +module: devices.device-list +title: Device List +tab: Internal +order: 20 +audience: internal +--- + +Ops/support view of the Device List (frontend `AtmManagement/AtmList.svelte`, served by the **hiveops-devices** backend, port **8096**). This is the fleet inventory grid at `devices.bcos.cloud`. + +## What powers each part +- **Grid rows / Total count** → `GET /api/atms/paginated` (`AtmController.getAllAtmsPaginated`). Read-only, no admin role required — but results are **institution-scoped** (see below). +- **Model filter dropdown** → `GET /api/atms/distinct-models`. +- **Agent Version filter dropdown** → `GET /api/atms/distinct-agent-versions`. +- **Group filter** → groups loaded via `GET /api/atm-groups`. +- **+ Add Device** → `POST /api/atms` — **admin only** (`hasAnyRole('MSP_ADMIN','BCOS_ADMIN')`). +- **📍 Lookup Coords** → `GET /api/geocode?address=...` — this is served by **hiveops-incident** (`GeocodingController`), NOT devices. Called as a relative `fetch('/api/geocode')`, so it depends on nginx routing. + +## Roles required +- **Viewing the list, filters, distinct-models/versions:** any authenticated user; data is scoped by institution. +- **Add Device / any mutation** (`POST/PUT/PATCH/DELETE /api/atms*`): `MSP_ADMIN` or `BCOS_ADMIN`. A `CUSTOMER` gets 403 on Add Device. +- **Institution scoping** (`InstitutionContext.resolveScope`): callers with `ROLE_CUSTOMER` or `ROLE_MSP_ADMIN` are scoped to their institution key(s) (from JWT credentials); `BCOS_ADMIN` (and other unscoped roles) see the whole fleet. + +## First things to check when it misbehaves +1. **List is empty / "No ATMs found" for one user only** → almost always institution scoping. Confirm the user's JWT institution keys; an MSP/customer only sees their own devices. Compare against a `BCOS_ADMIN` login (unscoped). +2. **Device shows "AGENT OFFLINE" unexpectedly** → the **Status** badge is overridden to `AGENT OFFLINE` whenever `agentConnectionStatus` is `DISCONNECTED` or `NEVER_CONNECTED`, regardless of the real service state. Connection status is derived from **last heartbeat**: `CONNECTED` ≤15 min, `DISCONNECTED` >15 min, `NEVER_CONNECTED` if never seen. So an offline badge = no heartbeat in 15 min, not a service fault. Check the device's actual `lastHeartbeat` / agent connectivity. +3. **Wrong institution stuck on a device** → institution is auto-derived from the agent token; a mis-imaged box keeps the wrong institution. Fix via `PATCH /api/atms/{id}/institution` (admin). Cross-check LOCATION to find the true owner. +4. **A device edit doesn't show up in Incident/Fleet/Reports/Recon** → those are **Kafka mirrors** of `hiveops.device.events`. If the devices backend can't reach Kafka, the mirrors go stale (there is no reconciler). Check devices-backend Kafka connectivity; a stale mirror is the usual cause of "wrong data in app X but right in Devices." +5. **Add Device fails silently / terse error** → devices backend runs `server.error.include-message=never`, so prod errors are terse. Check the container logs (Loki) rather than the UI message. +6. **"Lookup Coords" fails but everything else works** → geocode is a different service (incident) on a different nginx path. Verify `/api/geocode` routes to incident and that the upstream (US Census / geocoder) is reachable. +7. **Filters don't narrow the list** → the grid re-queries the backend on every filter change (server-side filtering). `AGENT OFFLINE` in the Status filter is translated client-side into an `agentStatus` filter (disconnected + never_connected), not a DB status value. + +## Common failure modes + fixes +| Symptom | Likely cause | Fix | +|---------|-------------|-----| +| One user sees far fewer devices | Institution scoping via JWT | Expected; verify with BCOS_ADMIN | +| Status = AGENT OFFLINE | No heartbeat >15 min | Check agent/network, not service state | +| Add Device → 403 | Non-admin role | Needs MSP_ADMIN / BCOS_ADMIN | +| Edit not reflected in other apps | Kafka mirror stale | Check devices→Kafka; republish (`POST /api/atms/republish-all`, admin) | +| Lookup Coords broken | incident/geocode down or nginx | Check incident + `/api/geocode` route | +| Terse/empty error text | `include-message=never` | Read backend logs, not UI | diff --git a/backend/src/main/resources/content/devices/map/architect.md b/backend/src/main/resources/content/devices/map/architect.md new file mode 100644 index 0000000..c6a58a8 --- /dev/null +++ b/backend/src/main/resources/content/devices/map/architect.md @@ -0,0 +1,68 @@ +--- +module: devices.map +title: Map +tab: Architect +order: 30 +audience: internal +--- + +> **Internal · architecture.** Grounded in `AtmMap.svelte`, `frontend/src/lib/{api,stores}.ts`, and the devices/incident controllers, 2026-07-01. + +## Shape of the feature + +The Map is a **pure read/aggregate view** with **zero server-side state of its own**. There is no `devices.map` controller, table, executor, or Kafka topic. The Svelte component orchestrates two backends and does all clustering/colour/filter logic in the browser. See [platform.service-ownership]. + +``` +AtmMap.svelte (devices SPA, port 5177) + ├─ loadAtms() → devicesApi GET /api/atms → hiveops-devices (8096) → atms table + ├─ loadIncidents() → incidentApi GET /incident/incidents → hiveops-incident (8080) → incidents table + └─ loadAtmGroups() → devicesApi GET /api/atm-groups → hiveops-devices (8096) → atm_groups table + (Leaflet circleMarkers, client-side grouping + colour + filter) +``` + +## Services involved & how they interact + +- **hiveops-devices** — system of record for the pins. `AtmController` (`@RequestMapping({"/api/atms","/api/devices"})`) serves the ATM inventory incl. `latitude`/`longitude`/`status`/`inSupervisor`/`agentConnectionStatus`. `AtmGroupController` (`/api/atm-groups`) serves group definitions for the Group filter. See [technical.devices]. +- **hiveops-incident** — supplies open incidents. `IncidentController.getAllIncidents()` (`GET /api/incidents`, institution-scoped via `InstitutionContext.resolveScope()`). The map keeps only `status === 'OPEN'`, indexes them by `atmId`, and uses that to (a) colour a pin orange, (b) build the Incident Type filter, (c) render incident tags in the pin popup. + +The two calls are **independent** — an incident-backend outage degrades colour/type-filter but still renders every pin. + +### Frontend API wiring (`lib/api.ts`) + +- `devicesApi` baseURL = `window.__APP_CONFIG__.apiUrl` (prod `https://api.bcos.cloud/devices`, dev `http://localhost:8096`). +- `incidentApi` baseURL = `${gatewayUrl}/incident`, where `gatewayUrl` = devices URL with the trailing `/devices` stripped. So `incidentAPI.getAll()` hits `…/incident/incidents`; nginx rewrites that to the controller's `/api/incidents`. + +## Data flow (no Kafka in the read path) + +The map does **not** consume Kafka. It reads live from both services on each refresh. Note the platform-level relationship for context: `hiveops-devices` publishes `hiveops.device.events` (`DEVICE_CREATED/UPDATED/DELETED`) which mirrors device data into incident/fleet/reports/recon `device_summary` tables — but **this map bypasses those mirrors** and queries devices + incident directly, so it is never stale-mirror-affected. See [platform.kafka], [platform.data-architecture]. + +Auto-refresh is a browser `setInterval`; each tick calls `loadAtms()` + `loadIncidents()` (interval = `settings.dashboardRefreshIntervalSeconds`). + +## DB tables (read-only) + +| Table | DB | Service | Used for | +|-------|----|---------|----------| +| `atms` (cols incl. `latitude`, `longitude`, `status`, `in_supervisor`, `agent_connection_status`) | `hiveiq_devices` | devices | Pin position, colour, popup | +| `atm_groups` | `hiveiq_devices` | devices | Group filter options | +| `incidents` | `hiveiq_incident` | incident | Open-incident colour + Incident Type filter + popup tags | + +No table is written by this view. + +## Key API endpoints + +| Method + Path | Service | Role | Purpose | +|---------------|---------|------|---------| +| `GET /api/atms` | devices | authenticated (no `@PreAuthorize`) | Pins. Falls back to `GET /api/atms/paginated?size=9999&groupId=` when a default group is set | +| `GET /api/atm-groups` | devices | authenticated | Group filter | +| `GET /api/incidents` | incident | authenticated, institution-scoped | Open incidents | +| `PUT /api/atms/{id}` | devices | `MSP_ADMIN`/`BCOS_ADMIN` | Set lat/long to un-hide an ATM (done from ATM Detail, not the map) | + +## Client-side logic worth knowing + +- **Clustering:** ATMs are grouped by exact `"${latitude},${longitude}"` string key; a group of >1 renders one `circleMarker` + a count badge `divIcon`, coloured by the worst member (`STATUS_PRIORITY`: DOWN < MAINTENANCE < INACTIVE < OUT_OF_CASH < IN_SERVICE). +- **Pin colour** (`getPinColor`): not IN_SERVICE/OUT_OF_CASH → red; open incident → amber; OUT_OF_CASH → amber; `inSupervisor` → blue; else green. +- **Persistence** is entirely `localStorage`: `hiveops_map_filters_v2` (current filters + presets), `devices_map_autoRefresh`, `hiveops_defaultGroupId`. + +## Cross-links + +- [platform.data-architecture] · [platform.kafka] · [platform.service-ownership] · [technical.devices] diff --git a/backend/src/main/resources/content/devices/map/claude.md b/backend/src/main/resources/content/devices/map/claude.md new file mode 100644 index 0000000..3bdbfae --- /dev/null +++ b/backend/src/main/resources/content/devices/map/claude.md @@ -0,0 +1,68 @@ +--- +module: devices.map +title: Map +tab: Claude +order: 40 +audience: internal +--- + +> Terse agent reference. Grounded in `AtmMap.svelte`, `lib/api.ts`, `lib/stores.ts`, `AtmController`, `AtmGroupController`, `IncidentController` (2026-07-01). Read-only view — no writes, no dedicated backend. + +## Ownership + +- **View:** `hiveops-devices` frontend, `frontend/src/components/AtmMap/AtmMap.svelte` (Leaflet, port 5177). +- **Pin/group data owner:** `hiveops-devices` backend (8096). +- **Incident data owner:** `hiveops-incident` backend (8080). +- No `devices.map` controller / table / Kafka topic exists. + +## Endpoints (all GET, all read-only) + +| Method + Path | Service | Auth | Notes | +|---|---|---|---| +| `GET /api/atms` | devices | any JWT (no `@PreAuthorize`) | pins; frontend `atmAPI.getAll()` | +| `GET /api/atms/paginated?size=9999&groupId=&sort=atmId,asc` | devices | any JWT | used instead when `localStorage.hiveops_defaultGroupId` set | +| `GET /api/atm-groups` | devices | any JWT | Group filter (`atmGroupAPI.getAll()`) | +| `GET /api/incidents` | incident | any JWT, institution-scoped | open-incident colour + type filter; frontend calls gateway path `…/incident/incidents` | +| `PUT /api/atms/{id}` | devices | `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` | ONLY way to set lat/long (unmapped fix); done in ATM Detail, not the map | + +- Frontend base URLs: `devicesApi` = `window.__APP_CONFIG__.apiUrl` (`https://api.bcos.cloud/devices`); `incidentApi` = that with `/devices` stripped + `/incident`. nginx injects `/api` for the incident call. +- Devices controller also answers Phase-2 aliases `/api/devices` and `/api/device-groups`. + +## Tables (read-only) + +- `atms` (`hiveiq_devices`) — cols used: `latitude`, `longitude`, `status`, `in_supervisor`, `agent_connection_status`. +- `atm_groups` (`hiveiq_devices`). +- `incidents` (`hiveiq_incident`). + +## Topics + +- None consumed/produced by this view. (Platform context only: devices publishes `hiveops.device.events`; the map does NOT read those mirrors — it queries devices + incident live.) + +## Client-only state (localStorage keys) + +- `hiveops_map_filters_v2` — current filters + saved presets. +- `devices_map_autoRefresh` — auto-refresh toggle. +- `hiveops_defaultGroupId` — forces group-scoped `loadAtms()`. + +## Enums + +- `status`: `IN_SERVICE`, `DOWN`, `MAINTENANCE`, `INACTIVE`, `OUT_OF_CASH`. +- `agentConnectionStatus`: `CONNECTED`, `DISCONNECTED`, `NEVER_CONNECTED`. +- incident: only `status === 'OPEN'` colours a pin; `severity`: `CRITICAL`/`HIGH`/other. + +## Gotchas + +- Pins need **non-null lat AND long**; others dropped + counted in amber banner. +- Clustering keys on **exact** `"lat,lon"` string — near-but-not-equal coords do not merge. +- Two-service view: incident outage ⇒ pins still render, but no orange/incident-type filter. +- Default group active ⇒ map capped at 9999 rows and scoped to that group. +- Presets/filters are per-browser only; not persisted server-side. +- Reads have no role gate; only lat/long edits (`PUT /api/atms/{id}`) need `MSP_ADMIN`/`BCOS_ADMIN`. + +## Do NOT + +- Do NOT invent a `devices.map` endpoint/table/topic — none exists; it aggregates existing device + incident reads. +- Do NOT expect an incident endpoint at `/incident/api/incidents`; frontend uses `/incident/incidents` (nginx adds `/api`). +- Do NOT tell users presets are recoverable after clearing browser data — they are `localStorage`-only. +- Do NOT attribute stale pins to Kafka `device_summary` mirrors — this view bypasses them. +- Do NOT add write actions to the map; corrections belong in ATM Detail (devices) / Incident app. diff --git a/backend/src/main/resources/content/devices/map/internal.md b/backend/src/main/resources/content/devices/map/internal.md new file mode 100644 index 0000000..33905b1 --- /dev/null +++ b/backend/src/main/resources/content/devices/map/internal.md @@ -0,0 +1,54 @@ +--- +module: devices.map +title: Map +tab: Internal +order: 20 +audience: internal +--- + +> **Internal · ops/support.** Grounded in `hiveops-devices/frontend/src/components/AtmMap/AtmMap.svelte` + the devices/incident backends, 2026-07-01. + +## What this view actually does + +The Fleet Map is a **read-only** Leaflet view. It makes no writes. On mount and on each auto-refresh it pulls three lists and does all clustering, colouring, and filtering **client-side**: + +| Data | Source (frontend fn) | Backend | +|------|----------------------|---------| +| ATMs (pins) | `loadAtms()` → `atmAPI.getAll()` | `hiveops-devices` `GET /api/atms` | +| Open incidents (orange colour + Incident Type filter) | `loadIncidents()` → `incidentAPI.getAll()` | `hiveops-incident` `GET /api/incidents` | +| Groups (Group filter) | `loadAtmGroups()` → `atmGroupAPI.getAll()` | `hiveops-devices` `GET /api/atm-groups` | + +**Key consequence:** the map straddles **two services**. Pins come from devices; the orange "Open Incident" colour and the Incident Type filter come from incident. They fail independently. + +## Roles required + +- **To view the map:** any authenticated JWT. The three GET endpoints above have **no `@PreAuthorize`** — reads are open to any logged-in role (incidents are still institution-scoped server-side via `InstitutionContext.resolveScope()`). +- **To fix an unmapped ATM (set lat/long):** `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` — coordinates live on the `atms` row and are edited through the admin-gated `PUT /api/atms/{id}` (ATM detail → properties). +- **To manage the groups** that populate the Group filter: `MSP_ADMIN`/`BCOS_ADMIN` (`/api/atm-groups` mutations). + +## First things to check when it misbehaves + +**"Pins are missing / fleet looks half-empty"** +1. Almost always **missing coordinates**. Only ATMs with non-null `latitude` AND `longitude` are plotted; the rest are silently dropped and counted in the amber "N ATMs have no coordinates" banner. Fix: open each via **View in ATM List →** and set lat/long (admin). +2. A **default group** is active. If the user has a default group set (browser `localStorage: hiveops_defaultGroupId`), `loadAtms()` switches to `GET /api/atms/paginated?size=9999&groupId=` — so the map only ever shows that group, capped at 9999 rows. +3. An **active status/type/group filter** is narrowing the set — check the "Filters:" tag bar and the legend count (`X of Y filtered`). Hit **Clear all**. + +**"No ATMs show as Open Incident (nothing goes orange) / Incident Type filter is empty"** +- The incident backend call failed or returned nothing. Pins still render (devices is independent), but incident colouring/filtering is gone. Check `hiveops-incident` health and that `GET /api/incidents` returns 200 for this user's institution. +- Only incidents with `status === 'OPEN'` count — resolved/closed incidents never colour a pin. + +**"Two ATMs at the same site aren't clustered into one pin"** +- Clustering keys on the **exact** `"lat,lon"` string. Coordinates that differ even slightly get separate pins. To force a shared count-badge pin, give the co-located ATMs identical lat/long. + +**"My saved presets / filters vanished"** +- Presets and the last filter state are **browser-local only** (`localStorage: hiveops_map_filters_v2`), never stored server-side. Clearing site data, a different browser, or a different machine = presets gone. This is expected, not a bug. + +**"Map won't stop reloading / reloads too often (or never)"** +- Auto-refresh is a client timer; interval = `settings.dashboardRefreshIntervalSeconds`. The toggle state persists in `localStorage: devices_map_autoRefresh`. Each tick re-runs `loadAtms()` + `loadIncidents()`. + +**"401/403 loading the map"** +- Expired/again JWT, or the incident call is being blocked by nginx. The incident list is fetched at gateway path `/incident/incidents` (nginx injects the `/api` segment to reach the controller's `/api/incidents`); a stale nginx block for `incident.*`/`devices.*` is the usual culprit for a one-service failure. + +## What you can NOT fix from this screen + +- You can't edit coordinates, status, or incidents here — the map only reads. All corrections happen in **ATM Detail** (devices) or the **Incident** app. diff --git a/backend/src/main/resources/content/devices/sw-deploy/architect.md b/backend/src/main/resources/content/devices/sw-deploy/architect.md new file mode 100644 index 0000000..aa585a8 --- /dev/null +++ b/backend/src/main/resources/content/devices/sw-deploy/architect.md @@ -0,0 +1,77 @@ +--- +module: devices.sw-deploy +title: Software Deployment +tab: Architect +order: 30 +audience: internal +--- + +> **Internal · architecture.** Written 2026-07-01 from `hiveops-devices/frontend` + `hiveops-fleet/backend`. Verify against code before relying on specifics. + +## Ownership — this feature lives in Fleet, rendered by Devices + +The SW Deploy tab is a **Devices SPA view** (`DeviceSoftwareTab.svelte`) but the domain — software download/install tasks — is owned entirely by **hiveops-fleet**. Devices contributes only the device identity and the UI shell. See [platform.service-ownership]. + +| Concern | Owner | +|---------|-------| +| Tab UI / `atm` object | hiveops-devices (frontend, port 5177) | +| `SOFTWARE` / `SOFTWARE_INSTALL` task lifecycle, storage, dispatch | **hiveops-fleet** (backend, port **8097**, DB `hiveiq_fleet`) | +| Agent auth / device identity source of truth | hiveops-devices backend (`atms`) → mirrored to fleet `device_summary` via Kafka | + +## Data flow + +### 1. Read path (rendering the tab) +`DeviceSoftwareTab` → `fleetAPI.getTasksPaginated({ atmId, taskKind:['SOFTWARE','SOFTWARE_INSTALL'] })` → gateway `/fleet/tasks/paginated` → `FleetApiController.getFiltered` → `FleetTaskService.getFilteredPaginated` → `FleetTaskSpecification.withFilters` over the **live `fleet_tasks`** table. Frontend derives the two step cards (`latestDownload`, `latestInstall`) and the history table client-side. + +### 2. Create path (Schedule Install) +`fleetAPI.createTask({ atmIds:[id], taskKind:'SOFTWARE_INSTALL', scheduledAt? })` → `POST /fleet/tasks` → `FleetTaskService.createTask`: +- Resolves each `atmId` against `device_summary` (`deviceSummaryRepository.findById`); skips if missing, **offline (>15 min since `lastHeartbeat`)**, or out of institution scope. +- `SOFTWARE`/`SOFTWARE_INSTALL` (and `HOTFIX`/`HOTFIX_INSTALL`) are created with initial status **`PENDING_APPROVAL`**. +- If no `scheduledAt` and kind is `SOFTWARE_INSTALL`, defers to the device's assigned **patch window** (`PatchWindowService.nextOccurrence`). +- Persists to `fleet_tasks`, then `FleetTaskEventPublisher.publishCreated` → Kafka. + +### 3. Approval → dispatch +`POST /fleet/tasks/{id}/approve` (`FLEET_APPROVER` / `BCOS_ADMIN`) moves the task `PENDING_APPROVAL → PENDING`. The agent (via agent-proxy, role `INTERNAL`) polls `GET /api/internal/fleet/tasks/next?atm={atmId}&kind=...`; `getNextPendingTaskOfKind` returns the oldest `PENDING` task whose `scheduledAt` is null/past, marks it `QUEUED`, and returns artifact metadata (name, version, sha256, CDN url + token) for `SOFTWARE`. `SOFTWARE_INSTALL` carries no artifact — the agent runs `softwareinstall.cmd` against its staged `software/` dir. + +### 4. Status reporting → archival (executor → record-store) +Agent reports progress via `POST /api/internal/fleet/tasks/{taskId}/status` (`downloadOffset`, `totalSize`, `status`) → `FleetTaskService.updateTaskStatus`: +- `RUNNING` sets `startedAt`; `COMPLETED`/`FAILED` set `completedAt`; download progress percent is derived from offset/total. +- Every update publishes `FleetTaskEventPublisher.publishStatusUpdate`. +- On **`COMPLETED`**, the row is **archived to `fleet_task_history` and deleted from `fleet_tasks`** (`archiveAndDelete`, also emits `publishArchived`). A `@Scheduled` sweep every 5 min archives stray `COMPLETED`/`CANCELLED`/`REJECTED`; history is purged after 30 days. This is the classic **live-table + archived-record-store** split. See [platform.data-architecture]. + +## Kafka + +| Topic | Direction | Class | Notes | +|-------|-----------|-------|-------| +| `hiveops.fleet.task.events` | **produced** by fleet | `FleetTaskEventPublisher` / `FleetTaskKafkaConfig.FLEET_TASK_EVENTS_TOPIC` | eventTypes `TASK_CREATED`, `TASK_STATUS`, `TASK_ARCHIVED`; keyed per task. Consumed downstream by devices (`FleetTaskEventConsumer` → `device_fleet_tasks` mirror). | +| `hiveops.device.events` | **consumed** by fleet | `DeviceEventConsumer` (group `hiveops-fleet-device-sync`) | Upserts/deletes `device_summary` from devices' `DEVICE_CREATED/UPDATED/DELETED`. This is how fleet knows the device exists, its `institutionKey`, and `lastHeartbeat` seed. | + +See [platform.kafka]. + +## Tables (DB `hiveiq_fleet`) + +| Table | Entity | Role in this feature | +|-------|--------|----------------------| +| `fleet_tasks` | `FleetTask` | Live SOFTWARE/SOFTWARE_INSTALL rows — the tab's read source | +| `fleet_task_history` | `FleetTaskHistory` | Archived terminal tasks (completed installs/downloads land here) | +| `device_summary` | `DeviceSummary` | Fleet's mirror of devices; target resolution, online check, institution scope | +| `fleet_artifacts` | `FleetArtifact` | Software package metadata for `SOFTWARE` downloads (enabled flag, CDN url, sha256) | +| patch window tables | `PatchWindow` / `PatchWindowDevice` | Auto-schedule for `SOFTWARE_INSTALL` | + +## Key endpoints + +| Method + Path | Purpose | Auth | +|---------------|---------|------| +| `GET /api/fleet/tasks/paginated` | Tab read (filter `taskKind`, `atmId`) | USER…BCOS_ADMIN | +| `POST /api/fleet/tasks` | Create install/download task | ADMIN/MSP_ADMIN/BCOS_ADMIN | +| `POST /api/fleet/tasks/{id}/approve` \| `/reject` | Approve gate | FLEET_APPROVER / BCOS_ADMIN | +| `POST /api/fleet/tasks/{id}/cancel` \| `/retry` | Lifecycle | ADMIN/MSP_ADMIN/BCOS_ADMIN | +| `GET /api/fleet/tasks/history` | Archived (completed) tasks | USER…BCOS_ADMIN | +| `GET /api/internal/fleet/tasks/next` | Agent poll/dispatch | INTERNAL | +| `POST /api/internal/fleet/tasks/{id}/status` | Agent status/progress | INTERNAL | + +## Architectural note (see uncertainties) + +The tab reads the **live** `fleet_tasks` table, but `COMPLETED` tasks are archived+deleted immediately. Completed software history therefore lives only in `fleet_task_history` / the `/tasks/history` endpoint, which this tab does not query. Treat the tab as "in-flight + failed" state; use history for the completed record. + +Cross-links: [platform.data-architecture] · [platform.kafka] · [platform.service-ownership] diff --git a/backend/src/main/resources/content/devices/sw-deploy/claude.md b/backend/src/main/resources/content/devices/sw-deploy/claude.md new file mode 100644 index 0000000..5f7c13e --- /dev/null +++ b/backend/src/main/resources/content/devices/sw-deploy/claude.md @@ -0,0 +1,59 @@ +--- +module: devices.sw-deploy +title: Software Deployment +tab: Claude +order: 40 +audience: internal +--- + +**Owner service:** `hiveops-fleet` (port **8097**, DB **`hiveiq_fleet`**). Frontend `DeviceSoftwareTab.svelte` lives in `hiveops-devices` but calls fleet only. NOT owned by devices/incident. + +## Task kinds (this tab) +- `SOFTWARE` = download (has `FleetArtifact`). +- `SOFTWARE_INSTALL` = install; **no artifact**; agent runs `softwareinstall.cmd` on staged `software/` dir. + +## Endpoints (METHOD path — auth) +| Method | Path | Auth | +|--------|------|------| +| GET | `/api/fleet/tasks/paginated?atmId={id}&taskKind=SOFTWARE&taskKind=SOFTWARE_INSTALL` | USER…BCOS_ADMIN | +| POST | `/api/fleet/tasks` body `{atmIds:[id],taskKind,artifactId?,scheduledAt?}` | ADMIN/MSP_ADMIN/BCOS_ADMIN | +| POST | `/api/fleet/tasks/{id}/approve` \| `/reject` | `FLEET_APPROVER` or `BCOS_ADMIN` | +| POST | `/api/fleet/tasks/{id}/cancel` \| `/retry` | ADMIN/MSP_ADMIN/BCOS_ADMIN | +| GET | `/api/fleet/tasks/history?atmId={id}` | USER…BCOS_ADMIN | +| GET | `/api/fleet/tasks/pending-approval` | FLEET_APPROVER or MSP_ADMIN/BCOS_ADMIN | +| GET | `/api/internal/fleet/tasks/next?atm={atmId}&kind=` | `INTERNAL` (agent) | +| POST | `/api/internal/fleet/tasks/{id}/status` body `{status,downloadOffset,totalSize}` | `INTERNAL` | + +Frontend routes hit gateway prefix `/fleet/...` (nginx). Controller base = `/api/fleet`, internal base = `/api/internal/fleet`. + +## Tables (`hiveiq_fleet`) +- `fleet_tasks` — live tasks (tab read source). +- `fleet_task_history` — archived terminal tasks (completed downloads/installs land here). +- `device_summary` — device mirror; target lookup + online check + institution scope. +- `fleet_artifacts` — software package (enabled flag, cdn_url, sha256). +- patch window: `PatchWindow` / `PatchWindowDevice`. + +## Kafka topics +- Produce: `hiveops.fleet.task.events` (`FleetTaskEventPublisher`; TASK_CREATED/TASK_STATUS/TASK_ARCHIVED). +- Consume: `hiveops.device.events` (group `hiveops-fleet-device-sync` → upsert `device_summary`). + +## Status lifecycle +`PENDING_APPROVAL → PENDING → QUEUED → RUNNING → COMPLETED|FAILED` (also `CANCELLED`,`REJECTED`). +- `SOFTWARE`/`SOFTWARE_INSTALL` START in `PENDING_APPROVAL` (need approve). +- On `COMPLETED`: row archived to `fleet_task_history` and **deleted** from `fleet_tasks` (`archiveAndDelete`). 5-min sweep archives COMPLETED/CANCELLED/REJECTED; FAILED stays live. + +## Gotchas +- Completed SOFTWARE tasks are gone from `fleet_tasks` → the tab (live paginated) won't show them; use `/tasks/history`. +- Create silently skips a device if: not in `device_summary`, `lastHeartbeat` >15 min old (offline), or out of institution scope. No error surfaced — check fleet logs. +- Disabled artifact → `400 "Artifact '…' is disabled"`. +- `SOFTWARE_INSTALL` with no `scheduledAt` auto-defers to device patch window if assigned. +- Only `PENDING` + (scheduledAt null/past) tasks dispatch; `PENDING_APPROVAL` never reaches agent. +- Create is admin-role; approve is a SEPARATE authority (`FLEET_APPROVER`/`BCOS_ADMIN`) — MSP_ADMIN alone can create but not approve. +- Frontend `getTasksPaginated` sends repeated `taskKind` params (`paramsSerializer indexes:null`). + +## Do NOT +- Do NOT look in `hiveops-devices` / `hiveops-incident` DB for these tasks — they are in `hiveiq_fleet`. +- Do NOT expect completed installs in `/tasks/paginated` — query `/tasks/history`. +- Do NOT assume creating the task dispatches it — it sits in `PENDING_APPROVAL` until approved. +- Do NOT invent an artifact for `SOFTWARE_INSTALL` — it has none. +- Do NOT create tasks for offline/out-of-scope devices expecting success — they are skipped silently. diff --git a/backend/src/main/resources/content/devices/sw-deploy/internal.md b/backend/src/main/resources/content/devices/sw-deploy/internal.md new file mode 100644 index 0000000..1f29490 --- /dev/null +++ b/backend/src/main/resources/content/devices/sw-deploy/internal.md @@ -0,0 +1,56 @@ +--- +module: devices.sw-deploy +title: Software Deployment +tab: Internal +order: 20 +audience: internal +--- + +> **Internal · ops/support.** Written 2026-07-01 from source (`hiveops-devices/frontend` + `hiveops-fleet/backend`). The SW Deploy tab is a **thin view over Fleet tasks** — the data and all actions live in the **hiveops-fleet** service, not devices. + +## What you're actually looking at + +The **SW Deploy** tab (`DeviceSoftwareTab.svelte`, under a device's detail view in `devices.bcos.cloud`) renders two fleet task kinds for one device: + +- **Step 1 — Software Download** = fleet task kind `SOFTWARE` (delivers the package to the ATM). +- **Step 2 — Software Install** = fleet task kind `SOFTWARE_INSTALL` (runs `softwareinstall.cmd` against the staged `software/` dir on the device). + +It calls **only** the fleet backend: `GET /fleet/tasks/paginated?atmId={id}&taskKind=SOFTWARE&taskKind=SOFTWARE_INSTALL` to read, and `POST /fleet/tasks` to create an install. No devices-backend or incident call is involved for this tab's data. + +## Roles required + +| Action | Endpoint | Required authority | +|--------|----------|--------------------| +| View history / step cards | `GET /api/fleet/tasks/paginated` | `USER`,`CUSTOMER`,`ADMIN`,`MSP_ADMIN`,`BCOS_ADMIN` | +| Create the install task ("Schedule Install") | `POST /api/fleet/tasks` | `hasAnyRole('ADMIN','MSP_ADMIN','BCOS_ADMIN')` | +| **Approve/reject** the SOFTWARE / SOFTWARE_INSTALL task | `POST /api/fleet/tasks/{id}/approve` \| `/reject` | `hasAuthority('FLEET_APPROVER') or hasRole('BCOS_ADMIN')` | +| Cancel / retry | `POST /api/fleet/tasks/{id}/cancel` \| `/retry` | `hasAnyRole('ADMIN','MSP_ADMIN','BCOS_ADMIN')` | + +**Gotcha:** creating the install is admin-gated, but **approving it is a separate gate** — `SOFTWARE` and `SOFTWARE_INSTALL` are born in status `PENDING_APPROVAL` (see below). A plain `MSP_ADMIN` who is not a `FLEET_APPROVER` can create the task but **cannot approve it**; a `BCOS_ADMIN` (or `FLEET_APPROVER`) must approve before the agent will ever see it. + +## The dispatch lifecycle (why a task "isn't doing anything") + +`SOFTWARE` / `SOFTWARE_INSTALL` follow the approval flow: + +`PENDING_APPROVAL` → (approve) → `PENDING` → (agent polls) → `QUEUED` → `RUNNING` → `COMPLETED` / `FAILED` + +- The agent only receives tasks in `PENDING` whose `scheduledAt` is null or in the past (`getNextPendingTaskOfKind`). So **PENDING_APPROVAL never dispatches** — it waits for an approver. +- On create, the target device must be **online** (heartbeat within **15 minutes**) and in the caller's **institution scope**, or the task is silently skipped (logged as a warning, no error to the user). +- `SOFTWARE_INSTALL` with no explicit schedule auto-defers to the device's **patch window** if one is assigned (`PatchWindowService.nextOccurrence`). + +## First things to check when it misbehaves + +1. **Nothing in the tab / expected history missing.** The tab reads the **live `fleet_tasks` table only**. Completed tasks are archived to `fleet_task_history` and deleted from `fleet_tasks` the instant the agent reports `COMPLETED` (`archiveAndDelete`), plus a 5-minute sweep (`archiveStaleTerminalTasks`) for `COMPLETED`/`CANCELLED`/`REJECTED`. So **completed downloads/installs disappear from this tab** — look in fleet task **history** (`GET /api/fleet/tasks/history?atmId={id}`) or `fleet_task_history` in DB. `FAILED` stays in the live table and is visible. +2. **"Schedule Install" button never appears.** Frontend `canInstall` requires the latest `SOFTWARE` download to be `status === 'COMPLETED'` *and still present in the live list*. Because completion archives+deletes the row, the button window is effectively zero — see uncertainties/architect notes. Workaround: create the `SOFTWARE_INSTALL` directly via `POST /fleet/tasks` with `taskKind:"SOFTWARE_INSTALL"`. +3. **Task created but stuck in PENDING_APPROVAL.** Needs an approver — `FLEET_APPROVER` authority or `BCOS_ADMIN`. Check `GET /api/fleet/tasks/pending-approval`. +4. **Task silently not created for a device.** Almost always **offline device** (no heartbeat in 15 min) or **out of institution scope**. Confirm via the device's `lastHeartbeat` in `device_summary` and the caller's institution scope. Check fleet backend logs for `offline — skipping` / `out of scope`. +5. **Download package rejected on create.** Artifact must be **enabled** — a disabled artifact returns `400 "Artifact '…' is disabled"`. `SOFTWARE_INSTALL` itself carries no artifact (it acts on the already-staged dir). +6. **Stuck in QUEUED/RUNNING.** A task idle in `QUEUED`/`RUNNING` past the stale threshold is auto-reset to `PENDING` (offset zeroed) on the next agent poll for re-dispatch. + +## Where to look + +- Frontend: `hiveops-devices/frontend/src/components/AtmDetail/DeviceSoftwareTab.svelte` +- Backend service: **hiveops-fleet** (port **8097**, DB **`hiveiq_fleet`**), `FleetTaskService` + `FleetApiController` + `InternalFleetController`. +- Logs: fleet backend (Loki `hiveops-fleet`), not devices. + +See also [devices.sw-deploy] Overview (customer) and the Architect tab for the full data flow. diff --git a/backend/src/main/resources/content/fleet/dashboard/architect.md b/backend/src/main/resources/content/fleet/dashboard/architect.md new file mode 100644 index 0000000..8554686 --- /dev/null +++ b/backend/src/main/resources/content/fleet/dashboard/architect.md @@ -0,0 +1,90 @@ +--- +module: fleet.dashboard +title: Fleet Dashboard +tab: Architect +order: 30 +audience: internal +--- + +How the **Fleet Stats** dashboard is built. It is a thin read-only view over hiveops-fleet's own database — no cross-service HTTP at request time. Cross-link [platform.service-ownership], [platform.data-architecture], [platform.kafka]. + +## Services involved + +| Concern | Owner | Role for this screen | +|---------|-------|----------------------| +| Dashboard UI | `hiveops-fleet/frontend` (Svelte, port 5176) | `FleetDashboard.svelte`, aggregates ATMs client-side | +| Stats + device list API | `hiveops-fleet/backend` (Spring Boot 3.4, port 8097) | `FleetApiController` → `FleetTaskService.getStats(...)` + `DeviceSummaryRepository` | +| Device state feed | `hiveops-devices` | Publishes `hiveops.device.events`; fleet mirrors it | + +Only hiveops-fleet is on the request path. hiveops-devices contributes **asynchronously** via Kafka, never a live call. This is the deliberate no-cross-service-HTTP pattern for fleet. + +## Data flow + +**Task/artifact counters (Task Statistics + Fleet Overview bars):** + +``` +FleetDashboard onMount / 60s timer + → GET /api/fleet/tasks/stats + → FleetTaskService.getStats(InstitutionContext.resolveScope()) + → fleet_tasks (count / countByTaskKindAndStatus / findByStatus) + → fleet_artifacts (count) + → FleetDashboardStats DTO +``` + +**ATMs by Model / by Institution tables:** + +``` +FleetDashboard onMount (once, not on the 60s timer) + → GET /api/fleet/devices + → InstitutionContext.resolveScope() (null = unrestricted; else List) + → deviceSummaryRepository.findAll() OR findByInstitutionKeyIn(scope) + → List {atmId, institution(Key), status, model, ...} + → frontend groups by model & institution, counts operational/offline +``` + +The device rows are populated out-of-band by the Kafka consumer, so the dashboard reads a **local mirror**, decoupled from device-service availability. + +## Kafka (async, off the request path) — [platform.kafka] + +**Consumed by fleet (feeds this screen's device tables):** + +| Topic | Consumer | Effect | +|-------|----------|--------| +| `hiveops.device.events` | `DeviceEventConsumer` (`@KafkaListener`, groupId `hiveops-fleet-device-sync`, `auto-offset-reset=earliest`) | Upserts `device_summary` rows read by `GET /api/fleet/devices` | + +**Produced by fleet (NOT read by this screen — listed for completeness):** + +| Topic | Producer | Notes | +|-------|----------|-------| +| `hiveops.fleet.task.events` | `FleetTaskEventPublisher` via `FleetTaskService` | created/status/archived events for downstream consumers; the dashboard reads `fleet_tasks` directly, not this topic | + +There is **no reconciler** — if devices stops publishing, `device_summary` (and therefore the ATM tables) goes stale silently. Cross-link [platform.data-architecture]. + +## DB tables (all in `hiveiq_fleet`) + +| Table | Access for this screen | Notes | +|-------|------------------------|-------| +| `fleet_tasks` | READ (counts) | active tasks (PENDING/QUEUED/RUNNING) | +| `fleet_artifacts` | READ (count) | uploadable/CDN-imported artifacts | +| `device_summary` | READ | Kafka-synced mirror of device state; the only source for the ATM tables | + +`fleet_task_history`, `patch_windows`, `atm_captures` exist in fleet but are **not** touched by the dashboard. + +## Key API endpoints (this feature) + +| Method Path | Handler | Auth | +|-------------|---------|------| +| `GET /api/fleet/tasks/stats` (alias `/api/fleet/stats`) | `FleetApiController.getStats` → `FleetTaskService.getStats` | `hasAnyRole('USER','CUSTOMER','ADMIN','MSP_ADMIN','BCOS_ADMIN')` | +| `GET /api/fleet/devices` | `FleetApiController.listDevices` | same | + +Base default in frontend: `http://localhost:8097/api/fleet`; prod `https://api.bcos.cloud/fleet`. + +## Executor → Kafka → record-store pattern (context) + +The broader fleet task lifecycle follows executor → `hiveops.fleet.task.events` → devices record-store, and terminal tasks archive to `fleet_task_history`. **This dashboard sits outside that loop** — it only aggregates current-state reads (task/artifact counts + the device mirror). Worth knowing so you don't wire the dashboard into the task-event pipeline. + +## Notable implementation facts (verify before relying on displayed numbers) + +- `getStats` currently hard-codes `completedTasks`/`failedTasks` to `0`, scopes `pendingTasks` to `AUTO_UPDATE`+`PENDING` only, and **ignores** its `institutionKeys` argument for the global `fleet_tasks`/`fleet_artifacts` counts. +- The `FleetDashboardStats` DTO has no `totalModulesReporting` field, so the frontend's "Modules Reporting" always renders its 0 default. +- Device status mapping is client-side: `IN_SERVICE`→operational; `OUT_OF_SERVICE`/`DOWN`/`INACTIVE`→offline. diff --git a/backend/src/main/resources/content/fleet/dashboard/claude.md b/backend/src/main/resources/content/fleet/dashboard/claude.md new file mode 100644 index 0000000..0d94670 --- /dev/null +++ b/backend/src/main/resources/content/fleet/dashboard/claude.md @@ -0,0 +1,53 @@ +--- +module: fleet.dashboard +title: Fleet Dashboard +tab: Claude +order: 40 +audience: internal +--- + +Terse agent reference for the **Fleet Stats** dashboard (`FleetDashboard.svelte`). + +## Ownership +- Service: **hiveops-fleet** (backend port 8097, DB `hiveiq_fleet`, package `com.hiveops.fleet`). +- Frontend: hiveops-fleet/frontend, port 5176, `components/FleetManagement/FleetDashboard.svelte`. +- No other service on the request path. hiveops-devices contributes only via Kafka. + +## Endpoints (the only two this screen calls) +| Method | Path | Returns | Auth | +|--------|------|---------|------| +| GET | `/api/fleet/tasks/stats` (alias `/api/fleet/stats`) | `FleetDashboardStats` | `hasAnyRole('USER','CUSTOMER','ADMIN','MSP_ADMIN','BCOS_ADMIN')` | +| GET | `/api/fleet/devices` | `List` device rows | same | + +- Frontend base: `http://localhost:8097/api/fleet` (dev) / `https://api.bcos.cloud/fleet` (prod). + +## Tables (read-only, `hiveiq_fleet`) +- `fleet_tasks` — task counts. +- `fleet_artifacts` — artifact count. +- `device_summary` — ATM rows for the model/institution tables (Kafka mirror). + +## Kafka +- Consumes `hiveops.device.events` → `DeviceEventConsumer`, groupId `hiveops-fleet-device-sync` → upserts `device_summary`. +- Produces `hiveops.fleet.task.events` (NOT read by this screen). + +## Scoping +- `InstitutionContext.resolveScope()`: `ROLE_CUSTOMER`/`ROLE_MSP_ADMIN` → scoped to JWT-credential institution-key list; else `null` = unrestricted. +- `/devices` IS scoped. `/tasks/stats` is NOT (see gotchas). + +## Gotchas (grounded in current code — do not "fix" the numbers blindly) +- `getStats`: `completedTasks` and `failedTasks` are hard-coded `0`. They never move. +- `getStats`: `pendingTasks` = only `AUTO_UPDATE` + `PENDING` (not all pending kinds). +- `getStats`: ignores its `institutionKeys` arg → `totalTasks`/`totalArtifacts` are GLOBAL counts even for scoped users. +- `FleetDashboardStats` DTO has NO `totalModulesReporting` field → frontend "Modules Reporting" always 0. Real module data is `GET /api/fleet/modules/summary`. +- 60s auto-refresh reloads **stats only**; ATM tables load once on mount (stale until page reload). +- Stats fetch failures are swallowed (`console.warn`), no on-screen error → zeros ≠ "no data". +- Device status → operational/offline map: `IN_SERVICE`=operational; `OUT_OF_SERVICE`/`DOWN`/`INACTIVE`=offline; other = counted in Total only. +- `device_summary` has no reconciler; stale if devices stops publishing. + +## Do NOT +- Do NOT add an endpoint/table for this screen in incident/journal/devices — fleet owns it. +- Do NOT trust Completed/Failed/Modules-Reporting counters as live data (see gotchas). +- Do NOT expect `/tasks/stats` numbers to respect institution scope. +- Do NOT read `hiveops.fleet.task.events` to build this view — read `fleet_tasks` directly. +- Do NOT call hiveops-devices over HTTP to refresh ATM tables — data arrives via Kafka only. +- Do NOT restrict this screen to admin roles; `USER`/`CUSTOMER` are allowed to read. diff --git a/backend/src/main/resources/content/fleet/dashboard/internal.md b/backend/src/main/resources/content/fleet/dashboard/internal.md new file mode 100644 index 0000000..e2c8529 --- /dev/null +++ b/backend/src/main/resources/content/fleet/dashboard/internal.md @@ -0,0 +1,53 @@ +--- +module: fleet.dashboard +title: Fleet Dashboard +tab: Internal +order: 20 +audience: internal +--- + +Ops/support view for the **Fleet Stats** screen (`FleetDashboard.svelte`, route inside `fleet.bcos.cloud`). It is a read-only dashboard: two stat bars (Task Statistics, Fleet Overview) plus two expandable tables (ATMs by Model, ATMs by Institution). It calls exactly **two** endpoints — everything on the screen comes from these: + +- `GET /api/fleet/tasks/stats` (alias `/api/fleet/stats`) → the five task counters + Artifacts + Modules Reporting. +- `GET /api/fleet/devices` → the ATM rows, aggregated client-side by model/institution. + +Owned end-to-end by **hiveops-fleet** (backend port 8097, DB `hiveiq_fleet`). No other service is called for this screen. + +## Roles required + +- Both endpoints: `@PreAuthorize("hasAnyRole('USER','CUSTOMER','ADMIN','MSP_ADMIN','BCOS_ADMIN')")`. This is **not** admin-only — any authenticated fleet user can view it. +- There are **no admin-only actions on this screen.** It is display-only (no create/cancel/approve here — those live on the Fleet Tasks screen). +- Data is **institution-scoped** for `CUSTOMER` and `MSP_ADMIN` tokens: `InstitutionContext.resolveScope()` reads the institution-key list from the JWT credentials and filters `device_summary`. `ADMIN`/`BCOS_ADMIN`/`USER` see everything (unrestricted scope). + +## Refresh behaviour (know this before chasing "stale" reports) + +- On mount the component loads **both** stats and ATMs once. +- The 60-second auto-refresh timer reloads **stats only** (`loadFleetStats`). The **ATMs by Model / by Institution tables do NOT auto-refresh** — they only update on a full page reload. A user reporting "device counts didn't update for an hour" is expected behaviour, not a bug. +- The accordion "N models · M ATMs" header count is derived from the same one-time ATM load. + +## First things to check when it misbehaves + +1. **Blank / all-zero task counters** → hit `GET /api/fleet/tasks/stats` directly with a real JWT. On failure the frontend silently swallows the error (`console.warn` only) and keeps the last values/zeros — there is no on-screen error banner for stats. +2. **"No ATM data available" in both tables** → `GET /api/fleet/devices` returned empty. Root cause is almost always an empty/stale `device_summary` table, not the dashboard. `device_summary` is a Kafka mirror — see failure modes below. +3. **Wrong institution split** → stale `device_summary` mirror. Fleet does not reconcile; it only trusts `hiveops.device.events`. If hiveops-devices stopped publishing, the split is stale. Cross-check against the devices service. +4. **403 on load** → token missing one of the five allowed roles, or an expired session (frontend maps 401→"session expired", 403→"no permission"). +5. **Scoped user sees more/less than expected** → confirm the JWT's institution-key credentials list; `resolveScope()` returns `null` (unrestricted) for anyone lacking `ROLE_CUSTOMER`/`ROLE_MSP_ADMIN`. + +## Known-quirks that look like bugs but are current behaviour + +> These are grounded in `FleetTaskService.getStats(...)` as written today. Treat "the numbers look wrong" reports as product limitations, not incidents, until the service is changed. + +- **Completed and Failed always show 0.** The service hard-codes `completed = 0` and `failed = 0`. The counters render, but never move. +- **Modules Reporting always shows 0.** The backend `FleetDashboardStats` DTO has no `totalModulesReporting` field, so the frontend default (0) is never overwritten. (The real module numbers live behind `GET /api/fleet/modules/summary`, a different screen.) +- **Pending counts only auto-update tasks.** `pendingTasks` = count of `AUTO_UPDATE` tasks in `PENDING` only — not all pending tasks of every kind. +- **Total tasks / Artifacts are NOT institution-scoped.** `getStats` ignores the scope argument and uses global `taskRepository.count()` / `artifactRepository.count()`. So a scoped MSP_ADMIN sees fleet-wide task and artifact totals but only their own ATMs in the tables — an intentional-looking mismatch that is actually the scope bug above. + +## Failure modes + fixes + +| Symptom | Likely cause | Fix | +|---------|--------------|-----| +| Both ATM tables empty | `device_summary` empty/stale | Check the `hiveops.device.events` consumer (`groupId hiveops-fleet-device-sync`) is running and lag is 0; confirm hiveops-devices is publishing | +| Stats stuck at last values | `/tasks/stats` failing silently | Curl the endpoint; check fleet backend logs (Loki), not the browser | +| 401/403 | expired/under-privileged JWT | Re-login; verify role + `fleetTaskApprover` not required here | +| Institution split wrong for one inst | stale mirror for that inst | Reconcile via devices service; fleet has no reconciler | +| "Operational/Offline" miscounts | status mapping | `IN_SERVICE`→operational; `OUT_OF_SERVICE`/`DOWN`/`INACTIVE`→offline; anything else counts toward Total only | diff --git a/backend/src/main/resources/content/fleet/modules/architect.md b/backend/src/main/resources/content/fleet/modules/architect.md new file mode 100644 index 0000000..692a41c --- /dev/null +++ b/backend/src/main/resources/content/fleet/modules/architect.md @@ -0,0 +1,53 @@ +--- +module: fleet.modules +title: Modules +tab: Architect +order: 30 +audience: internal +--- + +> **Internal · architecture.** Written 2026-07-01 from `hiveops-fleet` source. Verify against code before relying on specifics. + +## Shape of the feature +The **Agent Modules** screen is intentionally thin. It is a **client-owned catalog** plus a **single fleet-wide summary endpoint**. There is no dedicated module-status table, no Kafka topic, and no executor→record-store pipeline behind this specific screen today. Cross-link [platform.service-ownership]. + +### Two data sources, one of them static +1. **Module catalog (static, frontend):** `MODULE_CATALOG` in `hiveops-fleet/frontend/src/components/FleetManagement/FleetModules.svelte` — a hardcoded array of 16 `{ key, displayName, description, category }` entries across four categories: `Data Collection`, `Monitoring`, `Fleet Management`, `Updates`. The `key`s (e.g. `journal-events`, `command-center`, `auto-update`) mirror the agent's module identifiers. No backend call produces this list. +2. **Status summary (dynamic, backend):** `GET /api/fleet/modules/summary` on `hiveops-fleet`, deserialized to `ModuleSummary[]` in the frontend store `moduleSummary` (`loadModuleSummary()` in `stores.ts`). The component joins the two by matching `ModuleSummary.moduleName === catalog.key`. + +## Service ownership +- **Owner:** `hiveops-fleet` (port 8097, DB `hiveiq_fleet`). It serves both the SPA (`fleet.bcos.cloud`) and the `/api/fleet` REST surface. +- No other service participates in **this** feature. (Broadly, fleet syncs a `device_summary` mirror from `hiveops.device.events` and publishes task events on `hiveops.fleet.task.events` — see [technical.fleet] — but neither pipeline feeds the Modules screen.) + +## Data flow (current) +``` +FleetModules.svelte (onMount) + → loadModuleSummary() [stores.ts] + → fleetAPI.getModuleSummary() [api.ts] GET {apiUrl}/modules/summary + → FleetApiController.getModuleSummary() [hiveops-fleet] + → return List.of(); ← STUB: always empty + → moduleSummary store = [] + → component renders 16 static cards; no badges (summaryFor() always null) +``` + +### Endpoint detail (confirmed) +| Method + Path | Controller | Auth | Returns | +|---------------|-----------|------|---------| +| `GET /api/fleet/modules/summary` | `FleetApiController.getModuleSummary()` | `@PreAuthorize hasAnyRole('USER','CUSTOMER','ADMIN','MSP_ADMIN','BCOS_ADMIN')` | `List` — currently `List.of()` (empty) | + +`ModuleSummaryDTO` fields: `moduleName` (String), `runningCount`, `disabledCount`, `failedCount`, `totalCount` (all `long`). Frontend `ModuleSummary` interface mirrors these. + +## DB tables read/written +- **None for this feature.** The summary endpoint returns a static empty list; it does not query any table. The `hiveiq_fleet` schema (V1__init_fleet.sql: `fleet_tasks`, `fleet_task_history`, `fleet_artifacts`, `patch_windows`, `atm_captures`, `device_summary`) has **no module-status table**. Cross-link [platform.data-architecture]. + +## Kafka +- **None for this feature.** No producer or consumer touches module status. (Fleet's Kafka wiring — `FleetTaskEventPublisher` → `hiveops.fleet.task.events`, and `DeviceEventConsumer` ← `hiveops.device.events` — is unrelated to the Modules screen.) Cross-link [platform.kafka]. + +## If/when this gets wired for real +The DTO already models per-module aggregate counts (running/disabled/failed/total), which implies the intended source is a per-ATM module-status feed aggregated by `moduleName`. That feed does not exist in fleet today. A future implementation would need agents to report module state (via agent-proxy internal endpoints, the pattern used elsewhere: agent → `X-Internal-Secret` `/api/internal/fleet/**`), land it in a new fleet table, and aggregate in `getModuleSummary()`. Until then, treat the screen as documentation-only. + +## Cross-links +- [platform.data-architecture] +- [platform.kafka] +- [platform.service-ownership] +- [technical.fleet] — fleet task orchestration, artifact delivery, the real Kafka/table surface of `hiveops-fleet` diff --git a/backend/src/main/resources/content/fleet/modules/claude.md b/backend/src/main/resources/content/fleet/modules/claude.md new file mode 100644 index 0000000..92cfce4 --- /dev/null +++ b/backend/src/main/resources/content/fleet/modules/claude.md @@ -0,0 +1,42 @@ +--- +module: fleet.modules +title: Modules +tab: Claude +order: 40 +audience: internal +--- + +> **Internal · agent reference.** Terse. Confirmed against `hiveops-fleet` source 2026-07-01. + +## Owner +- Service: **`hiveops-fleet`** (port **8097**, DB **`hiveiq_fleet`**). Serves SPA `fleet.bcos.cloud` + `/api/fleet`. +- Frontend view: `hiveops-fleet/frontend/src/components/FleetManagement/FleetModules.svelte`. + +## Endpoints (confirmed) +| Method | Path | Auth (`@PreAuthorize`) | Behavior | +|--------|------|------------------------|----------| +| GET | `/api/fleet/modules/summary` | `hasAnyRole('USER','CUSTOMER','ADMIN','MSP_ADMIN','BCOS_ADMIN')` | Returns `List` — **hardcoded `List.of()` (always empty)** | + +Base path `@RequestMapping("/api/fleet")` in `FleetApiController`. Frontend base = `window.__APP_CONFIG__.apiUrl` (default `http://localhost:8097/api/fleet`). + +## DTO +- `ModuleSummaryDTO { String moduleName; long runningCount; long disabledCount; long failedCount; long totalCount; }` +- Joined in UI by `moduleName === catalog.key`. + +## Data facts +- Module catalog = **16 entries hardcoded in `FleetModules.svelte`** (`MODULE_CATALOG`), NOT from DB/API. Categories: `Data Collection`, `Monitoring`, `Fleet Management`, `Updates`. +- **No DB table** backs module status. **No Kafka topic** backs module status. +- Status badges never render today (summary is empty → `summaryFor()` returns null → `{#if summary}` skipped). + +## Gotchas +- `/modules/summary` returning `[]` is **by design (stub)**, not a bug/outage. "No badges" = expected. +- Client methods `getModules()`→`GET /modules`, `getModulesByAtm(id)`→`GET /modules/atm/{id}` exist in `api.ts` but **have NO backend endpoint** in `FleetApiController` (would 404). Not called by this view. (unverified whether any other service implements them — none found in `hiveops-fleet`.) +- Module enable/disable is agent-config driven (`CONFIG_UPDATE` fleet task / config server), NOT this screen. This screen is read-only. +- `/api/fleet/**` = JWT plane (`JWT_SECRET`). Internal agent endpoints are `/api/internal/fleet/**` (`hasRole('INTERNAL')`, `X-Internal-Secret`) — unrelated to this feature. + +## Do NOT +- Do NOT claim a `module_status` / `agent_module` table or a `hiveops.*.modules` Kafka topic exists — none do. +- Do NOT tell anyone module counts are "live" or reflect real ATM state — the endpoint returns empty. +- Do NOT route users to `/modules` or `/modules/atm/{id}` — no backend, will 404. +- Do NOT look in `hiveops-incident`/`devices`/`journal` for this screen's data — owner is `hiveops-fleet`, and the summary is a fleet stub. +- Do NOT edit the module list in the backend — the catalog lives in `FleetModules.svelte` (frontend), rebuild the fleet frontend to change it. diff --git a/backend/src/main/resources/content/fleet/modules/internal.md b/backend/src/main/resources/content/fleet/modules/internal.md new file mode 100644 index 0000000..c8a36e9 --- /dev/null +++ b/backend/src/main/resources/content/fleet/modules/internal.md @@ -0,0 +1,43 @@ +--- +module: fleet.modules +title: Modules +tab: Internal +order: 20 +audience: internal +--- + +> **Internal · ops/support.** Written 2026-07-01 from `hiveops-fleet` source. Verify against code before relying on specifics. + +## What this screen actually is +The **Agent Modules** screen (`FleetModules.svelte`) is a mostly **static reference catalog**. The list of 16 modules, their display names, descriptions, and the four categories (**Data Collection / Monitoring / Fleet Management / Updates**) are **hardcoded in the frontend** (`MODULE_CATALOG` const in `FleetModules.svelte`) — they are not fetched from the backend. + +The only live data is the per-module status badge counts (running / disabled / failed / not reporting), fetched from `GET /api/fleet/modules/summary` on `hiveops-fleet` (port 8097). + +## ⚠️ Known state: status badges are not wired up +`FleetApiController.getModuleSummary()` currently returns a **hardcoded empty list** (`return List.of();`). Because of this: +- The screen renders all 16 module cards with names + descriptions, but **no status badges appear** (the `{#if summary}` block in the component never matches, since `$moduleSummary` is always empty). +- This is expected current behaviour, **not** an outage. If someone reports "no running/failed counts on the Modules screen," that is the stub endpoint, not a broken agent or a data-sync problem. + +## Roles required +- **Read (this screen):** `hasAnyRole('USER','CUSTOMER','ADMIN','MSP_ADMIN','BCOS_ADMIN')` on `GET /api/fleet/modules/summary`. Any logged-in fleet user can view it. +- There are **no admin-only mutating actions** on this screen — it is strictly read-only. There is nothing to enable, disable, or approve here. Module enablement is driven by agent config (`CONFIG_UPDATE` fleet tasks / config server), not from this view. + +## First things to check when it misbehaves +1. **Page loads but no badges** → expected: `/modules/summary` is a stub returning `[]`. Not a bug to escalate for a customer. +2. **Page blank / cards missing entirely** → frontend issue. The catalog is client-side, so a blank page means the SPA failed to load, not a backend outage. Check the fleet frontend bundle / nginx for `fleet.bcos.cloud`. +3. **500 / 401 / 403 on `/modules/summary`** → auth plane. `/api/fleet/**` (non-internal) uses the JWT `JWT_SECRET`; a 401/403 means a bad/expired JWT or a role the token lacks. Confirm the caller's JWT role is one of the allowed roles above. +4. **404 on `/modules/summary`** → wrong base path or service down. Confirm requests hit `hiveops-fleet` at `/api/fleet` (nginx location block), not a stale blue/green backend. `curl http://localhost:8097/actuator/health` on the services VM. +5. **Descriptions look wrong / a module is missing** → it's a frontend catalog edit, not data. Update `MODULE_CATALOG` in `FleetModules.svelte`, rebuild the fleet frontend. + +## Common failure modes + fixes +| Symptom | Likely cause | Fix | +|---------|--------------|-----| +| No status badges on any card | `/modules/summary` returns `List.of()` (stub) | Expected today; wiring the real summary is a backend change, not an ops fix | +| Whole page blank | SPA/bundle didn't load | Check nginx for `fleet.bcos.cloud`, rebuild/redeploy fleet frontend | +| 401/403 on the API call | Bad/expired JWT or missing role | Re-auth; confirm role is USER/CUSTOMER/ADMIN/MSP_ADMIN/BCOS_ADMIN | +| 404 on the API call | Stale blue/green backend or wrong path | Recheck nginx `/api/fleet/` upstream; hit active fleet color | +| Module counts look stale (if/when wired) | Would depend on the future data source (agent status reporting) | N/A today — no live source exists yet | + +## Notes for support +- This screen does **not** tell you whether a specific ATM has a module running — it is a fleet-wide catalog/summary, and the summary is currently empty. To check a real ATM's module state, look at agent config / fleet tasks / the device record, not here. +- Related client methods `getModules()` → `/modules` and `getModulesByAtm()` → `/modules/atm/{id}` exist in the fleet API client but have **no matching backend endpoint** in `FleetApiController` and are **not** used by this screen — do not point people at them. diff --git a/backend/src/main/resources/content/incident/dashboard/architect.md b/backend/src/main/resources/content/incident/dashboard/architect.md new file mode 100644 index 0000000..d5e89d1 --- /dev/null +++ b/backend/src/main/resources/content/incident/dashboard/architect.md @@ -0,0 +1,75 @@ +--- +module: incident.dashboard +title: Dashboard +tab: Architect +order: 30 +audience: internal +--- + +> **How it's built.** The Dashboard is a thin read-only aggregation view: one Svelte component, three GET endpoints, all served by **hiveops-incident**. There is no dedicated dashboard aggregation service — most of the "analytics" (trend chart, incidents-by-type, device/agent status breakdowns) is computed **client-side** in the component from two data stores. See [platform.service-ownership]. + +## Owning service + +`hiveops-incident` owns every endpoint the Dashboard calls, plus both source tables (`incidents`, `device_summary`). The device data in `device_summary` is a **Kafka-fed mirror** — the source of truth is `hiveops-devices`. See [platform.data-architecture]. + +## Data flow + +``` +hiveops-devices ──hiveops.device.events──▶ DeviceEventConsumer ──▶ device_summary (hiveiq_incident) + (group hiveops-incident-device-sync) │ + ▼ +Dashboard.svelte ── GET /api/device-summary ─────────────────────────────────────▶ $atms store + │ +agent-proxy / ARIA / journal ──▶ IncidentAutoCreationService ──▶ incidents (hiveiq_incident) + ▼ +Dashboard.svelte ── GET /api/incidents/paginated (90d, ≤2000) ───────────────────▶ $incidents store +Dashboard.svelte ── GET /api/incident-management/stats/summary ───────────────────▶ incidentStats +``` + +- **Device Status / Agent Connection panels** and the top "Device Status" cards are derived in-browser from `$atms` (the `device_summary` snapshot): fields `status` / `serviceState`, `inSupervisor`, `deviceType` (ATM vs TCR), `agentConnectionStatus`. +- **Incident Trends** and **Incidents by Type** are derived in-browser from `$incidents` (last 90 days, ≤2000 rows), bucketed by `createdAt` / `type` / `severity` / `actualResolutionTime`. +- **Ticket cards** (Open/Assigned/In Progress/Critical) come from the server-computed `IncidentStatisticsDTO` (all-time, institution-scoped), so they don't inherit the 90-day/2000 cap. + +## Kafka + +See [platform.kafka]. + +**Consumed by incident (relevant to this view):** + +| Topic | Listener (group) | Effect on Dashboard | +|---|---|---| +| `hiveops.device.events` | `DeviceEventConsumer` (`hiveops-incident-device-sync`) | Upserts/deletes `device_summary` rows → device counts + agent-connection panel; also drives `IncidentAutoCreationService` | +| `hiveops.aria.threats` | `AriaThreatEventConsumer` | Auto-creates incidents → shows up in ticket/trend/type panels | + +No topics are produced *for* the Dashboard; incident's lifecycle producers (`hiveops.incidents.created/updated/resolved`) fan out to other services (analytics/reports/recon mirrors) but the Dashboard reads Postgres directly, not those topics. + +There is **no reconciler** for `device_summary` — if `hiveops-devices` stops publishing, the mirror (and therefore the device counts) silently goes stale. This is the single most common architectural failure point for this view. + +## Tables read + +| Table (DB `hiveiq_incident`) | Read by | For | +|---|---|---| +| `device_summary` | `DeviceSummaryController` → `DeviceSummaryRepository` | device + agent-connection stats | +| `incidents` (joins `atm.institution`) | `IncidentController.getIncidentsPaginated`, `IncidentService.getStatistics` | trend/type panels, ticket cards | +| `atm_groups` (via `AtmGroupRepository.findByIdWithAtms`) | `DeviceSummaryController` when `groupId` set | group-scoped device filtering | + +The Dashboard **writes nothing** — it is pure read. + +## Key API endpoints + +| Method + path | Controller | Auth | Scope | +|---|---|---|---| +| `GET /api/device-summary` | `DeviceSummaryController` | authenticated | `InstitutionContext.resolveScope()` | +| `GET /api/device-summary/paginated?groupId&page&size` | `DeviceSummaryController` | authenticated | scope + group ATM-id set | +| `GET /api/incidents/paginated` | `IncidentController` | authenticated (no `@PreAuthorize`) | scope + `dateFrom`/`groupId` | +| `GET /api/incident-management/stats/summary` | `IncidentManagementController` | authenticated (no `@PreAuthorize`) | `resolveScope()` → institution names | + +`stats/summary` counts by loading incidents (`findAll` or an `atm.institution` specification) and reducing in memory — it is not a SQL aggregate, so cost grows with incident volume. + +## Notable design points + +- **Read-open by design:** the Dashboard's four endpoints carry no role gate; only incident *mutations* require `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')`. +- **Two different truth windows on one screen:** ticket cards = all-time server stats; trend/type/"This Week" = client-computed over a capped 90-day window. Expect small mismatches on busy fleets — this is structural, not a bug in the endpoint. +- Institution isolation is enforced at the query layer (`InstitutionContext` + `institutionKeyService.resolveInstitutionNames`), not in the frontend. + +Cross-links: [platform.data-architecture] · [platform.kafka] · [platform.service-ownership] diff --git a/backend/src/main/resources/content/incident/dashboard/claude.md b/backend/src/main/resources/content/incident/dashboard/claude.md new file mode 100644 index 0000000..c6b29de --- /dev/null +++ b/backend/src/main/resources/content/incident/dashboard/claude.md @@ -0,0 +1,53 @@ +--- +module: incident.dashboard +title: Dashboard +tab: Claude +order: 40 +audience: internal +--- + +## Owner +- Service: **hiveops-incident** (port 8080, NGINX `/api/incident/`). Owns all Dashboard endpoints + both source tables. +- Frontend: `hiveops-incident/frontend/src/components/Dashboard/Dashboard.svelte` (Vite+Svelte SPA; h1 renders literal text "Overview"). +- Read-only view. No writes, no mutations. + +## Endpoints (all authenticated; NONE role-gated) +| Method | Path | Returns | Feeds | +|---|---|---|---| +| GET | `/api/device-summary` | `DeviceSummary[]` | device + agent-connection panels | +| GET | `/api/device-summary/paginated?groupId&page&size` | `Page` | group-scoped device counts | +| GET | `/api/incidents/paginated?page=0&size=2000&dateFrom=<90d>&groupId` | `Page` | trends, by-type, "This Week" | +| GET | `/api/incident-management/stats/summary` | `IncidentStatisticsDTO` | ticket cards (all-time) | + +- `IncidentStatisticsDTO` fields: `totalIncidents, openIncidents, assignedIncidents, inProgressIncidents, resolvedIncidents, closedIncidents, criticalCount, highCount, avgResolutionTime`. +- Controllers: `DeviceSummaryController`, `IncidentController.getIncidentsPaginated`, `IncidentManagementController.getIncidentStats`. + +## Tables (DB `hiveiq_incident`) +- `device_summary` — device/agent state (read via `DeviceSummaryRepository`). **Kafka-fed mirror, not authored here.** +- `incidents` — read via `IncidentController` + `IncidentService.getStatistics` (joins `atm.institution`). +- `atm_groups` — group→ATM-id set when `groupId` present. + +## Kafka +- Consume `hiveops.device.events` → `DeviceEventConsumer` (group **`hiveops-incident-device-sync`**) → upserts `device_summary`. Constant: `DeviceEventKafkaConfig.TOPIC`. +- Consume `hiveops.aria.threats` → auto-creates incidents. +- Dashboard produces **no** topics. + +## Scoping / auth +- Every endpoint filters by `InstitutionContext.resolveScope()` (from caller JWT). `null` scope = all institutions. +- View requires only a valid JWT. `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` gates only incident *mutations* elsewhere, NOT this view. + +## Gotchas +- Trend/by-type/"This Week" derive from a **90-day, ≤2000-row** client slice (`loadIncidentsForDashboard`) → silently truncates on busy fleets. Ticket cards (server stats) are all-time and unaffected. +- `fetchIncidentStats()` failure is **swallowed** (`catch {}`) → UI silently falls back to capped-store counts; ticket cards under-report with no error shown. +- Device counts stale = `device_summary` mirror stale. **No reconciler.** Root cause is upstream (hiveops-devices not publishing, or consumer group lag) — not incident. +- Agent Connection "Disconnected" bucket = everything not `CONNECTED`/`NEVER_CONNECTED`, including null → can inflate. +- Active `defaultGroupId` scopes both ATMs and incidents; stale group id → 404 "Group not found" on `device-summary/paginated`. +- Auto-refresh toggle persists in `localStorage['incident_dashboard_autoRefresh']`; interval = `settings.dashboardRefreshIntervalSeconds`. +- Frontend `API_BASE` = `/api` (dev) → prod resolves via `window.__APP_CONFIG__.apiUrl`; through NGINX = `/api/incident/...`. + +## Do NOT +- Do NOT assume the Dashboard writes/owns device data — it mirrors `hiveops-devices` via Kafka; fix stale counts upstream. +- Do NOT treat trend/type/"This Week" as authoritative counts — they are capped (90d/2000). +- Do NOT add a role gate expecting to "lock down" the Dashboard endpoints; they are read-open by design and shared with other views. +- Do NOT invent a `stats/summary`-style aggregate SQL — current impl loads rows and reduces in memory. +- Do NOT look for Dashboard data in journal/mgmt/fleet — all four endpoints are hiveops-incident. diff --git a/backend/src/main/resources/content/incident/dashboard/internal.md b/backend/src/main/resources/content/incident/dashboard/internal.md new file mode 100644 index 0000000..7df88dd --- /dev/null +++ b/backend/src/main/resources/content/incident/dashboard/internal.md @@ -0,0 +1,51 @@ +--- +module: incident.dashboard +title: Dashboard +tab: Internal +order: 20 +audience: internal +--- + +> **Internal ops/support view.** The Dashboard is **read-only** — there are no admin-only actions *on* this screen. It renders live counts from three GET endpoints and derives the charts client-side. Most "the dashboard is wrong" tickets are upstream data problems, not dashboard bugs. + +## What it actually loads + +Component: `hiveops-incident/frontend/src/components/Dashboard/Dashboard.svelte`. On mount and on each refresh it fires **three** calls (all against `hiveops-incident`, NGINX path `/api/incident/`): + +| Frontend call | Endpoint | Feeds | +|---|---|---| +| `loadAtms()` | `GET /api/device-summary` (or `/api/device-summary/paginated?groupId=` when a group is active) | Device Status cards, Device Status panel, Agent Connection panel | +| `loadIncidentsForDashboard()` | `GET /api/incidents/paginated?page=0&size=2000&dateFrom=<90d ago>` | Incident Trends, Incidents by Type, "This Week" | +| `fetchIncidentStats()` | `GET /api/incident-management/stats/summary` | Ticket cards: Open / Assigned / In Progress / Critical | + +Key split to remember when triaging: **ticket cards come from the server** (`stats/summary`, accurate across all time); **trend + type panels + "This Week" are computed in the browser** from the last-90-day / max-2000-record incident slice. + +## Roles / access + +- All three endpoints are **read-open to any authenticated user** — no `@PreAuthorize` on `stats/summary`, `incidents/paginated`, or `device-summary`. There is no MSP_ADMIN gate to *view* the Dashboard. +- The admin-only actions (`hasAnyRole('MSP_ADMIN','BCOS_ADMIN')`) live on the incident *management* mutations (assign/transition/bulk/notes) — not on anything the Dashboard renders. +- Numbers are **institution-scoped** via `InstitutionContext.resolveScope()` (derived from the caller's JWT). A user only ever sees their own institution's devices/incidents. "User X sees the wrong institution's numbers" = a JWT/scope or stale-mirror issue, not a dashboard bug. + +## First things to check when it misbehaves + +1. **All device counts are 0 / stale (but devices exist in the Devices app)** → the `device_summary` mirror in `hiveiq_incident` is stale. This table is fed by Kafka (`hiveops.device.events`), **not** written by incident itself. Check: is `hiveops-devices` publishing, and is the incident consumer group `hiveops-incident-device-sync` alive / not lagging? See [platform.kafka]. +2. **Ticket cards (Open/Assigned/In Progress/Critical) show 0 but incidents exist** → `stats/summary` call is failing. The frontend **swallows that error silently** (`catch {}`) and falls back to counting the capped 90-day store, so the UI looks "fine" while under-reporting. Curl `GET /api/incident-management/stats/summary` with a real JWT. +3. **Trends / "Incidents by Type" / "This Week" look low on a busy fleet** → expected limitation, not a bug: `loadIncidentsForDashboard()` pulls only the **last 90 days, capped at 2000 records**. A fleet with >2000 incidents in 90 days will truncate these panels. Ticket cards (server stats) stay correct. +4. **Header shows a group name and numbers look small** → a device group is selected (`defaultGroupId`). Both ATMs and incidents are then filtered to that group. Clear the group to see the whole fleet. +5. **"Group not found" / 404 on load** → `device-summary/paginated` 404s when the active `groupId` no longer exists. Reset the saved group. +6. **Nothing loads at all** → check NGINX routing to `/api/incident/` and JWT validity; the error banner at the top of the card shows the last store error. + +## Common failure modes → fix + +| Symptom | Likely cause | Fix | +|---|---|---| +| Device totals frozen / wrong | `device_summary` mirror stale (no reconciler exists) | Confirm devices publishing `hiveops.device.events`; restart/inspect `hiveops-incident-device-sync` consumer | +| Agent Connection "Disconnected" inflated | Any status that isn't `CONNECTED`/`NEVER_CONNECTED` (incl. null) buckets as Disconnected | Verify agents actually reporting; null status ≠ truly disconnected | +| Wrong institution's counts | JWT scope / stale mirror institutionKey | Check caller JWT scope + device_summary institution_key | +| Ticket cards zero, panels populated | `stats/summary` erroring (silent fallback) | Curl the endpoint; check incident service logs | +| Auto-refresh stopped | Toggle off (persisted in `localStorage` key `incident_dashboard_autoRefresh`) | Flip Auto-refresh back on; interval = `settings.dashboardRefreshIntervalSeconds` | + +## Auto-refresh mechanics + +- Toggle state persists per-browser in `localStorage['incident_dashboard_autoRefresh']`. +- Countdown resets to `settings.dashboardRefreshIntervalSeconds` each cycle; the **↻** button forces an immediate `doRefresh()` (all three calls in parallel). diff --git a/backend/src/main/resources/content/incident/incident-management/architect.md b/backend/src/main/resources/content/incident/incident-management/architect.md new file mode 100644 index 0000000..0b4b224 --- /dev/null +++ b/backend/src/main/resources/content/incident/incident-management/architect.md @@ -0,0 +1,72 @@ +--- +module: incident.incident-management +title: Incident Management +tab: Architect +order: 30 +audience: internal +--- + +> How the Incident Management screen is built. Owner service: **hiveops-incident**. See [platform.service-ownership], [platform.data-architecture], [platform.kafka]. + +## Shape + +- **Frontend:** `hiveops-incident/frontend` — `components/Incident/IncidentList/IncidentList.svelte` (the Active/Parked grid, filters, bulk actions, link/group, fraud-close modal). API client `lib/api.ts` (`incidentAPI`, `journalEventAPI`, `helpdeskPersonAPI`), base `/api`. +- **Backend:** `hiveops-incident` (Spring Boot, port **8080**, NGINX `/api/incident/`). Two controllers back this screen: + - `IncidentController` — `@RequestMapping("/api/incidents")` — reads/CRUD (`/paginated`, `/status/open`, `/severity/critical`, `/atm|device/{id}`). + - `IncidentManagementController` — `@RequestMapping("/api/incident-management")` — workflow: assign, transition, notes, bulk, link/group, stats. +- **Database:** `hiveiq_incident` (Postgres) via **`DB_URL`** full JDBC URL; Flyway `validate`. + +## Data it reads/writes + +| Table / entity | Role in this feature | +|----------------|----------------------| +| `incidents` (`Incident`) | primary record — type, severity, status, assignment, timestamps | +| `incident_events` | audit stream (`IncidentEventService.record`, e.g. `STATUS_CHANGED`) | +| `incident_notes` / notes on the entity | free-text notes added via `/notes` and workflow transitions | +| `workflow_transitions` | valid status-edge definitions consulted by `workflowStateService` | +| `technicians`, `helpdesk_persons` | assignment targets | +| `atms` (`Atm`) | device the incident belongs to | +| `device_summary` | Kafka-fed mirror of device metadata (name/institution shown on rows) | +| `incident_embeddings` | Adoons/AI indexing of incidents | + +## Workflow transition flow + +`POST /api/incident-management/{id}/transition` (admin-gated): +1. Load incident, resolve `WorkflowTransition` for `(currentStatus → newStatus)`; reject 400 if no edge. +2. If `transition.requiresAssignment` and no assignee → 400. +3. `assertNoUnfixedChildren()` blocks closing a primary with unfixed linked children. +4. Mutate status; set `resolvedAt`/`actualResolutionTime` on RESOLVED, `closedAt` on CLOSED. +5. Append a note, record an `incident_events` `STATUS_CHANGED` row, re-index for Adoons. +6. Broadcast STOMP `/topic/incidents/status-changed` for live UI update. + +`PARKED` is a first-class status: while any incident for a device is `PARKED`, `IncidentAutoCreationService` suppresses new auto-incidents for that device (#219). + +## Kafka — incident lifecycle (producer side) + +`IncidentLifecycleProducer` publishes on create and update (config `IncidentLifecycleKafkaConfig`, 3 partitions each): + +| Topic | When | +|-------|------| +| `hiveops.incidents.created` | `publishCreated` — on incident create (`IncidentService:73`) | +| `hiveops.incidents.updated` | `publishUpdated` — non-resolved update (`IncidentService:189,433`) | +| `hiveops.incidents.resolved` | `publishUpdated` when status RESOLVED/CLOSED | + +Downstream services (analytics, reports, messaging, etc.) consume these — this screen is the source of those events. + +## Kafka — auto-creation (consumer side) + +Incidents shown here are largely machine-generated. Consumers: + +| Topic | Listener (group) | Effect | +|-------|------------------|--------| +| `hiveops.device.events` | `DeviceEventConsumer` (`hiveops-incident-device-sync`) | sync `device_summary`; `IncidentAutoCreationService` raises incidents from device events | +| `hiveops.aria.threats` | `AriaThreatEventConsumer` (`hiveops-incident-aria`) | raise incidents from ARIA threat events | +| `adoons.rule-suggestion.ready` | `RuleSuggestionResultConsumer` (`hiveops-incident-adoons`) | ingest completed Adoons rule suggestions | + +Pattern: **executor/consumer → mutate incident → lifecycle producer → Kafka → downstream record stores**. The UI only ever sees the resulting `incidents` rows. + +## Boundaries + +- Agents do **not** hit this service directly. hiveops-agent-proxy calls incident `/api/internal/**` (secured by `X-Internal-Secret` → `ROLE_INTERNAL`) to ingest journal events / heartbeats that feed auto-creation. See [platform.service-ownership]. +- Device master data is owned by **hiveops-devices**; incident holds a read-only `device_summary` mirror fed over Kafka — no reconciler, so publish-on-every-change is required upstream. See [platform.data-architecture]. +- `JWT_SECRET` is shared with hiveops-auth / hiveops-mgmt; `mgmt.service.url` used for agent-token validation. See [platform.kafka] for bootstrap/topic conventions. diff --git a/backend/src/main/resources/content/incident/incident-management/claude.md b/backend/src/main/resources/content/incident/incident-management/claude.md new file mode 100644 index 0000000..4ae368c --- /dev/null +++ b/backend/src/main/resources/content/incident/incident-management/claude.md @@ -0,0 +1,63 @@ +--- +module: incident.incident-management +title: Incident Management +tab: Claude +order: 40 +audience: internal +--- + +## Ownership +- **Service:** `hiveops-incident` (Spring Boot, port **8080**, NGINX `/api/incident/`). +- **DB:** `hiveiq_incident` (Postgres). Env var **`DB_URL`** = single full JDBC URL (NOT split host/port/name). +- **Frontend view:** `hiveops-incident/frontend/src/components/Incident/IncidentList/IncidentList.svelte`; API client `lib/api.ts` (`incidentAPI`). + +## Endpoints (METHOD path) — all under `/api` +| Method | Path | Role | +|--------|------|------| +| GET | `/api/incidents/paginated` | any auth | +| GET | `/api/incidents` · `/api/incidents/{id}` | any auth | +| GET | `/api/incidents/status/open` · `/api/incidents/severity/critical` | any auth | +| GET | `/api/incidents/atm/{atmId}` · `/api/incidents/device/{atmId}` (aliases) | any auth | +| PUT | `/api/incidents/{id}` | any auth | +| POST | `/api/incident-management` (create) | MSP_ADMIN/BCOS_ADMIN | +| POST | `/api/incident-management/{id}/assign` · `/reassign` · `/unassign` | MSP_ADMIN/BCOS_ADMIN | +| POST | `/api/incident-management/{id}/transition` | MSP_ADMIN/BCOS_ADMIN | +| POST | `/api/incident-management/{id}/notes` | MSP_ADMIN/BCOS_ADMIN | +| POST | `/api/incident-management/bulk/update` | MSP_ADMIN/BCOS_ADMIN | +| POST | `/api/incident-management/{id}/link` · `/group` | MSP_ADMIN/BCOS_ADMIN | +| DELETE | `/api/incident-management/{id}/link/{linkedId}` | MSP_ADMIN/BCOS_ADMIN | +| GET | `/api/incident-management/{id}/links` · `/audit-trail` · `/stats/summary` | any auth | +| GET | `/api/incident-management/technicians/available` | any auth | + +Bulk close body: `{ "incidentIds": ["1","2"], "newStatus": "CLOSED" }` (ids are strings). + +## Roles +- Every mutation: `@PreAuthorize("hasAnyRole('MSP_ADMIN','BCOS_ADMIN')")`. Reads: authenticated only. + +## Tables (`hiveiq_incident`) +- `incidents`, `incident_events`, `incident_notes`, `workflow_transitions`, `atms`, `technicians`, `helpdesk_persons`, `device_summary` (Kafka mirror), `incident_embeddings`. + +## Kafka topics +- **Produced:** `hiveops.incidents.created`, `hiveops.incidents.updated`, `hiveops.incidents.resolved` (`IncidentLifecycleProducer` / `IncidentLifecycleKafkaConfig`). +- **Consumed (auto-create):** `hiveops.device.events` (group `hiveops-incident-device-sync`), `hiveops.aria.threats` (group `hiveops-incident-aria`), `adoons.rule-suggestion.ready` (group `hiveops-incident-adoons`). +- WebSocket (STOMP): `/topic/incidents/status-changed`. + +## Statuses +- `OPEN, ASSIGNED, IN_PROGRESS, RESOLVED, CLOSED, PARKED`. +- A device with any `PARKED` incident suppresses ALL auto-creation for that device (#219). + +## Gotchas +- Transition requires a valid `workflow_transitions` edge; if `requiresAssignment`, assignee must be set first (else 400). +- Closing blocked by `assertNoUnfixedChildren()` if linked children unfixed. +- **Possible Fraud note-before-close is FRONTEND-only** (`IncidentList.svelte` fraud-close modal) — NOT enforced by the API. +- Agents never call this service; they go via **hiveops-agent-proxy** → `/api/internal/**` (`X-Internal-Secret`). +- `device_summary` is a read-only Kafka mirror from hiveops-devices; no reconciler → stale = fix upstream. +- `DB_URL` default points at a stale `hiveops_incident` DB — prod overrides to `hiveiq_incident`. + +## Do NOT +- Do NOT add device/institution writes here — device data is owned by hiveops-devices. +- Do NOT assume the fraud-note rule protects direct API closes — it doesn't. +- Do NOT expect UPDATE/transition without an admin JWT — reads succeed, writes 403. +- Do NOT split `DB_URL` into host/port/name. +- Do NOT create incidents for a parked device expecting auto-flow — parking silences it. +- Do NOT invent transition endpoints per-status (start/resolve/close); all go through one `POST /{id}/transition` with `newStatus`. diff --git a/backend/src/main/resources/content/incident/incident-management/internal.md b/backend/src/main/resources/content/incident/incident-management/internal.md new file mode 100644 index 0000000..ca0c884 --- /dev/null +++ b/backend/src/main/resources/content/incident/incident-management/internal.md @@ -0,0 +1,52 @@ +--- +module: incident.incident-management +title: Incident Management +tab: Internal +order: 20 +audience: internal +--- + +> Ops/support/admin view for the Incident Management screen (`IncidentList.svelte`). Served by **hiveops-incident** (port 8080, NGINX `/api/incident/`). All facts below grounded in source as of 2026-07-01. + +## Who can do what + +Read/list is open to any authenticated user. **Every mutating action requires `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')`** (`@PreAuthorize` on `IncidentManagementController`): + +| Action | Endpoint | Role gate | +|--------|----------|-----------| +| Assign / reassign / unassign | `POST /api/incident-management/{id}/assign` · `/reassign` · `/unassign` | admin | +| Workflow transition (Start/Resolve/Close/Reopen/Park) | `POST /api/incident-management/{id}/transition` | admin | +| Add note | `POST /api/incident-management/{id}/notes` | admin | +| Bulk close | `POST /api/incident-management/bulk/update` | admin | +| Link / unlink / group | `POST /api/incident-management/{id}/link` · `DELETE .../link/{linkedId}` · `POST .../group` | admin | +| Create incident (manual) | `POST /api/incident-management` or `POST /api/incidents` | admin | + +If an admin reports "buttons do nothing / 403 on save," first confirm their JWT actually carries `MSP_ADMIN` or `BCOS_ADMIN` — a plain customer role can load the list but cannot transition or assign. + +## First things to check when it misbehaves + +1. **List empty / won't load** — the grid calls `GET /api/incidents/paginated`. Check the incident service is up: `curl http://localhost:8080/actuator/health` on the services VM. A 401/403 through NGINX usually means a missing/expired JWT, not a data problem. +2. **"No new incidents appearing for a device"** — the device may be **parked**. Any incident in `PARKED` status silences that device: `IncidentAutoCreationService.isDeviceParked()` suppresses all auto-creation (device events, log-errors, AGENT_OFFLINE) until the device is un-parked (issue #219). This is intended, not a bug — un-park the incident to resume auto-creation. +3. **Auto-created incidents not arriving at all** — auto-creation is driven by Kafka, not by the UI. Incidents are created by consumers of `hiveops.device.events` (`DeviceEventConsumer`) and `hiveops.aria.threats` (`AriaThreatEventConsumer`). If none are appearing fleet-wide, check Kafka connectivity (`KAFKA_BOOTSTRAP_SERVERS`) and consumer group lag for `hiveops-incident-device-sync` / `hiveops-incident-aria`. +4. **Wrong institution / stale device name on a row** — the row's device metadata comes from the `device_summary` mirror, fed by `hiveops.device.events` from **hiveops-devices**. There is no reconciler; a stale mirror means devices didn't publish. Fix at the source (devices), not here. +5. **Can't close an incident** — two guards: + - The incident has **unfixed linked children** — `assertNoUnfixedChildren()` blocks closing a primary while linked child incidents remain unfixed. + - **Possible Fraud** incidents require a note before closing — this is enforced **in the frontend** (`fraud-close-modal` in `IncidentList.svelte`), so it only applies via the UI, not direct API calls. +6. **Transition rejected (400)** — the target status must be a valid edge in the workflow (`workflowStateService.getTransition(from, to)`) and, if the transition `requiresAssignment`, the incident must already have an assignee. Assign first, then Start/Resolve. + +## Common failure modes + fixes + +| Symptom | Likely cause | Fix | +|---------|-------------|-----| +| 403 on assign/transition/note | JWT lacks `MSP_ADMIN`/`BCOS_ADMIN` | issue a token with the right role | +| New tickets not raised for one device | device is parked (#219) | un-park its `PARKED` incident | +| No incidents fleet-wide | Kafka down / consumer lag | check `KAFKA_BOOTSTRAP_SERVERS`, consumer groups | +| Stale device name/institution on row | `device_summary` mirror stale | fix in hiveops-devices (source of truth) | +| Close button blocked | unfixed linked children or fraud-note missing | fix children first / add note in UI | +| Service can't reach DB | `DB_URL` misconfigured | it's a **single full JDBC URL** (`DB_URL`), not split host/port/name; default points at a stale `hiveops_incident` DB and MUST be overridden to `hiveiq_incident` | + +## Notes for support + +- Real-time list updates use a STOMP WebSocket topic `/topic/incidents/status-changed`; the "Auto-refresh" toggle also re-polls `/api/incidents/paginated`. If a user sees stale data, have them refresh — polling is the reliable path. +- Agents never write here directly. All agent journal/heartbeat traffic goes through **hiveops-agent-proxy** → incident `/api/internal/**` (secured by `X-Internal-Secret`). A gap in incoming events is an agent-proxy / agent problem, not an incident-UI problem. +- Adoons (AI) rule suggestions surface via `adoons.rule-suggestion.*` topics and separate rule screens — not part of this list view. diff --git a/backend/src/main/resources/content/incident/incident-tracker/architect.md b/backend/src/main/resources/content/incident/incident-tracker/architect.md new file mode 100644 index 0000000..876fa91 --- /dev/null +++ b/backend/src/main/resources/content/incident/incident-tracker/architect.md @@ -0,0 +1,79 @@ +--- +module: incident.incident-tracker +title: Incident Tracker +tab: Architect +order: 30 +audience: internal +--- + +> How the Incident Tracker is built. Grounded in `hiveops-incident` frontend + backend as of 2026-07-01. See [platform.data-architecture], [platform.kafka], [platform.service-ownership]. + +## Shape of the feature + +The tracker is a **pure read view with client-side aggregation**. There is no server-side "tracker" projection, endpoint, or table — the Svelte component fans out to two existing controllers and assembles the board in the browser. All state (pipeline stages, severity rollup, sort, "recently closed" toggle) is computed in `IncidentTracker.svelte`. + +Everything is owned by a **single service, `hiveops-incident`** ([platform.service-ownership]). The only cross-service dependency is the `device_summary` mirror, which is fed from **hiveops-devices** over Kafka. + +## Services involved + +| Service | Role for this feature | +|---------|-----------------------| +| `hiveops-incident` (backend, port 8080) | Serves `/api/device-summary` and `/api/incidents/paginated`; owns `incidents` + `device_summary` tables | +| `hiveops-incident` (frontend, Vite+Svelte SPA) | `IncidentTracker.svelte` — does all joining/aggregation | +| `hiveops-devices` | Source of truth for device data; publishes `hiveops.device.events` | +| NGINX | Routes `/api/incident/` → incident backend `:8080` | + +## Data flow (per 60s refresh) + +``` +IncidentTracker.svelte ──(3 parallel GETs)──► hiveops-incident @ 8080 + 1. deviceSummaryAPI.getAll() → GET /api/device-summary → DeviceSummaryController.getAll() → device_summary + 2. incidentAPI.getPaginated(open) → GET /api/incidents/paginated → IncidentController.getIncidentsPaginated() → incidents + 3. incidentAPI.getPaginated(closed)→ GET /api/incidents/paginated (…) → same handler → incidents +``` + +Client-side assembly: +1. Build `dsMap` keyed by `device_summary.atm_id` (string natural key). +2. Concatenate active + closed incidents; group by `incident.atmName ?? String(incident.atmId)`. +3. Join each incident group to `dsMap` for location; if no match → stub row (`location: ''`). +4. Per ATM: `hasAction` if any active; `currentStage = min(stage index)` over active; `highestSeverity` = first of `[CRITICAL,HIGH,MEDIUM,LOW]` present; `closedStage = max(stage index)` over closed. +5. Filter to rows that `hasAction || recentlyClosed`; sort action-first, then by severity, then closed-most-recent, then atmId. + +`incident.atmId` (integer FK) is retained only for the `viewAtmIncidents` dispatch that drills into the filtered `IncidentList`. + +## How `device_summary` stays current (Kafka) — [platform.kafka] + +The left column is **not** incident's own device data — it's a Kafka-synced mirror: + +| Topic | Consumer (class / group) | Effect | +|-------|--------------------------|--------| +| `hiveops.device.events` | `DeviceEventConsumer` / `hiveops-incident-device-sync` | Upsert (`repo.save`) or delete rows in `device_summary`; also updates `atms` and can trigger `IncidentAutoCreationService` | + +Topic + group are constants in `config/DeviceEventKafkaConfig` (`TOPIC = "hiveops.device.events"`, `CONSUMER_GROUP = "hiveops-incident-device-sync"`). No reconciler — if devices stops publishing, the mirror silently ages. This is the same `device_summary` mirror pattern used across incident/fleet/reports/recon ([platform.data-architecture]). + +Incident lifecycle itself produces `hiveops.incidents.created/updated/resolved` (via `IncidentLifecycleProducer`), but **the tracker does not consume these** — it re-polls REST every 60s instead. + +## DB tables + +| Table | Access | Notes | +|-------|--------|-------| +| `incidents` (`@Table(name="incidents")`) | read | status, severity, atmName, atmId, resolvedAt, updatedAt | +| `device_summary` (`@Table(name="device_summary")`) | read | Kafka-fed mirror; keyed by `atm_id` string | + +Both live in DB `hiveiq_incident`. Institution scoping is applied server-side in each controller via `InstitutionContext.resolveScope()` (repository `findByInstitutionKeyIn(scope)` when scoped, `findAll` when unrestricted). + +## Key API endpoints (this feature) + +| Method + path | Controller | Auth | +|---------------|-----------|------| +| `GET /api/device-summary` | `DeviceSummaryController.getAll` | any JWT; institution-scoped | +| `GET /api/incidents/paginated` | `IncidentController.getIncidentsPaginated` | any JWT; institution-scoped | +| `GET /api/incidents` | `IncidentController.getAllIncidents` | any JWT; institution-scoped (not used by tracker) | + +Note this feature has **no executor→Kafka→record-store write path** — it is entirely read-side. The write/lifecycle path (create/assign/transition → `hiveops.incidents.*`) belongs to the broader incident domain, not to the tracker view. + +## Cross-links + +- [platform.data-architecture] — device_summary mirror pattern +- [platform.kafka] — topic/consumer conventions +- [platform.service-ownership] — incident owns incidents + journal-event store diff --git a/backend/src/main/resources/content/incident/incident-tracker/claude.md b/backend/src/main/resources/content/incident/incident-tracker/claude.md new file mode 100644 index 0000000..2e4d6e9 --- /dev/null +++ b/backend/src/main/resources/content/incident/incident-tracker/claude.md @@ -0,0 +1,60 @@ +--- +module: incident.incident-tracker +title: Incident Tracker +tab: Claude +order: 40 +audience: internal +--- + +> Terse act-without-guessing reference. Confirmed against `hiveops-incident` code 2026-07-01. + +## Owner +- Service: **hiveops-incident** (backend port 8080, NGINX `/api/incident/`). DB: **hiveiq_incident** (env `DB_URL` = full JDBC URL). +- Frontend: `hiveops-incident/frontend/src/components/IncidentTracker/IncidentTracker.svelte` (Vite+Svelte SPA, state-routed via `currentView`). +- The tracker is **read-only + client-side aggregated**. No tracker endpoint/table/topic exists. + +## Endpoints the tracker calls (all GET, all via NGINX `/api/incident/` → base `/api`) +| Method + path | Params used | Handler | +|---------------|-------------|---------| +| GET `/api/device-summary` | — | `DeviceSummaryController.getAll` | +| GET `/api/incidents/paginated` | `status=OPEN,ASSIGNED,IN_PROGRESS&size=1000&groupId` | `IncidentController.getIncidentsPaginated` | +| GET `/api/incidents/paginated` | `status=RESOLVED,CLOSED&size=500&sort=updatedAt,desc&groupId` | same | + +## Drill-down (View Incidents button) +- Dispatches `viewAtmIncidents { atmId: , statuses }` into `IncidentList` — filter is client-side, not a new endpoint. + +## Tables +| Table | R/W here | Key | +|-------|----------|-----| +| `incidents` | read | join key = `atmName` (string), `atmId` = integer FK | +| `device_summary` | read | key = `atm_id` (string, natural key) | + +## Kafka (feeds `device_summary`, not the tracker directly) +| Topic | Consumer / group | +|-------|------------------| +| `hiveops.device.events` | `DeviceEventConsumer` / `hiveops-incident-device-sync` | + +Constants in `config/DeviceEventKafkaConfig`: `TOPIC`, `CONSUMER_GROUP`. + +## Roles +- Viewing tracker: **any valid JWT**. Read endpoints have **no `@PreAuthorize`**; scoped by `InstitutionContext.resolveScope()`: + - CUSTOMER / MSP_ADMIN → scoped to JWT-granted institution keys. + - BCOS_ADMIN / ADMIN / QDS_ADMIN / USER → `null` = all institutions. +- Mutations (create/assign/transition/bulk): `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')`. + +## Gotchas +- Row join is **client-side** on `incident.atmName == device_summary.atm_id`. No match → stub row, blank location. Blank location = **stale `device_summary` mirror** (devices didn't publish; no reconciler). +- `groupId` filter applies to **incidents only**, not `device-summary` (per code comment). +- Board shows only ATMs with active incident, or resolved/closed when the toggle is on. +- Pipeline stage & severity are computed in JS (`Math.min` stage over active; severity precedence CRITICAL>HIGH>MEDIUM>LOW). To change a row, fix the `incidents` record. +- Silent caps: active `size=1000`, closed `size=500`, no UI pagination → truncates on very large fleets. +- Auto-refresh 60s (`setInterval`), no websocket. Lifecycle topics `hiveops.incidents.created/updated/resolved` are produced but **not consumed** by the tracker. +- `DB_URL` hardcoded default is a stale `hiveops_incident` on 192.168.200.103 — prod overrides to `hiveiq_incident`. + +## Do NOT +- Do NOT look for a `/api/incident-tracker` endpoint or a `tracker` table — none exist. +- Do NOT expect the tracker to update `device_summary` — it's read-only; devices owns device data. +- Do NOT assume it consumes `hiveops.incidents.*` — it re-polls REST. +- Do NOT add a write to `incidents`/`device_summary` from this view — mutations belong to incident-management endpoints ([platform.service-ownership]). +- Do NOT call these agent-facing; agents never hit incident directly (agent-proxy → `/api/internal/**`). +- Do NOT report the tracker as "AI" — hiveops-ai is branded **Adoons**; the tracker has no AI. diff --git a/backend/src/main/resources/content/incident/incident-tracker/internal.md b/backend/src/main/resources/content/incident/incident-tracker/internal.md new file mode 100644 index 0000000..5ce87e7 --- /dev/null +++ b/backend/src/main/resources/content/incident/incident-tracker/internal.md @@ -0,0 +1,53 @@ +--- +module: incident.incident-tracker +title: Incident Tracker +tab: Internal +order: 20 +audience: internal +--- + +> Ops / support / admin view. What to check when the Incident Tracker board is wrong or empty. Grounded in `hiveops-incident` frontend + backend as of 2026-07-01. + +## What the board actually is + +The Incident Tracker is a **read-only, client-side aggregation**. The Svelte component (`frontend/src/components/IncidentTracker/IncidentTracker.svelte`) makes three GET calls in parallel and builds every row, pipeline stage, severity, and sort order **in the browser**. There is no dedicated "tracker" endpoint or table — nothing is written back. + +Three calls per refresh (auto every **60s**, or manual ↻ button): +1. `GET /api/device-summary` — ATM inventory (id + location), authoritative source for the left column. +2. `GET /api/incidents/paginated?status=OPEN,ASSIGNED,IN_PROGRESS&size=1000&groupId=…` — active incidents. +3. `GET /api/incidents/paginated?status=RESOLVED,CLOSED&size=500&sort=updatedAt,desc&groupId=…` — recently closed. + +Rows are joined **in JS** on the ATM business key: `incident.atmName` ↔ `device_summary.atm_id`. + +## Roles required + +- **Viewing the tracker** needs only a **valid JWT** — the two read endpoints (`/api/device-summary`, `/api/incidents/paginated`) carry **no `@PreAuthorize`**. Access is scoped by `InstitutionContext.resolveScope()`: + - `ROLE_CUSTOMER` / `ROLE_MSP_ADMIN` → **scoped** to their granted institution keys (list comes from the JWT credentials). + - `BCOS_ADMIN` / `ADMIN` / `QDS_ADMIN` / `USER` → `null` scope = **sees all institutions**. +- **Acting on an incident** (the "View Incidents" drill-down leads to create/assign/transition) requires `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` — enforced on `POST /api/incidents`, `POST /api/incident-management/{id}/assign`, `/transition`, `/bulk/update`. + +## First things to check when it misbehaves + +**Board is empty / "All ATMs clear" but you expect incidents** +- The board only shows ATMs with an **open** incident (`OPEN`/`ASSIGNED`/`IN_PROGRESS`) or, with the toggle on, a `RESOLVED`/`CLOSED` one. If every incident is closed and the toggle is off, you correctly see "All clear." +- Confirm the user's **institution scope** — an MSP_ADMIN only sees incidents for their granted institution keys. A wrong/empty scope in the JWT hides everything. +- Check the group filter: `defaultGroupId` store applies `groupId` to the **incident** queries. A stale/wrong default group can filter out all incidents. + +**Rows show a blank location or the ATM ID looks raw** +- The left column comes from `device_summary`. If the join key (`incident.atmName`) has no matching `device_summary.atm_id`, the row falls back to a **stub** (`location: ''`). Root cause is almost always a **stale `device_summary` mirror**. +- `device_summary` is Kafka-fed from **hiveops-devices** via topic `hiveops.device.events` (consumer `DeviceEventConsumer`, group `hiveops-incident-device-sync`). If devices didn't publish, or the consumer lagged/failed, incident's mirror goes stale. Check `DeviceEventConsumer` logs (`Upserted device_summary for …` / `Removed device_summary for deleted device …`). + +**Severity or pipeline stage looks wrong** +- Both are computed client-side. Pipeline stage = **lowest** stage index among that ATM's active incidents (`Math.min`); severity = the most urgent among active incidents (`CRITICAL > HIGH > MEDIUM > LOW`). This reflects the underlying `incidents.status` / `incidents.severity` values — fix the incident record, not the board. + +**Board silently missing some incidents on a huge fleet** +- Active list is capped at `size=1000`, closed at `size=500`, both **unpaginated in the UI**. An institution with more open incidents than the cap will silently truncate. See uncertainties in the architect notes. + +**Nothing loads / "Failed to load data"** +- One of the three GETs failed. Check browser network tab for which. Common causes: NGINX `/api/incident/` route down, expired JWT (401), or CORS. Backend port is **8080** behind NGINX path `/api/incident/`. + +## Where to look + +- Backend service: `hiveops-incident`, DB `hiveiq_incident` (env `DB_URL`, full JDBC URL — not split host/port/name). +- Tables read: `incidents`, `device_summary`. +- Logs: use **Loki** (services VM) for `hiveops-incident`, don't SSH+cat. diff --git a/backend/src/main/resources/content/incident/journal-events/architect.md b/backend/src/main/resources/content/incident/journal-events/architect.md new file mode 100644 index 0000000..ea66eab --- /dev/null +++ b/backend/src/main/resources/content/incident/journal-events/architect.md @@ -0,0 +1,68 @@ +--- +module: incident.journal-events +title: Journal Events +tab: Architect +order: 30 +audience: internal +--- + +How the **Event Journal** feature is built. This module is a thin read view over the journal-event store that **hiveops-incident** owns; the write side is agent ingestion routed through **hiveops-agent-proxy**. See [platform.service-ownership]. + +## Services involved + +| Service | Role for this feature | +|---|---| +| incident-frontend (`JournalEvents.svelte`) | Vite+Svelte SPA view; per-ATM paginated table + filters | +| **hiveops-incident** | Owns reads and the `journal_events` store; `JournalEventController` (browser) + `InternalJournalController` (ingest) | +| **hiveops-agent-proxy** | Sole agent entry point; native `JournalEventController` forwards ingest to incident's internal API | +| hiveops-agent | Runs on the ATM; POSTs journal events | + +Agents never call incident directly — see [platform.service-ownership]. + +## Data flow — read path + +1. SPA selects an ATM (numeric `atms.id`) and calls `GET /api/journal-events/atm/{atmId}/paginated` with `page/size/sort/hoursBack/eventTypes[]` (axios base `/api`; `eventTypes` serialized as repeated keys, not indexed). +2. `JournalEventController.getEventsPaginated` → `JournalEventService.getEventsPaginated` builds a JPA `Specification` (`JournalEventSpecification.withFilters`) filtering on `atm.id`, `eventType IN (...)`, and `eventTime >= now-hoursBack`. +3. `journalEventRepository.findAll(spec, pageable)` → mapped to `JournalEventDTO`, returned as a Spring `Page` (`{content, page:{totalElements,totalPages,number}}`). + +Aggregate reads (`/activity`, `/fleet/cassette-inventory`) additionally resolve institution scope via `InstitutionContext.resolveScope()` (null = unrestricted for `USER`/`ADMIN`/`QDS_ADMIN`/`BCOS_ADMIN`; scoped list for `CUSTOMER`/`MSP_ADMIN`). The per-ATM read endpoints do **not** apply that scope (they filter by `atmId` only). + +## Data flow — write/ingest path + +``` +agent --POST /api/journal-events--> agent-proxy (ROLE_AGENT) + JournalEventController.createEvent + -> IncidentInternalService.createJournalEvent(body, contentType) + --POST /api/internal/journal-events (X-Internal-Secret)--> + incident InternalJournalController (ROLE_INTERNAL) + -> JournalEventService.createEvent(request) +``` + +`JournalEventService.createEvent`: +- Resolves/auto-registers the ATM via `atmService.findOrCreateAtm(agentAtmId, deviceType)` and updates last heartbeat. +- If `eventType` is not in the `event_type` lookup (`eventTypeService.isKnown`) → recorded via `eventExceptionService.record(...)`, returns `null` → HTTP `202`. Known types → saved, HTTP `201`. +- Side effects: flips ATM service state for `ATM_IN_SERVICE`/`ATM_OUT_OF_SERVICE`/`SUPERVISOR_ENTER`/`SUPERVISOR_EXIT`; runs `incidentAutoCreationService.processRecovery` + `processEvent` (may attach a newly created incident to the event). +- Wrapped in `@Retryable(PessimisticLockingFailureException, 3x, backoff)` — deadlock guard for the morning-reboot burst (incident#212). + +**No Kafka in the ingest path** for this feature — journal ingestion is synchronous HTTP. This is *not* the executor→Kafka→record-store pattern; there is no `hiveops.journal.*` topic. (Downstream, incident *does* emit `hiveops.incidents.created/updated/resolved` when auto-incident creation fires, but that is the incidents domain, not the journal event itself.) See [platform.kafka]. + +## DB tables + +- **`journal_events`** (DB `hiveiq_incident`, entity `JournalEvent`) — written on ingest, read by this view. Columns: `atm_id` (FK→`atms`), `incident_id` (nullable FK→`incidents`), `eventType`, `eventDetails` (TEXT), card-reader (`cardReaderSlot`,`cardReaderStatus`), cassette (`cassetteType`,`cassetteFillLevel`,`cassetteBillCount`,`cassetteCurrency`,`cassettePosition`,`cassetteDenomination`), `eventTime`, `eventSource`. Indexes: `idx_journal_events_atm_time (atm_id, eventTime DESC)`, `idx_journal_events_event_type`. +- **`atms`** — read for ATM name/location in the DTO; written (auto-register + heartbeat) on ingest. +- **`event_type`** — lookup that decides known vs. exception. +- **`incidents`** — linked when auto-incident creation fires. + +See [platform.data-architecture]. + +## Key API endpoints + +| Method + path | Service | Auth | +|---|---|---| +| `GET /api/journal-events/atm/{id}/paginated` | incident | JWT/browser role | +| `GET /api/journal-events/atm/{id}` `.../recent` `.../card-reader` `.../cassette` `.../cassette-inventory` | incident | JWT/browser role | +| `GET /api/journal-events/activity` · `/fleet/cassette-inventory` | incident | JWT/browser role (institution-scoped) | +| `POST /api/journal-events` | incident (`JournalEventController`) / agent-proxy native | AGENT or JWT/browser role | +| `POST /api/internal/journal-events` | incident (`InternalJournalController`) | `ROLE_INTERNAL` via `X-Internal-Secret` | + +All `/atm/{id}/...` paths have a Phase-2 `/device/{id}/...` alias mapping to the same handler. diff --git a/backend/src/main/resources/content/incident/journal-events/claude.md b/backend/src/main/resources/content/incident/journal-events/claude.md new file mode 100644 index 0000000..0f6cfb1 --- /dev/null +++ b/backend/src/main/resources/content/incident/journal-events/claude.md @@ -0,0 +1,63 @@ +--- +module: incident.journal-events +title: Journal Events +tab: Claude +order: 40 +audience: internal +--- + +Act-without-guessing reference. Everything below is confirmed in code. + +## Ownership +- **Reads + store owner:** `hiveops-incident` (port 8080, NGINX `/api/incident/`). +- **Ingest gateway:** `hiveops-agent-proxy` (port 8093/host 8015). Agents NEVER hit incident directly. +- Frontend: `hiveops-incident/frontend/src/components/JournalEvents/JournalEvents.svelte` (view label "Event Journal"). + +## Endpoints (METHOD path) +| Method + path | Service | Auth | +|---|---|---| +| GET `/api/journal-events/atm/{atmId}/paginated` | incident | JWT/browser role | +| GET `/api/journal-events/atm/{atmId}` | incident | JWT/browser role | +| GET `/api/journal-events/atm/{atmId}/recent?hoursBack=24` | incident | JWT/browser role | +| GET `/api/journal-events/atm/{atmId}/card-reader` | incident | JWT/browser role | +| GET `/api/journal-events/atm/{atmId}/cassette` | incident | JWT/browser role | +| GET `/api/journal-events/atm/{atmId}/cassette-inventory` | incident | JWT/browser role | +| GET `/api/journal-events/activity?hoursBack=24` | incident | JWT/browser role (institution-scoped) | +| GET `/api/journal-events/fleet/cassette-inventory` | incident | JWT/browser role (institution-scoped) | +| POST `/api/journal-events` | incident controller / agent-proxy native | AGENT or JWT/browser role | +| POST `/api/internal/journal-events` | incident `InternalJournalController` | ROLE_INTERNAL (`X-Internal-Secret`) | + +- `{atmId}` = numeric `atms.id`, NOT the `AT000xxx` agent id. +- Every `/atm/{id}/...` has an identical `/device/{id}/...` alias. +- Paginated query params: `page`, `size`, `sort=eventTime,desc`, `hoursBack`, `eventTypes` (repeated key, e.g. `eventTypes=CARD_READER_FAIL&eventTypes=...`). + +## Roles +- Read `/api/journal-events/**`: `hasAnyRole('USER','CUSTOMER','ADMIN','MSP_ADMIN','QDS_ADMIN','BCOS_ADMIN')`. NO `@PreAuthorize`; NOT admin-only. +- Ingest internal: `hasRole('INTERNAL')` (header `X-Internal-Secret` = env `INTERNAL_SERVICE_SECRET`). +- Agent path `POST /api/journal-events` also allows `ROLE_AGENT`. + +## Tables / DB +- Table `journal_events` in DB **`hiveiq_incident`**. Entity `JournalEvent`. +- Reads also touch `atms`; ingest touches `atms`, `event_type` (known-type lookup), `incidents` (auto-link). + +## Kafka +- NONE for journal events. Ingest is synchronous HTTP. No `hiveops.journal.*` topic exists. + +## Ingest chain (exact) +`agent → agent-proxy JournalEventController POST /api/journal-events → IncidentInternalService.createJournalEvent → incident POST /api/internal/journal-events → JournalEventService.createEvent` + +## Gotchas +- Unknown `eventType` (not in `event_type` lookup) → NOT stored; recorded as event exception; returns HTTP 202. Known → 201. +- `createEvent` is `@Retryable` on `PessimisticLockingFailureException` (incident#212 deadlock guard). +- `eventTime` parsed as `LocalDateTime` (no timezone); unparseable/blank → `now()`. +- Auto-registers ATM on `agentAtmId` via `findOrCreateAtm`; also updates last heartbeat + may flip service state (`ATM_IN_SERVICE`/`ATM_OUT_OF_SERVICE`/`SUPERVISOR_ENTER`/`SUPERVISOR_EXIT`) + trigger auto-incident. +- Category filter in UI is an allowlist of exact type strings (8 groups); types outside the groups are hidden when any category is ticked. +- Per-ATM read endpoints filter by `atmId` only — they do NOT apply `InstitutionContext` scope (only `/activity` and `/fleet/cassette-inventory` do). + +## Do NOT +- Do NOT POST agent journal events straight to incident — go through agent-proxy. +- Do NOT call `/api/internal/journal-events` without `X-Internal-Secret` — 401/403. +- Do NOT pass the `AT000xxx` agent id where `{atmId}` (numeric) is expected. +- Do NOT expect a Kafka topic for journal ingest — there is none. +- Do NOT assume this view is admin-gated — any authenticated user role can read it. +- Do NOT treat a 202 on ingest as "stored" — it means the type was unknown. diff --git a/backend/src/main/resources/content/incident/journal-events/internal.md b/backend/src/main/resources/content/incident/journal-events/internal.md new file mode 100644 index 0000000..c5fba39 --- /dev/null +++ b/backend/src/main/resources/content/incident/journal-events/internal.md @@ -0,0 +1,45 @@ +--- +module: incident.journal-events +title: Journal Events +tab: Internal +order: 20 +audience: internal +--- + +Ops/support view of the **Event Journal** screen (frontend component `JournalEvents.svelte`). It is a read-only, per-ATM event viewer served by **hiveops-incident**. There are no admin write actions in this view — the only mutation path is agent ingestion through hiveops-agent-proxy. + +## Who can see it + +- Read endpoints under `/api/journal-events/**` fall through to the `/api/**` rule in incident's `SecurityConfig` → `hasAnyRole('USER','CUSTOMER','ADMIN','MSP_ADMIN','QDS_ADMIN','BCOS_ADMIN')`. +- **Not admin-gated.** There is no `@PreAuthorize` on `JournalEventController` — any authenticated browser (`USER`) or JWT user can read journal events. This is intentional (it is a diagnostic view), unlike `/api/incident-management/**` which is `MSP_ADMIN`/`BCOS_ADMIN` only. +- `USER` = HiveIQ Browser session; `CUSTOMER`/`MSP_ADMIN` = customer JWT. + +## What the screen actually calls + +- Selecting an ATM → `GET /api/journal-events/atm/{atmId}/paginated?page=&size=&sort=eventTime,desc&hoursBack=24&eventTypes=...` +- `{atmId}` is the **numeric DB id** of the `atms` row, not the human `AT000123` agent id. +- Category checkboxes expand client-side into concrete `eventTypes` (e.g. `cardReader` → `CARD_READER_DETECTED`,`CARD_READER_FAIL`,`CARD_READER_RECOVERED`). The backend filters on the exact `eventType` strings passed. +- Default window is `hoursBack=24`; options are 1/6/24/72/168. + +## First things to check when it misbehaves + +1. **"No events" for an ATM you know is live** — widen Period to 168h and clear category filters first. Category filters are an allowlist of exact event-type strings; a type the agent sends that isn't in one of the 8 category groups (e.g. `ATM_IN_SERVICE`, `CASSETTE_INVENTORY`) will be hidden whenever any category is ticked. +2. **Events stop arriving entirely** — the problem is almost always upstream of this view. Ingestion is `agent → hiveops-agent-proxy (POST /api/journal-events) → incident POST /api/internal/journal-events`. Check agent-proxy logs and confirm `INTERNAL_SERVICE_SECRET` matches between agent-proxy and incident (mismatch → 401/403 on the internal call, agent-proxy returns the error upstream). +3. **Unknown event type "vanishes"** — if `eventType` isn't in incident's `event_type` lookup, the event is **not** stored in `journal_events`; it is recorded as an *event exception* and the ingest returns `202 Accepted`. So a missing event may be a not-yet-registered type, not a lost message. Check the event-exceptions/settings area. +4. **Empty screen with a red error banner** — a 401/403 from the read call means the browser session/JWT lacks one of the six roles above; a 5xx means incident-backend is down. Confirm `GET /actuator/health` on incident (port 8080). +5. **Wrong ATM's data / cross-institution** — see the scoping caveat below. + +## Common failure modes + fixes + +| Symptom | Likely cause | Fix | +|---|---|---| +| No new events fleet-wide | agent-proxy → incident internal call failing | Check `X-Internal-Secret` / `INTERNAL_SERVICE_SECRET` parity; check agent-proxy logs | +| Sporadic ingest 500s during morning reboot burst | Pessimistic lock contention (incident#212) between journal ingest and heartbeat | Already mitigated: `createEvent` is `@Retryable` (3 attempts, backoff). Persistent failures = investigate DB locks | +| Event type never appears in the table | Type not in `event_type` lookup → stored as exception, 202 | Register the event type, or confirm agent is sending the expected string | +| Times look off | `eventTime` parsed as `LocalDateTime` (no zone); unparseable → `now()` | Confirm agent sends ISO local date-time | +| "Details"/hardware fields empty on expand | Agent didn't populate cardReader*/cassette* fields for that type | Expected for non-hardware events | + +## Notes + +- Table/DB: `journal_events` in Postgres DB **`hiveiq_incident`** (incident owns it). No separate journal-events database. +- Ingestion also drives ATM service state: `ATM_IN_SERVICE`/`ATM_OUT_OF_SERVICE`/`SUPERVISOR_ENTER`/`SUPERVISOR_EXIT` flip device state as a side effect, and every known event runs auto-incident creation/recovery. So journal events are not purely cosmetic — a stuck ingest also freezes auto-incidents. diff --git a/backend/src/main/resources/content/incident/processing-rules/architect.md b/backend/src/main/resources/content/incident/processing-rules/architect.md new file mode 100644 index 0000000..464bcd8 --- /dev/null +++ b/backend/src/main/resources/content/incident/processing-rules/architect.md @@ -0,0 +1,102 @@ +--- +module: incident.processing-rules +title: Processing Rules +tab: Architect +order: 30 +audience: internal +--- + +> **Architecture view.** How Processing Rules are built and how events flow through them. All rule storage + evaluation is owned by **hiveops-incident**; Adoons suggestions are delegated to **hiveops-ai** over Kafka + a sync proxy. Grounded in code 2026-07-01. See [platform.service-ownership]. + +## Services involved + +| Service | Role in this feature | +|---------|----------------------| +| **hiveops-incident** | Owns rule tables, CRUD controllers, and the evaluation engine (`IncidentAutoCreationService`). Serves the frontend directly. | +| **hiveops-devices** | Produces `hiveops.device.events`; incident's `DeviceEventConsumer` drives auto-creation, which is where opening rules are evaluated. | +| **hiveops-ai (Adoons)** | Generates rule suggestions. Reached two ways: sync proxy (`AdoonsController`) and async via Kafka. | +| **hiveops-agent-proxy** | Agents post journal events here; proxy forwards to incident `/api/internal/journal-events`, feeding `JournalEventService` (which records Event Exceptions + triggers auto-creation/recovery). | + +## Data model (all in `hiveiq_incident`, Flyway-managed) + +| Table | Entity | Purpose | +|-------|--------|---------| +| `ticket_processing_rules` | `TicketProcessingRule` | Opening rules: `event_type`, `action` (CREATE_INCIDENT/SUPPRESS/OPEN_AND_CLOSE), `override_severity`, `override_incident_type`, `text_pattern`, `institution`, `enabled`, `draft`, `version` | +| `ticket_processing_rule_audit` | `TicketProcessingRuleAudit` | Full change log per rule (History view) | +| `incident_closing_rules` | `IncidentClosingRule` | Closing rules: `trigger_event_type`, `close_incident_types` (CSV), `text_pattern`, `target_status` (RESOLVED/CLOSED), `institution`, `enabled`, `version` | +| `event_exceptions` | `EventException` | Unrecognised event types, aggregated per ATM with occurrence count + first/last seen + sample | +| `rule_suggestions` | `RuleSuggestion` | Async Adoons suggestion rows: `request_id` (UUID), `kind` (PROCESSING/CLOSING), `status` (PENDING/READY/FAILED), suggested_* fields, `institution`, `requested_by` | + +See [platform.data-architecture]. + +## Data flow — opening rules (the core path) + +``` +device event ──► hiveops.device.events ──► DeviceEventConsumer + (also: agent journal event ──► agent-proxy ──► /api/internal/journal-events ──► JournalEventService) + │ + ▼ + IncidentAutoCreationService + │ + findApplicableRule(eventType, institution, details) + │ + ┌──────────────┬───────────────────────────────┬───────────────────────┐ + SUPPRESS OPEN_AND_CLOSE CREATE_INCIDENT no rule → default + (skip create) (create + immediate close, (create incident, (built-in behaviour) + transition OPEN→CLOSED) apply override sev/type) +``` + +Rule selection precedence inside `findApplicableRule`: +1. Filter enabled rules for the event type (`findEnabledByEventType`). +2. Drop rules whose `text_pattern` (if set) is not `contains`-ed by the lowercased event details. +3. Prefer **institution-scoped** rules (rule institution key → institution name, compared to ATM institution) over **Global** (`institution` null/blank). +4. Within a scope, **text-pattern-specific** rules win over catch-all (`Comparator` on pattern presence). +5. No match → built-in default incident behaviour. + +## Data flow — closing rules + +`IncidentAutoCreationService.processRecovery(event)` runs on recovery events: applies the built-in `RECOVERY_MAP` first, then `closingRuleService.findEnabledByTriggerEventType(...)`, resolving/closing incidents whose type is in the rule's `close_incident_types` CSV, to the rule's `target_status`. + +## Data flow — Event Exceptions + +When `JournalEventService` ingests an event whose type isn't recognised, it upserts an `event_exceptions` row (increment `occurrence_count`, update `last_seen`) rather than creating an incident. The screen reads these via `EventExceptionService.findAll()` and admins Dismiss them (single row or by type). + +## Adoons rule suggestions — two paths + +**Sync proxy** (`AdoonsController`, `/api/adoons/**`): validates user JWT, then calls hiveops-ai with `X-Internal-Secret`. +- `GET /status` → hiveops-ai `/actuator/health` +- `POST /suggest-rule` → hiveops-ai `POST /api/adoons/suggest-rule` +- `POST /suggest-closing-rule` → hiveops-ai `POST /api/adoons/suggest-closing-rule` + +**Async / Kafka** (`AdoonsSuggestionController` + `RuleSuggestionAsyncService`, `/api/adoons/rule-suggestions`): + +``` +POST /api/adoons/rule-suggestions (kind=PROCESSING|CLOSING) + │ persist RuleSuggestion(status=PENDING), return 202 + requestId + ▼ +RuleSuggestionProducer ──► adoons.rule-suggestion.requested ──► hiveops-ai + │ (generates) + RuleSuggestionResultConsumer ◄── adoons.rule-suggestion.ready ◄─┘ + (group hiveops-incident-adoons) → update row status READY/FAILED + suggested_* + ▼ +Frontend polls GET /api/adoons/rule-suggestions[/{requestId}] (institution-scoped) +``` + +See [platform.kafka]. + +## Kafka topics (this feature) + +| Topic | Direction | Class | Group | +|-------|-----------|-------|-------| +| `hiveops.device.events` | consume | `DeviceEventConsumer` | `hiveops-incident-device-sync` | +| `adoons.rule-suggestion.requested` | produce | `RuleSuggestionProducer` | — | +| `adoons.rule-suggestion.ready` | consume | `RuleSuggestionResultConsumer` | `hiveops-incident-adoons` | + +## Key API endpoints + +- Opening rules: `GET/POST /api/ticket-processing-rules`, `/drafts`, `/{id}` (PUT), `/{id}/enabled` (PATCH), `/{id}/approve` (PATCH), `/{id}/audit`, `/audit`, DELETE `/{id}` +- Closing rules: `GET/POST /api/incident-closing-rules`, `/{id}` (PUT), `/{id}/enabled` (PATCH), DELETE `/{id}` +- Event exceptions: `GET /api/event-exceptions`, DELETE `/{id}`, DELETE `/by-type/{eventTypeRaw}` +- Adoons: `/api/adoons/status|suggest-rule|suggest-closing-rule|rule-suggestions` + +Related: [platform.data-architecture] · [platform.kafka] · [platform.service-ownership] · [technical.incident] diff --git a/backend/src/main/resources/content/incident/processing-rules/claude.md b/backend/src/main/resources/content/incident/processing-rules/claude.md new file mode 100644 index 0000000..0271607 --- /dev/null +++ b/backend/src/main/resources/content/incident/processing-rules/claude.md @@ -0,0 +1,86 @@ +--- +module: incident.processing-rules +title: Processing Rules +tab: Claude +order: 40 +audience: internal +--- + +> Terse agent reference. Every endpoint/table/topic/role below is confirmed in hiveops-incident source (2026-07-01). Controller paths are as mapped in code; externally reached via NGINX `/api/incident/` (incident, port 8080). + +## Ownership +- **hiveops-incident owns ALL rule data + evaluation.** Rules are NOT in journal/devices/mgmt. +- Rule evaluation engine: `IncidentAutoCreationService` (`findApplicableRule`, `processRecovery`). +- Adoons suggestions delegated to **hiveops-ai**; incident only proxies/persists. + +## Endpoints (METHOD path) +Opening rules — controller `@RequestMapping("/api/ticket-processing-rules")`: +- `GET /api/ticket-processing-rules` — all +- `GET /api/ticket-processing-rules/drafts` +- `GET /api/ticket-processing-rules/audit?limit=` — recent (cap 200) +- `GET /api/ticket-processing-rules/{id}/audit` +- `POST /api/ticket-processing-rules` — admin +- `POST /api/ticket-processing-rules/drafts` — admin +- `PUT /api/ticket-processing-rules/{id}` — admin +- `PATCH /api/ticket-processing-rules/{id}/enabled` body `{enabled:bool}` — admin +- `PATCH /api/ticket-processing-rules/{id}/approve` — admin +- `DELETE /api/ticket-processing-rules/{id}` — admin + +Closing rules — `@RequestMapping("/api/incident-closing-rules")`: +- `GET /api/incident-closing-rules` +- `POST /api/incident-closing-rules` — admin +- `PUT /api/incident-closing-rules/{id}` — admin +- `PATCH /api/incident-closing-rules/{id}/enabled` — admin +- `DELETE /api/incident-closing-rules/{id}` — admin + +Event exceptions — `@RequestMapping("/api/event-exceptions")`: +- `GET /api/event-exceptions` +- `DELETE /api/event-exceptions/{id}` — admin +- `DELETE /api/event-exceptions/by-type/{eventTypeRaw}` — admin (URL-encode the type) + +Adoons — `@RequestMapping("/api/adoons")` + `/api/adoons/rule-suggestions`: +- `GET /api/adoons/status` → proxies ai `/actuator/health`, returns `{available:bool}` +- `POST /api/adoons/suggest-rule` → ai `POST /api/adoons/suggest-rule` +- `POST /api/adoons/suggest-closing-rule` → ai `POST /api/adoons/suggest-closing-rule` +- `POST /api/adoons/rule-suggestions` body `{kind:PROCESSING|CLOSING, eventType?, logSample?, description?}` → 202 `{requestId}` +- `GET /api/adoons/rule-suggestions?kind=` — institution-scoped list +- `GET /api/adoons/rule-suggestions/{requestId}` — 403 if outside caller institution scope + +## Roles +- All mutations: `@PreAuthorize("hasAnyRole('MSP_ADMIN','BCOS_ADMIN')")`. +- All GET rule/draft/audit/exception reads: authenticated, no role gate. +- `/api/adoons/rule-suggestions`: authenticated, scoped via `InstitutionContext.resolveScope()` (no admin gate). + +## Tables (DB `hiveiq_incident`) +- `ticket_processing_rules` +- `ticket_processing_rule_audit` +- `incident_closing_rules` +- `event_exceptions` +- `rule_suggestions` + +## Kafka topics +- consume `hiveops.device.events` (group `hiveops-incident-device-sync`) → `DeviceEventConsumer` → auto-creation → opening-rule eval +- produce `adoons.rule-suggestion.requested` (`RuleSuggestionProducer`) +- consume `adoons.rule-suggestion.ready` (group `hiveops-incident-adoons`, `RuleSuggestionResultConsumer`) + +## Enums / values +- `TicketProcessingAction`: `CREATE_INCIDENT`, `SUPPRESS`, `OPEN_AND_CLOSE` +- Closing `targetStatus`: `RESOLVED` (default) | `CLOSED` +- `rule_suggestions.status`: `PENDING` | `READY` | `FAILED`; `kind`: `PROCESSING` | `CLOSING` + +## Gotchas +- Opening-rule match precedence: institution-scoped > global; within scope, text-pattern-specific > catch-all. +- `text_pattern` match = case-insensitive `contains` on event details; blank/null = matches any. +- Institution match resolves rule's stored `institution` as a **KEY** → institution NAME, then compares to ATM's institution name; unresolved key falls back to raw value. Key-vs-name mismatch = rule silently falls through to global. +- Recovery/close path applies built-in `RECOVERY_MAP` BEFORE configurable closing rules. +- Unrecognised event types are recorded to `event_exceptions` by `JournalEventService`, not incidents. +- `event_type` strings are SCREAMING_SNAKE_CASE; exact match required. +- Rule eval decisions are logged (`Rule eval:` lines) — grep Loki for triage. + +## Do NOT +- Do NOT add rule tables/endpoints to journal, devices, or mgmt — incident owns this domain. +- Do NOT call hiveops-ai directly for suggestions from the frontend; go through `/api/adoons/*` (JWT validated, then `X-Internal-Secret` injected). +- Do NOT assume rule `institution` is a display name — it's stored as the institution KEY. +- Do NOT expect a mutation without MSP_ADMIN/BCOS_ADMIN to succeed (403). +- Do NOT confuse Adoons with a generic "AI" in user-facing copy — it is branded **Adoons**. +- Do NOT rely on suggestion delivery if hiveops-ai is down; `/status` returning `available:false` only blocks suggestions, not rule CRUD. diff --git a/backend/src/main/resources/content/incident/processing-rules/internal.md b/backend/src/main/resources/content/incident/processing-rules/internal.md new file mode 100644 index 0000000..0684735 --- /dev/null +++ b/backend/src/main/resources/content/incident/processing-rules/internal.md @@ -0,0 +1,70 @@ +--- +module: incident.processing-rules +title: Processing Rules +tab: Internal +order: 20 +audience: internal +--- + +> **Internal ops/support view.** Ownership, admin actions, and first-response triage for the Processing Rules screen. Served entirely by **hiveops-incident** (port 8080, NGINX `/api/incident/`). Grounded in code 2026-07-01. + +## What this screen controls + +Three independent rule sets, all owned by hiveops-incident and applied inside `IncidentAutoCreationService`: + +- **Opening Rules** (`ticket_processing_rules`) — decide whether a device event becomes an incident: `CREATE_INCIDENT`, `SUPPRESS`, or `OPEN_AND_CLOSE`. +- **Closing Rules** (`incident_closing_rules`) — auto-resolve/close open incidents when a recovery event arrives (`targetStatus` = `RESOLVED` or `CLOSED`). +- **Event Exceptions** (`event_exceptions`) — events received with an unrecognised event type, auto-recorded by `JournalEventService` for admins to review. + +Adoons (hiveops-ai) rule suggestions are an optional assist — see the Architect tab for the async pipeline. + +## Roles required + +Read operations (list rules, drafts, audit, exceptions, poll suggestions) are open to any authenticated user. **Every mutation is admin-gated** with `@PreAuthorize("hasAnyRole('MSP_ADMIN','BCOS_ADMIN')")`: + +| Action | Endpoint | Role gate | +|--------|----------|-----------| +| Create rule | `POST /api/ticket-processing-rules` | MSP_ADMIN / BCOS_ADMIN | +| Create draft | `POST /api/ticket-processing-rules/drafts` | MSP_ADMIN / BCOS_ADMIN | +| Update rule | `PUT /api/ticket-processing-rules/{id}` | MSP_ADMIN / BCOS_ADMIN | +| Enable/disable | `PATCH /api/ticket-processing-rules/{id}/enabled` | MSP_ADMIN / BCOS_ADMIN | +| Approve draft | `PATCH /api/ticket-processing-rules/{id}/approve` | MSP_ADMIN / BCOS_ADMIN | +| Delete rule | `DELETE /api/ticket-processing-rules/{id}` | MSP_ADMIN / BCOS_ADMIN | +| Closing rule create/update/enable/delete | `/api/incident-closing-rules...` | MSP_ADMIN / BCOS_ADMIN | +| Dismiss exception | `DELETE /api/event-exceptions/{id}` and `.../by-type/{eventTypeRaw}` | MSP_ADMIN / BCOS_ADMIN | + +Rule suggestion enqueue/poll (`/api/adoons/rule-suggestions`) is **not** admin-gated but is institution-scoped via `InstitutionContext.resolveScope()`. + +## First things to check when it misbehaves + +**"My rule isn't firing."** +1. Confirm the rule is **Enabled** — `findEnabledByEventType` only returns enabled rules. +2. Confirm the **event type matches exactly** — rules key on the raw `event_type` string (SCREAMING_SNAKE_CASE). If the event is arriving as an unrecognised type, it will show under **Event Exceptions**, not match any rule. +3. Check the **Text Pattern** — if set, the rule only fires when the event details *contain* that text (case-insensitive `contains`). A too-specific pattern silently blocks the rule. +4. Check **institution scope**. Institution-scoped rules win over Global, and text-pattern rules win over catch-all within the same scope. See the matching gotcha below. + +**"Wrong action taken (incident suppressed that shouldn't be)."** +- `SUPPRESS` matched — look for a Global suppress rule on that event type. Rule eval decisions are logged: grep incident logs (Loki, `localhost:3100` on services VM) for `Rule eval:` and `SUPPRESS rule` lines; every match logs rule name, action, institution, and pattern. + +**"Closing rule didn't resolve the incident."** +- Closing rules run in `processRecovery`, which **first** applies the built-in `RECOVERY_MAP`, then configurable closing rules. Confirm the trigger event type and that the incident's type is in the rule's `closeIncidentTypes` CSV. + +**"Adoons suggestion stuck / says unavailable."** +- `GET /api/adoons/status` proxies to hiveops-ai `/actuator/health`; if `available:false`, hiveops-ai is down/unreachable — the rules screen still works, only suggestions are affected. +- Async suggestions go through Kafka. A suggestion stuck at `PENDING` means hiveops-ai never published to `adoons.rule-suggestion.ready`. A stale-sweep in `RuleSuggestionAsyncService` marks old PENDING rows; check hiveops-ai and the `adoons.rule-suggestion.requested` topic. + +## Common failure modes + fixes + +| Symptom | Likely cause | Fix | +|---------|-------------|-----| +| Rule mutation returns 403 | Caller is not MSP_ADMIN/BCOS_ADMIN | Use an admin JWT | +| Rule saved but never fires | Disabled, wrong event type, or over-specific text pattern | Re-check enabled flag, exact event type, and pattern | +| Institution rule ignored | ATM institution name doesn't equal the rule's institution *name* (rule stores institution **key**, resolved to name at match time — see gotcha) | Verify the institution key resolves to the ATM's institution name | +| Unknown event floods Exceptions | Agent emitting an event type with no `event_type` lookup / no rule | Create a rule or add the event type; then Dismiss the exception | +| Suggestion never returns | hiveops-ai down or Kafka topic not consumed | Check hiveops-ai health + `adoons.rule-suggestion.ready` | + +## Matching gotcha (institution scope) + +In `findApplicableRule`, a rule's stored `institution` value is treated as an **institution key**, resolved via `institutionKeyService.findByKey(...).getInstitutionName()` and compared to the ATM's `institution` (a **name**). If the key doesn't resolve, it falls back to comparing the raw stored value. A mismatch between what's stored (key vs name) and the ATM's institution name will cause an institution-scoped rule to silently fall through to Global. Verify with the `Rule eval:` log line, which prints the ATM institution it tried to match. + +Related: [technical.incident] · [platform.service-ownership] diff --git a/backend/src/main/resources/content/msp/contact-groups/architect.md b/backend/src/main/resources/content/msp/contact-groups/architect.md new file mode 100644 index 0000000..d8cfc53 --- /dev/null +++ b/backend/src/main/resources/content/msp/contact-groups/architect.md @@ -0,0 +1,75 @@ +--- +module: msp.contact-groups +title: Contact Groups +tab: Architect +order: 30 +audience: internal +--- + +How the **Contact Groups** feature is built and wired. See also [platform.data-architecture], [platform.kafka], [platform.service-ownership]. + +## Services involved + +| Concern | Service | Notes | +|---------|---------|-------| +| UI | `hiveops-msp` (Svelte SPA, port 5186) | `ContactGroupsView.svelte`; no backend of its own | +| Group data + notify | **`hiveops-messaging`** (port 8086, DB `hiveiq_messaging`) | Owns all group tables and the notify action | +| User directory | `hiveops-auth` | Member picker calls `GET /auth/api/msp/users?size=500`; members stored as auth user IDs | +| Delivery surface | `hiveops-messaging` `messages` inbox | Electron app polls unread count | + +Ownership is clean: **messaging owns groups + subscriptions + delivery**; auth owns the user directory. Members are foreign user IDs — messaging does not store user names/emails (resolved client-side). + +## Request path + +Browser → nginx (`/messaging/*`, prepends `/api`) → `hiveops-messaging` `ContactGroupController` at base `/api/messaging/groups`. JWT is validated by `JwtAuthenticationFilter` (shared `hiveops-security-common` `JwtTokenValidator`), producing a `MessagingPrincipal(userId, email, role, institutionKey)` from the `sub`/`email`/`role`/`institutionKey` claims. Spring CORS is disabled — nginx is the CORS authority. + +## Data model (PostgreSQL `hiveiq_messaging`) + +- **`contact_groups`** (Flyway `V4`) — `id`, `uuid` (business key, exposed to API), `name` UNIQUE, `description`, `created_by_user_id`, `created_at`, `updated_at`. +- **`contact_group_members`** (V4) — PK `(group_id, user_id)`, `added_at`; `group_id` FK `ON DELETE CASCADE`. Index `idx_cgm_group_id`. +- **`group_subscriptions`** (Flyway `V5`) — `id`, `group_id` FK `ON DELETE CASCADE`, `event_type` VARCHAR(100), UNIQUE `(group_id, event_type)`. Index `idx_group_sub_event`. +- **`messages`** — reused as the delivery store; group notifications land here as `type = ACTION`. + +Deleting a group cascades to members and subscriptions. The API always addresses groups by `uuid`, never the numeric `id`. + +## Read flow + +- `getAllGroups()` — `findAll()` over `contact_groups`, mapped to `ContactGroupResponse` (adds `memberCount` via `countByGroupId` and `subscribedEventTypes` via `findEventTypesByGroupId`). **No institution filter.** +- `getGroupDetail(uuid)` — resolves by uuid, then loads `memberUserIds` + `eventTypes` into `ContactGroupDetailResponse`. + +## Write flow + +All mutations go through `ContactGroupService`, each calling `requireAdmin(principal)` first. Subscriptions are validated against `NotificationEventType.ALL` (`SYSTEM_UPDATE`, `INCIDENT_SUMMARY`, `REPORT_SYSTEM_CHANGE`, `SYSTEM_ANNOUNCEMENT`) and stored uppercased. + +## Notify flow (the fan-out) + +`POST /api/messaging/groups/notify` → controller re-checks `principal.isAdmin()` → `GroupNotificationService.notifyByEventType()`: + +1. `groupSubscriptionRepository.findByEventType(eventType)` → subscribed groups. +2. For each group, `contactGroupMemberRepository.findUserIdsByGroupId()` → member IDs. +3. For each member, **directly persist** a `Message` (`type = ACTION`, `senderEmail = "system"`, `recipientUserId = member`, subject/body/metadata from the request). One save per recipient; a failure for one member is logged and does not abort the rest. +4. Returns `{ eventType, dispatched }` where `dispatched` is the total messages written. + +> **Important divergence from the platform executor→Kafka→record-store pattern:** this notify path does **not** publish to Kafka. It writes `messages` rows synchronously via `messageRepository.save()`, bypassing `MessageProducer`. Contrast `MessageService.send()`, which persists then publishes to `hiveops.messages`. Groups therefore participate in the messaging domain's data flow (they are the fan-out target) but the operator-triggered notify is DB-direct. + +## Where Kafka does touch groups + +Groups are also a **fan-out target for inbound Kafka events**. `MessageService.persistFromEvent()` (fed by `MessageConsumer` on topic `hiveops.messages`) checks `event.getRecipientGroupId()`; if set, it resolves the group by uuid and writes one `messages` row per member — the same fan-out shape as notify, but driven by an external producer rather than the MSP admin action. Topics defined in `KafkaConfig`: `hiveops.messages`, `hiveops.reports.completed`. + +## Key endpoints (all under `/api/messaging/groups`) + +| Method | Path | Purpose | +|--------|------|---------| +| GET | `/` | List groups | +| POST | `/` | Create (admin) | +| GET | `/{groupId}` | Detail (members + subscriptions) | +| PUT | `/{groupId}` | Rename / edit (admin) | +| DELETE | `/{groupId}` | Delete + cascade (admin) | +| POST | `/{groupId}/members` | Add members (admin) | +| DELETE | `/{groupId}/members/{userId}` | Remove member (admin) | +| GET | `/{groupId}/subscriptions` | List event types | +| POST | `/{groupId}/subscriptions` | Subscribe (admin) | +| DELETE | `/{groupId}/subscriptions/{eventType}` | Unsubscribe (admin) | +| POST | `/notify` | Fan out ACTION messages (admin) | + +`{groupId}` is the group **uuid**. diff --git a/backend/src/main/resources/content/msp/contact-groups/claude.md b/backend/src/main/resources/content/msp/contact-groups/claude.md new file mode 100644 index 0000000..078d371 --- /dev/null +++ b/backend/src/main/resources/content/msp/contact-groups/claude.md @@ -0,0 +1,77 @@ +--- +module: msp.contact-groups +title: Contact Groups +tab: Claude +order: 40 +audience: internal +--- + +Act-without-guessing reference. All facts confirmed in `hiveops-messaging` + `hiveops-msp` source. + +## Ownership + +- **Data + logic owner:** `hiveops-messaging` (port 8086, DB `hiveiq_messaging`). +- **UI:** `hiveops-msp` SPA `ContactGroupsView.svelte` (`msp.bcos.cloud`, 5186). No MSP backend. +- **User directory (member picker):** `hiveops-auth` → `GET /auth/api/msp/users?size=500`. + +## Endpoints — base `/api/messaging/groups` (`ContactGroupController`) + +| Method | Path | Admin? | Body / notes | +|--------|------|--------|--------------| +| GET | `/api/messaging/groups` | no | list `ContactGroupResponse[]` | +| POST | `/api/messaging/groups` | yes | `{name, description?}` | +| GET | `/api/messaging/groups/{uuid}` | no | detail (`memberUserIds`, `subscribedEventTypes`) | +| PUT | `/api/messaging/groups/{uuid}` | yes | `{name?, description?}` | +| DELETE | `/api/messaging/groups/{uuid}` | yes | cascades members+subs | +| POST | `/api/messaging/groups/{uuid}/members` | yes | `{userIds: number[]}` | +| DELETE | `/api/messaging/groups/{uuid}/members/{userId}` | yes | | +| GET | `/api/messaging/groups/{uuid}/subscriptions` | no | `string[]` | +| POST | `/api/messaging/groups/{uuid}/subscriptions` | yes | `{eventType}` → returns `string[]` | +| DELETE | `/api/messaging/groups/{uuid}/subscriptions/{eventType}` | yes | | +| POST | `/api/messaging/groups/notify` | yes | `{eventType, subject, body, metadata?}` → `{eventType, dispatched}` | + +- Path param is the group **`uuid`**, never numeric `id`. +- Frontend `messagingApi` base omits `/api` (nginx prepends it) — actual controller path includes `/api`. + +## Roles + +- Admin gate = `MessagingPrincipal.isAdmin()` → role ∈ `ADMIN`, `MSP_ADMIN`, `BCOS_ADMIN` (case-insensitive), from JWT `role` claim. +- No `@PreAuthorize`; enforced in `ContactGroupService.requireAdmin()` + `notify()` controller check. +- Reads (list/get/subscriptions) require only a valid JWT. + +## Event types (fixed — `NotificationEventType.ALL`) + +`SYSTEM_UPDATE` · `INCIDENT_SUMMARY` · `REPORT_SYSTEM_CHANGE` · `SYSTEM_ANNOUNCEMENT` + +## Tables (`hiveiq_messaging`, Flyway V4/V5) + +| Table | Key | Notes | +|-------|-----|-------| +| `contact_groups` | `uuid` UNIQUE, `name` UNIQUE | + `created_by_user_id`, `created_at`, `updated_at` | +| `contact_group_members` | PK `(group_id, user_id)` | FK `ON DELETE CASCADE` | +| `group_subscriptions` | UNIQUE `(group_id, event_type)` | FK `ON DELETE CASCADE`; `event_type` stored UPPERCASE | +| `messages` | — | delivery target: `type=ACTION`, `sender_email='system'` | + +## Kafka + +- `notify` does **NOT** publish to Kafka — writes `messages` rows directly. +- Group fan-out from inbound Kafka: `MessageService.persistFromEvent()` on topic **`hiveops.messages`** when `recipientGroupId` (a group uuid) is set. +- Topics: `hiveops.messages`, `hiveops.reports.completed`. + +## Gotchas + +- `dispatched: 0` is normal when no group is subscribed to the type or subscribed groups are empty — not an error. +- `notify` does not validate `eventType` against `NotificationEventType.ALL` and `findByEventType` does **not** uppercase the input; a lowercase/typo `eventType` silently matches nothing → `dispatched: 0`. Send exact uppercase constants. +- `subscriptions` POST/DELETE uppercase the input; store is uppercase. +- Groups are **global** — no `institution_key` column; `getAllGroups()` returns all groups to any authenticated user. +- Member IDs are auth user IDs; names/emails resolved client-side from `/auth/api/msp/users`. +- CORS: messaging disables Spring CORS — nginx is the authority (curl-OK-but-browser-blocked = nginx origin). + +## Do NOT + +- Do **not** address groups by numeric `id` — use `uuid`. +- Do **not** expect a Kafka event from `notify` — inspect the `messages` table. +- Do **not** assume institution scoping on groups — there is none. +- Do **not** add group endpoints to `hiveops-msp` or any other service — messaging owns this domain. +- Do **not** POST arbitrary `eventType` to `/notify` and read a 400 — invalid types return 200 with `dispatched: 0`. +- Do **not** send lowercase event types. diff --git a/backend/src/main/resources/content/msp/contact-groups/internal.md b/backend/src/main/resources/content/msp/contact-groups/internal.md new file mode 100644 index 0000000..797bb20 --- /dev/null +++ b/backend/src/main/resources/content/msp/contact-groups/internal.md @@ -0,0 +1,57 @@ +--- +module: msp.contact-groups +title: Contact Groups +tab: Internal +order: 20 +audience: internal +--- + +Ops/support view for the MSP **Contact Groups** screen. This is a distribution-list feature: an admin builds groups of HiveIQ users, subscribes each group to notification event types, and can fan a system notification out to every subscribed group as an in-app ACTION message. + +## Where it actually lives + +- **Frontend:** `hiveops-msp` SPA — `ContactGroupsView.svelte` (`msp.bcos.cloud`, port 5186). No MSP backend of its own. +- **Backend / data owner:** **`hiveops-messaging`** (port 8086, DB `hiveiq_messaging`). All group data and the notify action are served here. +- **User list** for the member picker comes from **`hiveops-auth`** — `GET /auth/api/msp/users?size=500`. Members are stored as auth user IDs (`BIGINT`), not emails. + +## Roles required + +- **Read** (list groups, open a group, view subscriptions): any authenticated JWT. +- **All mutations** (create, update, delete, add/remove member, add/remove subscription, **send notification**): admin only. Backend gate is `MessagingPrincipal.isAdmin()`, which accepts role `ADMIN`, `MSP_ADMIN`, or `BCOS_ADMIN` (case-insensitive). Role is read from the JWT `role` claim. +- There is **no `@PreAuthorize`** — the check is in `ContactGroupService.requireAdmin()` and, for notify, directly in `ContactGroupController.notify()`. + +## Admin actions on this screen + +- **+ New Group** — name (required, unique, ≤100 chars) + optional description (≤500 chars). +- **Manage** (row / panel) — edit name/description, toggle subscriptions, add/remove members. +- **Subscriptions** — the four valid types are fixed: `SYSTEM_UPDATE`, `INCIDENT_SUMMARY`, `REPORT_SYSTEM_CHANGE`, `SYSTEM_ANNOUNCEMENT`. +- **Send Notification** — fans an ACTION message out to every member of every group subscribed to the chosen type. Response reports `dispatched` (recipient count). Delivered messages land in each user's normal messaging inbox (the Electron app polls unread count). + +## First things to check when it misbehaves + +1. **Whole screen fails to load / "Failed to load groups"** — `hiveops-messaging` is down or the JWT is bad. `curl http://localhost:8086/actuator/health` on the services VM; check the token has a `role` claim. +2. **CORS / network errors in the browser** — Spring CORS is **disabled** in messaging (`.cors(disable)`); **nginx is the CORS authority**. Confirm `msp.bcos.cloud` is allowed on the `/messaging/` location. This is the usual cause of a working curl but a failing browser call. +3. **"Send Notification" reports `dispatched: 0`** — expected if no group is subscribed to that type, or a subscribed group has zero members. Check the group's Subscriptions and Members before assuming a bug. +4. **403 on any create/edit/delete/notify** — the caller's JWT role is not one of `ADMIN` / `MSP_ADMIN` / `BCOS_ADMIN`. Read succeeds but writes 403 → it's a role problem, not a routing problem. +5. **"A group named 'X' already exists"** — group `name` is UNIQUE at the DB level; create and rename both reject duplicates. +6. **Member shows as "User #123"** instead of a name — the auth user list (`/auth/api/msp/users`) didn't load or the user isn't in the returned 500-row page. Group membership itself is fine; it's a display-only lookup miss. +7. **Recipient didn't see the notification** — it's persisted as an ACTION message in the `messages` table; check the recipient's inbox, not a Kafka topic. The notify path does **not** publish to Kafka. + +## Common failure modes + fixes + +| Symptom | Likely cause | Fix | +|--------|--------------|-----| +| All groups load but writes 403 | Non-admin role in JWT | Re-issue token with `MSP_ADMIN`/`ADMIN`/`BCOS_ADMIN` | +| Browser blocked, curl works | nginx CORS missing `msp.bcos.cloud` | Add origin to messaging location in nginx | +| `dispatched: 0` | No subscribed group, or empty group | Verify subscriptions + membership | +| Notify dispatches nothing for a valid-looking type | Type case/typo mismatch (see gotcha) | Send exact uppercase constant | +| Delete blocked / stale members | — | Members + subscriptions cascade-delete with the group | + +## Data footprint (for support queries) + +- `contact_groups` — one row per group (`uuid`, `name`, `description`, `created_by_user_id`). +- `contact_group_members` — PK `(group_id, user_id)`; `ON DELETE CASCADE`. +- `group_subscriptions` — `(group_id, event_type)` UNIQUE; `ON DELETE CASCADE`. +- Delivered notifications → `messages` rows (`type = ACTION`, `sender_email = 'system'`). + +> **Scope note:** groups are **not** institution-scoped — there is no `institution_key` on `contact_groups`, and `getAllGroups()` returns every group. Any authenticated user can read every group across all institutions. Treat "why can user X see group Y" as expected current behavior, not a bug. diff --git a/backend/src/main/resources/content/msp/institution-keys/architect.md b/backend/src/main/resources/content/msp/institution-keys/architect.md new file mode 100644 index 0000000..7a09c93 --- /dev/null +++ b/backend/src/main/resources/content/msp/institution-keys/architect.md @@ -0,0 +1,66 @@ +--- +module: msp.institution-keys +title: Institution Keys +tab: Architect +order: 30 +audience: internal +--- + +> How it's built. Grounded in `hiveops-devices` + `hiveops-msp` code, 2026-07-01. Cross-links: [platform.data-architecture], [platform.kafka], [platform.service-ownership]. + +## Ownership +Despite living in the **MSP** SPA (`msp.bcos.cloud`), this feature is owned end-to-end by **hiveops-devices**. The MSP frontend has no backend of its own; `hiveops-msp/frontend/src/lib/api.ts` points `api` at `window.__APP_CONFIG__.apiUrl` and hits `/devices/institution-keys`, which NGINX routes to the devices backend at `/api/institution-keys`. This respects [platform.service-ownership] — devices is the single source of truth for all device-domain data. + +Components: +- **Frontend:** `hiveops-msp/frontend/src/components/InstitutionKeys/InstitutionKeysView.svelte` — table + slide-in add/edit panel + delete-confirm modal, 60s auto-refresh. (An equivalent `Settings/InstitutionKeys` view also ships inside the Devices app.) +- **Controller:** `com.hiveops.devices.controller.InstitutionKeyController` (`@RequestMapping("/api/institution-keys")`). +- **Service:** `com.hiveops.devices.service.InstitutionKeyService` (Spring cache-backed). +- **Entity/Repo:** `entity.InstitutionKey` / `repository.InstitutionKeyRepository` (Spring Data JPA, `@Id` = `key`). + +## Data model (hiveiq_devices) +`institution_keys` — created in migration `V2__create_atm_core_tables.sql`: + +| Column | Type | Notes | +|--------|------|-------| +| `key` | VARCHAR(10) | PRIMARY KEY, stored upper-cased/trimmed | +| `institution_name` | VARCHAR(255) | NOT NULL, **UNIQUE** | +| `supported_device_types` | VARCHAR(20) | default `'ATM,TCR'`; CSV of `DeviceType` (`ATM,TCR,SRV`) | + +Related: `institution_agent_properties` (`V3__create_atm_properties.sql`) — PK `institution_key` **REFERENCES institution_keys(key) ON DELETE CASCADE**; holds `hiveops_config` / `ej_config` JSON + `config_pushed_at`. `institution_config_audit` records every agent-properties change (`changed_by`, `previous_config`, `new_config`). + +## Read/write flow +**CRUD (admin):** +`InstitutionKeysView` → `institutionKeyAPI.{getAll|create|update|delete}` → NGINX `/devices/*` → devices `InstitutionKeyController` → `InstitutionKeyService` → `InstitutionKeyRepository` → `institution_keys`. +- `getAll()` delegates to `getAccessible()`: `InstitutionContext.resolveScope()` returns the JWT institution-key list (CUSTOMER = 1, MSP_ADMIN = many) or `null` (admins, unrestricted). Scoped callers get `findByKey` per granted key; unrestricted callers get `repository.findAll()`. +- `create/update/delete` are `@CacheEvict(value="institutionKeys", key="#key")`; `findByKey` is `@Cacheable`. `getAll` is uncached. + +**Registration gate (the key's real job):** +Agent → **agent-proxy** → devices `InternalAtmController.register` (`POST /api/internal/atms/register?deviceType=&institutionKey=`): +1. `institutionKeyService.findByKey(institutionKey)`. +2. If mapped and `supported_device_types` non-blank and does not contain the parsed `DeviceType` → **403 `{linked:false,error}`** (registration rejected). +3. Else `findOrCreateAtm`, and if `atms.institution` is blank, set it to the mapping's `institution_name`. +4. `DeviceEventPublisher.publishCreated(atm)` emits to Kafka. + +**Scope translation (every device query):** +`AtmService` / `AtmModuleService` call `InstitutionKeyService.resolveInstitutionNames(keys)` to turn JWT **keys** into `atms.institution` **names** before filtering. This is the join that makes key→name scoping work platform-wide. + +## Kafka +See [platform.kafka]. Note the important asymmetry: +- **Institution-key CRUD publishes NOTHING.** `InstitutionKeyService` has no producer. Renaming/adding/deleting a key does not emit an event — no `device_summary` mirror update, no reconciler. +- Device **registration/changes** publish `hiveops.device.events` via `DeviceEventPublisher`; that topic is what mirrors device (incl. institution name) into incident/fleet/reports/recon/vault per [platform.data-architecture]. So a key rename only propagates once affected devices re-publish through their own lifecycle. + +## Key API endpoints (external path via `/devices` → internal `/api`) +| Method | Path | Auth | Purpose | +|--------|------|------|---------| +| GET | `/api/institution-keys` (+ `/accessible`) | any JWT, scoped | list mappings | +| POST | `/api/institution-keys` | MSP_ADMIN/BCOS_ADMIN | create | +| PUT | `/api/institution-keys/{key}` | MSP_ADMIN/BCOS_ADMIN | rename / change device types | +| DELETE | `/api/institution-keys/{key}` | MSP_ADMIN/BCOS_ADMIN | delete (cascades agent props) | +| GET/PUT | `/api/institution-keys/{key}/agent-properties` | read: scoped / write: MSP_ADMIN/BCOS_ADMIN | per-institution agent config | +| GET | `/api/institution-keys/{key}/agent-properties/flat` | scoped | flat dot-notation config (CONFIG_UPDATE payload) | +| GET | `/api/institution-keys/config-status` / `/config-audit` | scoped | config push status / audit log | +| POST | `/api/internal/atms/register` | agent-proxy internal | consumer of the device-type policy | + +## Notes for maintainers +- `create()` uses `repository.save()` on an `@Id` entity → posting an **existing key overwrites** it (JPA merge), no 409. +- No Kafka on key changes means downstream name mirrors can drift after a rename; treat renames as a data-migration event, not a config toggle. diff --git a/backend/src/main/resources/content/msp/institution-keys/claude.md b/backend/src/main/resources/content/msp/institution-keys/claude.md new file mode 100644 index 0000000..f440653 --- /dev/null +++ b/backend/src/main/resources/content/msp/institution-keys/claude.md @@ -0,0 +1,70 @@ +--- +module: msp.institution-keys +title: Institution Keys +tab: Claude +order: 40 +audience: internal +--- + +> Terse act-without-guessing reference. Confirmed against `hiveops-devices` + `hiveops-msp` code 2026-07-01. + +## Owner +- Service: **hiveops-devices** (Spring Boot; NOT hiveops-msp — MSP SPA is frontend-only). +- DB: **hiveiq_devices**. Frontend view: `hiveops-msp/frontend/src/components/InstitutionKeys/InstitutionKeysView.svelte`. +- Controller: `InstitutionKeyController` `@RequestMapping("/api/institution-keys")`. Service: `InstitutionKeyService`. + +## Endpoints (external `/devices/*` → NGINX → internal `/api/*`) +| Method + path | Auth | Handler | +|---------------|------|---------| +| GET `/api/institution-keys` | any JWT, scoped | `getAll` → `getAccessible` | +| GET `/api/institution-keys/accessible` | any JWT, scoped | `getAccessible` | +| POST `/api/institution-keys` | `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` | `create` (body: `key`,`institutionName`,`supportedDeviceTypes`) | +| PUT `/api/institution-keys/{key}` | `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` | `update` (body: `institutionName`,`supportedDeviceTypes`) | +| DELETE `/api/institution-keys/{key}` | `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` | `delete` | +| GET/PUT `/api/institution-keys/{key}/agent-properties` | GET scoped / PUT MSP_ADMIN,BCOS_ADMIN | agent config | +| GET `/api/institution-keys/{key}/agent-properties/flat` | scoped | flat CONFIG_UPDATE map | +| GET `/api/institution-keys/config-status` | scoped | pending-push status | +| GET `/api/institution-keys/config-audit?institutionKey=&page=&size=` | scoped | audit page | +| POST `/api/internal/atms/register?deviceType=&institutionKey=&atmName=&country=` | agent-proxy internal | enforces device-type policy | + +## Frontend calls (`institutionKeyAPI` in `hiveops-msp/frontend/src/lib/api.ts`) +- `getAll` → GET `/devices/institution-keys` +- `create` → POST `/devices/institution-keys` +- `update(key)` → PUT `/devices/institution-keys/${key}` +- `delete(key)` → DELETE `/devices/institution-keys/${key}` + +## Tables (hiveiq_devices) +| Table | R/W | Key / notes | +|-------|-----|-------------| +| `institution_keys` | R/W | PK `key` VARCHAR(10); `institution_name` NOT NULL **UNIQUE**; `supported_device_types` VARCHAR(20) default `'ATM,TCR'`. Migration `V2__create_atm_core_tables.sql` | +| `institution_agent_properties` | R/W (via agent-properties) | PK `institution_key` FK **ON DELETE CASCADE** → `institution_keys(key)`. Migration `V3` | +| `institution_config_audit` | W (on agent-props save) | audit of config changes | +| `atms` | R (scope join) | `atms.institution` = resolved `institution_name` | + +## Kafka +| Topic | Producer | Note | +|-------|----------|------| +| `hiveops.device.events` | `DeviceEventPublisher` (on register/device change) | mirrors device data downstream | +| — | none | **institution-key CRUD emits NO Kafka event** | + +## Roles +- Read (`getAll`/`accessible`/`config-*`): no `@PreAuthorize`; scoped by `InstitutionContext.resolveScope()`. `null` (BCOS_ADMIN/ADMIN/QDS_ADMIN/USER) = all; list (CUSTOMER/MSP_ADMIN) = JWT keys. +- Write (create/update/delete/save-agent-props): `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` — plain `ADMIN` is **excluded**. + +## Gotchas +- `DeviceType` enum = `ATM, TCR, SRV`. `parseDeviceType` maps unknown/blank → **`ATM`** (silent fallback). +- Registration rejected (403 `{linked:false,error}`) only when key **is mapped** AND `supported_device_types` non-blank AND lacks the type. Unmapped key → no type check; `atms.institution` left blank → invisible to scoped users. +- POST with an **existing key overwrites** it (JPA `save` on `@Id`, no 409). +- `institution_name` is UNIQUE → duplicate name save fails at DB (5xx, not clean 400). +- Renaming `institutionName` does NOT update `atms.institution` on already-registered devices; scope resolves key→current name, so old-named devices stop matching until re-registered/renamed. +- DELETE cascades → wipes `institution_agent_properties` for that key. +- `key` is stored upper-cased/trimmed; frontend also `.toUpperCase()` on create. `key` immutable after create (PK). +- Single-key lookup cached (`institutionKeys` cache); `getAll` uncached. + +## Do NOT +- Do NOT look for an institution-keys endpoint/table in **hiveops-msp** or **hiveops-mgmt** — it's **hiveops-devices** only. +- Do NOT expect a Kafka event or `device_summary` update after a key rename — none fires. +- Do NOT change `key` on a mapping — recreate; PK is immutable. +- Do NOT grant create/edit to a plain `ADMIN` role and expect it to work — needs MSP_ADMIN/BCOS_ADMIN. +- Do NOT delete a key to "clean up" — it cascades away its agent config and blinds JWT holders of that key. +- Do NOT treat unknown-`deviceType` reg failures as typos — the fallback is ATM; a 403 is a real type mismatch. diff --git a/backend/src/main/resources/content/msp/institution-keys/internal.md b/backend/src/main/resources/content/msp/institution-keys/internal.md new file mode 100644 index 0000000..38ba344 --- /dev/null +++ b/backend/src/main/resources/content/msp/institution-keys/internal.md @@ -0,0 +1,45 @@ +--- +module: msp.institution-keys +title: Institution Keys +tab: Internal +order: 20 +audience: internal +--- + +> Ops/support view. Grounded in `hiveops-devices` backend + `hiveops-msp` frontend, 2026-07-01. + +## What this screen actually does +Maps a short **key** (2–10 upper-case chars, e.g. `AICU`) → the full **institution name** stored on devices (`atms.institution`) → the **device types** allowed to register. The key is embedded in the agent JWT / `X-Institution-Key` and used to scope every device query for CUSTOMER and MSP_ADMIN users. + +Owning service is **hiveops-devices** (NOT hiveops-msp — the MSP SPA has no backend; it calls devices via NGINX `/devices/...`). Table lives in the **hiveiq_devices** DB. + +## Roles required +- **Read** (list/search): any valid JWT. `GET` endpoints have **no `@PreAuthorize`**; results are scoped by `InstitutionContext.resolveScope()`. + - BCOS_ADMIN / ADMIN / QDS_ADMIN / USER → unrestricted (sees all). + - CUSTOMER / MSP_ADMIN → only their JWT-granted keys. +- **Create / Edit / Delete**: `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` only. + - Note: a plain `ADMIN` (or QDS_ADMIN/USER) can *read all* but **cannot mutate** — the write `@PreAuthorize` does not include `ADMIN`. If a "why can't I add a key?" ticket comes in, check the caller's role first. + +## First things to check when it misbehaves +1. **New device won't come online / not visible.** The device's `deviceType` must be in `supported_device_types` for its key, and the key must exist. Registration (agent-proxy → devices `POST /api/internal/atms/register`) returns **403 `{linked:false, error:...}`** when the device type isn't supported. Add the missing type on the Edit panel. +2. **Device registers but disappears from a customer's view.** The key had **no mapping** at registration time → `atms.institution` was left blank, so scoped users can't see it. Create the mapping, then the device links its institution on next registration (only fills when `atms.institution` is still blank — it never overwrites an existing value). +3. **Renamed an institution and old devices vanished.** Editing the **Institution Name** changes only the `institution_keys` row. Already-registered devices keep the OLD name string in `atms.institution`; scope resolution translates key→*current* name, so the old-named devices stop matching. Fix the device institution names in the Devices app (or re-register) after a rename. +4. **Duplicate-name error on save.** `institution_name` has a **UNIQUE** constraint. Two keys cannot share a name; the save fails at the DB layer (surfaces as a 5xx/error toast, not a clean validation message). +5. **Stale list.** The single-key lookup is cached (`institutionKeys` cache); create/update/delete evict it. `getAll` (the list) is uncached, so the table is always fresh — a stale row usually means the frontend's 60s auto-refresh is off, not a cache issue. + +## Common failure modes → fix +| Symptom | Likely cause | Fix | +|---------|--------------|-----| +| 403 on Add/Edit/Delete | caller lacks MSP_ADMIN/BCOS_ADMIN | use a correctly-roled account | +| Agent reg rejected (403 linked:false) | device type not in supported list | add ATM/TCR/SRV on Edit panel | +| Device has no institution | key unmapped at first contact | create key, wait for next heartbeat/register | +| Save fails, no clear message | duplicate `institution_name` (UNIQUE) | pick a distinct name | +| Deleted a key, agent config gone | FK cascade wiped `institution_agent_properties` | recreate key + re-enter Agent Properties | + +## Deleting a key — what it takes with it +`institution_agent_properties.institution_key` has `ON DELETE CASCADE` → deleting a key **also deletes that institution's stored agent/EJ config**. Users whose JWT carries that key see no devices until a new mapping exists. Only delete a key you are sure is unused. + +## Gotcha worth knowing +An unknown/blank `deviceType` on registration is silently parsed as **`ATM`** (`AtmService.parseDeviceType`). A device sending a garbage type still passes if the institution allows ATM — so "rejected" tickets are always a real type mismatch, never a typo falling through. + +See also: [msp.institution-keys] Overview (user-facing steps), Architect tab (data flow), Claude tab (exact paths). diff --git a/backend/src/main/resources/content/msp/institutions/architect.md b/backend/src/main/resources/content/msp/institutions/architect.md new file mode 100644 index 0000000..09cb5d1 --- /dev/null +++ b/backend/src/main/resources/content/msp/institutions/architect.md @@ -0,0 +1,79 @@ +--- +module: msp.institutions +title: Institutions +tab: Architect +order: 30 +audience: internal +--- + +How the MSP **Institutions** feature is built. It is a thin Svelte SPA that composes data from **two independently-owned backends** — no aggregation layer, no message bus in the read/write path. + +## Components +- **Frontend** — `hiveops-msp/frontend`, `components/Institutions/InstitutionsView.svelte`. Svelte 4 + TS, port 5186, domain `msp.bcos.cloud`, image `hiveiq-msp-frontend`. Runtime API base injected as `window.__APP_CONFIG__.apiUrl`; JWT taken from `localStorage.token` and sent as `Authorization: Bearer`. +- **hiveops-mgmt** — owns institution membership, contact details, licensing. Serves the list + contact edit. +- **hiveops-devices** — owns agent tokens. Serves list/generate/revoke/delete-revoked. +- **NGINX gateway** — path prefix routing: `/mgmt/*` → hiveops-mgmt, `/devices/*` → hiveops-devices (`/devices/` rewrites onto the devices `/api/` base). See [platform.service-ownership]. + +## Data flow + +**Institution table (read)** +``` +InstitutionsView.load() + → GET /mgmt/api/v1/msp/institutions + → hiveops-mgmt MspController#getInstitutions @RequestMapping("/api/v1/msp") + ├─ BCOS_ADMIN → CustomerInstitutionAccessRepository.findAll() + └─ MSP_ADMIN → resolve MSP customer by email domain (customers.domain) + → findByCustomerIdOrderByInstitutionNameAsc(customerId) + ├─ LicenseRepository.findActiveByInstitutionKeyIn(keys) (license_key, expires_at) + ├─ CustomerRepository.findByInstitutionKeyIn(keys) (name/email/phone/domain) + └─ appends MSP company group row(s) for admin's own users +``` +Response is a hand-built `List>` (not a DTO): `institutionKey, name, grantedAt, licenseKey, licenseExpiresAt, type, customerId, domain, contactName, contactEmail, contactPhone`. + +**Contact edit (write)** +``` +saveContact() + → PUT /mgmt/api/v1/msp/institutions/{institutionKey}/contact {name,email,phone} + → MspController#updateInstitutionContact + ├─ MSP_ADMIN access guard: existsByCustomerIdAndInstitutionKey(...) → 403 + ├─ email uniqueness: existsByEmail(...) → 409 + ├─ customer lookup: findByInstitutionKey(...) → 404 if absent + └─ customers.save(name,email,phone) +``` + +**Agent tokens (write/read, different service)** +``` +agentTokenAPI.* → /devices/agent-tokens... + → hiveops-devices AgentTokenController @RequestMapping("/api/agent-tokens") + → AgentTokenService → agent_tokens (hiveiq_devices) + generate: raw = "agtkn_" + prefix(8) + "_" + secret(32); store SHA-256(raw) as token_hash, + prefix, institution_key, customer_id, status=ACTIVE. Raw returned once. +``` + +**Readiness** — no backend call. `computeChecks()` runs in the browser over the institution row + the token list (`agentTokenAPI.list`), grouping checks into Customer Profile / Licensing / Agent Connectivity. + +## Kafka +- **None in this feature's path.** Institution list, contact edit, and token CRUD are all synchronous REST + JPA. hiveops-devices publishes `hiveops.device.events` for *device* data generally, but agent-token and institution-contact mutations here do **not** emit events. See [platform.kafka]. + +## DB tables +| Service / DB | Table | Read/Write | Columns used | +|--------------|-------|-----------|--------------| +| mgmt / `hiveiq_mgmt` | `customer_institution_access` | R | institution_key, institution_name, granted_at, customer_id | +| mgmt / `hiveiq_mgmt` | `customers` | R/W | name, email, phone, domain, institution_key, company, customer_type | +| mgmt / `hiveiq_mgmt` | `licenses` | R | license_key, expires_at (active only) | +| devices / `hiveiq_devices` | `agent_tokens` | R/W | token_prefix, token_hash, institution_key, customer_id, status, last_used_at | + +See [platform.data-architecture]. + +## Key endpoints +| Method | External path (via gateway) | Service / controller | Roles | +|--------|------------------------------|----------------------|-------| +| GET | `/mgmt/api/v1/msp/institutions` | mgmt `MspController#getInstitutions` | MSP_ADMIN, BCOS_ADMIN | +| PUT | `/mgmt/api/v1/msp/institutions/{key}/contact` | mgmt `MspController#updateInstitutionContact` | MSP_ADMIN, BCOS_ADMIN | +| GET | `/devices/agent-tokens?institutionKey=` | devices `AgentTokenController#list` | MSP_ADMIN, BCOS_ADMIN | +| POST | `/devices/agent-tokens` | devices `AgentTokenController#generate` | MSP_ADMIN, BCOS_ADMIN | +| POST | `/devices/agent-tokens/{id}/revoke` | devices `AgentTokenController#revoke` | MSP_ADMIN, BCOS_ADMIN | +| DELETE | `/devices/agent-tokens/revoked?institutionKey=` | devices `AgentTokenController#deleteRevoked` | MSP_ADMIN, BCOS_ADMIN | + +## Cross-service note (token master) +Two `agent_tokens` tables exist: this UI writes to hiveops-**devices** (`hiveiq_devices`), while the runtime agent-proxy validates tokens against hiveops-**mgmt** (`AgentTokenValidationService` → `services.mgmt.url` + `/api/internal/agent-tokens/validate`, `hiveiq_mgmt.agent_tokens`). Reconciling these two stores is an open item — treat the mgmt table as the auth master until then. See [platform.service-ownership]. diff --git a/backend/src/main/resources/content/msp/institutions/claude.md b/backend/src/main/resources/content/msp/institutions/claude.md new file mode 100644 index 0000000..4c7cba1 --- /dev/null +++ b/backend/src/main/resources/content/msp/institutions/claude.md @@ -0,0 +1,62 @@ +--- +module: msp.institutions +title: Institutions +tab: Claude +order: 40 +audience: internal +--- + +Act-without-guessing reference. Frontend = `hiveops-msp/frontend` `Institutions/InstitutionsView.svelte` (port 5186, `msp.bcos.cloud`, image `hiveiq-msp-frontend`, no own backend). + +## Ownership +- Institution list + contact → **hiveops-mgmt** (`MspController`, `@RequestMapping("/api/v1/msp")`). +- Agent tokens → **hiveops-devices** (`AgentTokenController`, `@RequestMapping("/api/agent-tokens")`). +- Gateway prefixes: `/mgmt/*`→mgmt, `/devices/*`→devices. + +## Endpoints (METHOD + external path) +| METHOD | Path | Backend | Roles | +|--------|------|---------|-------| +| GET | `/mgmt/api/v1/msp/institutions` | mgmt `MspController#getInstitutions` | MSP_ADMIN, BCOS_ADMIN | +| PUT | `/mgmt/api/v1/msp/institutions/{institutionKey}/contact` | mgmt `#updateInstitutionContact` | MSP_ADMIN, BCOS_ADMIN | +| GET | `/devices/agent-tokens?institutionKey=` | devices `AgentTokenController#list` | MSP_ADMIN, BCOS_ADMIN | +| POST | `/devices/agent-tokens` (body `{institutionKey,name,customerId}`) | devices `#generate` → 201 | MSP_ADMIN, BCOS_ADMIN | +| POST | `/devices/agent-tokens/{id}/revoke` | devices `#revoke` | MSP_ADMIN, BCOS_ADMIN | +| DELETE | `/devices/agent-tokens/revoked?institutionKey=` | devices `#deleteRevoked` | MSP_ADMIN, BCOS_ADMIN | +| POST | `/api/internal/agent-tokens/validate` (agent-proxy→mgmt, internal) | mgmt `AgentTokenInternalController#validate` | internal secret | + +Readiness panel = **client-side only** (`computeChecks()`); no endpoint. + +## Roles +- All UI endpoints: `@PreAuthorize("hasAnyRole('MSP_ADMIN','BCOS_ADMIN')")`. +- MSP_ADMIN scope derived from `customers.domain` = email domain (NOT the JWT institution-key claim). BCOS_ADMIN = all institutions. + +## Tables +| DB | Table | Cols of note | +|----|-------|--------------| +| `hiveiq_mgmt` | `customer_institution_access` | institution_key, institution_name, granted_at, customer_id | +| `hiveiq_mgmt` | `customers` | name, email, phone, domain, institution_key, company, customer_type (BANK/MSP) | +| `hiveiq_mgmt` | `licenses` | license_key, expires_at | +| `hiveiq_mgmt` | `agent_tokens` | validation master (agent-proxy reads here) | +| `hiveiq_devices` | `agent_tokens` | token_prefix, token_hash(SHA-256), institution_key, customer_id, status, last_used_at — **MSP UI writes here** | + +## Topics +- None in this feature's read/write path. (`hiveops.device.events` exists in devices but is not emitted by token/contact mutations.) + +## Token format +- `agtkn__`; only SHA-256 hash + 8-char prefix stored; raw shown once at generation. + +## Gotchas +- **Split token store**: UI generates into `hiveiq_devices.agent_tokens`; agent-proxy validates against `hiveiq_mgmt.agent_tokens` (`services.mgmt.url` + `/api/internal/agent-tokens/validate`). Tokens minted in the MSP UI may not authenticate until stores are reconciled (open migration item). +- List response is a raw `Map`, not a typed DTO — field names come straight from `MspController` (`licenseKey`, `licenseExpiresAt`, `contactName/Email/Phone`, `customerId`, `type`). +- MSP_ADMIN with no matching `customers` row for its email domain → `[]` + HTTP 200 (looks like "no data", not an error). +- Contact PUT: 409 = email already used (`existsByEmail`); 403 = MSP lacks access to key; 404 = no customer for key. +- Auto-refresh 60s, client-side; `localStorage` key `msp_institutions_autoRefresh`. +- Revoked-token grace: agent-proxy caches validation `proxy.agent-token.cache-seconds` (default 300s). + +## Do NOT +- Do NOT assume the JWT institution-key claim scopes MSP_ADMIN — scope is DB-derived from email domain. +- Do NOT expect a readiness API — it is computed in the browser. +- Do NOT expect Kafka events from contact edits or token CRUD. +- Do NOT trust that a UI-generated token is agent-valid without checking the mgmt validation table. +- Do NOT revoke a live agent token before pushing a fleet-wide CONFIG_UPDATE to clear it (kills agent module threads). +- Do NOT look for a `hiveops-msp` backend — there isn't one; it calls mgmt + devices. diff --git a/backend/src/main/resources/content/msp/institutions/internal.md b/backend/src/main/resources/content/msp/institutions/internal.md new file mode 100644 index 0000000..8c3600d --- /dev/null +++ b/backend/src/main/resources/content/msp/institutions/internal.md @@ -0,0 +1,57 @@ +--- +module: msp.institutions +title: Institutions +tab: Internal +order: 20 +audience: internal +--- + +Ops/support view of the MSP **Institutions** screen (`msp.bcos.cloud`, frontend `hiveops-msp`, port 5186, image `hiveiq-msp-frontend`). The SPA has **no backend of its own** — every action hits **hiveops-mgmt** (institution list + contact) or **hiveops-devices** (agent tokens) through the NGINX API gateway. + +## Who can use it +- Every endpoint behind this screen is gated by `@PreAuthorize("hasAnyRole('MSP_ADMIN','BCOS_ADMIN')")`. +- **MSP_ADMIN** — sees only institutions granted to *their own MSP*. Access is derived **DB-fresh from the user's email domain** (`customers.domain`), NOT from the JWT institution-key claim (that claim goes stale when new institutions are added after last login). +- **BCOS_ADMIN** — sees **all** institutions (`findAll()`), plus every MSP company group appended at the end. + +## What each column is really reading +| Column | Source (hiveops-mgmt / `hiveiq_mgmt` DB) | +|--------|------------------------------------------| +| Institution name + key | `customer_institution_access.institution_name` / `institution_key` | +| Granted | `customer_institution_access.granted_at` | +| Contact (name/email/phone) | `customers.name` / `email` / `phone` (joined by institution key) | +| License key + expiry | active `licenses` row via `findActiveByInstitutionKeyIn` (`license_key`, `expires_at`) | + +Agent tokens come from a **different service + DB**: hiveops-devices, table `agent_tokens` in `hiveiq_devices`. + +## First things to check when it misbehaves + +**"MSP_ADMIN sees no institutions / empty list"** +- The list is keyed off email domain. Confirm a `customers` row exists with `customer_type = MSP` and `domain` = the exact domain of the logged-in user's email. No matching domain → the endpoint returns `[]` (HTTP 200, empty), not an error. +- Confirm `customer_institution_access` rows exist for that MSP customer id. + +**"An institution is missing but the MSP should manage it"** +- Missing row in `customer_institution_access` for that `customer_id` + `institution_key`. Grant it there. + +**"License shows dash / No expiry unexpectedly"** +- No active `licenses` row for that institution key, or `expires_at` is null (renders as *No expiry*). Expiry turns amber ≤30 days, red once past. + +**"Edit Contact fails with a conflict"** +- `PUT .../contact` returns **409** if the new email already exists on another customer (`existsByEmail`). Use a unique email. +- Returns **403** if an MSP_ADMIN edits an institution their MSP was never granted (`existsByCustomerIdAndInstitutionKey` check). +- Returns **404** if no `customers` row matches the institution key. + +**"Generated agent token doesn't authenticate the ATM"** +- KNOWN SPLIT: the MSP UI generates tokens into hiveops-**devices** (`hiveiq_devices.agent_tokens`), but the agent-proxy validates bearer tokens against hiveops-**mgmt** (`services.mgmt.url` → `/api/internal/agent-tokens/validate`, `hiveiq_mgmt.agent_tokens`). A token minted here may not be recognised at agent auth until the two stores are reconciled. Verify which table the validating path actually reads before declaring a token bad. (See Architect tab; tracked as an open token-data-migration item.) + +**"Revoked token still works for ~5 min"** +- agent-proxy caches validation results (`proxy.agent-token.cache-seconds`, default 300). Expected; wait out the cache. + +**"List looks stale"** +- Auto-refresh is client-side, 60s, toggle persisted in `localStorage` key `msp_institutions_autoRefresh`. Hit the manual refresh (↻) button. + +## Admin-only actions on this screen +- **Edit Contact** — writes `customers.name/email/phone`. +- **Tokens** — generate / revoke / delete-revoked agent tokens (raw value shown **once** at creation; only the SHA-256 hash + 8-char prefix are stored). +- **Readiness** — read-only pre-flight; **computed entirely in the browser** (no backend endpoint) from institution fields + the token list. + +> Token revocation reminder: before revoking an agent token that ATMs are actively using, push a fleet-wide CONFIG_UPDATE to clear it first — revoking a live token drops the agents' module threads. diff --git a/backend/src/main/resources/content/msp/module-allocations/architect.md b/backend/src/main/resources/content/msp/module-allocations/architect.md new file mode 100644 index 0000000..d946a22 --- /dev/null +++ b/backend/src/main/resources/content/msp/module-allocations/architect.md @@ -0,0 +1,51 @@ +--- +module: msp.module-allocations +title: Module Allocations +tab: Architect +order: 30 +audience: internal +--- + +How the **Module Allocations** feature is built. See also [platform.data-architecture], [platform.kafka], [platform.service-ownership]. + +## Services involved +- **`hiveops-msp` (frontend only, port 5186)** — Svelte 4 SPA. No backend. `AllocationsView.svelte` orchestrates everything client-side. Two axios instances (`api`, `authApi`) both point at the same gateway base and attach `Authorization: Bearer ` from `localStorage`. +- **`hiveops-mgmt` (owner of module data)** — serves the module catalogue, per-user allocations, and the MSP institution list. DB: `hiveiq_mgmt`. +- **`hiveops-auth` (owner of user data)** — serves the MSP-scoped user list. DB: `hiveiq_auth`. + +There is **no Kafka** on this path — no producers or consumers touch module allocation. It is a synchronous REST + relational-write feature. (No executor→Kafka→record-store pattern applies here.) + +## Data flow (single screen load + save) +1. On mount the SPA fires three parallel GETs: + - `GET /mgmt/api/v1/msp/modules` → `MspController.getModules()` → `AppModuleService.getAllModules()` filtered to `enabled && !isDefault`. + - `GET /auth/api/msp/users?size=200` → `MspUserController.listUsers()` (auth), scoped by MSP. + - `GET /mgmt/api/v1/msp/institutions` → `MspController.getInstitutions()`. +2. On row hover / panel open: `GET /mgmt/api/v1/msp/modules/users/{authUserId}` → `AppModuleService.getModulesForAuthUser()` returns **defaults + user allocations** combined; the SPA strips defaults to derive the editable set. +3. On save: `PUT /mgmt/api/v1/msp/modules/users/{authUserId}` with `{ moduleIds: [...] }` → `AppModuleService.setModulesForAuthUser()` → **delete-all-then-insert** into `user_module_allocations`, validating each id against the module catalogue. + +## MSP scoping logic (mgmt) +- `BCOS_ADMIN` → `institutionAccessRepository.findAll()` (all institutions). +- `MSP_ADMIN` → derive email domain → `customerRepository.findByDomain(domain)` → `findByCustomerIdOrderByInstitutionNameAsc(customerId)`. **DB-fresh, not JWT-cached** — a deliberate choice to avoid stale institution-key claims. Licenses, contacts, and an appended "MSP group" entry are joined on top. +- Auth's `MspUserController` scopes users by the resolved institution keys (BCOS_ADMIN = all non-SYSTEM users); pageable capped at `size ≤ 200`, sorted by `name`. + +## DB tables +| Table | DB | Read | Write | Role here | +|---|---|---|---|---| +| `app_modules` | `hiveiq_mgmt` | ✔ | — (this screen) | Module catalogue (`enabled`, `is_default`, `is_core`, `display_order`) | +| `user_module_allocations` | `hiveiq_mgmt` | ✔ | ✔ | Per-user grants (`auth_user_id` → `module_id`) | +| `institution_module_allocations` | `hiveiq_mgmt` | ✔ (launcher only) | — (this screen) | Institution gate applied at launcher time | +| `customer_institution_access` | `hiveiq_mgmt` | ✔ | — | MSP → institution scope | +| `customers` | `hiveiq_mgmt` | ✔ | — | Domain-based MSP resolution, contact/license join | +| `users` | `hiveiq_auth` | ✔ | — | User list (owned by auth) | + +## The gate: allocation ≠ visibility +Writing a `user_module_allocations` row does **not** guarantee the user sees the module. The browser launcher calls `GET /api/v1/users/me/modules` → `AppModuleService.getEffectiveModulesForUser(authUserId, institutionKeys, bypassGate)`, which returns: +`defaults` (always) + user-allocated modules that are **core OR allowed by `institution_module_allocations` for the user's institution keys** — unless `bypassGate` (BCOS_ADMIN). So institution-level allocation is a second, independent layer that must also be set for a non-core module to appear. + +## Key endpoints +- mgmt: `GET /api/v1/msp/modules`, `GET /api/v1/msp/institutions`, `GET/PUT /api/v1/msp/modules/users/{authUserId}` +- auth: `GET /auth/api/msp/users` +- launcher (not this screen): mgmt `GET /api/v1/users/me/modules` + +## Ownership +Per [platform.service-ownership]: module definitions and allocations belong to **mgmt**; user identity belongs to **auth**. The MSP SPA is a pure client composing the two — never persists state itself. diff --git a/backend/src/main/resources/content/msp/module-allocations/claude.md b/backend/src/main/resources/content/msp/module-allocations/claude.md new file mode 100644 index 0000000..39451e8 --- /dev/null +++ b/backend/src/main/resources/content/msp/module-allocations/claude.md @@ -0,0 +1,54 @@ +--- +module: msp.module-allocations +title: Module Allocations +tab: Claude +order: 40 +audience: internal +--- + +Terse agent reference. Frontend: `hiveops-msp/frontend` (`AllocationsView.svelte`). No dedicated backend. + +## Owners +- Module catalogue + allocations → **hiveops-mgmt** (DB `hiveiq_mgmt`) +- User list → **hiveops-auth** (DB `hiveiq_auth`) +- SPA → **hiveops-msp** (frontend only, port 5186, `msp.bcos.cloud`) + +## Endpoints (all require `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')`) +| Method | Path | Service | Purpose | +|---|---|---|---| +| GET | `/api/v1/msp/modules` | mgmt | Allocatable catalogue (`enabled && !isDefault`) | +| GET | `/api/v1/msp/institutions` | mgmt | MSP-scoped institutions | +| GET | `/api/v1/msp/modules/users/{authUserId}` | mgmt | User's modules (defaults + allocations) | +| PUT | `/api/v1/msp/modules/users/{authUserId}` | mgmt | Replace allocation; body `{ "moduleIds": [...] }`; returns 204 | +| GET | `/auth/api/msp/users?page&size&search&role&status&institutionKey` | auth | User page (`size` capped 200, sort `name`) | +| GET | `/api/v1/users/me/modules` | mgmt | Launcher-effective modules (NOT this screen) | + +Frontend prefixes: mgmt calls via `/mgmt/...`, auth via `/auth/...` (gateway strips prefix). + +## Tables (`hiveiq_mgmt` unless noted) +- `app_modules` — cols incl. `enabled`, `is_default`, `is_core`, `display_order` +- `user_module_allocations` — `auth_user_id` → `module_id` (this screen writes here) +- `institution_module_allocations` — `institution_key` → `module_id` (launcher gate) +- `customer_institution_access`, `customers` — MSP scope resolution +- `users` — in `hiveiq_auth` (auth-owned) + +## Topics +- **None.** No Kafka on this path. + +## Gotchas +- PUT is **delete-all-then-insert** (full replace), not a delta. Send the complete desired set. +- Catalogue = `enabled=true AND is_default=false`. Defaults are always granted, never listed. +- MSP scope is derived from **email domain → `customers.domain`**, not the JWT institution-keys claim (claim goes stale). +- Allocating a module ≠ user sees it. Launcher `getEffectiveModulesForUser` also filters by `institution_module_allocations` (core modules bypass; BCOS_ADMIN bypasses all). Non-core module needs an institution allocation too. +- `setModulesForAuthUser` silently drops module ids not in `app_modules` (no error). +- SPA swallows per-user allocation GET errors (empty catch) — no toast; check network tab. +- Changes apply on **next launcher open**; no push/websocket. +- `getEffectiveModulesForUser` keys on `principal.getCustomerId()` while this screen writes by `authUserId` from `/auth/api/msp/users` — assumed identical id space (verify before assuming a mismatch is the cause of "not showing"). + +## Do NOT +- Do NOT expect a Kafka event, mirror table, or record-store write — none exist here. +- Do NOT send a partial `moduleIds` diff expecting a merge; you will wipe the rest. +- Do NOT add allocation write endpoints to auth or the SPA — mgmt owns allocation writes. +- Do NOT treat "user has allocation but no launcher module" as a bug before checking the institution gate. +- Do NOT rely on the JWT institution-keys claim for MSP scope; use the domain-derived path. +- Do NOT expose `is_default=true` modules in the assign list. diff --git a/backend/src/main/resources/content/msp/module-allocations/internal.md b/backend/src/main/resources/content/msp/module-allocations/internal.md new file mode 100644 index 0000000..3e49373 --- /dev/null +++ b/backend/src/main/resources/content/msp/module-allocations/internal.md @@ -0,0 +1,42 @@ +--- +module: msp.module-allocations +title: Module Allocations +tab: Internal +order: 20 +audience: internal +--- + +Ops/support view for the **Module Allocations** screen in the MSP admin SPA (`msp.bcos.cloud`, port 5186). This screen lets an admin grant/revoke optional HiveIQ Browser app modules per user. The SPA has **no backend of its own** — it calls `hiveops-mgmt` (modules + institutions) and `hiveops-auth` (user list). + +## Who can use it +- Every backing endpoint is gated `@PreAuthorize("hasAnyRole('MSP_ADMIN','BCOS_ADMIN')")`. +- **MSP_ADMIN** — sees only institutions/users under their own MSP. Scope is resolved **from the email domain** (`customer.domain`), not from the JWT institution-keys claim (the claim goes stale when new institutions are added after last login). +- **BCOS_ADMIN** — sees all institutions and all non-SYSTEM users. +- Any other role → 403 (or empty result set from `/auth/api/msp/users`). + +## What the screen actually does +- Loads three lists in parallel: allocatable modules, users (paginated, `size=200`), institutions. +- **Allocatable module catalogue** = modules that are `enabled=true` AND `isDefault=false`. Default modules are intentionally hidden — everyone already gets them. +- Per-user allocations load lazily on row **hover** and on panel open. +- Saving does a **full replace**: the chosen module-id set overwrites the user's previous allocation (delete-all-then-insert), not a delta. +- Allocations take effect the **next time the user opens the HiveIQ Browser launcher** — no push, no live update. + +## First things to check when it misbehaves +1. **"No modules to assign" / empty legend** — the catalogue is empty because no module row is both `enabled=true` and `isDefault=false`. Check `app_modules` in `hiveiq_mgmt`. A newly registered module must be `enabled=TRUE, is_default=FALSE` to appear here. +2. **User was granted a module but it never shows in their launcher** — this is the **institution gate**, not a bug in this screen. This screen writes *user* allocations only. The launcher (`GET /api/v1/users/me/modules`) returns defaults + user-allocations **filtered to modules the user's institution is allowed** (`institution_module_allocations`) unless the user is BCOS_ADMIN. If the institution has no allocation row for that module, the user won't see it. Fix: allocate the module to the institution too. +3. **A whole institution / all its users are missing** — MSP scope is domain-derived. If the admin's `customer.domain` doesn't match, or the MSP has no `customer_institution_access` rows, they see nothing. Verify the MSP customer record and its institution-access grants in `hiveiq_mgmt`. +4. **403 on load** — token role isn't MSP_ADMIN/BCOS_ADMIN, or the JWT isn't being forwarded. Both mgmt and auth enforce the same role check. +5. **Users load but allocations column stuck on "…"** — the per-user `GET /modules/users/{id}` is failing (network/CORS). Failures are swallowed silently in the UI (empty `catch`), so check the browser network tab / mgmt logs, not the screen. + +## Common failure modes → fixes +| Symptom | Likely cause | Fix | +|---|---|---| +| Module not in the assign list | `enabled=false` or `is_default=true` | Toggle module flags in `app_modules` | +| Saved, but user still can't open module in browser | Institution gate blocks it | Add institution-level allocation for that module | +| MSP_ADMIN sees no institutions | Email domain ≠ `customer.domain`, or no access grants | Fix domain / add `customer_institution_access` rows | +| Users list empty for MSP_ADMIN | No institution keys in MSP scope | Grant institution access to the MSP customer | +| Changes "don't apply" | User hasn't reopened the launcher | Have them relaunch HiveIQ Browser | + +## Notes +- Auto-refresh reloads the three base lists every 60s (toggle persisted in `localStorage` key `msp_allocations_autoRefresh`); it does **not** re-pull per-user allocation maps already cached in the session. +- Saving is idempotent-safe: re-saving the same set just rewrites the rows. diff --git a/backend/src/main/resources/content/msp/users/architect.md b/backend/src/main/resources/content/msp/users/architect.md new file mode 100644 index 0000000..5f02c30 --- /dev/null +++ b/backend/src/main/resources/content/msp/users/architect.md @@ -0,0 +1,83 @@ +--- +module: msp.users +title: Users +tab: Architect +order: 30 +audience: internal +--- + +How the **Users** screen is built. It is a two-service read/write feature: user records are owned by **hiveops-auth**; the institution dropdown/scoping data is owned by **hiveops-mgmt**. The `msp` SPA has **no backend of its own** — it calls both services directly. There is **no Kafka** and no executor→record-store pattern in this feature; every mutation is a synchronous REST call. See [platform.service-ownership]. + +## Services involved + +| Service | Role for this feature | +|---|---| +| `msp` frontend (`UsersView.svelte`, port 5186 / `msp.bcos.cloud`) | Svelte 4 SPA; loads the full list (`size=200`) and does search/filter/group/paginate client-side | +| **hiveops-auth** (port 8082, NGINX `/api/auth/`, DB `hiveiq_auth`) | **Owns the user records.** `MspUserController` = list/edit/enable/disable/reset-password | +| **hiveops-mgmt** (port 8080, NGINX `/api/mgmt/` → gateway strips `/mgmt`, DB `hiveiq_mgmt`) | Institution dropdown (`MspController`) + internal institution-key resolver used by auth for scoping | + +Two axios clients in `lib/api.ts`: `authApi` (base → auth, all `/auth/api/...` calls) and `api` (base → gateway, `/mgmt/...` and `/devices/...`). Both inject `Authorization: Bearer `. + +## Data flow — read (list) + +``` +UsersView.load() + --GET /auth/api/msp/users?size=200--> hiveops-auth MspUserController.listUsers + 1. extractToken(); isBcosAdmin(token) from JWT 'role' claim + 2. non-BCOS: resolveInstitutionKeys(token) + --GET /api/internal/customers/domain/{domain}/institution-keys (X-Internal-Secret)--> + hiveops-mgmt CustomerInternalController (MgmtServiceClient) + 3. UserRepository.findByInstitutionKeys(keys) | searchByInstitutionKeys(keys, q) + (BCOS_ADMIN with no filter -> findAll / search) + 4. post-filter role/status in-memory, drop SYSTEM role, toMap() + 5. return { content:[...], page:{totalElements,totalPages,number,size} } +``` + +- Institution dropdown loads separately: `UsersView.loadInstitutions()` → `GET /mgmt/api/v1/msp/institutions` → mgmt `MspController.getInstitutions` reads `customer_institution_access` + `customers` + `licenses` (DB `hiveiq_mgmt`), deriving the MSP from the caller's email domain (DB-fresh, not JWT-cached). +- **Scope resolution is fresh per-request**: auth calls mgmt's internal endpoint every time rather than trusting the JWT institution claim (which goes stale when institutions are added post-login). BCOS_ADMIN bypasses scoping entirely. + +## Data flow — write + +All four writes are `MspUserController` handlers; each re-verifies ownership via `isOwned(token, user)` (BCOS_ADMIN → always true; else target's `customer_license_key` ∈ scope keys, or email-domain→`customer_domains`→institutionKey ∈ scope keys): + +| Action | Handler | Effect on `users` row | +|---|---|---| +| Edit | `PUT /users/{id}` | sets `name`, `role` (via `Role.valueOf`), `fleet_task_approver` | +| Enable | `POST /users/{id}/enable` | `enabled=true`, `failed_login_attempts=0`, `locked_until=null` | +| Disable | `POST /users/{id}/disable` | `enabled=false`; blocks self-disable (caller email == target → 400) | +| Reset PW | `POST /users/{id}/reset-password` | no user mutation; delegates to `PasswordResetService` | + +Reset-password chain: `resetPassword` → `PasswordResetService.initiatePasswordReset(email)` → invalidate prior unused tokens → write a new row to `password_reset_tokens` → `EmailService.sendPasswordResetEmail` (async, via `mail.bcos.cloud`). Failures are caught + logged; endpoint returns 200 regardless (no enumeration leak). + +## DB tables + +**hiveops-auth (`hiveiq_auth`):** +- **`users`** (entity `User`) — the record of truth. Read for the list; written on edit/enable/disable. Relevant columns: `id`, `email` (unique, immutable here), `name`, `role`, `enabled`, `mfa_enabled`, `customer_license_key` (len 29), `fleet_task_approver`, `failed_login_attempts`, `locked_until`, `last_login_at`, `created_at`. `Role` enum includes `SYSTEM` (always excluded), `CUSTOMER`, `MSP_ADMIN`, `ADMIN`, `BCOS_ADMIN`. +- **`customer_domains`** (entity `CustomerDomain`) — email-domain → institutionKey/institutionName. Used both for scope-ownership fallback and to populate `institutionKey`/`institution` in the response DTO (`toMap`). +- **`password_reset_tokens`** — written by the reset flow. + +**hiveops-mgmt (`hiveiq_mgmt`):** `customer_institution_access`, `customers`, `licenses` — read by `MspController.getInstitutions` and the internal institution-keys resolver. + +See [platform.data-architecture]. + +## Key API endpoints + +| Method + path | Service | Auth | +|---|---|---| +| `GET /auth/api/msp/users` | hiveops-auth | `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` (class-level) | +| `PUT /auth/api/msp/users/{id}` | hiveops-auth | same + `isOwned` check | +| `POST /auth/api/msp/users/{id}/enable` | hiveops-auth | same + `isOwned` | +| `POST /auth/api/msp/users/{id}/disable` | hiveops-auth | same + `isOwned` + self-disable guard | +| `POST /auth/api/msp/users/{id}/reset-password` | hiveops-auth | same + `isOwned` | +| `GET /api/v1/msp/institutions` (SPA path `/mgmt/api/v1/msp/institutions`) | hiveops-mgmt | `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` | +| `GET /api/internal/customers/domain/{domain}/institution-keys` | hiveops-mgmt | `X-Internal-Secret` (service-to-service, not user JWT) | + +## Kafka + +None. This feature is entirely synchronous HTTP across auth ↔ mgmt. No topics are produced or consumed for user list/edit/enable/disable/reset. See [platform.kafka]. + +## Notes / caveats + +- **Pagination is a bit of a lie when role/status filter server-side**: `listUsers` applies role/status filtering in-memory *after* the page is fetched, but `page.totalElements/totalPages` reflect the pre-filter count. The SPA sidesteps this by pulling `size=200` and filtering client-side, but a direct API consumer using `role=`/`status=` will get inconsistent counts. +- **`customer_license_key IN keys` rarely matches** in `findByInstitutionKeys`: license keys (`XXXXX-…`, len 29) are not institution keys, so scoping effectively rides on the `customer_domains` email-domain join. +- `fleetTaskApprover` is transmitted as a string (`"true"`/`"false"`) and parsed with `Boolean.parseBoolean`; the flag is consumed downstream by the fleet task-approval feature (out of scope here). diff --git a/backend/src/main/resources/content/msp/users/claude.md b/backend/src/main/resources/content/msp/users/claude.md new file mode 100644 index 0000000..55f9fea --- /dev/null +++ b/backend/src/main/resources/content/msp/users/claude.md @@ -0,0 +1,68 @@ +--- +module: msp.users +title: Users +tab: Claude +order: 40 +audience: internal +--- + +Act-without-guessing reference. Everything below is confirmed in code. + +## Ownership +- **User records owner:** `hiveops-auth` — `MspUserController` (port 8082, NGINX `/api/auth/`, DB `hiveiq_auth`). +- **Institution list + scope resolver:** `hiveops-mgmt` — `MspController` + `CustomerInternalController` (port 8080, DB `hiveiq_mgmt`). +- Frontend: `hiveops-msp/frontend/src/components/Users/UsersView.svelte` (app `msp`, `msp.bcos.cloud`, port 5186). No dedicated backend for the SPA. + +## Endpoints (METHOD path) +| Method + path | Service | Auth | +|---|---|---| +| GET `/auth/api/msp/users?page=&size=&search=&role=&status=&institutionKey=` | auth | `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` | +| PUT `/auth/api/msp/users/{id}` | auth | class role + `isOwned` | +| POST `/auth/api/msp/users/{id}/enable` | auth | class role + `isOwned` | +| POST `/auth/api/msp/users/{id}/disable` | auth | class role + `isOwned` + self-disable guard | +| POST `/auth/api/msp/users/{id}/reset-password` | auth | class role + `isOwned` | +| GET `/api/v1/msp/institutions` (SPA calls `/mgmt/api/v1/msp/institutions`) | mgmt | `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` | +| GET `/api/internal/customers/domain/{domain}/institution-keys` | mgmt | header `X-Internal-Secret` (NOT user JWT) | + +- `{id}` = numeric `users.id` (DB `hiveiq_auth`). +- PUT body: `{name, role, fleetTaskApprover}` — `fleetTaskApprover` is a **string** `"true"`/`"false"`. `role` must be a valid `User.Role` name. +- List response shape: `{content:[user...], page:{totalElements,totalPages,number,size}}`. SPA reads `res.data.content` only. +- SPA always requests `size=200` and filters/groups client-side; `page`/`search`/`role`/`status` params exist server-side but the SPA doesn't use them. + +## Roles +- All `MspUserController` endpoints: class-level `@PreAuthorize("hasAnyRole('MSP_ADMIN','BCOS_ADMIN')")`. +- `BCOS_ADMIN` = superuser (all institutions, bypasses scoping). `MSP_ADMIN` = scoped to own institutions. +- `mgmt` MspController endpoints: `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')`. +- Internal keys endpoint: service secret only (`X-Internal-Secret` = auth env `internal.service-secret` / mgmt side). + +## Tables / DB +- **`users`** (DB `hiveiq_auth`, entity `User`) — read/written. `SYSTEM` role always excluded from list. +- **`customer_domains`** (DB `hiveiq_auth`) — email-domain→institutionKey; drives scope fallback + response `institutionKey`/`institution`. +- **`password_reset_tokens`** (DB `hiveiq_auth`) — written by reset-password. +- mgmt (DB `hiveiq_mgmt`): `customer_institution_access`, `customers`, `licenses`. + +## Kafka +- NONE. Fully synchronous HTTP (auth ↔ mgmt). No topics produced or consumed for this feature. + +## Scope resolution (exact) +`auth MspUserController.resolveInstitutionKeys` → JWT email domain → `MgmtServiceClient.getInstitutionKeys` → mgmt `GET /api/internal/customers/domain/{domain}/institution-keys` (X-Internal-Secret). Empty/failure → caller sees empty list. Ownership (`isOwned`): `user.customer_license_key ∈ keys` OR email-domain→`customer_domains`.institutionKey ∈ keys. + +## Gotchas +- `enable` also clears `failed_login_attempts=0` + `locked_until=null` → it doubles as **unlock**. +- `disable` self-disable → HTTP **400** (`"You cannot disable your own account"`). +- `reset-password` always returns **200** even if email send fails (caught+logged WARN); does NOT set a password, only mails a token. +- Invalid `role` in PUT → silently ignored (`Role.valueOf` in try/catch), other fields still saved. +- Email is immutable via this controller — no change-email path. +- Role/status server-side filter is applied **after** pagination → `page.totalElements/totalPages` are pre-filter and inconsistent; SPA avoids this by client-side filtering `size=200`. +- SPA shows max 200 users (hard-coded `size=200`, no pagination UI). +- `customer_license_key IN keys` clause rarely matches (license keys ≠ institution keys); scoping effectively rides `customer_domains`. +- PUT accepts ANY valid `User.Role` name (e.g. `BCOS_ADMIN`, `ADMIN`) — no allowlist; SPA only offers CUSTOMER/MSP_ADMIN. (see Do NOT) + +## Do NOT +- Do NOT look for user records in `hiveiq_mgmt` — users live in `hiveiq_auth`, owned by hiveops-auth. +- Do NOT expect a create-user endpoint here — `MspUserController` has list/edit/enable/disable/reset only (no POST create; mock server's `POST /api/msp/users` is not implemented in the real backend). +- Do NOT call `/api/internal/customers/domain/.../institution-keys` with a user JWT — it needs `X-Internal-Secret`. +- Do NOT trust the JWT institution claim for scope — auth re-resolves from mgmt per request. +- Do NOT expect a Kafka topic — there is none for this feature. +- Do NOT PUT `role` to a privileged value expecting the API to block it — it won't; enforce role allowlists caller-side. +- Do NOT treat a 200 from reset-password as "email delivered." diff --git a/backend/src/main/resources/content/msp/users/internal.md b/backend/src/main/resources/content/msp/users/internal.md new file mode 100644 index 0000000..3da38d7 --- /dev/null +++ b/backend/src/main/resources/content/msp/users/internal.md @@ -0,0 +1,63 @@ +--- +module: msp.users +title: Users +tab: Internal +order: 20 +audience: internal +--- + +Ops/support/admin view of the **Users** screen (frontend `hiveops-msp/frontend/src/components/Users/UsersView.svelte`, app `msp`, `msp.bcos.cloud`). This is a self-service user-admin panel: an MSP admin lists, edits, enables/disables, and password-resets users across the institutions their MSP manages. The user records live in **hiveops-auth** (`users` table, DB `hiveiq_auth`); the institution dropdown comes from **hiveops-mgmt**. See [platform.service-ownership]. + +## Who can use it + +- Every endpoint the screen calls is class-gated on `MspUserController` with `@PreAuthorize("hasAnyRole('MSP_ADMIN', 'BCOS_ADMIN')")`. A plain `CUSTOMER`/`USER` token gets 403. +- **`MSP_ADMIN`** → scoped to their own institutions only (see scoping below). +- **`BCOS_ADMIN`** → superuser; sees and edits every non-`SYSTEM` user, all institutions. +- The panel is admin-only by role, but there is **no per-action gate** beyond the class annotation — any `MSP_ADMIN` in scope can edit/disable/reset any user they own. + +## What the screen actually calls + +- Load list → `GET /auth/api/msp/users?size=200` (hiveops-auth, via NGINX `/api/auth/`). The frontend always pulls `size=200` and does **all** search/role/status/institution filtering and institution-grouping **client-side**. +- Institution dropdown → `GET /mgmt/api/v1/msp/institutions` (hiveops-mgmt `MspController`). +- Edit → `PUT /auth/api/msp/users/{id}` body `{name, role, fleetTaskApprover}` (email is never sent/changed). +- Enable → `POST /auth/api/msp/users/{id}/enable` (also clears `failed_login_attempts` and `locked_until` — doubles as an unlock). +- Disable → `POST /auth/api/msp/users/{id}/disable`. +- Reset PW → `POST /auth/api/msp/users/{id}/reset-password` (sends a reset email; does **not** set a password). +- Auto-refresh re-runs the list `GET` every 60s (toggle persisted in `localStorage` key `msp_users_autoRefresh`). + +## How MSP scoping is resolved (important for "why can't I see user X") + +For a non-`BCOS_ADMIN` caller, hiveops-auth derives the MSP's institution keys **fresh from mgmt at request time**, not from the JWT: + +1. Take the email domain from the caller's JWT. +2. Call hiveops-mgmt internal API `GET /api/internal/customers/domain/{domain}/institution-keys` (header `X-Internal-Secret`). +3. A user is "owned" (visible/editable) if their `customer_license_key` is in those keys **OR** their email domain maps to an in-scope key via the `customer_domains` table (DB `hiveiq_auth`). +- If mgmt is unreachable or returns no keys → the caller sees an **empty list** (silent). This is the #1 cause of "all my users disappeared." + +## First things to check when it misbehaves + +1. **Empty user list for an MSP admin who should have users** → the mgmt internal lookup failed or returned no keys. Check: is `hiveops-mgmt` up? Does `internal.service-secret` in auth match mgmt's expected `X-Internal-Secret`? Does the caller's email domain resolve to a Customer with institution access in `hiveiq_mgmt`? +2. **A user is missing but others in the same institution show** → their institution linkage is unresolved: `customer_license_key` is null AND their email domain has no `customer_domains` row for an in-scope key. Fix the `customer_domains` mapping or set the user's `customer_license_key`. +3. **Institution dropdown empty but users load** → mgmt `GET /api/v1/msp/institutions` is failing while the auth list still returns (different service). Check mgmt health; the dropdown is sourced from `customer_institution_access` in `hiveiq_mgmt`. +4. **Only 200 users show for a huge MSP** → the frontend hard-codes `size=200` with no pagination UI; users past 200 (post-`name` sort) never render. Filter server-side by institution or raise `size`. +5. **403 opening the panel** → token lacks `MSP_ADMIN`/`BCOS_ADMIN`. A customer promoted to MSP admin must re-login to get a fresh role claim. +6. **Reset email never arrives** → reset is fire-and-forget: `POST .../reset-password` returns 200 even if the email send throws (logged as WARN in auth, not surfaced). Check hiveops-auth logs + `mail.bcos.cloud` relay; token is written to `password_reset_tokens`. + +## Common failure modes + fixes + +| Symptom | Likely cause | Fix | +|---|---|---| +| MSP admin sees zero users | mgmt institution-keys lookup failed/empty | Check mgmt up + `X-Internal-Secret` parity; verify domain→institution mapping in `hiveiq_mgmt` | +| One user missing from list | No `customer_license_key` and no `customer_domains` row for an in-scope key | Add `customer_domains` mapping or set the user's license key | +| "You cannot disable your own account" (400) | Self-disable guard in `disableUser` | Expected; have another admin (or BCOS_ADMIN) do it | +| Disabled/locked user can't log in after "Enable" | — | Enable already clears `failed_login_attempts` + `locked_until`; if still locked, check DB directly | +| Reset email not received | Send failed silently after 200 | Check auth logs + SMTP relay; token row exists in `password_reset_tokens` | +| Role change didn't persist | Unknown enum name in `role` | `role` must be a valid `User.Role` name (`CUSTOMER`/`MSP_ADMIN`); invalid values are silently ignored | + +## Notes + +- **Enable = unlock.** `POST .../enable` sets `enabled=true`, `failed_login_attempts=0`, `locked_until=null`. Use it to recover a lockout, not just a disabled account. +- **Self-disable is blocked** (returns 400) — an admin can't lock themselves out via this screen. +- **Email is immutable here** — the edit panel shows it read-only; there is no change-email path in this controller. +- `SYSTEM`-role users are always filtered out of the list, even for BCOS_ADMIN. +- No Kafka, no background jobs — every action is a synchronous REST call. If the screen is stale, it's a failed/mis-scoped `GET`, not an event lag. diff --git a/backend/src/main/resources/content/profile/appearance/architect.md b/backend/src/main/resources/content/profile/appearance/architect.md new file mode 100644 index 0000000..6941a87 --- /dev/null +++ b/backend/src/main/resources/content/profile/appearance/architect.md @@ -0,0 +1,69 @@ +--- +module: profile.appearance +title: Appearance +tab: Architect +order: 30 +audience: internal +--- + +How the **Appearance** feature is built. It is a thin CRUD over a JSONB column — **no Kafka, no executor→record-store pattern, no cross-service events**. Theme rendering is entirely client-side. + +## Services involved +| Concern | Service | Detail | +|--------|---------|--------| +| UI | `hiveops-profile` (SPA) | `AppearanceSettings.svelte`, `profile.bcos.cloud`, dev port 5184 | +| API + persistence | **hiveops-auth** | owns the `users` table and the preferences JSON; port 8082, nginx `/api/auth/` | +| Data store | Postgres `hiveiq_auth` | `users.preferences` JSONB column | + +`hiveops-auth` is the **single owner** of user preferences — no other service reads or writes `themeMode`/`tableDensity`. See [platform.service-ownership]. + +## Data flow (read + write) +``` +AppearanceSettings.svelte + └─ profileAPI.getPreferences() → GET /auth/api/users/me/preferences + └─ profileAPI.updatePreferences({themeMode, tableDensity}) + → PUT /auth/api/users/me/preferences + │ (axios authApi, baseURL = gatewayUrl; Bearer JWT from localStorage) + ▼ +UserProfileController (@RequestMapping "/auth/api/users/me") + └─ @AuthenticationPrincipal → email + ▼ +UserProfileService.getPreferences / updatePreferences + └─ UserRepository.findByEmail(email) + └─ merges non-null request fields onto User.preferences + └─ userRepository.save(user) + ▼ +Postgres hiveiq_auth: users.preferences (JSONB) +``` + +There is **no message bus in this path**. Nothing is published to Kafka on a preference change. (Contrast with the executor→Kafka→record-store pattern used elsewhere — see [platform.kafka] and [platform.data-architecture]; it does **not** apply here.) + +## Persistence detail +- `User.preferences` is a `UserPreferences` POJO persisted as JSON via Hibernate: + - `@JdbcTypeCode(SqlTypes.JSON)` + `@Column(columnDefinition = "jsonb")`. +- Column created by migration `V9__add_user_preferences.sql`: + `ALTER TABLE users ADD COLUMN IF NOT EXISTS preferences JSONB NOT NULL DEFAULT '{}'::jsonb;` +- `UserPreferences` bundles display / notifications / **appearance** / security settings in one blob. Appearance fields: + - `themeMode` — default `"SYSTEM"` (`LIGHT | DARK | SYSTEM`) + - `tableDensity` — default `"comfortable"` (`comfortable | compact`) +- `updatePreferences` is a **partial merge**: only non-null fields from `UpdatePreferencesRequest` overwrite the current blob, so the frontend safely PUTs just `{themeMode, tableDensity}` without clobbering notification/security prefs. + +## Key API endpoints +| Method | Path | Body | Returns | +|--------|------|------|---------| +| GET | `/auth/api/users/me/preferences` | — | full `UserPreferences` | +| PUT | `/auth/api/users/me/preferences` | `UpdatePreferencesRequest` (partial) | updated `UserPreferences` | + +Authz: both are `authenticated()` only (no role gate). Identity comes from the JWT principal, so the write target is always the caller's own row. + +## Theme application (client-side) +- On save, `applyTheme(themeMode)` toggles `document.documentElement.classList` `dark-mode`: + - `DARK` → add, `LIGHT` → remove, `SYSTEM` → follow `matchMedia('(prefers-color-scheme: dark)')`. +- Cross-module sync in the Electron Browser: `window.electronAPI?.setTheme?.(themeMode.toLowerCase())` so sibling SPAs repaint immediately. Plain web tabs pick up the theme on their next preferences load. +- CSS lives in per-component `:global(.dark-mode) …` rules (e.g. in `AppearanceSettings.svelte`); there is no central theme service. + +## Notes for future work +- `themeMode` and `tableDensity` are plain `String` in `UpdatePreferencesRequest` with **no** `@Pattern`/enum validation, so the API will accept and store arbitrary values. Consider server-side enum validation if this becomes a data-quality concern. +- Whether every SPA honors `tableDensity` is not centrally enforced (each frontend reads the same pref independently) — treat consistent density as a per-app contract, not something auth guarantees. + +Cross-links: [platform.data-architecture] · [platform.kafka] · [platform.service-ownership] diff --git a/backend/src/main/resources/content/profile/appearance/claude.md b/backend/src/main/resources/content/profile/appearance/claude.md new file mode 100644 index 0000000..883a0f9 --- /dev/null +++ b/backend/src/main/resources/content/profile/appearance/claude.md @@ -0,0 +1,63 @@ +--- +module: profile.appearance +title: Appearance +tab: Claude +order: 40 +audience: internal +--- + +Terse agent reference. **Owner: hiveops-auth.** Self-service per-user preference. No Kafka, no admin gate. + +## Ownership +- Data + API owner: **hiveops-auth** (port 8082, nginx `/api/auth/`). +- UI: `hiveops-profile` SPA (`profile.bcos.cloud`, dev 5184), `AppearanceSettings.svelte`, view id `appearance`. +- Only two keys: `themeMode`, `tableDensity` — subset of the shared preferences JSON. + +## Endpoints (confirmed in code) +| Method | Path | Body | Returns | +|--------|------|------|---------| +| GET | `/auth/api/users/me/preferences` | — | `UserPreferences` | +| PUT | `/auth/api/users/me/preferences` | `UpdatePreferencesRequest` (partial, null-skip) | updated `UserPreferences` | + +- Controller: `UserProfileController` (`@RequestMapping "/auth/api/users/me"`). +- Frontend axios baseURL = `window.__APP_CONFIG__.apiUrl` (gateway); Bearer JWT from `localStorage.token`. + +## Auth / roles +- `SecurityConfig`: `.anyRequest().authenticated()` — **any authenticated user**, NO `hasAnyRole(...)`. +- Target row = caller's own, resolved via `@AuthenticationPrincipal` → `findByEmail`. + +## Data store (confirmed) +| Item | Value | +|------|-------| +| DB | `hiveiq_auth` (Postgres, VM `10.10.10.188` via ProxyJump through `173.231.195.250`) | +| Table | `users` | +| Column | `preferences` (JSONB, `NOT NULL DEFAULT '{}'`), migration `V9__add_user_preferences.sql` | +| Mapping | `@JdbcTypeCode(SqlTypes.JSON)` → `UserPreferences` POJO | + +Appearance keys inside JSONB: +- `themeMode`: `LIGHT` | `DARK` | `SYSTEM` (default `SYSTEM`) +- `tableDensity`: `comfortable` | `compact` (default `comfortable`) + +```sql +SELECT preferences->>'themeMode', preferences->>'tableDensity' +FROM users WHERE email='...'; +UPDATE users SET preferences = preferences + || '{"themeMode":"SYSTEM","tableDensity":"comfortable"}' WHERE email='...'; +``` + +## Kafka / topics +- **None.** No producer/consumer for this feature. Do not look for one. + +## Gotchas +- Theme is applied client-side (`dark-mode` class on ``); `SYSTEM` = `matchMedia(prefers-color-scheme)`, not server-driven. +- Electron cross-module sync via `window.electronAPI.setTheme(...)`; plain web tabs update on next prefs load. +- PUT is a partial merge — sending only `{themeMode,tableDensity}` will NOT wipe notification/security prefs. +- `themeMode`/`tableDensity` have NO server-side validation (plain `String`, no `@Pattern`/enum) → arbitrary strings persist. +- Save error handling is a generic toast; a 401 (expired JWT) shows as "Save failed". + +## Do NOT +- Do NOT look for an appearance controller/table in `hiveops-profile` — it is frontend-only; backend is `hiveops-auth`. +- Do NOT assume an admin role or fleet/institution-wide theme setting exists — this is per-user only. +- Do NOT expect a Kafka event, executor, or record-store on preference change. +- Do NOT `exec` Postgres on the services VM (`173.231.195.250`) — `hiveiq_auth` is on `10.10.10.188`. +- Do NOT invent a `theme` / `preferences` REST path other than `/auth/api/users/me/preferences`. diff --git a/backend/src/main/resources/content/profile/appearance/internal.md b/backend/src/main/resources/content/profile/appearance/internal.md new file mode 100644 index 0000000..f9eaa74 --- /dev/null +++ b/backend/src/main/resources/content/profile/appearance/internal.md @@ -0,0 +1,57 @@ +--- +module: profile.appearance +title: Appearance +tab: Internal +order: 20 +audience: internal +--- + +Ops/support view of the **Appearance** panel (theme + table density) in the Profile SPA. This is a **per-user, self-service** preference — there is nothing admin-only here and no fleet/institution-wide toggle. + +## What it actually is +- Frontend: `hiveops-profile` SPA (`profile.bcos.cloud`, dev port **5184**), component `AppearanceSettings.svelte`, view id `appearance`. +- Backend: **hiveops-auth** (port 8082, nginx `/api/auth/`). Appearance is just two keys inside the user's preferences JSON. +- Two settings only: `themeMode` (`LIGHT` | `DARK` | `SYSTEM`, default `SYSTEM`) and `tableDensity` (`comfortable` | `compact`, default `comfortable`). + +## Roles required +- **Any authenticated user.** The endpoints fall under `.anyRequest().authenticated()` in `SecurityConfig` — there is **no** `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` gate. CUSTOMER, ADMIN, MSP_ADMIN, BCOS_ADMIN can all read/write **their own** prefs. +- User is resolved from the JWT via `@AuthenticationPrincipal` → `findByEmail`. A user can only ever change their own row; there is no "edit another user's appearance" path. + +## First things to check when it misbehaves +1. **"Save failed" toast** — the save calls `PUT /auth/api/users/me/preferences`. Check: + - Token present/valid? Expired JWT → 401 → generic "Save failed". Have the user re-login. + - hiveops-auth up? `curl http://localhost:8082/actuator/health` on the services VM. + - CORS: `profile.bcos.cloud` must be in `CORS_ALLOWED_ORIGINS` for hiveops-auth (it is, per auth CLAUDE.md). A new origin that 500s the whole service on boot will take this down too. +2. **Theme resets after reload / doesn't persist** — the value did not reach the DB. Confirm the JSON in Postgres (see below). If `preferences` is `{}` the PUT never landed. +3. **Dark mode "sticks" in one SPA but not others** — theme is applied **client-side** by toggling the `dark-mode` class on ``; each SPA applies it on its own load from the same stored `themeMode`. A stale tab won't repaint until it re-reads preferences. In the Electron Browser, save also calls `window.electronAPI.setTheme(...)` so other modules follow immediately; in a plain web tab they do not. +4. **`SYSTEM` looks wrong** — `SYSTEM` follows `window.matchMedia('(prefers-color-scheme: dark)')`, i.e. the OS/browser setting, not anything server-side. If the OS is light, `SYSTEM` renders light. Working as designed. + +## Inspect / fix in the DB +Preferences live as a JSONB blob on the user row in `hiveiq_auth`: + +```sql +-- read +SELECT email, preferences->>'themeMode' AS theme, + preferences->>'tableDensity' AS density +FROM users WHERE email = 'user@example.com'; + +-- force-reset appearance to defaults (leaves other prefs intact) +UPDATE users +SET preferences = preferences || '{"themeMode":"SYSTEM","tableDensity":"comfortable"}' +WHERE email = 'user@example.com'; +``` + +(Postgres is on the **hiveiq-database** VM `10.10.10.188`, reach it via ProxyJump through `173.231.195.250` — not the services VM.) + +## Common failure modes + fixes +| Symptom | Likely cause | Fix | +|---------|-------------|-----| +| Save fails for everyone | hiveops-auth down / CORS boot failure | Health-check auth; verify `CORS_ALLOWED_ORIGINS` includes profile origin; restart `hiveiq-auth` | +| Save fails for one user | expired/invalid token | Re-login | +| Theme not persisting | PUT never reached (network/401) | Check browser devtools; confirm `preferences` JSONB updated | +| Garbage theme value stored | no server-side validation on `themeMode`/`tableDensity` (see Architect) | Reset the JSON keys via SQL above | +| Density change has no visible effect | consuming SPA may not honor `tableDensity` yet (unverified per-app) | Not an auth bug; treat as frontend feature request | + +## Escalate to engineering when +- Preferences write succeeds (200) but JSONB doesn't change → possible Hibernate JSON mapping / transaction issue in `UserProfileService.updatePreferences`. +- Invalid `themeMode`/`tableDensity` strings are being persisted from a client → backend has no enum/`@Pattern` guard on these two fields (see Architect notes). diff --git a/backend/src/main/resources/content/profile/display/architect.md b/backend/src/main/resources/content/profile/display/architect.md new file mode 100644 index 0000000..7b5d1d2 --- /dev/null +++ b/backend/src/main/resources/content/profile/display/architect.md @@ -0,0 +1,60 @@ +--- +module: profile.display +title: Display +tab: Architect +order: 30 +audience: internal +--- + +How **Profile → Display** is built. Two moving parts: a thin Svelte view in `hiveops-profile` and a preferences store owned entirely by `hiveops-auth`. There is **no Kafka, no executor/record-store pipeline, and no dedicated table** for this feature — it is a synchronous read/write of a JSONB blob. + +## Services involved +- **hiveops-profile (frontend only)** — `profile.bcos.cloud`. Svelte SPA. Component: `frontend/src/components/Profile/DisplaySettings.svelte`. It calls `profileAPI` in `frontend/src/lib/api.ts`, which points an axios client at the gateway (`window.__APP_CONFIG__.apiUrl`, fallback `localhost:8080`). +- **hiveops-auth (backend + data owner)** — port **8082**, DB **`hiveiq_auth`**. Owns the user record and the preferences blob. No other service writes Display prefs. See [platform.service-ownership]. + +## Data flow (write) +``` +DisplaySettings.svelte (Save) + └─ profileAPI.updatePreferences({itemsPerPage,dateFormat,timeFormat,timezone}) + └─ PUT {gateway}/auth/api/users/me/preferences (Bearer JWT) + └─ UserProfileController.updatePreferences(@AuthenticationPrincipal) + └─ UserProfileService.updatePreferences(email, req) [@Transactional] + • load User by email + • copy only non-null request fields onto UserPreferences + • userRepository.save(user) ──► UPDATE users SET preferences = +``` +Read path is the mirror: `GET /auth/api/users/me/preferences` → `UserProfileService.getPreferences(email)` → returns the user's `UserPreferences` (or a fresh defaults object if the column is null). + +**Partial-update semantics:** `updatePreferences` applies a field only when the request value is non-null. The Display view sends only its four fields, so notification/appearance/security prefs in the same blob are preserved untouched. This is a deliberate merge, not a full replace. + +## DB tables read/written +- **`users`** (`hiveiq_auth`) — single table. Column `preferences JSONB NOT NULL DEFAULT '{}'` added by Flyway `V9__add_user_preferences.sql`. +- Mapped on the entity via Hibernate `@JdbcTypeCode(SqlTypes.JSON)` + `@Column(columnDefinition = "jsonb")` on `User.preferences` (type `UserPreferences`, a plain `@Data` POJO — **not** a separate `@Entity`, so no join, no child table). +- No other tables. No `device_summary`-style mirror. See [platform.data-architecture]. + +## Kafka +- **None for this feature.** Display preferences produce/consume no topics. (Contrast with device or messaging data.) See [platform.kafka] for where the platform does use eventing — Display simply isn't one of those paths. + +## Key API endpoints (owned by hiveops-auth) +| Method | Path | Purpose | +|--------|------|---------| +| GET | `/auth/api/users/me/preferences` | Fetch current user's full prefs blob (incl. Display) | +| PUT | `/auth/api/users/me/preferences` | Merge-update prefs; returns saved `UserPreferences` | + +Both resolve the target user from the JWT (`@AuthenticationPrincipal UserDetails` → email), never from a path variable — one user can only ever touch their own row. + +## Model (Display subset of `UserPreferences`) +| Field | Type | Default | Validation | +|-------|------|---------|-----------| +| `itemsPerPage` | int | 25 | `@Min(1) @Max(500)` on request DTO | +| `dateFormat` | String | `MM/DD/YYYY` | none server-side | +| `timeFormat` | String | `12-hour` | none server-side (UI: `12-hour`/`24-hour`) | +| `timezone` | String | `America/New_York` | none server-side | + +## Consumption +Display prefs are **applied client-side by each SPA**, not enforced by a gateway or shared filter. Any app that wants to honor a user's page size / date format calls `GET /auth/api/users/me/preferences` and formats locally. hiveops-auth is the single source of truth; rendering is distributed. + +## Cross-links +- [platform.data-architecture] — JSONB-on-entity storage vs dedicated tables +- [platform.kafka] — eventing overview (Display uses none) +- [platform.service-ownership] — auth owns identity + user preferences diff --git a/backend/src/main/resources/content/profile/display/claude.md b/backend/src/main/resources/content/profile/display/claude.md new file mode 100644 index 0000000..30498b5 --- /dev/null +++ b/backend/src/main/resources/content/profile/display/claude.md @@ -0,0 +1,64 @@ +--- +module: profile.display +title: Display +tab: Claude +order: 40 +audience: internal +--- + +Terse agent reference for **Profile → Display** (items-per-page, date/time format, timezone). + +## Ownership +- **Backend + data owner: `hiveops-auth`** (port 8082, DB `hiveiq_auth`). +- **Frontend: `hiveops-profile`** (`profile.bcos.cloud`) — view-only; no backend of its own. +- Do NOT look for a `hiveops-profile` controller/DB — there is none. + +## Endpoints (via gateway; JWT bearer required) +| Method | Path | Notes | +|--------|------|-------| +| GET | `/auth/api/users/me/preferences` | Returns full `UserPreferences` blob | +| PUT | `/auth/api/users/me/preferences` | Merge-update; non-null fields only; returns saved blob | + +- Controller: `UserProfileController` `@RequestMapping("/auth/api/users/me")`. +- Target user = JWT subject (`@AuthenticationPrincipal`). No user-id path param exists. + +## Auth / roles +- Route falls under `.anyRequest().authenticated()` in `SecurityConfig`. +- **Any authenticated role** (CUSTOMER/ADMIN/MSP_ADMIN/BCOS_ADMIN) — edits **own** prefs only. +- NOT `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')`. No admin cross-user path. + +## Storage +| Item | Value | +|------|-------| +| DB | `hiveiq_auth` | +| Table | `users` | +| Column | `preferences` (JSONB, `NOT NULL DEFAULT '{}'`) | +| Migration | `V9__add_user_preferences.sql` | +| Entity | `User.preferences` → `UserPreferences` POJO, `@JdbcTypeCode(SqlTypes.JSON)` | + +## Display fields (subset of the blob) +| Field | Default | Server validation | +|-------|---------|-------------------| +| `itemsPerPage` | 25 | `@Min(1) @Max(500)` | +| `dateFormat` | `MM/DD/YYYY` | none | +| `timeFormat` | `12-hour` | none (`12-hour`\|`24-hour`) | +| `timezone` | `America/New_York` | none | + +## Kafka / topics +- **None.** No producer, no consumer, no record-store/executor path for this feature. + +## Gotchas +- PUT is a **partial merge** — only non-null request fields overwrite; omitting a field leaves it unchanged (safe to send just the 4 Display fields). +- `dateFormat`/`timeFormat`/`timezone` have **no server-side allowlist** — any string is persisted. Bad values propagate to consumer SPAs. +- Prefs are **applied client-side by each SPA** (each app calls GET). "App X ignores format" = that app not reading prefs, not an auth bug. +- Postgres is on the database VM (10.10.10.188) — ProxyJump, not the services VM. +- CORS is enforced by hiveops-auth `CORS_ALLOWED_ORIGINS`; `https://profile.bcos.cloud` must be listed. +- New/pre-V9 users have `preferences = '{}'` → GET still returns entity defaults. + +## Do NOT +- Do NOT create/expect endpoints or tables in `hiveops-profile`. +- Do NOT assume admin gating — these are self-service, per-user. +- Do NOT PUT `itemsPerPage` outside `1..500` (400). +- Do NOT expect a Kafka event or mirror table on Display changes. +- Do NOT try to set another user's prefs via API — no such route; use a direct `hiveiq_auth.users` SQL edit. +- Do NOT full-replace the blob; send only the fields you intend to change. diff --git a/backend/src/main/resources/content/profile/display/internal.md b/backend/src/main/resources/content/profile/display/internal.md new file mode 100644 index 0000000..95282f7 --- /dev/null +++ b/backend/src/main/resources/content/profile/display/internal.md @@ -0,0 +1,47 @@ +--- +module: profile.display +title: Display +tab: Internal +order: 20 +audience: internal +--- + +Ops/support view of **Profile → Display** (items-per-page, date format, time format, timezone). These are **per-user, self-service** preferences — there is no admin override screen and no "manage another user's display settings" path. + +## Who can change what +- **Not admin-gated.** The backend maps `/auth/api/users/me/**` under `.anyRequest().authenticated()` in `hiveops-auth` `SecurityConfig`. Any authenticated principal (CUSTOMER, ADMIN, MSP_ADMIN, BCOS_ADMIN) edits **only their own** preferences — the endpoint keys off the JWT subject (`@AuthenticationPrincipal` email), never a path/user id. +- There is **no** `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` on these routes. If someone asks "how do I set a user's timezone for them" — you can't from the UI; it requires a DB edit (see below). +- Preferences are **personal and isolated** — changing one user's Display settings never affects a teammate (matches the Overview "personal to your account" note). + +## What actually gets stored +- One JSONB blob per user: `users.preferences` in DB **`hiveiq_auth`**. The four Display fields live alongside notification/appearance/security prefs in the same object. +- Display defaults (server-side, `UserPreferences` entity): `itemsPerPage=25`, `dateFormat=MM/DD/YYYY`, `timeFormat=12-hour`, `timezone=America/New_York`. +- A brand-new user (or one migrated before V9) has `preferences = '{}'` — the service returns entity defaults, so the UI shows defaults even though the column is effectively empty until first Save. + +## First things to check when it misbehaves +1. **"My display settings won't save / revert after reload"** → the save is a `PUT /auth/api/users/me/preferences` to **hiveops-auth (port 8082)**. Confirm the request 200s. A 401 = expired/missing bearer token (localStorage `token`); 403 = CORS/gateway, not authorization. +2. **CORS on profile.bcos.cloud** → `hiveops-auth` requires the frontend origin in `CORS_ALLOWED_ORIGINS` (env). `https://profile.bcos.cloud` must be present; a missing origin surfaces as a browser CORS failure, not a server error. +3. **"Save button stays greyed out"** → by design: Save is disabled until a field changes (`unsaved` flag). Not a bug. +4. **"Dates still show US format everywhere"** → Display prefs are **read and applied by each consuming SPA**, not enforced centrally. If one app ignores the pref, that app isn't calling `GET /auth/api/users/me/preferences` — it's an app-side bug, not an auth/profile bug. +5. **Settings load but are wrong per user** → look at the JWT: the wrong `sub`/email = wrong user row. Verify the token, not the preferences. + +## Manual fix (support-side) +Read or reset one user's Display prefs directly (auth DB is on the database VM — ProxyJump, not the services VM): +```sql +-- inspect +SELECT email, preferences FROM users WHERE email = 'user@example.com'; +-- reset just the display fields to defaults +UPDATE users +SET preferences = preferences + || '{"itemsPerPage":25,"dateFormat":"MM/DD/YYYY","timeFormat":"12-hour","timezone":"America/New_York"}'::jsonb +WHERE email = 'user@example.com'; +``` + +## Common failure modes → fix +| Symptom | Likely cause | Fix | +|---------|--------------|-----| +| Save returns 401 | Expired bearer token | Re-login (refresh access token) | +| Save/GET blocked in browser (CORS) | Origin not in `CORS_ALLOWED_ORIGINS` | Add `https://profile.bcos.cloud` to hiveops-auth env, restart | +| `itemsPerPage` rejected (400) | Value out of `1..500` bound | Frontend only offers 10/25/50/100; a custom PUT outside range is validation-rejected | +| Prefs empty for old user | Pre-V9 row / `'{}'` | Harmless — defaults returned; populated on first Save | +| One app ignores format | That SPA not reading prefs | App-side bug; not hiveops-auth | diff --git a/backend/src/main/resources/content/profile/inbox/architect.md b/backend/src/main/resources/content/profile/inbox/architect.md new file mode 100644 index 0000000..84c5abc --- /dev/null +++ b/backend/src/main/resources/content/profile/inbox/architect.md @@ -0,0 +1,59 @@ +--- +module: profile.inbox +title: Inbox +tab: Architect +order: 30 +audience: internal +--- + +How the Profile **Inbox** is built and how data flows. The feature spans three services: the SPA (`hiveops-profile`), the message store (`hiveops-messaging`), plus two read-only lookups (`hiveops-auth`, `hiveops-mgmt`). See [platform.service-ownership]. + +## Services involved +| Concern | Service | Where | +|---|---|---| +| UI (view/compose/tabs) | `hiveops-profile` | `frontend/src/components/Profile/Inbox.svelte` | +| Message CRUD + inbox/sent/read/delete | `hiveops-messaging` (8086, `hiveiq_messaging`) | `MessageController` → `MessageService` | +| Recipient picker (`GET /auth/api/admin/users`) | `hiveops-auth` | `UserAdminController` | +| Institution dropdown (`GET /api/v1/msp/institutions`) | `hiveops-mgmt` | `MspController` | + +Every call carries the same JWT (Bearer). Messaging validates it with `hiveops-security-common`'s `JwtTokenValidator` and the shared `JWT_SECRET`, building a `MessagingPrincipal(userId, email, role, institutionKey)` from the `sub` / `email` / `role` / `institutionKey` claims (`JwtAuthenticationFilter`). + +## Read path (inbox / sent) +1. SPA calls `GET /messaging/messages?page&size&sort=createdAt,desc` (nginx rewrites `/messaging/` → `/api/messaging/`). +2. `MessageService.getInbox(userId, institutionKey, pageable)` runs `MessageRepository.findInboxForUser`, which returns rows where: + - `type <> BROADCAST AND recipient_user_id = userId` (DIRECT / ACTION addressed to you), **or** + - `type = BROADCAST AND institution_key IS NULL` (global), **or** + - `type = BROADCAST AND institution_key = :institutionKey` (scoped to your institution), + - excluding any `message_deletes` rows for you, newest first. +3. Per-row read state is resolved by loading the user's `message_reads` ids once and setting `MessageResponse.read`. +4. **Sent** (`GET /messaging/messages/sent`) → `findSentByUser` where `sender_user_id = userId`, minus your soft-deletes; always flagged read. + +## Write path (compose) — REST → DB → Kafka +`MessageService.sendMessage()` is the executor→store→publish pattern (see [platform.kafka]): +1. Validate (DIRECT requires `recipientUserId`; BROADCAST allowed only if `principal.isAdmin()`). +2. **Persist first** — save the `messages` row (scoped broadcasts store `institution_key`). +3. **Then publish** a `MessageEvent` to Kafka topic **`hiveops.messages`** with `sourceService = "hiveops-messaging"`. +4. Direct-to-many: the SPA fires one POST per selected recipient (`Promise.all`), so each becomes its own `messages` row. + +## Inbound Kafka (external ACTION / system messages) +- `MessageConsumer` (`@KafkaListener` on `hiveops.messages`, group `hiveops-messaging`) consumes events. It **skips events where `sourceService = "hiveops-messaging"`** (already persisted via REST) to avoid double-writes, then calls `persistFromEvent()` to insert a `messages` row (typically `type=ACTION`). This is how other services (e.g. incident alerts) drop notifications into a user's inbox. +- `ReportCompletedConsumer` (`@KafkaListener` on `hiveops.reports.completed`) fans a completed report out to `recipientUserIds` as `DIRECT` messages — same store, surfaces in this same inbox. +- Group fan-out: if an event/request carries `recipientGroupId`, the service expands it to member user-ids (`ContactGroup` / `ContactGroupMember`) and writes one row per member. + +## DB tables (`hiveiq_messaging`) +| Table | Role | Key columns | +|---|---|---| +| `messages` | canonical message | `id`, `uuid` (unique), `type` (DIRECT\|BROADCAST\|ACTION), `sender_user_id`, `sender_email`, `recipient_user_id` (NULL for BROADCAST), `institution_key` (broadcast scope), `subject`, `body`, `metadata` (jsonb), `created_at` | +| `message_reads` | per-user read state | unique `(message_id, user_id)`, `read_at` | +| `message_deletes` | per-user soft-delete | composite PK `(message_id, user_id)`, `deleted_at` | + +Read and delete are modeled as **side tables**, never mutations of `messages` — the same broadcast row is independently read/deleted per user. See [platform.data-architecture]. + +## Kafka topics +| Topic | Direction | Consumer group | Purpose | +|---|---|---|---| +| `hiveops.messages` | publish + consume | `hiveops-messaging` | outbound after REST send; inbound external ACTION events (self-filtered by `sourceService`) | +| `hiveops.reports.completed` | consume | (report-completed listener) | report-run notifications → DIRECT inbox messages | + +## Cross-links +[platform.service-ownership] · [platform.kafka] · [platform.data-architecture] diff --git a/backend/src/main/resources/content/profile/inbox/claude.md b/backend/src/main/resources/content/profile/inbox/claude.md new file mode 100644 index 0000000..cc137e2 --- /dev/null +++ b/backend/src/main/resources/content/profile/inbox/claude.md @@ -0,0 +1,65 @@ +--- +module: profile.inbox +title: Inbox +tab: Claude +order: 40 +audience: internal +--- + +Terse agent reference. Owner service: **`hiveops-messaging`** (port 8086, DB `hiveiq_messaging`). UI: `hiveops-profile/frontend/src/components/Profile/Inbox.svelte`. + +## Ownership +- Message store/CRUD → `hiveops-messaging` (`MessageController` `@RequestMapping("/api/messaging/messages")`). +- Recipient picker → `hiveops-auth` `GET /auth/api/admin/users`. +- Institution dropdown → `hiveops-mgmt` `GET /api/v1/msp/institutions`. +- Do NOT add message tables/endpoints to profile, auth, or incident — messaging owns messages. + +## Endpoints (backend paths; SPA hits `/messaging/...`, nginx prepends `/api`) +| METHOD | Path | Notes | +|---|---|---| +| GET | `/api/messaging/messages?page&size&sort` | paged inbox (Page) | +| GET | `/api/messaging/messages/sent` | sent list (sender_user_id = you) | +| GET | `/api/messaging/messages/unread` | unread list | +| GET | `/api/messaging/messages/unread/count` | `{unreadCount}`; cached per userId | +| POST | `/api/messaging/messages` | send; body `SendMessageRequest`; 201 | +| PATCH | `/api/messaging/messages/{id}/read` | mark one read; 204 | +| PATCH | `/api/messaging/messages/read-all` | mark all read; 204 | +| DELETE | `/api/messaging/messages/{id}` | per-user soft-delete; 204 | +| GET | `/auth/api/admin/users` | recipients; `authenticated()` (not admin-gated) | +| GET | `/api/v1/msp/institutions` | needs `MSP_ADMIN`/`BCOS_ADMIN` | + +## Send payload (`SendMessageRequest`) +- `type`: `DIRECT` | `BROADCAST` (`ACTION` used internally / via Kafka, not user-composed). +- `recipientUserId` required unless BROADCAST. +- `institutionKey` optional (BROADCAST scope; blank/absent = global). +- `subject?`, `body` (required), `metadata?`, `recipientGroupId?` (fan-out). + +## Tables (`hiveiq_messaging`) +- `messages` — `id,uuid,type,sender_user_id,sender_email,recipient_user_id,institution_key,subject,body,metadata(jsonb),created_at`. +- `message_reads` — unique `(message_id,user_id)`, `read_at`. +- `message_deletes` — PK `(message_id,user_id)`, `deleted_at`. + +## Kafka topics +- `hiveops.messages` — messaging publishes after REST send AND consumes external events (group `hiveops-messaging`); self-events filtered by `sourceService`. +- `hiveops.reports.completed` — consumed → DIRECT messages to `recipientUserIds`. + +## Roles +- BROADCAST requires `MessagingPrincipal.isAdmin()` = role ∈ {`ADMIN`,`MSP_ADMIN`,`BCOS_ADMIN`} (case-insensitive), enforced in controller (manual check, not `@PreAuthorize`). +- DIRECT/read/delete: any authenticated user with access (recipient, sender, or matching-institution broadcast) via `verifyMessageAccess`. +- Principal claims: `sub`→userId, `email`, `role`, `institutionKey`. Shared `JWT_SECRET` across auth/mgmt/messaging. + +## Gotchas +- SPA base is `/messaging/messages`; real controller is `/api/messaging/messages` — nginx rewrites the `/api` segment. Don't assume the SPA path == controller path. +- Institution-scoped broadcasts appear ONLY when JWT `institutionKey` matches `messages.institution_key`; global broadcasts have `institution_key = NULL`. +- Delete = soft + per-user (writes `message_deletes`, also marks read); the `messages` row and other users are untouched. +- Inbox query hardcodes `ORDER BY created_at DESC` — the SPA's `sort` param is effectively redundant. +- unread count is `@Cacheable("unreadCount", key=userId)`, evicted on read/read-all/delete. +- `loadRecipients()` and `loadInstitutions()` fail silently in the SPA (non-fatal catch). + +## Do NOT +- Do NOT create message endpoints/tables outside `hiveops-messaging`. +- Do NOT hard-delete from `messages` to "remove" a user's message — use per-user soft-delete. +- Do NOT publish to `hiveops.messages` from messaging's own REST path without `sourceService="hiveops-messaging"` (consumer would double-persist). +- Do NOT expect BROADCAST to carry `recipient_user_id` (always NULL). +- Do NOT gate the recipient picker as admin-only — `/auth/api/admin/users` GET is `authenticated()`. +- Do NOT call `/api/v1/msp/institutions` for non-admins (403). diff --git a/backend/src/main/resources/content/profile/inbox/internal.md b/backend/src/main/resources/content/profile/inbox/internal.md new file mode 100644 index 0000000..14c35a3 --- /dev/null +++ b/backend/src/main/resources/content/profile/inbox/internal.md @@ -0,0 +1,48 @@ +--- +module: profile.inbox +title: Inbox +tab: Internal +order: 20 +audience: internal +--- + +Ops/support view of the Profile **Inbox**. The UI lives in `hiveops-profile` (`Inbox.svelte`) but **all message data is owned by `hiveops-messaging`** (port 8086, DB `hiveiq_messaging`). If the inbox misbehaves, the profile SPA is almost never the cause — check messaging first. + +## Who owns what +- **Frontend:** `hiveops-profile/frontend` → `src/components/Profile/Inbox.svelte`, API in `src/lib/api.ts`. +- **Backend:** `hiveops-messaging` → `MessageController` + `MessageService`. Everything under `/messaging/messages` proxies here. +- Recipient picker and institution dropdown are **cross-service** reads (auth + mgmt), not messaging — see below. + +## Roles & admin-only actions +- **Reading / sending Direct / delete / mark-read** — any authenticated user (recipient, sender, or a broadcast to their institution). +- **Broadcast** — admin only. `MessageController.send()` rejects `type=BROADCAST` unless `MessagingPrincipal.isAdmin()`, which is true for role `ADMIN`, `MSP_ADMIN`, or `BCOS_ADMIN` (case-insensitive). Note: this is a manual principal check in the controller, **not** a `@PreAuthorize` annotation. Non-admins never see the Broadcast tab or Institution selector in the UI (`isAdmin` gate on `role ∈ {ADMIN, MSP_ADMIN, BCOS_ADMIN}`). +- **Institution-scoped broadcast** — admin picks an institution in Compose; blank = global (all institutions). Backend stores `institution_key` on the message; a user only sees a scoped broadcast if their JWT `institutionKey` claim matches (or the broadcast is global/NULL). +- **Compose recipient list** comes from `GET /auth/api/admin/users` — that endpoint is `authenticated()` (any logged-in user), so the picker works for non-admins too. +- **Compose institution list** comes from `GET /api/v1/msp/institutions` (mgmt) which **requires `MSP_ADMIN`/`BCOS_ADMIN`** — non-admins never call it. + +## First things to check when it misbehaves +1. **Is `hiveiq-messaging` up?** `curl http://localhost:8086/actuator/health`. A dead messaging container = empty inbox / spinner, even though the Profile app itself loads. +2. **401/403 on `/messaging/messages`** — JWT problem. Messaging validates the token with the **shared `JWT_SECRET`** (same secret as auth + mgmt). A rotated/mismatched secret across services breaks messaging silently. Confirm `JWT_SECRET` matches auth. +3. **Empty inbox but user expects messages** — check `institution_key` scoping. Institution-scoped broadcasts only appear when the JWT `institutionKey` claim equals the message's `institution_key`. A user with a missing/blank claim sees only DIRECT messages + global broadcasts. +4. **Unread count wrong / stale** — the count is `@Cacheable(value="unreadCount", key=userId)`. It is evicted on mark-read / mark-all-read / delete. If a count looks stuck, the cache is per-userId; a rare desync self-corrects on next send/read. +5. **Action/system messages missing** — these arrive via Kafka (`hiveops.messages` topic) from external services. If ACTION messages stopped, check Kafka connectivity and the `hiveops-messaging` consumer group — not the Profile app. +6. **Compose "To" list empty** — `GET /auth/api/admin/users` failed. `loadRecipients()` swallows the error (non-fatal), so the picker just shows "No users found". Check auth service health. +7. **Institution dropdown empty for an admin** — `GET /api/v1/msp/institutions` (mgmt) failed or the user lacks `MSP_ADMIN`/`BCOS_ADMIN`. Frontend logs `[Inbox] loadInstitutions failed`. + +## Common failure modes → fix +| Symptom | Likely cause | Fix | +|---|---|---| +| Whole inbox blank + toast "Load failed" | messaging down / 5xx | restart `hiveiq-messaging`, check logs | +| 401 on every messaging call | JWT_SECRET mismatch after redeploy | align `JWT_SECRET` across auth/mgmt/messaging | +| Scoped broadcast not seen by target users | JWT missing `institutionKey` claim | verify auth issues `institutionKey` in token | +| ACTION alerts not arriving | Kafka down / consumer lag | check broker + `hiveops-messaging` consumer group | +| Broadcast button missing for an admin | role not in {ADMIN,MSP_ADMIN,BCOS_ADMIN} | fix the user's role claim | +| Deleted message reappears | delete is **per-user soft-delete** only | expected — other users still see it | + +## Data behaviors worth knowing +- **Delete is per-user and soft.** It writes a `message_deletes` row and also marks the message read; the underlying `messages` row is never removed and other recipients are unaffected. +- **Mark-read is per-user** via `message_reads`. "Sent" messages always render as read. +- **Sent tab** lists rows where `sender_user_id = you`, excluding your own soft-deletes. +- Inbox auto-refreshes every 60s (client-side timer, toggle persisted in `localStorage` key `profile_inbox_autoRefresh`). + +Related: [profile.inbox] Overview (customer), [platform.service-ownership]. diff --git a/backend/src/main/resources/content/profile/notifications/architect.md b/backend/src/main/resources/content/profile/notifications/architect.md new file mode 100644 index 0000000..8fe84d6 --- /dev/null +++ b/backend/src/main/resources/content/profile/notifications/architect.md @@ -0,0 +1,70 @@ +--- +module: profile.notifications +title: Notifications +tab: Architect +order: 30 +audience: internal +--- + +How the **Notifications** preference feature is built. It is a thin, self-scoped CRUD over a JSON column — no async pipeline, no Kafka, no executor→topic→record-store pattern. + +## Services involved +- **hiveops-profile** (frontend only) — Svelte SPA at `profile.bcos.cloud`. No backend module; `hiveops-profile/backend` does not exist. Component: `frontend/src/components/Profile/NotificationSettings.svelte`, data layer `frontend/src/lib/api.ts` (`profileAPI`). +- **hiveops-auth** — the sole backend of record. Port 8082, NGINX `/api/auth/`, DB `hiveiq_auth`. Owns the `users` table and the embedded preferences JSON. +- The frontend also talks to `hiveops-messaging` and `hiveops-mgmt` for other Profile screens, but the Notifications tab uses **only** the auth service. + +See [platform.service-ownership] — user identity + preferences are auth's domain; no other service writes them. + +## Data flow (synchronous, request/response) +``` +NotificationSettings.svelte + └─ profileAPI.getPreferences() → GET /auth/api/users/me/preferences + └─ profileAPI.updatePreferences() → PUT /auth/api/users/me/preferences + (baseURL = window.__APP_CONFIG__.apiUrl → NGINX gateway → hiveiq-auth) + Authorization: Bearer + +hiveops-auth + UserProfileController (@RequestMapping "/auth/api/users/me") + └─ UserProfileService.getPreferences(email) / updatePreferences(email, req) + └─ UserRepository.findByEmail(principal) → User.preferences (JSON) + └─ userRepository.save(user) → UPDATE users SET preferences = ?::jsonb +``` +- **No Kafka topics** are produced or consumed by this feature. See [platform.kafka] — this module is intentionally absent from the event bus. +- **No async executor / record-store.** Read and write are both in-request, `@Transactional` on the write. + +## DB tables read/written +- **`users`** (schema `hiveiq_auth`) — read and written. + - Column **`preferences`** — `JSONB NOT NULL DEFAULT '{}'` (added by Flyway `V9__add_user_preferences.sql`). + - Hibernate mapping: `@JdbcTypeCode(SqlTypes.JSON)` + `@Column(columnDefinition = "jsonb")` on `User.preferences`, deserialized to the `UserPreferences` POJO. +- No join tables, no per-preference rows. All notification fields are keys inside the one JSON document. See [platform.data-architecture] for the JSONB-column-vs-table tradeoff. + +## The preferences document (notification subset) +`UserPreferences` (POJO, not an `@Entity`) — notification-relevant fields with defaults: +| Field | Type | Default | +|---|---|---| +| `emailNotificationsEnabled` | boolean | `true` | +| `pushNotificationsEnabled` | boolean | `true` | +| `notifyOnCritical` | boolean | `true` | +| `notifyOnHigh` | boolean | `true` | +| `notifyOnAssignment` | boolean | `false` | +| `notifyOnStatusChange` | boolean | `false` | +| `notificationQuietStart` | String `HH:MM` \| null | `null` | +| `notificationQuietEnd` | String `HH:MM` \| null | `null` | + +The same document also carries display/appearance/security prefs (itemsPerPage, dateFormat, timeFormat, timezone, themeMode, tableDensity, sessionTimeoutMinutes) — one blob, one column, shared across all Profile tabs. + +## Key API endpoints +| Method | Path | Purpose | Auth | +|---|---|---|---| +| GET | `/auth/api/users/me/preferences` | Read caller's full prefs doc | authenticated (self) | +| PUT | `/auth/api/users/me/preferences` | Merge-update caller's prefs | authenticated (self) | +| GET | `/auth/api/users/me` | Caller's profile (not prefs) | authenticated (self) | + +## Merge semantics (important for correctness) +`updatePreferences` applies each incoming field only when non-null (`if (req.getX() != null) prefs.setX(...)`), so partial PUTs are safe — the Notifications tab sends only its own fields and leaves display/appearance/security untouched. **Consequence:** a `null` for a quiet-hours field is a no-op, so quiet hours cannot be cleared through this endpoint once set (see Internal tab). + +## What is NOT here (by design / by gap) +- No producer to any notification/delivery topic; no consumer reads these flags. Delivery wiring (email/push, severity gating, quiet-hours enforcement) does not exist in the codebase yet — the screen is a stored-preference surface awaiting a consumer. +- No admin/cross-user editing path; identity is pinned to the JWT principal. + +Cross-links: [platform.data-architecture] · [platform.kafka] · [platform.service-ownership] diff --git a/backend/src/main/resources/content/profile/notifications/claude.md b/backend/src/main/resources/content/profile/notifications/claude.md new file mode 100644 index 0000000..6c64e9f --- /dev/null +++ b/backend/src/main/resources/content/profile/notifications/claude.md @@ -0,0 +1,50 @@ +--- +module: profile.notifications +title: Notifications +tab: Claude +order: 40 +audience: internal +--- + +Terse agent reference. Act from this; do not infer beyond it. + +## Ownership +- **Owning backend:** `hiveops-auth` (port 8082, NGINX `/api/auth/`, DB `hiveiq_auth`). +- **Frontend:** `hiveops-profile` → `frontend/src/components/Profile/NotificationSettings.svelte`; data via `frontend/src/lib/api.ts` `profileAPI`. +- **No** `hiveops-profile/backend`. **No** Kafka. **No** other service reads these flags. + +## Endpoints (exact) +| Method | Path | Notes | +|---|---|---| +| GET | `/auth/api/users/me/preferences` | returns full `UserPreferences` JSON for caller | +| PUT | `/auth/api/users/me/preferences` | merge-update; null field = skip | +| GET | `/auth/api/users/me` | profile (not prefs) | +- Frontend baseURL = `window.__APP_CONFIG__.apiUrl` (NGINX gateway, default `http://localhost:8080`) → auth. Header: `Authorization: Bearer `. + +## Auth / roles +- Security chain `/auth/api/**` → `.anyRequest().authenticated()`. **No** `@PreAuthorize`, **no** `hasAnyRole`. +- Row resolved by `@AuthenticationPrincipal` (JWT email). Strictly self-scoped. No admin cross-user path. + +## Storage (exact) +- Table **`users`**, column **`preferences`** — `JSONB NOT NULL DEFAULT '{}'` (Flyway `V9__add_user_preferences.sql`). +- Hibernate: `@JdbcTypeCode(SqlTypes.JSON)` on `User.preferences` → POJO `UserPreferences` (not an entity). +- Notification JSON keys: `emailNotificationsEnabled`, `pushNotificationsEnabled`, `notifyOnCritical`, `notifyOnHigh`, `notifyOnAssignment`, `notifyOnStatusChange`, `notificationQuietStart`, `notificationQuietEnd`. +- Defaults: email/push/critical/high = `true`; assignment/statusChange = `false`; quiet start/end = `null`. + +## Validation (PUT body → `UpdatePreferencesRequest`) +- `notificationQuietStart` / `notificationQuietEnd`: regex `^([01]\d|2[0-3]):[0-5]\d$` (24h `HH:MM`) or omit. +- (Non-notification, same doc) `sessionTimeoutMinutes` 5–480; `itemsPerPage` 1–500. + +## Gotchas +- Merge is null-skip: PUT `notificationQuietStart: null` does **not** clear it — old value persists. To clear, edit DB: `UPDATE users SET preferences = preferences - 'notificationQuietStart' - 'notificationQuietEnd' WHERE email=...`. +- Flags are **stored only** — no consumer anywhere sends email/push, gates on severity, or enforces quiet hours. Changing toggles has no delivery effect currently. +- Empty/`{}` `preferences` column reads as all-defaults, not an error. +- DB is on VM `10.10.10.188` (ProxyJump via services VM), not the services host. +- CORS: `profile.bcos.cloud` must be in `hiveiq-auth` `CORS_ALLOWED_ORIGINS` (and mgmt). + +## Do NOT +- Do NOT look for a `hiveops-profile` backend, a `preferences` table, or a Kafka topic — none exist. +- Do NOT claim admin/`MSP_ADMIN` can edit another user's notification prefs here. +- Do NOT tell a user these toggles change alert delivery — no pipeline consumes them yet. +- Do NOT send `null` to clear quiet hours and expect it to clear (server skips nulls). +- Do NOT route these calls to incident/messaging/devices — the Notifications tab uses only `hiveops-auth`. diff --git a/backend/src/main/resources/content/profile/notifications/internal.md b/backend/src/main/resources/content/profile/notifications/internal.md new file mode 100644 index 0000000..fcc0133 --- /dev/null +++ b/backend/src/main/resources/content/profile/notifications/internal.md @@ -0,0 +1,55 @@ +--- +module: profile.notifications +title: Notifications +tab: Internal +order: 20 +audience: internal +--- + +Ops/support view of the **Notifications** preference screen. This is a **self-service, per-user** settings page — it is NOT an admin console. Every user edits only their own preferences; there is no admin-facing "edit another user's notification settings" path in this feature. + +## Who owns it +- **Backend:** `hiveops-auth` (port 8082, NGINX `/api/auth/`, DB `hiveiq_auth`). +- **Frontend:** `hiveops-profile` → `NotificationSettings.svelte`, served at `profile.bcos.cloud`. +- Preferences live as a JSONB blob on the `users` row — there is no separate preferences table. + +## Roles required +- **Any authenticated user.** The endpoints sit under the `/auth/api/**` chain's `.anyRequest().authenticated()` rule — no `@PreAuthorize`, no `hasAnyRole(...)`. +- Scope is enforced by identity, not role: the controller resolves the row via `@AuthenticationPrincipal` (the JWT's email/username). A user can only ever read/write **their own** preferences. +- There is **no** `MSP_ADMIN`/`BCOS_ADMIN` override for this screen. Admins editing a customer's notification prefs is not supported here. + +## First things to check when it misbehaves +1. **"My settings won't save"** → open dev tools, watch `PUT /auth/api/users/me/preferences`. + - `401` → expired/missing JWT (localStorage `token`). Re-login. Auth access tokens are 24h. + - `400` → validation reject. Quiet-hours fields must match `HH:MM` 24-hour (`^([01]\d|2[0-3]):[0-5]\d$`). A malformed time is the usual culprit. + - `403`/CORS → `profile.bcos.cloud` origin missing from `CORS_ALLOWED_ORIGINS` on `hiveiq-auth` (and mgmt). See hiveops-auth CLAUDE.md. +2. **"Settings saved but I still don't get emails/push"** → this is expected, not a bug in the screen. See failure modes below — nothing consumes these flags yet. +3. **"Can't turn quiet hours back off"** → known behavior: clearing a quiet-hours field does not persist. See failure modes. +4. **Preferences reset / all defaults** → the `users.preferences` JSONB is `{}` or missing for that row. Defaults come from the `UserPreferences` POJO (email/push/critical/high ON; assignment/status-change OFF; quiet hours null), so a blank column reads as "defaults", not an error. + +## Common failure modes + fixes +| Symptom | Likely cause | Fix | +|---|---|---| +| PUT returns 400 on save | Quiet start/end not `HH:MM` 24h | Enter a valid time or clear (see caveat) | +| Save 401 | Stale JWT | Re-login on `profile.bcos.cloud` | +| Save 403 / CORS error | `profile.bcos.cloud` not in `CORS_ALLOWED_ORIGINS` | Add origin to `hiveiq-auth` env, restart | +| Toggles save, no alerts arrive | No delivery pipeline reads these flags | Product gap — set expectations, do not "fix" in this screen | +| Quiet hours won't clear | Service skips null fields on merge | Known bug — DB edit or backend fix needed | + +## Direct DB inspection (hiveiq_auth, on database VM 10.10.10.188 via ProxyJump) +```sql +SELECT email, preferences FROM users WHERE email = 'user@example.com'; +-- notification keys inside the JSONB: +-- emailNotificationsEnabled, pushNotificationsEnabled, +-- notifyOnCritical, notifyOnHigh, notifyOnAssignment, notifyOnStatusChange, +-- notificationQuietStart, notificationQuietEnd +``` +To force-clear a stuck quiet window until the merge bug is fixed: +```sql +UPDATE users +SET preferences = preferences - 'notificationQuietStart' - 'notificationQuietEnd' +WHERE email = 'user@example.com'; +``` + +## Reality check (tell support this up front) +The four severity toggles, both channel toggles, and quiet hours are **stored** but, at the time of writing, **not consumed by any service**. A repo-wide search found no reader of these flags outside `hiveops-auth`. So changes here are recorded correctly but currently have no observable effect on whether/when alerts are delivered. Treat "notifications not respecting my prefs" as a product-wiring gap, not a bug in this page. diff --git a/backend/src/main/resources/content/profile/profile/architect.md b/backend/src/main/resources/content/profile/profile/architect.md new file mode 100644 index 0000000..9497583 --- /dev/null +++ b/backend/src/main/resources/content/profile/profile/architect.md @@ -0,0 +1,54 @@ +--- +module: profile.profile +title: Profile +tab: Architect +order: 30 +audience: internal +--- + +How the **Profile** tab is built. It is a self-service editor over a single `users` row, served entirely by **hiveops-auth**, with one synchronous cross-service call to **hiveops-mgmt** to resolve the display institution. There is **no Kafka, no executor, and no record-store** in this feature — it is a plain request/response CRUD over a relational table plus a jsonb column. + +## Services involved +| Service | Role in this feature | +|---|---| +| **hiveops-profile** (frontend) | Svelte SPA `ProfileInfo.svelte`; port 5184; `profile.bcos.cloud`. Holds state in the `userInfo` store, does client-side avatar crop/size-check. | +| **hiveops-auth** (backend, owner) | Port 8082 / prod host 8001. Owns `UserProfileController` + `UserProfileService`, the `users` table, and DB `hiveiq_auth`. Source of truth for all editable fields. | +| **hiveops-mgmt** | Called synchronously at response-build time to turn the user's email domain into a company/institution display name. Optional — degrades to `null`. | + +See [platform.service-ownership] — user identity/profile is an **auth-owned** domain; mgmt owns institution/company mapping. + +## Data flow (read) +1. SPA calls `GET /auth/api/users/me` with `Authorization: Bearer `. +2. `UserProfileController.getMe` resolves the caller from `@AuthenticationPrincipal` (email) → `userRepository.findByEmail`. +3. `buildResponse` derives the email **domain** and calls `MgmtServiceClient.getCompanyName(domain)` → HTTP `GET {mgmt}/api/internal/customers/domain/{domain}/institution-keys` with header `X-Internal-Secret`. It reads `companyName` from the JSON body; on any failure it returns `null` (graceful degrade). +4. `UserMeResponse.from(user, companyName)` maps entity → DTO. **The `institution` field in the response is the mgmt companyName, not the `users.institution` column.** + +## Data flow (write) +1. SPA `PUT /auth/api/users/me` with `{ name, phone, jobTitle, avatarData }`. +2. `UserProfileService.updateProfile` loads the user by email, applies **only non-null** fields (`name`, `phone`, `jobTitle`, `avatarData`), and `save()`s. +3. Controller re-runs `buildResponse` (another mgmt call) and returns the fresh `UserMeResponse`; the SPA writes it back into the `userInfo` store. + +There are **no Kafka topics** produced or consumed by this feature (contrast with device/incident flows in [platform.kafka]). Avatar bytes travel inline as a base64 data URL in the request/response body — there is no object store or CDN in the path. + +## DB tables (all in `hiveiq_auth`) +| Table | Access | Columns this feature touches | +|---|---|---| +| `users` | read + write | `email` (lookup key), `name`, `phone`, `job_title`, `avatar_data` (TEXT), `role`, `mfa_enabled`, `fleet_task_approver`, `institution` (present but **not** surfaced in the /me response), `preferences` (jsonb) | + +- Editable profile columns were added in Flyway migration `V10__add_profile_fields.sql` (`phone`, `job_title`, `institution`, `avatar_data`). +- `avatar_data` is unbounded `TEXT` holding a `data:image/jpeg;base64,...` string (client crops to 200×200, JPEG q0.85). +- `preferences` is a single `jsonb` column (theme, notifications, display, session timeout) — edited by the **sibling** Appearance/Display/Notification tabs via `/auth/api/users/me/preferences`, not by this Profile tab. + +See [platform.data-architecture] for the per-service DB ownership model. + +## Key API endpoints (this feature) +| Method | Path | Handler | Purpose | +|---|---|---|---| +| GET | `/auth/api/users/me` | `UserProfileController.getMe` | Load profile + resolved institution | +| PUT | `/auth/api/users/me` | `UserProfileController.updateMe` → `UserProfileService.updateProfile` | Partial update of name/phone/jobTitle/avatar | + +(Adjacent, not this tab: `GET|PUT /auth/api/users/me/preferences`, `POST /auth/api/change-password`.) + +## Design notes / coupling +- **Auth → mgmt is a hard synchronous dependency for the institution label only.** Every `GET`/`PUT` of `/users/me` makes a REST call to mgmt. It fails soft (institution → null) but adds latency and couples auth to mgmt availability. Consider caching the domain→company mapping if this hot path grows. +- Identity is always the JWT subject email; there is no path to edit another user's profile through this controller, so no ownership/authorization branching is needed. diff --git a/backend/src/main/resources/content/profile/profile/claude.md b/backend/src/main/resources/content/profile/profile/claude.md new file mode 100644 index 0000000..dc2c3cc --- /dev/null +++ b/backend/src/main/resources/content/profile/profile/claude.md @@ -0,0 +1,55 @@ +--- +module: profile.profile +title: Profile +tab: Claude +order: 40 +audience: internal +--- + +Terse agent reference for the **Profile** tab (`ProfileInfo.svelte`). Self-service edit of the caller's own `users` row. + +## Owner +- **Backend owner:** hiveops-auth (port 8082, prod host 8001), DB `hiveiq_auth`, table `users`. +- **Frontend:** hiveops-profile SPA, `ProfileInfo.svelte`, port 5184, `profile.bcos.cloud`. +- **Cross-call:** hiveops-mgmt (institution label only). + +## Endpoints (exact) +| Method | Path | Body / returns | +|---|---|---| +| GET | `/auth/api/users/me` | → `UserMeResponse` | +| PUT | `/auth/api/users/me` | `{ name?, phone?, jobTitle?, avatarData? }` → `UserMeResponse` | +| GET (internal, mgmt) | `/api/internal/customers/domain/{domain}/institution-keys` | header `X-Internal-Secret`; reads `companyName` | + +- Via NGINX prod: prefix `https://api.bcos.cloud` → `.../auth/api/users/me`. +- Adjacent tabs (NOT this module): `GET|PUT /auth/api/users/me/preferences`, `POST /auth/api/change-password`. + +## Auth / roles +- Identity = JWT subject email (`@AuthenticationPrincipal`). **No role annotation** — any authenticated user, own record only. +- Do NOT assume `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` here; that gate does **not** exist on these endpoints. + +## Storage (table `users`, DB `hiveiq_auth`) +| Column | Notes | +|---|---| +| `name`, `phone`, `job_title` | editable; partial update (non-null only) | +| `avatar_data` | TEXT, base64 `data:image/jpeg;base64,...`; client crops 200×200, JPEG q0.85 | +| `institution` | column exists (V10) but **NOT** returned in `/me` response | +| `preferences` | jsonb; owned by preferences endpoints, not this tab | + +- Migration: `V10__add_profile_fields.sql`. + +## Kafka / executor +- **None.** No topics produced/consumed, no executor→Kafka→record-store pattern in this feature. + +## Gotchas +- `UserMeResponse.institution` = **mgmt `companyName` resolved from email domain**, not `users.institution`. Blank institution ⇒ mgmt down / domain unmapped / bad `X-Internal-Secret`. +- 2 MB avatar limit is **client-side only** (`file.size > 2*1024*1024`); server does **not** validate avatar size — a direct `PUT` can store an arbitrarily large blob in `avatar_data` TEXT. +- Every `GET`/`PUT /users/me` makes a synchronous mgmt REST call (institution label). mgmt failure ⇒ institution `null`, not an error. +- `updateProfile` applies only non-null fields; sending `avatarData: ""` (empty string, as the "Remove" button does) clears the avatar; omitting the field leaves it unchanged. + +## Do NOT +- Do NOT route profile reads/writes to any service other than hiveops-auth. +- Do NOT invent a per-user admin edit endpoint — none exists on this tab. +- Do NOT rely on `users.institution` for the displayed institution. +- Do NOT trust the 2 MB cap on the server side. +- Do NOT expect a Kafka event when a profile changes. +- Do NOT use this tab's endpoints for password/MFA changes (Security tab: `POST /auth/api/change-password`). diff --git a/backend/src/main/resources/content/profile/profile/internal.md b/backend/src/main/resources/content/profile/profile/internal.md new file mode 100644 index 0000000..9d340a1 --- /dev/null +++ b/backend/src/main/resources/content/profile/profile/internal.md @@ -0,0 +1,50 @@ +--- +module: profile.profile +title: Profile +tab: Internal +order: 20 +audience: internal +--- + +Ops/support view of the **Profile** tab (`ProfileInfo.svelte`) in the HiveIQ profile SPA (`profile.bcos.cloud`, container port 5184). It is a thin self-service editor over one user row in **hiveops-auth**. There is no admin-only action here — every user edits **only their own** record, identified by the email in their JWT. + +## Who can do what +- **Any authenticated user** can read and edit their own name, job title, phone, and avatar. There is **no** `@PreAuthorize` / role gate on these endpoints — identity comes from `@AuthenticationPrincipal` (`userDetails.getUsername()` = the caller's email). +- There is **no admin endpoint to edit another user's** name/phone/avatar from this screen. Editing someone else's profile is not possible via this tab. +- Email, System role, and Institution are **read-only** here. Role and license are managed in `hiveops-mgmt`; email is the login identity. + +## What the tab actually calls +- `GET /auth/api/users/me` — loads the current values (fired by the SPA shell, cached in the `userInfo` store). +- `PUT /auth/api/users/me` — saves `{ name, phone, jobTitle, avatarData }`. Only non-null fields are applied (partial update). +- Backend: **hiveops-auth** (service port 8082, prod host port 8001), DB **`hiveiq_auth`**, table **`users`**. + +## First things to check when it misbehaves +1. **"Save failed" toast** → the `PUT /auth/api/users/me` returned non-2xx. Check auth-service logs (Loki, service `hiveiq-auth`) and confirm the caller's JWT is valid/unexpired. A 401 means the token expired (access token = 24h). +2. **Institution shows blank / "—"** → this field is **not** stored on the user row. It is resolved live from **hiveops-mgmt** by the email domain at response-build time (`MgmtServiceClient.getCompanyName`). If mgmt is down or the domain isn't mapped, it degrades to `null` and shows blank. Check `hiveiq-mgmt` health and the `internal.service-secret` (`X-Internal-Secret`) config on both services. +3. **Avatar won't upload** → client rejects files over **2 MB** before any request is sent (pure browser check, no network call). Larger images never reach the server. Have the user shrink the image. +4. **Avatar looks cropped** → expected. The browser cover-crops to a **200×200** square and re-encodes as JPEG (quality 0.85) before saving as a base64 data URL. +5. **Edits don't stick after reload** → the SPA writes the `PUT` response back into the `userInfo` store; if the store looks stale, it's a frontend cache issue — hard-reload. The persisted source of truth is the `users` row. +6. **CORS error in console** → `profile.bcos.cloud` must be present in `CORS_ALLOWED_ORIGINS` on hiveops-auth (and mirrored on mgmt). It is currently listed; a missing origin after an `.env` regen is the usual cause. + +## Common failure modes + fixes +| Symptom | Likely cause | Fix | +|---|---|---| +| Save 401 | Expired/absent JWT | Re-login; token TTL is 24h | +| Institution blank | mgmt down / domain not mapped / wrong internal secret | Check mgmt health + `X-Internal-Secret` match | +| Avatar rejected | File > 2 MB (client check) | Resize below 2 MB | +| Save 500 | auth DB / `users` row missing | Check `hiveiq_auth` connectivity; user must exist | +| Whole SPA fails to load `me` | CORS origin missing on auth | Re-add `profile.bcos.cloud` to `CORS_ALLOWED_ORIGINS`, restart auth | + +## Direct DB / API checks +```sql +-- inspect a user's editable profile fields (hiveiq_auth, on 10.10.10.188 via ProxyJump) +SELECT email, name, job_title, phone, + (avatar_data IS NOT NULL) AS has_avatar, length(avatar_data) AS avatar_bytes +FROM users WHERE email = 'user@example.com'; +``` +```bash +# reproduce what the tab does, with a real JWT +curl -s https://api.bcos.cloud/auth/api/users/me -H "Authorization: Bearer $JWT" +``` + +> Note: this tab does **not** change passwords or MFA. Password change (`POST /auth/api/change-password`) and MFA live in the sibling **Security** tab, not here. diff --git a/backend/src/main/resources/content/profile/security/architect.md b/backend/src/main/resources/content/profile/security/architect.md new file mode 100644 index 0000000..b18020b --- /dev/null +++ b/backend/src/main/resources/content/profile/security/architect.md @@ -0,0 +1,74 @@ +--- +module: profile.security +title: Security +tab: Architect +order: 30 +audience: internal +--- + +> **Internal · architecture.** Written 2026-07-01 from `hiveops-profile` frontend + `hiveops-auth` source. Verify against code before relying on specifics. + +## Services involved + +| Piece | Where | Role | +|-------|-------|------| +| `SecuritySettings.svelte` | `hiveops-profile/frontend` (`app: profile`) | Renders the two cards; calls `profileAPI` in `src/lib/api.ts` | +| `hiveops-auth` | Spring Boot, port **8082**, NGINX `/api/auth/` | **Owns** password + preferences. All reads/writes land here | + +This feature is entirely **frontend → auth**. No other microservice participates. `mgmt` is only touched elsewhere in the profile app (company name / institution lookups), **not** by the Security screen. Cross-link [platform.service-ownership]. + +## Request wiring + +The SPA builds `authApi` with `baseURL = window.__APP_CONFIG__.apiUrl` (the gateway) and attaches `Authorization: Bearer ` per request. Paths already include the `/auth/api` prefix: + +| UI action | Frontend call (`api.ts`) | Controller | +|-----------|--------------------------|------------| +| Load timeout value | `GET /auth/api/users/me/preferences` | `UserProfileController.getPreferences` | +| Save timeout | `PUT /auth/api/users/me/preferences` | `UserProfileController.updatePreferences` | +| Change password | `POST /auth/api/change-password` | `AuthController.changePassword` | + +## Data flow — Change Password + +``` +POST /auth/api/change-password {newPassword, confirmPassword} + → AuthController: reject 400 if newPassword != confirmPassword + → AuthService.changePasswordAuthenticated(email, newPassword) + → PasswordValidator.validatePassword() // 12+ chars, upper/lower/digit/special + → user.setPasswordHash(passwordEncoder.encode(newPassword)) // BCrypt + → user.setMustChangePassword(false) + → user.setPasswordChangedAt(Instant.now()) + → userRepository.save(user) // table: users + → auditService.logPasswordChanged(user) // table: auth_audit_logs (PASSWORD_CHANGED) +``` + +Identity comes from the JWT principal (`UserDetails.getUsername()` = email); no user id is passed in the body. No current-password re-check. + +## Data flow — Session Timeout + +``` +PUT /auth/api/users/me/preferences {sessionTimeoutMinutes} + → UpdatePreferencesRequest // @Min(5) @Max(480) sessionTimeoutMinutes + → UserProfileService.updatePreferences(email, req) + → load user.getPreferences() (or new UserPreferences() if null) + → copy only non-null request fields onto the POJO + → user.setPreferences(prefs); userRepository.save(user) + → returns full UserPreferences +``` + +## DB tables + +| Table (DB `hiveiq_auth`) | Read/Written | Notes | +|--------------------------|--------------|-------| +| `users` | R/W | `password_hash`, `must_change_password`, `password_changed_at`; **`preferences` is a single `jsonb` column** (`@Column(columnDefinition="jsonb")`) holding the serialized `UserPreferences` POJO — `sessionTimeoutMinutes` lives inside that JSON, **not** a dedicated column or table | +| `auth_audit_logs` | W | one `PASSWORD_CHANGED` row per successful change | + +There is **no** separate preferences table and **no** join entity — `UserPreferences` is a plain POJO (not `@Entity`) marshaled into the `users.preferences` jsonb blob. Cross-link [platform.data-architecture]. + +## Kafka + +**None.** `hiveops-auth` has no producers, listeners, `KafkaTemplate`, or `StreamBridge`. Password changes and preference updates are synchronous DB writes only — there is no executor→Kafka→record-store fan-out for this feature. Cross-link [platform.kafka]. + +## Enforcement boundary + +- **Password rules** are enforced server-side in `PasswordValidator` (authoritative). The Svelte client's `length < 8` check is advisory and *weaker* than the server — a client-valid password can still 400 (see internal tab / uncertainties). +- **Session timeout** value is *stored* by auth but idle-logout enforcement is client-side in the consuming SPA. Auth's own JWT access-token TTL is fixed (24h) and independent of this preference. diff --git a/backend/src/main/resources/content/profile/security/claude.md b/backend/src/main/resources/content/profile/security/claude.md new file mode 100644 index 0000000..d632c4e --- /dev/null +++ b/backend/src/main/resources/content/profile/security/claude.md @@ -0,0 +1,51 @@ +--- +module: profile.security +title: Security +tab: Claude +order: 40 +audience: internal +--- + +> **Internal · agent reference.** Confirmed against `hiveops-profile` frontend + `hiveops-auth` source 2026-07-01. + +## Owner +- Service: **`hiveops-auth`** (port 8082, NGINX `/api/auth/`, DB `hiveiq_auth`). +- Frontend: `hiveops-profile/frontend` → `SecuritySettings.svelte` + `src/lib/api.ts` (`profileAPI`). +- No other service, **no Kafka**, no mgmt call for this screen. + +## Endpoints (exact) +| Method | Path | Body | Guard | +|--------|------|------|-------| +| POST | `/auth/api/change-password` | `{newPassword, confirmPassword}` | authenticated (any role) | +| GET | `/auth/api/users/me/preferences` | — | authenticated | +| PUT | `/auth/api/users/me/preferences` | `{sessionTimeoutMinutes, ...}` | authenticated | + +- Client sends `Authorization: Bearer `; base URL = gateway (`__APP_CONFIG__.apiUrl`); paths already carry `/auth/api`. +- Identity = JWT principal email; no userId in body. + +## Tables +- `users` — `password_hash`, `must_change_password`, `password_changed_at`; **`preferences` = one `jsonb` column** holding serialized `UserPreferences` POJO (`sessionTimeoutMinutes` is a JSON key inside it). +- `auth_audit_logs` — action `PASSWORD_CHANGED` per success. + +## Topics +- None. + +## Validation (server-authoritative) +- Password: **≥12 chars + uppercase + lowercase + digit + special** (`PasswordValidator`). Mismatch → `400 {"error":"Passwords do not match"}`; rule fail → `400 {"error":"Password must be at least 12 characters"}` (or specific rule). +- `sessionTimeoutMinutes`: `@Min(5) @Max(480)`. + +## Gotchas +- Frontend + customer Overview claim **"min 8 characters"**; backend actually requires **12 + complexity**. Trust the backend (`PasswordValidator`). +- `change-password` does **not** verify current password — trusts the bearer JWT only. +- Success clears `must_change_password` and stamps `password_changed_at`. +- `GET .../preferences` returns default POJO (`sessionTimeoutMinutes=60`) when the user has no stored prefs — absence ≠ error. +- Session-timeout enforcement is client-side; auth JWT access TTL is 24h regardless. +- Admin/reset of *other* users is NOT here — that's `/auth/api/msp/**` (`MSP_ADMIN`/`BCOS_ADMIN`) or `/auth/api/admin/users/**` (`ADMIN`). + +## Do NOT +- Do NOT tell users the password minimum is 8 — it is **12 + complexity**. +- Do NOT look for a `user_preferences` / `sessions` table — preferences are jsonb on `users`; there is none. +- Do NOT expect a Kafka event on password/preference change — there is none. +- Do NOT route this screen's calls through `mgmt`, `incident`, or `devices` — it is `hiveops-auth` only. +- Do NOT assume changing the timeout logs the user out server-side — it only stores a value. +- Do NOT PATCH other users' passwords via this endpoint — it always targets the JWT's own account. diff --git a/backend/src/main/resources/content/profile/security/internal.md b/backend/src/main/resources/content/profile/security/internal.md new file mode 100644 index 0000000..51c804f --- /dev/null +++ b/backend/src/main/resources/content/profile/security/internal.md @@ -0,0 +1,52 @@ +--- +module: profile.security +title: Security +tab: Internal +order: 20 +audience: internal +--- + +> **Internal · ops/support.** Written 2026-07-01 from `hiveops-profile` frontend + `hiveops-auth` source. Verify against code before relying on specifics. + +## What this screen actually does + +Two self-service controls, both owned by **`hiveops-auth`** (port 8082, NGINX `/api/auth/`): + +1. **Change Password** → `POST /auth/api/change-password` +2. **Session Timeout** → `PUT /auth/api/users/me/preferences` (`sessionTimeoutMinutes`) + +There is **no admin-only action on this screen** — it operates on the caller's *own* account, keyed off the JWT (`@AuthenticationPrincipal`). Any authenticated user (`anyRequest().authenticated()` in `SecurityConfig`) can use it; no `MSP_ADMIN`/`BCOS_ADMIN` role is required. Admin-driven password/lockout operations are separate paths (see below). + +## Roles required + +| Action | Guard (confirmed in `SecurityConfig`) | +|--------|----------------------------------------| +| Change own password | authenticated (any role) | +| Save own session timeout | authenticated (any role) | +| Admin reset another user | `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` via `/auth/api/msp/**`, or `hasRole('ADMIN')` via `/auth/api/admin/users/**` — **not** this screen | + +## First things to check when it misbehaves + +- **"Password change fails but looks valid"** → the frontend and the customer Overview say *min 8 characters*, but the backend `PasswordValidator` enforces **min 12 chars + uppercase + lowercase + digit + special char**. An 8–11 char (or non-complex) password passes the client check, then the API returns `400 {"error":"Password must be at least 12 characters"}` (or the specific rule). This is the #1 support surprise here — tell the user the real rule. (See uncertainties — this is a real doc/UX mismatch.) +- **"Passwords do not match"** → returned by the controller *before* hitting the validator when `newPassword != confirmPassword`. Frontend also blocks this locally. +- **Timeout won't save** → `sessionTimeoutMinutes` is bean-validated `@Min(5) @Max(480)`. A value outside 5–480 returns `400`. The **Save** button also stays disabled until the value actually changes (client `timeoutUnsaved` flag). +- **Preferences look reset to defaults** → `GET /auth/api/users/me/preferences` returns a fresh default `UserPreferences` (sessionTimeoutMinutes=60) when the user row has no stored prefs. If a user's `users.preferences` jsonb is null/blank they will see defaults, not an error. +- **401 on the whole screen** → expired/invalid JWT. This service is stateless (`SessionCreationPolicy.STATELESS`); the SPA must send `Authorization: Bearer `. + +## Common failure modes + fixes + +| Symptom | Likely cause | Fix | +|---------|--------------|-----| +| Change-password 400, message about length/complexity | 12-char + complexity rule | Give the user the real rule; not a bug in the API | +| Change-password 400 "Passwords do not match" | mismatch | Re-enter confirm field | +| Account locked, can't log in to reach this screen | 5 failed logins → 15-min lockout | `UPDATE users SET failed_login_attempts=0, locked_until=NULL WHERE email='...';` (DB `hiveiq_auth`) | +| User must change password on next login | `must_change_password=true` (admin reset / 90-day expiry) | Changing password here clears the flag (`setMustChangePassword(false)`) | +| Timeout saved but user not logged out at that interval | Idle-timeout enforcement is client-side, not enforced by auth's JWT (access token TTL is 24h) | Confirm the calling SPA honors the value (unverified per-app) | + +## Audit / traceability + +Every successful change writes an `auth_audit_logs` row with action `PASSWORD_CHANGED` (via `auditService.logPasswordChanged`). Use `AuditAdminController` (`/auth/api/admin/audit/**`, `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')`) or query the table directly to confirm a change happened and when (`password_changed_at` on `users`). + +## Note (security characteristic) + +The authenticated `change-password` path does **not** require the user's *current* password — it trusts the bearer JWT and overwrites the hash. A stolen/leaked live token can change the password. Not a bug per se, but relevant for support/security triage. diff --git a/backend/src/main/resources/content/recon/atm-cycle-history/architect.md b/backend/src/main/resources/content/recon/atm-cycle-history/architect.md new file mode 100644 index 0000000..2bbaa4f --- /dev/null +++ b/backend/src/main/resources/content/recon/atm-cycle-history/architect.md @@ -0,0 +1,86 @@ +--- +module: recon.atm-cycle-history +title: ATM Cycle History +tab: Architect +order: 30 +audience: internal +--- + +How the ATM Cycle History feature is built. Owned end-to-end by **hiveops-recon** — see [platform.service-ownership]. Recon does its own reconciliation math and only reaches out to hiveops-journal and hiveops-incident for raw inputs. + +## Services involved + +| Service | Role for this feature | +|---|---| +| **hiveops-recon** (8091, `/api/recon`) | Owns cycles, cassette config, variance math, institution scoping. Serves the screen. | +| **hiveops-journal** (`services.journal.url`, default `http://hiveops-journal:8087`) | Source of transactions: WITHDRAWAL/DEPOSIT/REVERSAL for sync, STARTUP for reboot snapshots, REPLENISHMENT for prefill. Read via `journalWebClient`. | +| **hiveops-incident** (`services.incident.url`, default `http://hiveiq-incident:8081`) | Source of cassette layout for **Sync from Device**. Read via `incidentWebClient`. | + +## Frontend + +- `frontend/src/components/AtmCycleHistory.svelte` — left panel = cycle list, right panel = `CycleDetailPanel.svelte`; header has the ⚙ `CassetteConfigPanel.svelte` and ↻ Sync Journal buttons. Data goes through `reconAPI` in `frontend/src/lib/api.ts` (base `window.__APP_CONFIG__.apiUrl` → `/api/recon`). +- Cycle list from `getCycles`; detail from the selected row (`getCurrentCycle` seeds the open one). `CycleDetailPanel` lazily loads opening/closing cassette entries via `getReplenishment(id)` and reboot snapshots via `getStartupSnapshots(cycleId)`. Header format badge comes from `getAtmSummary().journalFormat`. + +## Data flow + +**Browse / open a cycle (read path)** +``` +Svelte → GET /api/recon/atm/{atmId}/cycles → reconciliation_cycles (institution-scoped) + → GET /api/recon/cycles/{id} → reconciliation_cycles row + → GET /api/recon/replenishments/{id} → replenishment_sessions + replenishment_cassette_entries +``` + +**↻ Sync Journal (recalc open cycle)** +``` +Svelte → POST /api/recon/atm/{atmId}/sync (admin) +recon CycleService → JournalFetchService → journalWebClient GET /api/journal/transactions + → recompute totals on the open reconciliation_cycles row → save +``` + +**Daily Reboot Snapshots (live, not stored)** +``` +GET /api/recon/cycles/{id}/startup-snapshots +recon → JournalFetchService.fetchStartupSnapshots(atmId, cycleStart..cycleEnd) + → journal GET /api/journal/transactions (STARTUP) + → AtmCassetteService.buildStartupCassetteEntries(...) → response (nothing persisted) +``` + +**Cassette Configuration → Sync from Device** +``` +POST /api/recon/atm/{atmId}/cassettes/sync (admin) +recon AtmCassetteService.syncFromIncident → IncidentFetchService.fetchCassetteConfig + → incident GET /api/atms (string atmId → numeric id map) + → incident GET /api/atms/{id}/properties + → upsert atm_cassette_configs +``` + +## Kafka — consumer-only + +Recon **publishes nothing** for this feature; it reacts to upstream events. See [platform.kafka]. + +| Topic | Consumer | Group | Effect | +|---|---|---|---| +| `hiveops.device.events` | `DeviceEventConsumer` | `hiveops-recon-device-sync` | Maintains local `device_summary` mirror (drives institution scope; no reconciler — goes stale if upstream stops). | +| `journal.replenishments` | `ReplenishmentEventConsumer` | `recon-replenishment-group` | Ingests replenishment events that seed/close cycles. | +| `hiveops.journal.transactions.recorded` | `JournalTransactionConsumer` | `recon-transaction-group` | Reacts to newly recorded journal transactions. | + +## DB tables (this feature) + +`hiveiq_recon` (prod builds JDBC from `DB_HOST`/`DB_PORT`/`DB_NAME`, default `hiveiq_recon`; dev = H2). See [platform.data-architecture]. + +| Table | Read/Write here | +|---|---| +| `reconciliation_cycles` | R/W — cycle list, detail, variance, open/close | +| `replenishment_sessions` | R — opening/closing session for a cycle | +| `replenishment_cassette_entries` | R — per-cassette opening/closing detail | +| `atm_cassette_configs` | R/W — cassette layout (Sync from Device / manual save) | +| `atm_cash_positions` | R — holds `journalFormat` (parse driver, header badge) | +| `device_summary` | R — institution scoping (Kafka-fed mirror) | + +## Notes for changes + +- Reads are gated only by institution scope, not `@PreAuthorize`; all mutating cycle/cassette/sync endpoints require `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')`. +- Startup snapshots are computed on demand — no snapshot table; changing the journal STARTUP format changes what appears with no migration. +- Cassette Sync from Device couples recon to incident's numeric ATM id map — a rename of incident's `/api/atms` contract breaks it. + +Cross-links: [platform.data-architecture], [platform.kafka], [platform.service-ownership], [technical.recon]. diff --git a/backend/src/main/resources/content/recon/atm-cycle-history/claude.md b/backend/src/main/resources/content/recon/atm-cycle-history/claude.md new file mode 100644 index 0000000..802ca44 --- /dev/null +++ b/backend/src/main/resources/content/recon/atm-cycle-history/claude.md @@ -0,0 +1,64 @@ +--- +module: recon.atm-cycle-history +title: ATM Cycle History +tab: Claude +order: 40 +audience: internal +--- + +Terse act-without-guessing reference. Owner: **hiveops-recon** (port 8091, NGINX `/api/recon/`, prod external 8013). DB `hiveiq_recon`. + +## Endpoints (all base `/api/recon`) + +| Method + path | Role | Backend | Source | +|---|---|---|---| +| GET `/atm/{atmId}/cycles?page=&size=20` | any auth (inst-scoped) | `CycleController.list` | `reconciliation_cycles` | +| GET `/atm/{atmId}/cycles/current` | any auth (inst-scoped) | `CycleController.current` | `reconciliation_cycles` | +| GET `/cycles/{id}` | any auth (inst-scoped) | `CycleController.get` | `reconciliation_cycles` | +| GET `/cycles/{id}/startup-snapshots` | any auth (inst-scoped) | `CycleController.startupSnapshots` | live journal STARTUP (not stored) | +| POST `/atm/{atmId}/sync` | MSP_ADMIN/BCOS_ADMIN | `CycleController.sync` | journal `/api/journal/transactions` | +| GET `/atm/{atmId}/cassettes` | any auth | `AtmCassetteController.getConfig` | `atm_cassette_configs` | +| PUT `/atm/{atmId}/cassettes` | MSP_ADMIN/BCOS_ADMIN | `AtmCassetteController.upsertConfig` | `atm_cassette_configs` | +| POST `/atm/{atmId}/cassettes/sync` | MSP_ADMIN/BCOS_ADMIN | `AtmCassetteController.syncFromIncident` | incident `/api/atms/{id}/properties` | +| GET `/replenishments/{id}` | any auth | `ReplenishmentController.get` | `replenishment_sessions` + `replenishment_cassette_entries` | +| GET `/atm/{atmId}/summary` | any auth | `SummaryController` | `atm_cash_positions` (`journalFormat`) | + +`atmId` is the **string** device id. Cassette sync needs incident's numeric id (recon maps it internally via `GET /api/atms`). + +## Tables (`hiveiq_recon`) + +`reconciliation_cycles` · `replenishment_sessions` · `replenishment_cassette_entries` · `atm_cassette_configs` · `atm_cash_positions` · `device_summary` + +## Kafka (recon = consumer-only, no producers) + +| Topic | Consumer / group | +|---|---| +| `hiveops.device.events` | `DeviceEventConsumer` / `hiveops-recon-device-sync` → `device_summary` | +| `journal.replenishments` | `ReplenishmentEventConsumer` / `recon-replenishment-group` | +| `hiveops.journal.transactions.recorded` | `JournalTransactionConsumer` / `recon-transaction-group` | + +## Upstream deps (WebClient) + +- journal: `services.journal.url` (default `http://hiveops-journal:8087`) — `GET /api/journal/transactions` +- incident: `services.incident.url` (default `http://hiveiq-incident:8081`) — `GET /api/atms`, `GET /api/atms/{id}/properties` + +## Gotchas + +- Reads are **institution-scoped** via `InstitutionContext.resolveScope()`; null scope = all. Missing cycles for a user is usually scope, not data loss. +- ↻ Sync Journal is disabled in UI unless a **current open cycle** exists. +- Startup snapshots are fetched **live from journal every call** — no table; empty means no journal STARTUP txns in `[cycleStart, cycleEnd]`. +- "Sync from Device" (cassettes) reads **incident**, NOT devices, NOT journal. Empty result ⇒ incident down or ATM not in incident. +- `journalFormat` (on `atm_cash_positions`) drives cassette parsing; wrong format ⇒ wrong cassette math. +- `device_summary` has no reconciler — stale if `hiveops.device.events` stops. +- Variance: `expected = opening + added − removed − withdrawals + deposits − reversals`; `variance = actual − expected`; status BALANCED iff variance 0. + +## Do NOT + +- Do NOT expect a `startup_snapshots` / snapshot table — it does not exist. +- Do NOT route cassette sync to hiveops-devices — it goes to hiveops-incident. +- Do NOT assume read GETs are role-annotated — they are not; only writes require MSP_ADMIN/BCOS_ADMIN. +- Do NOT treat a 403 on Sync/Save as a bug for non-admin users — it is by design. +- Do NOT look for a `DB_URL` var — recon prod uses `DB_HOST`/`DB_PORT`/`DB_NAME`. +- Do NOT expect recon to publish Kafka events — it is consumer-only. + +Cross-links: [platform.service-ownership], [platform.kafka], [platform.data-architecture], [technical.recon]. diff --git a/backend/src/main/resources/content/recon/atm-cycle-history/internal.md b/backend/src/main/resources/content/recon/atm-cycle-history/internal.md new file mode 100644 index 0000000..d4b095c --- /dev/null +++ b/backend/src/main/resources/content/recon/atm-cycle-history/internal.md @@ -0,0 +1,45 @@ +--- +module: recon.atm-cycle-history +title: ATM Cycle History +tab: Internal +order: 20 +audience: internal +--- + +Ops/support view for the ATM Cycle History screen. Owned by **hiveops-recon** (backend port 8091, NGINX `/api/recon/`, prod external 8013; frontend `recon.bcos.cloud`). All cycle/cassette data lives in `hiveiq_recon`. + +## Roles & who can do what + +- **Read (browse cycles, open detail, view snapshots)** — any authenticated user with a valid JWT. Results are **institution-scoped**: `CycleController` calls `InstitutionContext.resolveScope()` and filters every cycle by `institution`. A null scope (MSP/BCOS admin) sees all institutions. +- **Admin-only writes** — annotated `@PreAuthorize("hasAnyRole('MSP_ADMIN','BCOS_ADMIN')")`: + - `POST /api/recon/atm/{atmId}/sync` — the **↻ Sync Journal** button + - `PUT /api/recon/atm/{atmId}/cassettes` — save Cassette Configuration + - `POST /api/recon/atm/{atmId}/cassettes/sync` — **Sync from Device** in the cassette panel + - `POST /api/recon/atm/{atmId}/replenishments` (creating/closing cycles), `PUT /replenishments/{id}`, `POST /replenishments/{id}/lock` +- A non-admin who clicks Sync Journal / Save Cassettes / Sync from Device gets **403** — that is expected, not a bug. + +## First things to check when it misbehaves + +1. **"No cycles recorded yet"** — the ATM has no `reconciliation_cycles` row yet, OR the caller's institution scope excludes it. Confirm the JWT institution matches the device's institution (recon scopes reads by institution). Cycles only exist once a replenishment has been recorded for that ATM. +2. **↻ Sync Journal is greyed out** — the button is disabled unless there is a **current open cycle** (`$currentCycle`). No open cycle → nothing to sync. +3. **Sync Journal returns but totals look empty/wrong** — sync pulls WITHDRAWAL/DEPOSIT/REVERSAL transactions from **hiveops-journal** (`GET /api/journal/transactions`). Check journal is up and that the ATM's journal actually contains transactions for the open cycle window. Requires the `REPLENISHMENT`/txn types to be defined in journal-rules. +4. **Cassette slots wrong/empty after "Sync from Device"** — this does **not** read hiveops-devices. It calls **hiveops-incident**: `GET /api/atms` (to map the string `atmId` → incident's numeric id) then `GET /api/atms/{id}/properties`. Fails/returns empty if incident is down or the ATM is unknown to incident. Fall back to entering cassettes by hand and Save. +5. **"Daily Reboot Snapshots" empty** — snapshots are **not stored**; `GET /cycles/{id}/startup-snapshots` fetches STARTUP transactions live from journal for the cycle window each time. Empty = journal has no STARTUP entries in that window (or journal is down). +6. **Cassette parse is malformed** — the ATM's `journalFormat` (stored on `atm_cash_positions`, shown as the format badge in the header) drives how cassette/denomination blocks are parsed. Wrong format → wrong or missing cassette math. + +## Common failure modes → fix + +| Symptom | Likely cause | Fix | +|---|---|---| +| 403 on Sync / Save cassettes | user lacks MSP_ADMIN/BCOS_ADMIN | use an admin account | +| Wrong ATM's cycles missing for a user | institution scope excludes it | check JWT `institution` vs device institution | +| Sync succeeds, totals stale/zero | journal down / no txns / rules missing | verify hiveops-journal + journal-rules txn types | +| Sync from Device empty | incident down or ATM not in incident | verify hiveops-incident; enter cassettes manually | +| Device/institution shown wrong | stale `device_summary` mirror (no reconciler) | ensure `hiveops.device.events` is flowing from devices | +| Snapshots always empty | journal has no STARTUP txns in window | expected; not a recon bug | + +## Variance sanity check + +`expected = opening + added − removed − withdrawals + deposits − reversals`; `variance = actual (physical count) − expected`. Status is **BALANCED** when variance is 0, else **VARIANCE**. If a cycle shows a variance you can't explain, re-run Sync Journal first (pulls fresh journal txns), then confirm the cassette config and opening balance. + +Related: [recon.atm-cycle-history] Overview (customer), [technical.recon]. diff --git a/backend/src/main/resources/content/recon/cash-forecast/architect.md b/backend/src/main/resources/content/recon/cash-forecast/architect.md new file mode 100644 index 0000000..844d57f --- /dev/null +++ b/backend/src/main/resources/content/recon/cash-forecast/architect.md @@ -0,0 +1,94 @@ +--- +module: recon.cash-forecast +title: Cash Forecast +tab: Architect +order: 30 +audience: internal +--- + +> **Internal — architecture.** Grounded in `hiveops-recon` source. Cross-links: [platform.data-architecture], [platform.kafka], [platform.service-ownership]. + +## Ownership + +Cash Forecast is **owned end-to-end by `hiveops-recon`** — no other service serves or computes it. Recon owns the recon domain exclusively ([platform.service-ownership]). Backend port **8091** (`/api/recon/`, prod external **8013**); frontend `recon.bcos.cloud` (`ForecastView.svelte`), port 5175. DB: `hiveiq_recon`. + +There is **no executor→Kafka→record-store round-trip** for this feature — forecasting is an in-process batch computation over already-ingested replenishment data, persisted to a single table. The only Kafka involvement is **upstream**, feeding the replenishment sessions the forecast reads. + +## Data flow + +``` +journal.replenishments (Kafka) + │ ReplenishmentEventConsumer (group recon-replenishment-group) + ▼ +replenishment_sessions + replenishment_cassette_entries (hiveiq_recon) + │ (nightly 03:00 backfill safety net also writes these) + ▼ +CashForecastService.recomputeAll() ← nightly 03:30 OR admin POST /admin/forecast/recompute + │ reads last 6 CONFIRMED/LOCKED sessions per enrolled ATM + │ flat burn-rate model per cassette slot + ▼ +cash_forecasts (delete-by-atm + saveAll, atomic per ATM) + │ + ▼ +GET /api/recon/fleet/forecast → CashForecastResponse[] → ForecastView.svelte +``` + +### Who triggers a recompute + +- **Scheduled:** `ReconSyncScheduler.recomputeForecasts()` — `@Scheduled(cron = "${recon.forecast.cron:0 30 3 * * *}")` → 03:30 nightly, after the 03:00 replenishment ingest (`recon.ingest.cron`) and the 6-hourly open-cycle sync. +- **On demand:** POST `/api/recon/admin/forecast/recompute` (admin) → `recomputeAll()`. +- Both call the same code path; the endpoint returns `{ atmsProcessed }`. + +### Enrollment gate + +`recomputeAll()` iterates only ATMs from `DeviceSelectionService.getAllEnrolledAtmIds()` (all rows in `recon_device_selection`). Unenrolled devices are never forecast, regardless of replenishment history. + +## The algorithm (v1, flat burn rate — `CashForecastService`) + +Per enrolled ATM: +1. Fetch last **6** (`lookback-sessions`) sessions with status `CONFIRMED` or `LOCKED`, newest first; reorder oldest→newest. +2. Load `replenishment_cassette_entries` per session, keyed slot→entry. +3. Candidate slots = union across sessions, **excluding** `RETRACT` cassette type and `denominationCents == 0`. Slot must also exist in the **latest** session (it is the current-level baseline). +4. For each consecutive session pair, `durationDays = seconds(prev.start → curr.start)/86400`; pairs with `duration ≤ 0` or `> 45` (`max-gap-days`) are dropped. Accumulate `netUsed` per slot. + - `netUsed = usedCents`, minus `impliedDepositsCents` for `RECYCLER`/`MIX` cassettes (deposits refill the cassette); clamped ≥ 0. +5. `burnPerDay = round(Σ netUsed / Σ durationDays)`. +6. **Current level** priority: `physicalCountCents` → `journalClosingCents` → `max(0, opening − used + added)`. +7. `projectedRunoutAt = latestSession.start + (currentLevel / burnPerDay) days`; `recommendedFillByAt = projectedRunout − safetyBufferDays (2)`. + +**Status resolution:** +- `< 2` sessions, or `< 1` valid pair, or `totalDays < 7` (`min-history-days`) → `INSUFFICIENT_HISTORY`. +- `burnPerDay ≤ 0` → `NO_BURN`. +- otherwise → `OK` (runout/Fill By populated only if current level > 0). + +## Tables (all in `hiveiq_recon`) + +| Table | Role in this feature | R/W | +|---|---|---| +| `cash_forecasts` | Output — one row per (atm_id, slot), unique `uq_forecast_atm_slot` | Write (delete+insert), Read | +| `replenishment_sessions` | Burn-rate + timing input (CONFIRMED/LOCKED) | Read | +| `replenishment_cassette_entries` | Per-slot denomination/used/opening/physical count | Read | +| `recon_device_selection` | Enrollment — which ATMs get forecast | Read | +| `atm_cash_positions` | `verifyAtmAccess` institution check on `/atm/{id}/forecast` | Read | +| `atm_cassette_configs` | Cassette-type semantics (`CassetteType` enum: RETRACT/RECYCLER/MIX) | Referenced via entries | + +## Kafka ([platform.kafka]) + +Recon is **consumer-only** (no producers in source). Relevant to this feature (upstream ingestion of the data forecasts read): + +| Topic | Consumer | Group | Feeds | +|---|---|---|---| +| `journal.replenishments` | `ReplenishmentEventConsumer` | `recon-replenishment-group` | `replenishment_sessions` (primary forecast input) | +| `hiveops.device.events` | `DeviceEventConsumer` | `hiveops-recon-device-sync` | `device_summary` mirror (device/institution scoping) | +| `hiveops.journal.transactions.recorded` | `JournalTransactionConsumer` | `recon-transaction-group` | cycle txn totals (not forecast-critical) | + +The `device_summary` mirror has **no reconciler** — if `hiveops.device.events` stops, scoping data goes stale ([platform.data-architecture]). + +## API surface (this feature) + +| Method + path | Handler | Notes | +|---|---|---| +| GET `/api/recon/fleet/forecast` | `ForecastController.getFleetForecast` | Institution-scoped; sorted soonest-runout-first (SQL `ORDER BY projectedRunoutAt ASC NULLS LAST`) | +| GET `/api/recon/atm/{atmId}/forecast` | `getAtmForecast` | Ordered by slot; `verifyAtmAccess` | +| POST `/api/recon/admin/forecast/recompute` | `recompute` | `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')`; returns `{ atmsProcessed }` | + +Frontend `apiBase()` defaults to `http://localhost:8091/api/recon`, overridden at runtime by `window.__APP_CONFIG__.apiUrl`. diff --git a/backend/src/main/resources/content/recon/cash-forecast/claude.md b/backend/src/main/resources/content/recon/cash-forecast/claude.md new file mode 100644 index 0000000..5b73ce3 --- /dev/null +++ b/backend/src/main/resources/content/recon/cash-forecast/claude.md @@ -0,0 +1,57 @@ +--- +module: recon.cash-forecast +title: Cash Forecast +tab: Claude +order: 40 +audience: internal +--- + +> Terse agent reference. Every path/table/topic below is confirmed in `hiveops-recon` source. + +## Owner +- Service: **`hiveops-recon`** (owns it fully; no cross-service call for this feature). +- Backend port **8091**, NGINX `/api/recon/`, prod external **8013**. DB **`hiveiq_recon`**. +- Frontend: `ForecastView.svelte` @ `recon.bcos.cloud`; `apiBase()` = `window.__APP_CONFIG__.apiUrl` || `http://localhost:8091/api/recon`. + +## Endpoints +| METHOD | Path | Auth | Returns | +|---|---|---|---| +| GET | `/api/recon/fleet/forecast` | authed, institution-scoped | `CashForecastResponse[]` | +| GET | `/api/recon/atm/{atmId}/forecast` | authed + `verifyAtmAccess` (403 if out of scope) | `CashForecastResponse[]` | +| POST | `/api/recon/admin/forecast/recompute` | `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` | `{ "atmsProcessed": N }` | + +## Tables (`hiveiq_recon`) +- Write+read: `cash_forecasts` (unique `(atm_id, slot)` = `uq_forecast_atm_slot`). +- Read only: `replenishment_sessions`, `replenishment_cassette_entries`, `recon_device_selection` (enrollment), `atm_cash_positions` (access check). +- Status enum values: `OK`, `INSUFFICIENT_HISTORY`, `NO_BURN`. + +## Kafka (consumer-only; upstream of this feature) +- `journal.replenishments` → `ReplenishmentEventConsumer` (group `recon-replenishment-group`) → writes `replenishment_sessions`. +- `hiveops.device.events` → `DeviceEventConsumer` (group `hiveops-recon-device-sync`) → `device_summary` scoping. +- Cash forecast itself consumes/produces **no** topic — it's a batch read over `replenishment_sessions`. + +## Recompute triggers +- Nightly cron `0 30 3 * * *` (`recon.forecast.cron`) → `ReconSyncScheduler.recomputeForecasts` → `CashForecastService.recomputeAll()`. +- Admin POST above → same `recomputeAll()`. +- `recomputeAll()` loops only enrolled ATMs (`recon_device_selection`); per-ATM = delete-by-atm + saveAll. + +## Config (`recon.forecast.*`) +- `lookback-sessions`=6 · `safety-buffer-days`=2 · `max-gap-days`=45 · `min-history-days`=7 · `cron`=`0 30 3 * * *`. + +## Gotchas +- Input is **replenishment history, not live journal** — needs ≥2 `CONFIRMED`/`LOCKED` sessions AND ≥7 days span for `OK`, else `INSUFFICIENT_HISTORY`. +- Device must be enrolled in `recon_device_selection` or it's never computed (empty result, no error). +- `RETRACT` cassettes and `denominationCents == 0` slots are excluded; slots absent from the latest session are skipped. +- Current level source order: `physicalCountCents` → `journalClosingCents` → `opening − used + added`. +- `RECYCLER`/`MIX` use net burn (`used − impliedDeposits`, clamped ≥0). +- All money fields are **cents** (Long). `notesPerDay` = burnPerDay / denominationCents. +- Fleet GET has no role annotation — scoping is institution-based inside the service (admin scope = null = all). +- Empty UI message references the 03:30 nightly job. + +## Do NOT +- Do NOT look for forecast logic in `hiveops-incident` / `hiveops-journal` / `hiveops-devices` — it's all in `hiveops-recon`. +- Do NOT POST recompute as a non-admin (403). +- Do NOT expect a Kafka topic named for forecasts — none exists; recon is consumer-only. +- Do NOT assume a device with no replenishment sessions or not enrolled will ever appear — it won't. +- Do NOT use `DB_URL` for recon; prod uses split `DB_HOST`/`DB_PORT`/`DB_NAME` (default `hiveiq_recon`). +- Do NOT confuse the admin recompute path — it is `/api/recon/admin/forecast/recompute`, not `/admin/recompute`. diff --git a/backend/src/main/resources/content/recon/cash-forecast/internal.md b/backend/src/main/resources/content/recon/cash-forecast/internal.md new file mode 100644 index 0000000..f8049f4 --- /dev/null +++ b/backend/src/main/resources/content/recon/cash-forecast/internal.md @@ -0,0 +1,75 @@ +--- +module: recon.cash-forecast +title: Cash Forecast +tab: Internal +order: 20 +audience: internal +--- + +> **Internal — ops/support/admin.** Grounded in `hiveops-recon` source (`ForecastController`, `CashForecastService`, `ReconSyncScheduler`, `ForecastView.svelte`). For the how-it's-built view see the **Architect** tab. + +Cash Forecast is served entirely by **`hiveops-recon`** (backend port **8091**, NGINX `/api/recon/`, prod external **8013**; frontend `recon.bcos.cloud`). The screen is `ForecastView.svelte` and hits three endpoints only. + +## Roles / access + +| Action | Endpoint | Auth | +|---|---|---| +| View fleet forecast | GET `/api/recon/fleet/forecast` | Any authenticated user; **institution-scoped** via `InstitutionContext.resolveScope()` (admin = null = all ATMs) | +| View one ATM's forecast | GET `/api/recon/atm/{atmId}/forecast` | Authenticated; `verifyAtmAccess` 403s if the ATM's institution isn't in the caller's scope | +| **Recompute now** | POST `/api/recon/admin/forecast/recompute` | **`@PreAuthorize("hasAnyRole('MSP_ADMIN','BCOS_ADMIN')")`** — admin only | + +- The **Recompute** button in the header calls the admin POST. A non-admin will get 403 there even though they can read the table. +- Read GETs have **no method-level `@PreAuthorize`** — scoping is done by institution inside the service, not by role. + +## How the numbers get there + +Forecasts are **derived from replenishment history, not live journal data**. The pipeline is: + +1. Device must be **enrolled** in recon (`recon_device_selection`) — `recomputeAll()` only iterates enrolled ATMs. +2. Each replenishment produces a `replenishment_sessions` row (+ per-slot `replenishment_cassette_entries`). +3. `CashForecastService` reads the last **6** `CONFIRMED`/`LOCKED` sessions per ATM, computes a flat burn rate per cassette slot, and writes `cash_forecasts`. +4. Nightly recompute runs at **03:30** (`ReconSyncScheduler.recomputeForecasts`, after the 03:00 replenishment ingest). Recompute deletes and re-writes all rows for each ATM atomically. + +## First things to check when it misbehaves + +**"No forecast data yet" / table empty** +- Is the device **enrolled**? No rows in `recon_device_selection` for that ATM → it's never processed. Enroll via `POST /api/recon/device-selection/enroll/{atmId}` (admin). +- Does the ATM have replenishment history? Forecasts need **≥2** `CONFIRMED`/`LOCKED` sessions. A brand-new ATM with 0–1 sessions produces nothing. +- Was the nightly job run? Click **Recompute** (admin) to force it; response `{ "atmsProcessed": N }`. + +**Rows stuck at "Insufficient History"** +- Fewer than 2 valid session pairs, **or** total spanned history `< 7 days` (`recon.forecast.min-history-days`), **or** every session pair exceeded the **45-day** gap cap (`recon.forecast.max-gap-days`) and got skipped. Long ATM outages between replenishments dilute/skip pairs. +- Also shown when only 1 eligible session exists. + +**Rows show "No Burn"** +- Computed burn rate ≤ 0 — cassette isn't being dispensed (or recycler deposits offset all withdrawals). Expected for idle/deposit-only slots; no runout date is projected. + +**A cassette is missing from the list** +- `RETRACT`-type slots and any slot with `denominationCents == 0` are **excluded by design**. +- A slot present in older sessions but **absent from the latest session** is skipped (current-level baseline comes from the latest session only). + +**Runout / Fill By blank but status OK** +- Current level resolved to null or 0. Level is derived per entry: `physicalCountCents` → `journalClosingCents` → computed `opening − used + added`. If none are present, no projection is made. + +**Wrong institution / customer sees ATMs they shouldn't (or misses their own)** +- Forecast institution is copied from the replenishment session's institution. If the device's institution is wrong upstream, the forecast inherits it. This is the same stale-mirror class of issue tracked platform-wide — the fix is upstream in devices, not here. + +## Colour / status legend (UI) + +- **Amber row** — projected runout within 3 days. +- **Red row** — recommended Fill By date already passed. +- Status badges map: `OK`, `INSUFFICIENT_HISTORY` → "Insufficient History", `NO_BURN` → "No Burn". + +## Tunables (Spring config, `recon.forecast.*`) + +| Property | Default | Effect | +|---|---|---| +| `recon.forecast.lookback-sessions` | 6 | Sessions considered per ATM | +| `recon.forecast.safety-buffer-days` | 2 | Fill By = runout − buffer | +| `recon.forecast.max-gap-days` | 45 | Session pairs longer than this are dropped | +| `recon.forecast.min-history-days` | 7 | Below this total span → Insufficient History | +| `recon.forecast.cron` | `0 30 3 * * *` | Nightly recompute (03:30) | + +## Logs + +Backend logs on the services VM (`docker compose logs hiveiq-recon`, or **Loki**). Key lines: `Cash forecast: starting nightly recompute`, `Cash forecast: recomputed N ATMs`, and per-ATM `Cash forecast failed for ATM {}`. One ATM failing does **not** abort the run (logged as `warn`, loop continues). diff --git a/backend/src/main/resources/content/recon/cash-reconciliation/architect.md b/backend/src/main/resources/content/recon/cash-reconciliation/architect.md new file mode 100644 index 0000000..bf2e3d4 --- /dev/null +++ b/backend/src/main/resources/content/recon/cash-reconciliation/architect.md @@ -0,0 +1,80 @@ +--- +module: recon.cash-reconciliation +title: Cash Reconciliation +tab: Architect +order: 30 +audience: internal +--- + +> **Internal · Architecture.** How the Cash Reconciliation fleet grid is built. Owned end-to-end by **hiveops-recon** (`hiveiq_recon` DB). See [platform.service-ownership], [platform.data-architecture], [platform.kafka]. + +## Components for this feature + +| Layer | Artifact | Role | +|---|---|---| +| Frontend | `FleetReconTable.svelte`, `App.svelte`, `lib/stores.ts` (`fleetSummary`), `lib/api.ts` (`reconAPI`) | Renders the grid; does all filter/sort/page **client-side** | +| Backend controller | `SummaryController` (`@RequestMapping("/api/recon")`) | Serves `/fleet/summary`, `/groups`, `/atm/{id}/summary`, `/fleet/pending` | +| Backend services | `AtmPositionService`, `CycleService`, `DeviceSelectionService`, `IncidentFetchService` | Assemble the response | +| Security | `InstitutionContext.resolveScope()` + `JwtAuthenticationFilter` | Institution scoping from JWT | + +## Data flow — loading the grid + +``` +Browser (recon.bcos.cloud) + └─ GET /api/recon/fleet/summary ──▶ SummaryController.fleetSummary() + 1. scope = InstitutionContext.resolveScope() // null=admin, else institution keys + 2. positions = AtmPositionService.findAllScopedByInstitution(scope) // atm_cash_positions + 3. if admin (scope==null): merge in every id from IncidentFetchService.fetchAllAtmIds() + └─ reads LOCAL device_summary table (NO http call) + 4. apply DeviceSelectionService.getSelected(scope) // recon_device_selection filter + 5. per ATM, load current cycle from reconciliation_cycles → status, variance, syncedAt + 6. compute projectionStaleMins for OPEN cycles (minutes since syncedAt|cycleStart) + 7. pendingCount = replenishment_sessions WHERE status=PENDING_CONFIRMATION (scoped) + ◀─ FleetSummaryResponse { total, balanced, variance, open, pending, atms[] } +``` + +- The response carries **all** in-scope ATMs. The browser then filters by search/status/format/group/devices/date and paginates — **no server round-trip per filter**. +- **Device Group** filter loads separately: `GET /api/recon/groups` → `IncidentFetchService.fetchGroups()` → HTTP `GET /api/atm-groups` on **incident** (via `incidentWebClient`), then intersected with recon-owned ATMs. This is the **only** cross-service HTTP call this screen makes; failures are caught and return empty (grid still renders). + +## DB tables (all in `hiveiq_recon`) + +| Table | Read/Write on this screen | Purpose | +|---|---|---| +| `atm_cash_positions` | read | Per-ATM projected/last-known balance, `currentCycleId`, `lastSyncedAt`, `journalFormat`, institution | +| `reconciliation_cycles` | read | Current cycle status (OPEN/PENDING_RECONCILIATION/BALANCED/VARIANCE/UNRECONCILED), `varianceCents`, `syncedAt` | +| `replenishment_sessions` | read | Pending-confirmation count | +| `recon_device_selection` | read | Optional device allow-list filter | +| `device_summary` | read | Local device mirror; supplies full ATM list for admins + institution keys for scoping | + +Writes to `atm_cash_positions` / `reconciliation_cycles` happen on the **replenishment and sync** paths (executor recomputes balances), not on grid load. + +## Kafka — how `device_summary` stays current + +Recon is **consumer-only** (no producers in source). See [platform.kafka]. + +| Topic | Consumer | Group | Effect on this screen | +|---|---|---|---| +| `hiveops.device.events` | `DeviceEventConsumer` | `hiveops-recon-device-sync` | Maintains `device_summary` → admin fleet listing + institution scoping | +| `journal.replenishments` | `ReplenishmentEventConsumer` | `recon-replenishment-group` | Feeds cycle/position updates surfaced as balances/status | +| `hiveops.journal.transactions.recorded` | `JournalTransactionConsumer` | `recon-transaction-group` | Updates cycle totals → projected balance | + +There is **no reconciler** for `device_summary`; if `hiveops-devices` stops emitting `hiveops.device.events`, the mirror (and thus admin fleet listing + scoping) drifts. + +## Key API endpoints (this screen) + +| Method + path | Serves | +|---|---| +| GET `/api/recon/fleet/summary` | Whole grid (`FleetSummaryResponse`) | +| GET `/api/recon/groups` | Device Group filter (calls incident `/api/atm-groups`) | +| GET `/api/recon/fleet/pending` | Pending-confirmation list (used elsewhere; same data source) | +| GET `/api/recon/atm/{atmId}/summary` | Single-ATM refresh / detail entry | + +## Executor → Kafka → record-store pattern + +The grid is the **read side**. The write side (recording a replenishment / running a sync) is where recon consumes journal Kafka events, an executor recomputes the cycle (`expected = opening + added − removed − withdrawals + deposits − reversals`, `variance = actual − expected`), and persists to `reconciliation_cycles` + `atm_cash_positions`. The grid then simply reads those stored values. See the module Overview (customer tab) for the user-facing cycle lifecycle. + +## Cross-links + +- [platform.service-ownership] — recon owns the recon domain; incident owns groups/ATM registry. +- [platform.data-architecture] — `hiveiq_recon` store; `device_summary` mirror pattern shared across incident/fleet/reports/recon. +- [platform.kafka] — topic ownership and consumer-group conventions. diff --git a/backend/src/main/resources/content/recon/cash-reconciliation/claude.md b/backend/src/main/resources/content/recon/cash-reconciliation/claude.md new file mode 100644 index 0000000..8059dbe --- /dev/null +++ b/backend/src/main/resources/content/recon/cash-reconciliation/claude.md @@ -0,0 +1,69 @@ +--- +module: recon.cash-reconciliation +title: Cash Reconciliation +tab: Claude +order: 40 +audience: internal +--- + +> Act-without-guessing reference for the Cash Reconciliation fleet grid ("ATM Cash Positions"). + +## Ownership + +- **Service:** `hiveops-recon` (owns everything for this screen). +- **DB:** `hiveiq_recon` (Postgres prod; H2 in dev). +- **Ports:** backend `8091`, NGINX `/api/recon/`, prod external `8013`; frontend `recon.bcos.cloud` (5175). +- **Frontend:** `FleetReconTable.svelte` + store `fleetSummary` + `reconAPI` in `frontend/src/lib/api.ts`. + +## Endpoints (this screen) + +| METHOD | Path | Notes | +|---|---|---| +| GET | `/api/recon/fleet/summary` | Fills entire grid; returns ALL in-scope ATMs; client filters/sorts/pages | +| GET | `/api/recon/groups` | Device Group filter; internally HTTP→incident `/api/atm-groups` | +| GET | `/api/recon/atm/{atmId}/summary` | Single ATM | +| GET | `/api/recon/fleet/pending` | Pending-confirmation ATMs | + +Admin/write (not on grid load, `@PreAuthorize hasAnyRole('MSP_ADMIN','BCOS_ADMIN')`): `POST /api/recon/atm/{atmId}/sync`, replenishment create/edit/lock, `/api/recon/admin/*` (backfill, reparse, ingest-replenishments, repair-slots, rules), device-rules, device-selection. + +## Tables (`hiveiq_recon`) + +- `atm_cash_positions` — balances, `currentCycleId`, `lastSyncedAt`, `journalFormat`, institution. +- `reconciliation_cycles` — status, `varianceCents`, `syncedAt`. Status enum: `OPEN, PENDING_RECONCILIATION, BALANCED, VARIANCE, UNRECONCILED`. +- `replenishment_sessions` — status enum: `PENDING_CONFIRMATION, CONFIRMED, LOCKED`. +- `recon_device_selection` — optional allow-list filter. +- `device_summary` — local device mirror (admin fleet list + institution scoping). + +## Kafka (consumer-only; recon publishes nothing) + +| Topic | Consumer / group | +|---|---| +| `hiveops.device.events` | `DeviceEventConsumer` / `hiveops-recon-device-sync` → feeds `device_summary` | +| `journal.replenishments` | `ReplenishmentEventConsumer` / `recon-replenishment-group` | +| `hiveops.journal.transactions.recorded` | `JournalTransactionConsumer` / `recon-transaction-group` | + +## Scoping (JWT) + +- `InstitutionContext.resolveScope()`: `null` for BCOS_ADMIN/ADMIN/QDS_ADMIN (all ATMs); institution-key list for MSP_ADMIN; single key for CUSTOMER. +- Admin (`null`) grid = recon positions **merged with all `device_summary` ids**. Scoped users = only ATMs that already have a recon position. +- All endpoints need a valid JWT (`anyRequest().authenticated()`; only `/actuator/health*` public). + +## Gotchas + +- `fleet/summary` returns the full in-scope set; **filters are client-side** — do not expect query params to narrow it. +- `IncidentFetchService.fetchAllAtmIds()` reads **`device_summary` locally, NO http**. Only `/groups` (and cassette config) actually calls incident. +- `device_summary` has **no reconciler** → stale if `hiveops-devices` stops emitting `hiveops.device.events`. +- Staleness badge is computed for **OPEN cycles only**: `projectionStaleMins` = minutes since `syncedAt` (or `cycleStart`); frontend thresholds yellow ≥60, red ≥360. +- **Frontend status-filter mismatch:** dropdown offers value `PENDING_CONFIRMATION`, but cycle `currentCycleStatus` uses `PENDING_RECONCILIATION`. That filter option matches no cycle rows. (See uncertainties.) +- Money is in **cents** (`*Cents` fields). +- Prod DB config = `DB_HOST`/`DB_PORT`/`DB_NAME` (default `hiveiq_recon`) — NOT a single `DB_URL`. + +## Do NOT + +- Do NOT add recon endpoints/tables to another service — recon owns this domain. +- Do NOT assume `fleet/summary` is paginated/filtered server-side. +- Do NOT expect a Kafka producer in recon — it is consumer-only. +- Do NOT rely on incident being up for the grid to load (only the group filter needs it). +- Do NOT treat a scoped user's "missing ATM" as a bug — it needs a recon position first. +- Do NOT hit prod Postgres on the services VM; it is on `10.10.10.188` via ProxyJump. +- Do NOT expose PANs — this is journal-derived data; keep sanitization intact. diff --git a/backend/src/main/resources/content/recon/cash-reconciliation/internal.md b/backend/src/main/resources/content/recon/cash-reconciliation/internal.md new file mode 100644 index 0000000..d25d4de --- /dev/null +++ b/backend/src/main/resources/content/recon/cash-reconciliation/internal.md @@ -0,0 +1,60 @@ +--- +module: recon.cash-reconciliation +title: Cash Reconciliation +tab: Internal +order: 20 +audience: internal +--- + +> **Internal · Ops/Support/Admin.** The Cash Reconciliation screen is the fleet grid ("ATM Cash Positions"). Served by **hiveops-recon**. Backend port **8091**, NGINX `/api/recon/`, prod external **8013**; frontend `recon.bcos.cloud`. + +## What this screen actually calls + +- **The grid** is filled by a single call: `GET /api/recon/fleet/summary` (component `FleetReconTable.svelte`, store `fleetSummary`). It returns **every** ATM in scope; all filtering, sorting, and paging happen **client-side** in the browser. +- **Device Group** filter → `GET /api/recon/groups` (this is the only piece of this screen that reaches out to the **incident** service, for group membership). +- Clicking a row opens the ATM detail (cycles/summary) — not this tab. + +## Who can see / do what + +- **All endpoints require a valid JWT** — `SecurityConfig` sets `anyRequest().authenticated()` (only `/actuator/health*` is public). A 401 on the grid means the token is missing/expired, not a recon bug. +- **Read GETs** (`/fleet/summary`, `/groups`, `/atm/{id}/summary`) are **not** method-role-gated; visibility is by institution scope instead (below). +- **All writes are admin-only:** `@PreAuthorize("hasAnyRole('MSP_ADMIN','BCOS_ADMIN')")` — replenishment create/edit/lock, cycle `sync`, cassette update/sync, device-rules, device-selection, and every `/admin/*` endpoint (backfill, reparse, rules edit/reload, ingest-replenishments). + +### Institution scoping (why two users see different rows) + +`InstitutionContext.resolveScope()` decides visibility from the JWT roles: + +| Role | Scope | What appears in the grid | +|---|---|---| +| BCOS_ADMIN / ADMIN / QDS_ADMIN | `null` (unrestricted) | All recon ATMs **plus** every ATM in `device_summary` (merged in so the whole fleet shows even before first replenishment) | +| MSP_ADMIN | list of granted institution keys | Only ATMs that already have a recon position in those institutions | +| CUSTOMER (BANK) | single institution key | Only that institution's ATMs that already have a recon position | + +Consequence: a **non-admin only sees an ATM after it has a recon position** (first replenishment recorded / first sync). Admins see un-enrolled ATMs too. If a customer says "my ATM is missing," check whether it has been reconciled yet. + +## First things to check when it misbehaves + +1. **Grid empty / "No ATMs tracked yet."** — `fleet/summary` returned no rows. For a scoped user this is normal until a replenishment exists. For an admin, check that `device_summary` is populated (see stale-mirror below). +2. **Grid 401/403** — JWT problem, not data. Re-login; check NGINX `/api/recon/` routing and that `JWT_SECRET` matches the other services. +3. **Device Group dropdown empty / groups missing ATMs** — `/groups` calls **incident** (`/api/atm-groups`). If incident is down or slow, the call is caught and logged (`Failed to fetch ATM groups`) and returns nothing; the grid itself still loads. +4. **⚠ staleness badge (yellow ≥60m, red ≥360m) on Projected Balance** — the ATM's **open** cycle hasn't been synced recently. `projectionStaleMins` = minutes since the cycle's `syncedAt` (or `cycleStart` if never synced). Fix by running a sync: `POST /api/recon/atm/{atmId}/sync` (admin) or recording the next replenishment. Persistent staleness usually means journal events for that ATM stopped flowing. +5. **Wrong / stale institution on an ATM** — the `device_summary` mirror is fed by Kafka `hiveops.device.events`; there is **no reconciler**, so if `hiveops-devices` stopped emitting, the mirror (and therefore admin fleet listing + scoping) goes stale. Confirm devices is publishing. +6. **Variance looks wrong** — variance is a cycle-level number surfaced here; investigate on the ATM detail / cycle, not this grid. Formula: `expected = opening + added − removed − withdrawals + deposits − reversals`; `variance = actual − expected`. + +## Common failure modes → fix + +| Symptom | Likely cause | Fix | +|---|---|---| +| Admin sees full fleet, customer sees few/none | Scoping working as designed; non-admins need a recon position first | Record first replenishment for the ATM | +| Projected balance behind reality (⚠) | Open cycle not synced; journal flow gap | `POST /atm/{id}/sync`; verify journal is receiving events | +| "Pending Confirmation" status filter returns nothing | Filter value mismatch (see Architect/Claude notes) — cycles never carry that string | Known discrepancy; use other statuses to triage | +| Whole fleet missing for admins | `device_summary` empty/stale (Kafka gap) | Verify `hiveops-devices` emits `hiveops.device.events` | +| Groups filter broken, grid fine | incident service call failing | Check incident health; recon logs `Failed to fetch ATM groups` | + +## Admin tooling (not on this screen, but recon-owned) + +- `POST /api/recon/atm/{id}/sync` — recompute the open cycle from journal. +- `POST /api/recon/admin/backfill[/{atmId}]`, `/admin/reparse/{atmId}`, `/admin/ingest-replenishments[/{atmId}]`, `/admin/repair-slots` — bulk/repair tooling, all `MSP_ADMIN`/`BCOS_ADMIN`. +- `GET/PUT /api/recon/admin/rules` (text/plain) + `POST /api/recon/admin/reload-rules` — edit `recon-rules.yml`; reload required for changes to apply. + +See the **Architect** tab for data flow and the **Claude** tab for exact paths/tables. diff --git a/backend/src/main/resources/content/recon/device-cycle-mgmt/architect.md b/backend/src/main/resources/content/recon/device-cycle-mgmt/architect.md new file mode 100644 index 0000000..128f899 --- /dev/null +++ b/backend/src/main/resources/content/recon/device-cycle-mgmt/architect.md @@ -0,0 +1,93 @@ +--- +module: recon.device-cycle-mgmt +title: Device Cycle Management +tab: Architect +order: 30 +audience: internal +--- + +> **Architecture.** How the device-selection feature is built and wired. Grounded in `hiveops-recon` source (2026-07-01). Cross-links: [platform.data-architecture], [platform.kafka], [platform.service-ownership]. + +## Ownership + +- **`hiveops-recon` owns this feature end to end** — the selection list, the table, the endpoints, and the local device mirror it reads. No write crosses a service boundary. See [platform.service-ownership]. +- Backend port **8091** (NGINX `/api/recon/`, prod external `8013`); DB **`hiveiq_recon`**; frontend `recon.bcos.cloud`. + +## Components involved + +| Layer | Class / file | Role | +|---|---|---| +| UI | `frontend/src/components/DeviceCycleMgmt.svelte` | Dual-list transfer UI; calls `reconAPI.getDeviceSelection()` / `setDeviceSelection()` | +| API client | `frontend/src/lib/api.ts` → `reconAPI` | `GET`/`PUT /api/recon/device-selection` | +| Controller | `controller/DeviceSelectionController` | 3 endpoints (GET/PUT/POST enroll) | +| Service | `service/DeviceSelectionService` | read/replace/enroll selection rows | +| Entity/repo | `entity/ReconDeviceSelection`, `repository/ReconDeviceSelectionRepository` | maps `recon_device_selection` | +| "All ATMs" source | `service/IncidentFetchService` | reads local `device_summary` (not a live HTTP call) | +| Scope | `security/InstitutionContext` | resolves institution scope from JWT | +| Mirror feed | `kafka/DeviceEventConsumer` + `config/DeviceEventKafkaConfig` | populates `device_summary` from Kafka | + +## Data flow — loading the screen (read) + +``` +Browser → GET /api/recon/device-selection + DeviceSelectionController.getSelection() + ├─ InstitutionContext.resolveScope() // JWT → null (admin) | [institutionKeys] + ├─ selected = DeviceSelectionService.getSelected(scope) + │ scope==null → repo.findByInstitutionIsNull() + │ scope!=null → repo.findByInstitutionIn(scope) // UNION across granted institutions + └─ all = IncidentFetchService... + scope==null → fetchAllAtmIds() // device_summary.findAll() + scope!=null → fetchAtmIdsForUser(token) // decode JWT institutionKey → device_summary.findByInstitutionKeyIn + → { selected, all } +Browser computes available = all − selected +``` + +Note the **"all" source is `device_summary`**, recon's local read-model — not a synchronous call to incident/devices. This keeps the screen fast and lets recon scope by institution offline. + +## Data flow — saving (write) + +``` +Browser → PUT /api/recon/device-selection body: ["AT000123", ...] + @PreAuthorize hasAnyRole('MSP_ADMIN','BCOS_ADMIN') + DeviceSelectionController.setSelection() + ├─ institution = scope==null ? null : scope.get(0) // first key only + └─ DeviceSelectionService.setSelected(institution, atmIds) [@Transactional] + delete rows for that institution (or NULL) + saveAll(new ReconDeviceSelection(atmId, institution)) // full replace +Browser → then reloads fleet summary +``` + +`POST /device-selection/enroll/{atmId}` is an idempotent single-add (existence check before insert) for concurrent auto-enroll callers (e.g. simulator agents on startup) that avoids the GET→PUT race of the full-replace path. + +## Kafka — how `device_summary` is fed + +The Available list is only as good as the `device_summary` mirror. See [platform.kafka]. + +| Direction | Topic | Consumer | Group | +|---|---|---|---| +| Consumed | `hiveops.device.events` | `DeviceEventConsumer` | `hiveops-recon-device-sync` | + +- Constants in `config/DeviceEventKafkaConfig`: `TOPIC="hiveops.device.events"`, `CONSUMER_GROUP="hiveops-recon-device-sync"`, offset reset `earliest`. +- Consumer upserts a `device_summary` row per event; `DEVICE_DELETED` removes the row; events **without `deviceId`** are skipped on first insert. Missing `institutionKey` defaults to `"BCOS"`. +- **Consumer-only, no reconciler** — if the upstream device service stops emitting, the mirror (and therefore the Available list) goes stale silently. This is the recon-wide stale-mirror caveat, applied to this screen. + +## DB tables (in `hiveiq_recon`) + +| Table | R/W here | Shape | +|---|---|---| +| `recon_device_selection` | **read + full-replace write** | `id`, `atm_id` (≤50), `institution` (≤50, nullable), `created_at`; `UNIQUE(atm_id, institution)`, index on `institution` (Flyway `V7`) | +| `device_summary` | **read only** (written by `DeviceEventConsumer`) | local device mirror; keyed by `atm_id`, carries `institution_key`, `device_type`, etc. (Flyway `V10`/`V12`) | + +## How the selection is consumed downstream + +The selection is a filter, not a hard enrollment, for most reads: + +- `SummaryController` fleet summary: applies `getSelected(scope)`; **empty selection ⇒ no filter (all ATMs)**, non-empty ⇒ `retainAll(selection)`. +- `CashForecastService`: uses `getAllEnrolledAtmIds()` — **all rows regardless of institution**. +- `ReconSyncScheduler.ingestJournalReplenishments()` (daily safety-net cron): uses `getSelected(null)` — **only the global `NULL`-institution selection**; empty ⇒ no-op. + +These three consumers read the selection with three different scoping rules — relevant when reasoning about why a change on this screen does or doesn't affect a given job. + +## Executor → Kafka → record-store pattern + +Not applicable here in the classic form: this feature has **no producer** and no async executor. It is a synchronous CRUD screen over `recon_device_selection`, plus a read of the Kafka-fed `device_summary` mirror. The only event-driven part is the inbound `hiveops.device.events` consumer that keeps the Available list current. See [platform.data-architecture]. diff --git a/backend/src/main/resources/content/recon/device-cycle-mgmt/claude.md b/backend/src/main/resources/content/recon/device-cycle-mgmt/claude.md new file mode 100644 index 0000000..a7a173b --- /dev/null +++ b/backend/src/main/resources/content/recon/device-cycle-mgmt/claude.md @@ -0,0 +1,64 @@ +--- +module: recon.device-cycle-mgmt +title: Device Cycle Management +tab: Claude +order: 40 +audience: internal +--- + +> Terse agent reference. Only confirmed facts. Owner service: **`hiveops-recon`**. + +## Owns + +- Service: `hiveops-recon` (port 8091, NGINX `/api/recon/`, prod ext 8013), DB `hiveiq_recon`. +- Feature = "recon device selection": which ATMs appear in Cash Reconciliation. +- Frontend: `frontend/src/components/DeviceCycleMgmt.svelte`. + +## Endpoints (exact) + +| Method | Path | Auth | Body / returns | +|---|---|---|---| +| GET | `/api/recon/device-selection` | none (any authed) | → `{ "selected": string[], "all": string[] }` | +| PUT | `/api/recon/device-selection` | `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` | body = `string[]` (ATM IDs); → 204 | +| POST | `/api/recon/device-selection/enroll/{atmId}` | `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` | → 204; idempotent single-add | + +- PUT body is a **bare JSON array**, not an object. +- PUT is a **full replace** for the caller's institution scope. + +## Table + +- `recon_device_selection` (DB `hiveiq_recon`): `id`, `atm_id` VARCHAR(50), `institution` VARCHAR(50) nullable, `created_at`; `UNIQUE(atm_id, institution)`. Flyway `V7`. +- `device_summary` (same DB): source of the `all` list; **read-only** for this feature. + +## Kafka + +- Consumed: topic `hiveops.device.events`, group `hiveops-recon-device-sync` (`DeviceEventConsumer`) → upserts `device_summary`. +- **No producer.** This feature emits nothing. + +## Scope (from JWT, `InstitutionContext`) + +- BCOS_ADMIN / ADMIN / QDS_ADMIN → scope `null` → reads/writes `institution IS NULL` rows; `all` = every `device_summary` row. +- CUSTOMER → single institution key. +- MSP_ADMIN → list of granted institution keys. +- GET `selected`: `null`→`findByInstitutionIsNull()`; scoped→`findByInstitutionIn(scope)` (union). +- GET `all`: `null`→`device_summary.findAll()`; scoped→decode JWT `institutionKey` claim, `device_summary.findByInstitutionKeyIn`. + +## Gotchas + +- **Empty selection = show ALL ATMs** downstream (`SummaryController` applies no filter when list empty). +- `all` comes from local `device_summary` mirror, **not** a live incident/devices call. Stale mirror → wrong Available list; no reconciler. +- Device events **without `deviceId`** are skipped on insert → that ATM never appears in Available. +- Admin (`NULL`) selection and scoped-institution selections are **separate row sets**; they don't see each other. +- Write uses **only `scope.get(0)`** (first institution) and deletes only that institution's rows; multi-institution MSP_ADMIN writes are lopsided (see Do NOT). +- Three downstream consumers use three scopings: summary=`getSelected(scope)`, forecast=`getAllEnrolledAtmIds()` (all institutions), daily ingest cron=`getSelected(null)` (global only). +- GET is unauthenticated at method level — don't assume reading it proves admin rights. + +## Do NOT + +- Do NOT send PUT body as `{ "atmIds": [...] }` — it must be a raw array `[...]`. +- Do NOT expect a CUSTOMER/BANK token to save (PUT/POST are admin-only → 403). +- Do NOT treat an empty Available list as a selection bug — check `device_summary`/Kafka first. +- Do NOT assume saving as admin changes what a customer sees — different (`NULL` vs institution) rows. +- Do NOT rely on multi-institution MSP_ADMIN saves being per-institution — write collapses to the first key. +- Do NOT look for this table/endpoint in incident/devices/journal — it lives only in `hiveops-recon`. +- Do NOT expect a Kafka publish on save — there is none. diff --git a/backend/src/main/resources/content/recon/device-cycle-mgmt/internal.md b/backend/src/main/resources/content/recon/device-cycle-mgmt/internal.md new file mode 100644 index 0000000..f5644ea --- /dev/null +++ b/backend/src/main/resources/content/recon/device-cycle-mgmt/internal.md @@ -0,0 +1,73 @@ +--- +module: recon.device-cycle-mgmt +title: Device Cycle Management +tab: Internal +order: 20 +audience: internal +--- + +> **Internal ops/support.** Grounded in `hiveops-recon` source (2026-07-01). This tab manages the **recon device selection** — which ATMs appear in Cash Reconciliation. It writes one small table; most "bugs" are actually stale device data or institution-scope confusion. + +## What this screen actually does + +- Frontend component: `DeviceCycleMgmt.svelte` (recon frontend, `recon.bcos.cloud`). +- On load it calls **GET `/api/recon/device-selection`** → `{ selected: [...], all: [...] }`. + - `selected` = ATM IDs stored in the `recon_device_selection` table for the caller's institution scope. + - `all` = every ATM ID the caller can see, read from recon's local **`device_summary`** mirror (NOT a live call to incident/devices). + - Available (left) = `all − selected`, computed in the browser. +- **Save Selection** calls **PUT `/api/recon/device-selection`** with a plain JSON array of ATM IDs, then refreshes the fleet summary. +- Empty selection is intentional: when the saved list is empty, Cash Reconciliation shows **all** ATMs (the summary applies no filter). + +## Roles required + +| Action | Endpoint | Role gate | +|---|---|---| +| View lists (read) | GET `/api/recon/device-selection` | **None** — any authenticated user (incl. CUSTOMER) can read | +| Save selection | PUT `/api/recon/device-selection` | `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` | +| Enroll one ATM (idempotent) | POST `/api/recon/device-selection/enroll/{atmId}` | `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` | + +- A CUSTOMER/BANK user can open the screen and see it, but **Save** returns 403. +- Institution scope is derived from the JWT (see below), not from a request param. + +## Institution scope — read this before "the wrong ATMs show up" + +Scope is resolved from the JWT by `InstitutionContext`: + +- **BCOS_ADMIN / ADMIN / QDS_ADMIN** → *unrestricted* (scope = null). They read/write the **global** selection rows (`institution IS NULL`) and see **all** ATMs from `device_summary`. +- **CUSTOMER (BANK)** → scoped to their single institution key. +- **MSP_ADMIN** → scoped to their granted institution keys (a list). + +Consequences that trip people up: +- **Admin and scoped users keep SEPARATE selection lists.** An admin's global (`NULL`) selection is invisible to a customer, and vice versa. "I saved a selection as admin but the customer still sees everything" is expected — they read different rows. +- The **Available** list for a scoped user is filtered by the `institutionKey` claim in their JWT against `device_summary`. If that claim is missing/blank, they fall through to "all ATMs." + +## First things to check when it misbehaves + +1. **Available list is empty or missing ATMs** → the `device_summary` mirror is stale or incomplete. It is fed only by Kafka topic `hiveops.device.events`; there is **no reconciler**. Check that devices are still publishing and recon's consumer group `hiveops-recon-device-sync` is healthy. An ATM whose device event arrived **without a `deviceId`** is skipped on insert and will never appear in Available. +2. **Save returns 403** → caller lacks `MSP_ADMIN`/`BCOS_ADMIN`. Confirm the JWT role. GET works for everyone, so "I can see it but can't save" is a role problem, not an outage. +3. **Selection saved but Cash Reconciliation still shows everything** → the saved list is empty (empty = show all), OR the viewer's scope differs from the scope you saved under (admin vs customer). Check `recon_device_selection` rows and their `institution` column. +4. **Wrong ATMs / wrong institution in Available** → almost always a stale `device_summary` row (wrong `institution_key` mirrored in) — the classic cross-service stale-mirror issue, not a recon selection bug. +5. **500 on load** → check recon backend health and DB (`hiveiq_recon`); the GET reads both `recon_device_selection` and `device_summary`. + +## Common failure modes + fixes + +| Symptom | Likely cause | Fix | +|---|---|---| +| Available list empty | `device_summary` empty/stale; Kafka consumer down | Verify `hiveops.device.events` flowing; restart recon consumer; re-emit device events from devices | +| A known ATM never appears in Available | Its device event had no `deviceId` (skipped insert) | Ensure devices publishes a full event incl. `deviceId` for that ATM | +| Save button 403 | Non-admin role | Use `MSP_ADMIN`/`BCOS_ADMIN` account | +| Customer sees all ATMs despite admin saving a list | Admin wrote global (`NULL`) rows; customer reads their institution rows | Save the selection while scoped to that institution (or leave empty intentionally) | +| MSP_ADMIN with multiple institutions: saved list looks duplicated/stale on reload | Write path stores under only the **first** institution and deletes only that institution's rows; other institutions' rows survive and are re-unioned on read | Verify `recon_device_selection` rows per institution; see uncertainty note | + +## Direct DB inspection + +DB `hiveiq_recon`, table `recon_device_selection` (`id`, `atm_id`, `institution`, `created_at`; `UNIQUE(atm_id, institution)`): + +```sql +-- global (admin) selection +SELECT atm_id FROM recon_device_selection WHERE institution IS NULL ORDER BY atm_id; +-- a specific institution's selection +SELECT atm_id FROM recon_device_selection WHERE institution = 'FSCU' ORDER BY atm_id; +``` + +Related: `technical/recon/overview.md` for the full service picture; `[architect]` tab for data flow. diff --git a/backend/src/main/resources/content/recon/recon-rules/architect.md b/backend/src/main/resources/content/recon/recon-rules/architect.md new file mode 100644 index 0000000..62d7f8b --- /dev/null +++ b/backend/src/main/resources/content/recon/recon-rules/architect.md @@ -0,0 +1,70 @@ +--- +module: recon.recon-rules +title: Recon Rules +tab: Architect +order: 30 +audience: internal +--- + +> **Internal · architecture.** How the Recon Rules feature is built, grounded in `hiveops-recon` source (2026-07-01). See [platform.service-ownership], [platform.data-architecture], [platform.kafka]. + +## Ownership + +`hiveops-recon` owns this feature end-to-end — frontend and backend are in the same repo. No other service is involved in rules editing. Recon owns the recon domain exclusively per [platform.service-ownership]. + +## Components + +| Layer | Artifact | Role | +|---|---|---| +| Frontend view | `frontend/src/components/ReconRulesView.svelte` | Table of formats; loads `getReconFormats()` on mount | +| Frontend panel | `frontend/src/components/ReconRulesPanel.svelte` | Per-format detail + full-file YAML view/edit/reload | +| Frontend API | `frontend/src/lib/api.ts` → `reconAPI` | Base `window.__APP_CONFIG__.apiUrl` or `http://localhost:8091/api/recon` | +| Controller | `controller/ReconRulesController.java` | `@RequestMapping("/api/recon/admin")` | +| Service | `service/ReconRulesService.java` | Load/cache/validate/persist rules; format detection | +| Model | `model/ReconRules.java` (`ReplenishmentFormatConfig`) | Deserialized YAML structure | +| Config source | `src/main/resources/recon-rules.yml` (classpath) | Default rules, overridable via file | + +## Data flow — rules are a file, not a database row + +There is **no DB table** for these rules. `ReconRulesService` is the single source of truth: + +1. **Startup** — `@PostConstruct init()` → `loadRules()`. If `recon.rules.path` (`RECON_RULES_PATH`) is set and the file exists, load from there; otherwise load the classpath `recon-rules.yml`. Result is held in a `volatile ReconRules cachedRules`. +2. **Read (table)** — `GET /admin/rules/formats` → `getRules()` maps each `replenishmentFormats` entry to `{name, detectionSentinel, slotStrategy, deduplication, preReplenishmentBlockEnd, description}`. +3. **Read (raw)** — `GET /admin/rules` (text/plain) → `getRulesContent()` returns the raw file text (external path if set, else classpath bytes). +4. **Write** — `PUT /admin/rules` (text/plain) → `saveRulesContent(yaml)`: **validates** by parsing into `ReconRules.class`, then writes to the external `RECON_RULES_PATH` file and calls `loadRules()` to refresh the cache. If `RECON_RULES_PATH` is unset it throws `IllegalStateException` (→ 400) rather than mutate the classpath jar. +5. **Reload** — `POST /admin/reload-rules` → `reloadRules()` re-reads file→cache without editing; returns the current format-name list. + +## How the rules are consumed (the point of the feature) + +`ReconRulesService.detectFormat(rawLines)` and `getFormatConfig(name)` are called by recon's replenishment parsing path (prefill/sync) when it processes journal `STANDARD REPLENISHMENT` blocks: + +- Iterate `replenishmentFormats` in declaration order. +- First format whose `detectionSentinel` is `contains()`-matched in the raw journal text wins. +- A format with a `null` sentinel is retained as the **fallback** (used only if nothing else matches). +- The matched `ReplenishmentFormatConfig` (`slotStrategy` LETTER/DENOMINATION/POSITION, `deduplication`, `preReplenishmentBlockEnd`) governs how cassette BEG/USE/CUR lines are parsed into cash totals. + +## Kafka + +This feature (rules view/edit) involves **no Kafka**. It's a synchronous file-backed config CRUD. Recon is a **consumer-only** service overall (`hiveops.device.events`, `journal.replenishments`, `hiveops.journal.transactions.recorded`) — those feed the *reconciliation* pipeline, not rules editing. See [platform.kafka]. + +## Data flow diagram + +``` +Admin (MSP_ADMIN/BCOS_ADMIN) + │ ReconRulesView / ReconRulesPanel + ▼ +NGINX /api/recon/admin/* ──► ReconRulesController + │ + ▼ + ReconRulesService (volatile cachedRules) + │ + ┌──────────────┴───────────────┐ + ▼ ▼ + recon-rules.yml file detectFormat()/getFormatConfig() + (classpath or RECON_RULES_PATH) used by replenishment parsing + (prefill / sync from hiveops-journal) +``` + +## Related tables (context, not written by this feature) + +Rules parsing feeds the recon pipeline that writes `replenishment_sessions`, `replenishment_cassette_entries`, `reconciliation_cycles`, `atm_cash_positions`, `atm_cassette_configs` in `hiveiq_recon`. See [platform.data-architecture]. The rules file itself is not persisted to Postgres. diff --git a/backend/src/main/resources/content/recon/recon-rules/claude.md b/backend/src/main/resources/content/recon/recon-rules/claude.md new file mode 100644 index 0000000..921b27d --- /dev/null +++ b/backend/src/main/resources/content/recon/recon-rules/claude.md @@ -0,0 +1,57 @@ +--- +module: recon.recon-rules +title: Recon Rules +tab: Claude +order: 40 +audience: internal +--- + +> Act-without-guessing reference. All facts confirmed in `hiveops-recon` source (2026-07-01). + +## Owner +- Service: **hiveops-recon** (owns frontend + backend). Port **8091**, NGINX `/api/recon/`, prod external **8013**. +- Controller: `com.hiveops.recon.controller.ReconRulesController` (`@RequestMapping("/api/recon/admin")`). +- Service class: `ReconRulesService`. Model: `ReconRules` / `ReconRules.ReplenishmentFormatConfig`. + +## Endpoints (all `@PreAuthorize hasAnyRole('MSP_ADMIN','BCOS_ADMIN')`) +| Method | Path | Content | Purpose | +|---|---|---|---| +| GET | `/api/recon/admin/rules/formats` | JSON | Format list for the table | +| GET | `/api/recon/admin/rules` | text/plain | Raw `recon-rules.yml` text | +| PUT | `/api/recon/admin/rules` | text/plain body | Save YAML (validates, writes, reloads) | +| POST | `/api/recon/admin/reload-rules` | — | Reload file→cache, returns `{status, formats[]}` | + +## Frontend → endpoint map (`frontend/src/lib/api.ts` `reconAPI`) +- `getReconFormats()` → GET `/admin/rules/formats` +- `getReconRules()` → GET `/admin/rules` +- `saveReconRules(yaml)` → PUT `/admin/rules` +- `reloadReconRules()` → POST `/admin/reload-rules` +- Components: `ReconRulesView.svelte` (table), `ReconRulesPanel.svelte` (YAML editor). + +## Storage +- **No DB table.** Rules live in a YAML file: classpath `src/main/resources/recon-rules.yml`, overridable by env `RECON_RULES_PATH` → property `recon.rules.path` (default empty). +- Cached in memory: `ReconRulesService.cachedRules` (volatile), loaded `@PostConstruct`. +- Format DTO fields: `name`, `detectionSentinel`, `slotStrategy` (LETTER|DENOMINATION|POSITION), `deduplication`, `preReplenishmentBlockEnd`, `description`. + +## Kafka / topics +- **None for this feature.** (Recon overall is consumer-only: `hiveops.device.events`, `journal.replenishments`, `hiveops.journal.transactions.recorded` — unrelated to rules editing.) + +## Detection semantics +- Order-dependent; first `detectionSentinel` `String.contains()` match wins (`ReconRulesService.detectFormat`). +- Null-sentinel format = fallback (used only if no other matches). + +## Gotchas +- GET endpoints are admin-gated too → non-admin sees **403 on page load**, not just on save. +- PUT save **throws IllegalStateException → 400** if `RECON_RULES_PATH` unset ("recon.rules.path not configured"). This env var is **not set in the repo deployment** — save is disabled in prod until an external writable file is configured. +- Invalid YAML on PUT → **500** (parse into `ReconRules.class` fails, caught as IOException). +- Save auto-reloads the cache; manual out-of-band file edits require POST `/reload-rules` or restart. +- Rule edits affect **future** parsing only; past cycles need recon backfill/reparse tooling. +- `recon-rules.yml` (format parsing) ≠ `recon_device_rules` table / `ReconDeviceRuleController` (device auto-selection). Different feature. + +## Do NOT +- Do NOT assume a `recon_rules` DB table exists — it does not; it's a YAML file. +- Do NOT tell a user to PUT `/admin/rules` in prod without first confirming `RECON_RULES_PATH` points to a writable mounted file. +- Do NOT invent extra endpoints — only the four above exist on `ReconRulesController`. +- Do NOT claim any role other than MSP_ADMIN/BCOS_ADMIN can read this screen. +- Do NOT confuse this with `recon_device_rules` / device selection. +- Do NOT expect Kafka events from editing rules. diff --git a/backend/src/main/resources/content/recon/recon-rules/internal.md b/backend/src/main/resources/content/recon/recon-rules/internal.md new file mode 100644 index 0000000..2793db9 --- /dev/null +++ b/backend/src/main/resources/content/recon/recon-rules/internal.md @@ -0,0 +1,47 @@ +--- +module: recon.recon-rules +title: Recon Rules +tab: Internal +order: 20 +audience: internal +--- + +> **Internal · ops/support/admin.** Grounded in `hiveops-recon` source (2026-07-01). Owner service: **hiveops-recon** (port 8091, NGINX `/api/recon/`, prod external 8013). The feature edits a **YAML file**, not a DB table. + +## What this screen actually is + +The "Recon Rules" view (`ReconRulesView.svelte`) lists the **replenishment formats** defined in `recon-rules.yml`. Each row = one format's parsing config (detection sentinel, slot strategy, deduplication, pre-block end). Clicking a row opens `ReconRulesPanel.svelte`, which shows/edits the **raw YAML** of the *entire* rules file. Detection drives how recon parses `STANDARD REPLENISHMENT` journal blocks into cassette totals. + +## Roles required — everything here is admin-only + +Every endpoint on `ReconRulesController` is annotated `@PreAuthorize("hasAnyRole('MSP_ADMIN','BCOS_ADMIN')")` — **including the read GETs**. A user without one of those roles gets **403 on page load** (the table's `GET /admin/rules/formats` is gated), not just on save. + +| Action in UI | Call | Role | +|---|---|---| +| Load format table | GET `/api/recon/admin/rules/formats` | MSP_ADMIN / BCOS_ADMIN | +| View raw YAML (panel) | GET `/api/recon/admin/rules` | MSP_ADMIN / BCOS_ADMIN | +| Save edited YAML | PUT `/api/recon/admin/rules` | MSP_ADMIN / BCOS_ADMIN | +| Reload from disk | POST `/api/recon/admin/reload-rules` | MSP_ADMIN / BCOS_ADMIN | + +## First things to check when it misbehaves + +1. **"No rules configured" / empty table** — `getRules()` returned null or zero formats. Means `recon-rules.yml` failed to load at startup (`ReconRulesService.loadRules()` fell back to `new ReconRules()`). Check recon logs for `Failed to load recon-rules.yml`. Confirm the classpath resource (or `RECON_RULES_PATH` file) is valid YAML. +2. **403 loading the page** — the user isn't MSP_ADMIN/BCOS_ADMIN. This is expected, not a bug. +3. **Save fails with "recon.rules.path not configured"** — see failure modes below; this is the #1 gotcha. +4. **A machine's replenishment reads wrong** — check which format matched. Detection is **order-dependent, first sentinel `contains()` match wins**; a format with no sentinel is the **fallback**. If a specific format's sentinel string is wrong/missing, records fall through to fallback and parse incorrectly. +5. **Edited a rule but nothing changed** — edits only affect **future** parsing after the cache reloads; already-recorded cycles are unaffected. Use the recon backfill/reparse admin tooling to re-run past data. + +## Common failure modes + fixes + +| Symptom | Cause | Fix | +|---|---|---| +| **Save returns 400 "recon.rules.path not configured — cannot save rules to classpath resource"** | `RECON_RULES_PATH` env var is unset (default is empty). `saveRulesContent()` refuses to write to the packaged classpath file and throws `IllegalStateException`. **This env var is not set anywhere in the repo deployment** — so Save is effectively disabled in prod until an ops person mounts a writable file and sets `RECON_RULES_PATH` to it. | Set `RECON_RULES_PATH` to a writable external path (bind-mounted), seed it with the current `recon-rules.yml`, restart recon. Then Edit/Save works and persists across restarts. | +| **Save returns 500** | Submitted YAML is invalid — `saveRulesContent()` validates by parsing into `ReconRules.class` before writing; a parse failure surfaces as a 500 with the Jackson error message. | Fix the YAML syntax/structure; the panel shows the error banner text. | +| **Save "succeeds" but reverts after redeploy** | Rules were written to the classpath copy inside the running container (only possible if someone edited in place) — a new image overwrites it. | Use an external `RECON_RULES_PATH` file so edits live outside the image. | +| **Change saved but parsing still old** | In-memory `cachedRules` not refreshed. Save auto-reloads; a manual out-of-band file edit does not. | Click **Reload** (POST `/reload-rules`) or restart recon. | +| **Wrong format keeps matching** | Earlier format's `detectionSentinel` is a substring that matches too broadly. | Reorder/narrow sentinels — remember first match wins, fallback (null sentinel) is last resort. | + +## Don't confuse these two "rules" + +- **recon-rules.yml (THIS screen)** — replenishment *format parsing* config. A YAML file. No DB table. +- **`recon_device_rules` table / `ReconDeviceRuleController`** — a *different* feature that auto-selects which devices are enrolled in recon. Unrelated to this view. diff --git a/backend/src/main/resources/content/reports/builder/architect.md b/backend/src/main/resources/content/reports/builder/architect.md new file mode 100644 index 0000000..5550856 --- /dev/null +++ b/backend/src/main/resources/content/reports/builder/architect.md @@ -0,0 +1,52 @@ +--- +module: reports.builder +title: Report Builder +tab: Architect +order: 30 +audience: internal +--- + +How the Report Builder is actually wired. It is owned end-to-end by **hiveops-reports**, which stores definitions/runs and orchestrates async execution against *other* services' live APIs. See [platform.service-ownership], [platform.data-architecture], [platform.kafka]. + +## Components +- **Frontend** — `hiveops-reports/frontend` (`Reports.svelte`, module `reports.builder`, app `reports`). Axios client `reportsApi` base = `window.__APP_CONFIG__.apiUrl` (default `…/api/reports`). +- **Controllers** (`com.hiveops.reports.controller`) — `ReportTemplateController`, `ReportRunController`, `ReportQueryController`, `ReportSchemaController`, `DeviceSummaryController`, `UserController`. +- **Services** — `ReportTemplateService`, `ReportRunService` (executor), `ReportExportService` (CSV writer), `ReportSchedulerService`, `ReportDistributorService`, `ReportQueryService`. +- **Fetchers** (`fetcher/*`, one per data source, keyed by `supports()`), **security** (`JwtAuthenticationFilter`, `InternalTokenGenerator`), **kafka** (`DeviceEventConsumer`, `ReportCompletedEventProducer`). + +## Run data flow (executor → fetch → CSV → distribute) +1. `POST /api/reports/templates/{id}/run` → `ReportRunService.triggerUserRun(...)` inserts a `report_runs` row (PENDING) carrying the caller's identity, then calls `executeAsync(runId, bearerToken)`. +2. `executeAsync` (`@Async("reportExecutor")`, pool core=2/max=5) sets status **RUNNING**, resolves the fetcher for `template.dataSource`, and calls `fetcher.fetch(effectiveFilters, bearerToken)`. +3. **Fetchers** — the run's data comes from different services depending on data source: + + | DataSource | Fetcher | Downstream (WebClient `@Qualifier`) | Path | + |---|---|---|---| + | INCIDENTS | `IncidentReportFetcher` | incident | `/api/incident-management` | + | SOFTWARE_ERRORS | `SoftwareErrorReportFetcher` | incident | incident paginated, regex-grouped by error pattern | + | AGENT_MODULES | `AgentModuleReportFetcher` | incident | `/api/fleet/modules` | + | FLEET_TASKS | `FleetTaskReportFetcher` | fleet (`hiveiq-fleet:8097`) | `/api/fleet/tasks/paginated` | + | TRANSACTIONS | `TransactionReportFetcher` | journal (+ incident `/api/atms`) | `/api/journal/transactions` | + | ATM_HEALTH | `AtmHealthReportFetcher` | **local `device_summary`** | repo query, no HTTP | + | AGENT_VERSIONS | `AgentVersionReportFetcher` | **local `device_summary`** | repo query, no HTTP | + + Downstream base URLs from `WebClientConfig`: incident `hiveops-incident-backend:8080`, journal `hiveops-journal:8087`, fleet `hiveiq-fleet:8097`, mgmt `hiveops-mgmt:8080`, messaging `hiveops-messaging:8086`. HTTP fetchers page through downstream results. +4. Rows are processed (group-by + SUM/AVG/MIN/MAX aggregations, column projection). For CSV output, `ReportExportService.writeCsv` writes `{uploadDir}/{uuid}.csv` and the path is saved to `report_runs.csv_path`; JSON output is retained for in-app preview. +5. Status → **COMPLETED** (or **FAILED** with the exception message). On success, `distributeIfConfigured` runs. + +## Distribution (executor → Kafka → messaging) +- `ReportDistributorService.distribute(run, recipientUserIds, bearerToken)` builds a `ReportCompletedEvent` and calls `ReportCompletedEventProducer.publish(...)` → **Kafka `hiveops.reports.completed`** (3 partitions; key = `institutionKey` or `"global"`). +- **hiveops-messaging** consumes that topic and delivers the completed report to each recipient's in-app inbox. +- One `report_distribution_log` row is persisted per recipient (both on publish success and failure). + +## Scheduling +- `ReportSchedulerService` — `@Scheduled(fixedDelay=60_000)` polls `templateRepository.findScheduledDue(now)`. +- For each due template it mints a service JWT via `InternalTokenGenerator.generateForInstitution(institutionKey)` — short-lived (300s), `role=MSP_ADMIN` + `institutionKey` claim (falls back to unscoped `role=ADMIN` when institutionKey is null), signed `Keys.hmacShaKeyFor(jwt.secret)` — then calls `triggerScheduledRun(template, serviceToken)`. Same execution path as user runs; `computeNextRun(cron)` sets the next fire time. + +## Device mirror (Kafka → device_summary) +- `DeviceEventConsumer` — `@KafkaListener` on **`hiveops.device.events`**, group **`hiveops-reports-device-sync`** (offset reset `earliest`). Upserts `device_summary`; handles `DEVICE_DELETED`; null `institutionKey` defaults to `"BCOS"`. This is the sole source for ATM_HEALTH and AGENT_VERSIONS — no reconciler, so it drifts if the topic stalls. See the platform-wide device_summary mirror note in [platform.data-architecture]. + +## Persistence (DB `hiveiq_reports`, split `DB_*` env, Flyway V1–V10) +`report_templates` · `report_runs` · `report_queries` · `report_distribution_log` · `device_summary`. CSV artifacts live on disk (`REPORT_UPLOAD_DIR`), not in the DB. + +## Auth boundary +All endpoints require an authenticated JWT (`anyRequest().authenticated()`); user runs forward the caller's `Authorization` header downstream, scheduled runs forward the self-minted service token. Only `/api/devices/atms` carries a role `@PreAuthorize`. See [platform.service-ownership]. diff --git a/backend/src/main/resources/content/reports/builder/claude.md b/backend/src/main/resources/content/reports/builder/claude.md new file mode 100644 index 0000000..8237c7b --- /dev/null +++ b/backend/src/main/resources/content/reports/builder/claude.md @@ -0,0 +1,72 @@ +--- +module: reports.builder +title: Report Builder +tab: Claude +order: 40 +audience: internal +--- + +Terse act-without-guessing reference. **Owner service: `hiveops-reports`** (`hiveiq-reports`, internal `:8088`, external `:8011`). Route `api.bcos.cloud/reports/` → `hiveiq-reports:8088/api/reports/`. DB `hiveiq_reports` (split `DB_HOST/DB_PORT/DB_NAME/DB_USERNAME/DB_PASSWORD`, NOT `DB_URL`). + +## Endpoints (all require JWT Bearer) +| Method | Path | Purpose | +|---|---|---| +| GET | `/api/reports/schema` | all data-source schemas | +| GET | `/api/reports/schema/{dataSource}` | one schema | +| GET | `/api/reports/templates` | caller's templates | +| POST | `/api/reports/templates` | create | +| GET | `/api/reports/templates/{id}` | get one | +| PUT | `/api/reports/templates/{id}` | update | +| DELETE | `/api/reports/templates/{id}` | delete | +| POST | `/api/reports/templates/{id}/run` | async run → returns run | +| GET | `/api/reports/templates/shared` | templates shared with caller | +| POST | `/api/reports/templates/{id}/share` | share | +| DELETE | `/api/reports/templates/{id}/share` | unshare | +| GET | `/api/reports/templates/{templateId}/queries` | list queries | +| POST | `/api/reports/templates/{templateId}/queries` | create query | +| PUT | `/api/reports/templates/{templateId}/queries/{queryId}` | update query | +| DELETE | `/api/reports/templates/{templateId}/queries/{queryId}` | delete query | +| POST | `/api/reports/templates/{templateId}/queries/{queryId}/set-scheduled` | mark scheduled query | +| DELETE | `/api/reports/templates/{templateId}/queries/scheduled` | clear scheduled query | +| GET | `/api/reports/runs` | list runs (pageable) | +| GET | `/api/reports/runs/{id}` | one run | +| GET | `/api/reports/runs/{id}/preview` | JSON preview | +| GET | `/api/reports/runs/{id}/download` | CSV download | +| DELETE | `/api/reports/runs/{id}` | delete run | +| POST | `/api/reports/runs/{id}/distribute` | manual distribute | +| GET | `/api/devices/atms` | devices from local mirror | +| GET | `/api/users/me` | current principal | + +## Tables (DB `hiveiq_reports`) +- `report_templates` · `report_runs` (`status`, `csv_path`, `uuid`) · `report_queries` · `report_distribution_log` · `device_summary` + +## Kafka +- **Produce** → `hiveops.reports.completed` (3 partitions; key=`institutionKey`|`"global"`) — consumed by `hiveops-messaging` for in-app delivery. +- **Consume** ← `hiveops.device.events` — group `hiveops-reports-device-sync`, offset `earliest`; upserts `device_summary`. + +## Data sources → downstream (fetch happens at run time) +- INCIDENTS, SOFTWARE_ERRORS, AGENT_MODULES → **hiveops-incident** (`hiveops-incident-backend:8080`) +- FLEET_TASKS → **hiveops-fleet** (`hiveiq-fleet:8097`, `/api/fleet/tasks/paginated`) +- TRANSACTIONS → **hiveops-journal** (`/api/journal/transactions`) + incident (`/api/atms`) +- ATM_HEALTH, AGENT_VERSIONS → **local `device_summary`** (no HTTP) + +## Roles / auth +- `SecurityConfig` = `anyRequest().authenticated()`. **No `MSP_ADMIN`/`BCOS_ADMIN` gate on report actions** — any valid JWT works. +- Only `/api/devices/atms` = `@PreAuthorize("hasAnyRole('USER','CUSTOMER','ADMIN','MSP_ADMIN','BCOS_ADMIN')")`. +- User runs forward caller's `Authorization` header downstream. Scheduled runs mint their own JWT (`InternalTokenGenerator`, 300s, `role=MSP_ADMIN`+`institutionKey`, or `ADMIN` if null), signed with `jwt.secret`. + +## Gotchas +- Templates/runs scoped by `principal.userId()` — per-user, not per-institution; no cross-user admin listing endpoint. +- Async pool tiny (core=2/max=5); runs queue under load. +- CSV on disk at `REPORT_UPLOAD_DIR/{uuid}.csv` (default `/app/uploads/reports`, OpenMetal `/data/hiveiq/reports/`) — NOT in DB. Missing volume = COMPLETED run, broken download. +- `device_summary` has no reconciler; stale if `hiveops.device.events` stalls → stale ATM_HEALTH/AGENT_VERSIONS. +- Downstream outage surfaces as FAILED run (reason on the run), not empty rows. +- 7 data sources in code (`ReportTemplate.DataSource`) incl. `SOFTWARE_ERRORS` (seeded via V6); customer overview lists 6. + +## Do NOT +- Do NOT assume `MSP_ADMIN`/`BCOS_ADMIN` is required — reports only need an authenticated JWT. +- Do NOT set `DB_URL` for this service; use the split `DB_*` vars. +- Do NOT look for report/fleet data owned here beyond templates/runs/queries/dist-log/device_summary — the actual fleet data is fetched live from incident/journal/fleet. +- Do NOT expect CSVs in Postgres — they are files on the upload volume. +- Do NOT write to `device_summary` directly — it is Kafka-fed; publish/replay `hiveops.device.events` instead. +- Do NOT revoke/rotate `JWT_SECRET` on this service alone — scheduled runs will fail downstream auth. diff --git a/backend/src/main/resources/content/reports/builder/internal.md b/backend/src/main/resources/content/reports/builder/internal.md new file mode 100644 index 0000000..125401b --- /dev/null +++ b/backend/src/main/resources/content/reports/builder/internal.md @@ -0,0 +1,39 @@ +--- +module: reports.builder +title: Report Builder +tab: Internal +order: 20 +audience: internal +--- + +Ops/support view of the HiveIQ Report Builder. Owning service: **hiveops-reports** (`hiveiq-reports`, internal `:8088`, external `:8011`; frontend `report.bcos.cloud` @ `:5182`). Route: `api.bcos.cloud/reports/` → `hiveiq-reports:8088/api/reports/`. + +## Who can do what +- **No `MSP_ADMIN`/`BCOS_ADMIN` gate on report actions.** `SecurityConfig` is `anyRequest().authenticated()` behind `JwtAuthenticationFilter` — *any* logged-in HiveIQ user with a valid JWT can create/run/download reports. The one method-level guard is `GET /api/devices/atms` (`@PreAuthorize("hasAnyRole('USER','CUSTOMER','ADMIN','MSP_ADMIN','BCOS_ADMIN')")`). +- **Templates and runs are per-user, not per-institution.** Listing uses `principal.userId()` (`listForUser`, `getForUser`). A user sees only their own templates plus ones explicitly **Shared** with them. There is no admin "see everyone's reports" endpoint. +- **Scheduled runs impersonate.** The scheduler mints its own JWT (see below) — a scheduled report can read data the human owner might not, because it runs as a service `MSP_ADMIN`/`ADMIN` principal scoped to the template's `institutionKey`. + +## First things to check when it misbehaves +1. **A run is stuck PENDING/RUNNING** → check `hiveiq-reports` logs. Execution is `@Async("reportExecutor")` with a **small pool (core=2, max=5)**; a burst of runs queues behind it. `report_runs.status` never leaving RUNNING usually means a downstream fetch hung. +2. **A run FAILED** → the reason is stored on the run and shown on hover in Run History. Reports **fetch live** from other services at run time; a downstream outage = FAILED run, not empty data. Map the data source to the service: + - INCIDENTS / SOFTWARE_ERRORS / AGENT_MODULES → **hiveops-incident** + - FLEET_TASKS → **hiveops-fleet** (`hiveiq-fleet:8097`) + - TRANSACTIONS → **hiveops-journal** (+ incident for ATM list) + - ATM_HEALTH / AGENT_VERSIONS → **local `device_summary` mirror** (no HTTP call) +3. **Download returns 404/500** → CSV files live on disk under `REPORT_UPLOAD_DIR` (default `/app/uploads/reports/{uuid}.csv`; bind-mounted to `/data/hiveiq/reports/` in OpenMetal), **not** in the DB. If the volume is missing/rotated, the DB row exists but the file is gone. +4. **ATM Health / Agent Versions show stale or missing devices** → `device_summary` is a **Kafka-fed mirror with no reconciler**. If `hiveops.device.events` stops flowing (consumer group `hiveops-reports-device-sync` lagging/down), device data silently goes stale. Check consumer lag and restart the service to rejoin. +5. **Scheduled report never fires** → scheduler polls every 60s (`findScheduledDue`). Verify the template has a valid cron and that a query is marked scheduled (`set-scheduled`). An invalid cron is logged as a warning and skipped, not surfaced to the user. +6. **Scheduled report fires but downstream calls 401/403** → the self-minted service JWT is signed with `jwt.secret`. If `hiveops-reports`' `JWT_SECRET` drifts from the shared platform secret, scheduled (but not user-triggered) runs fail auth downstream. + +## Common failure modes → fix +| Symptom | Likely cause | Fix | +|---|---|---| +| Run FAILED, "No fetcher for data source" | Template `data_source` value has no matching fetcher | Data source enum drift; check template row in `report_templates` | +| Device data stale in reports | `hiveops.device.events` consumer stopped | Restart `hiveiq-reports`; check Kafka lag on `hiveops-reports-device-sync` | +| Download 404 with COMPLETED run | CSV file missing on disk | Check `REPORT_UPLOAD_DIR` volume mount; re-run the report | +| Scheduled runs fail auth, manual runs fine | `JWT_SECRET` mismatch | Align `hiveops-reports` secret with platform `JWT_SECRET` | +| Distributed report never lands in recipient inbox | Kafka `hiveops.reports.completed` not consumed | Check `hiveops-messaging` consumer; see `report_distribution_log` for per-recipient rows | +| Slow runs under load | 2–5 async threads saturated | Expect queueing; not a bug | + +## Data locations (DB `hiveiq_reports`) +`report_templates` (definitions, cron, sharing, institutionKey) · `report_runs` (status, row count, `csv_path`, `uuid`, triggered_by) · `report_queries` (saved date-range + ATM sets, scheduled flag) · `report_distribution_log` (one row per recipient) · `device_summary` (Kafka mirror). Connection is split `DB_HOST`/`DB_PORT`/`DB_NAME`/`DB_USERNAME`/`DB_PASSWORD` (not `DB_URL`). diff --git a/backend/src/main/resources/content/reports/queries/architect.md b/backend/src/main/resources/content/reports/queries/architect.md new file mode 100644 index 0000000..c97b38e --- /dev/null +++ b/backend/src/main/resources/content/reports/queries/architect.md @@ -0,0 +1,93 @@ +--- +module: reports.queries +title: Saved Queries +tab: Architect +order: 30 +audience: internal +--- + +> **Architect view.** Grounded in `hiveops-reports` source. Cross-links: [platform.data-architecture], [platform.kafka], [platform.service-ownership], [technical.reports]. + +## Ownership & scope + +Saved Queries are **entirely owned by hiveops-reports** ([platform.service-ownership]). No other service reads or writes `report_queries`; there is **no Kafka topic and no cross-service call** in the query CRUD path. This is a self-contained sub-feature of the report builder, not a distributed flow. + +The only external touch is indirect: the **ATM picker** that populates a query's `atm_ids` is fed by the reports-local `device_summary` mirror (endpoint `GET /api/devices/atms`), which is itself Kafka-fed from `hiveops.device.events` — see [technical.reports] and [platform.kafka]. Queries store bare `Long` ATM ids; they do not resolve or validate them against that mirror at save time. + +## Components + +| Layer | Class | Role | +|---|---|---| +| Controller | `controller.ReportQueryController` | REST at `/api/reports/templates/{templateId}/queries` | +| Service | `service.ReportQueryService` | CRUD + owner enforcement + schedule pointer | +| Entity | `entity.ReportQuery` | maps `report_queries`; `toFilterOverrides()` = the date/ATM → filter-map logic | +| Repository | `repository.ReportQueryRepository` | `findByTemplateIdOrderByCreatedAtAsc`, `findByIdAndTemplateId`, `findById` | +| DTOs | `CreateQueryRequest`, `UpdateQueryRequest`, `ReportQueryResponse` | request/response shaping | +| Consumer | `service.ReportRunService` | reads a query's filters into a run | + +## Data model + +Table **`report_queries`** (Flyway `V3__add_report_queries.sql`): + +| Column | Type | Notes | +|---|---|---| +| `id` | BIGSERIAL PK | | +| `uuid` | UUID UNIQUE | `gen_random_uuid()` default; also set app-side | +| `template_id` | BIGINT FK → `report_templates(id)` | **ON DELETE CASCADE** — delete a template, its queries go with it | +| `name` | VARCHAR(255) NOT NULL | | +| `date_range_type` | VARCHAR(20) | enum, default `ALL_TIME` | +| `date_from` / `date_to` | DATE | used only when `CUSTOM` | +| `atm_ids` | JSONB | `List`; empty/null ⇒ all ATMs | +| `created_at` / `updated_at` | TIMESTAMPTZ | Hibernate `@CreationTimestamp`/`@UpdateTimestamp` | + +Index: `idx_report_queries_template (template_id)`. + +Two FK pointers added in the same V3 migration tie queries into the report lifecycle: + +- `report_templates.schedule_query_id BIGINT FK → report_queries(id)` **ON DELETE SET NULL** — which query drives the cron schedule. +- `report_runs.query_id BIGINT FK → report_queries(id)` **ON DELETE SET NULL** — which query a run used (audit). + +See [platform.data-architecture] for the DB-per-service model (`hiveiq_reports`, split `DB_HOST/DB_PORT/DB_NAME` connection). + +## Enum → filter resolution (the core logic) + +`ReportQuery.DateRangeType` = `LAST_7_DAYS, LAST_30_DAYS, THIS_MONTH, LAST_MONTH, CUSTOM, ALL_TIME`. + +`ReportQuery.toFilterOverrides()` turns a query into a `Map` at **run time**, resolving relative ranges against `LocalDate.now(ZoneOffset.UTC)`: + +- `LAST_7_DAYS` → `dateFrom = today-7`, `dateTo = today` +- `LAST_30_DAYS` → `dateFrom = today-30`, `dateTo = today` +- `THIS_MONTH` → `dateFrom = firstOfMonth`, `dateTo = today` +- `LAST_MONTH` → `dateFrom = firstOfPrevMonth`, `dateTo = lastOfPrevMonth` +- `CUSTOM` → stored `date_from`/`date_to` +- `ALL_TIME` → no date keys +- Non-empty `atm_ids` → `atmIds` key added. + +## Data flow — query → report run + +Queries feed the **executor** stage of the report pipeline (the `executor → Kafka → record-store` pattern is at the *distribution* end of reports, not here — see [technical.reports]): + +**Manual run** — `POST /api/reports/templates/{id}/run` with `{ queryId, filterOverrides }`: +1. `ReportRunService.triggerUserRun` loads the template (`findByIdAndCreatedByUserId` OR `findByIdAndSharedTrue`). +2. `resolveFilters(queryId, overrides)` builds the effective filter map: + - if `queryId != null`: `effective.putAll(query.toFilterOverrides())` + - then `effective.putAll(overrides)` — **request overrides win over the saved query**. +3. A `report_runs` row is persisted with `query_id` + `filter_overrides`, then `executeAsync` runs the fetch using the **caller's forwarded bearer token**. + +**Scheduled run** — `ReportSchedulerService` (`@Scheduled` fixed-delay) → `triggerScheduledRun(template)`: +1. `queryId = template.getScheduleQueryId()`. +2. `resolveFilters(queryId, null)` — no overrides, so the query is authoritative. +3. Run executes under a self-minted ADMIN JWT from `InternalTokenGenerator` (not a user token). See [technical.reports] gotchas. + +Downstream, the resolved `filterOverrides` (dateFrom/dateTo/atmIds among them) are passed to the data-source fetcher (`INCIDENTS`/`TRANSACTIONS`/`FLEET_TASKS`/etc.), which makes live paginated HTTP calls to incident/journal/fleet. Queries never store fleet data. + +## Boundaries & invariants + +- **One template ↔ many queries**, ordered by `created_at`. **One template ↔ at most one scheduled query** (`schedule_query_id`, overwritten by `setScheduled`, nulled by `clearScheduled` or by deleting the pointed-to query). +- **Owner isolation** is enforced only at the service layer via `created_by_user_id`, not by DB constraint or role. Shared templates are runnable by others (`findByIdAndSharedTrue`) but query **management** stays owner-only. +- No events emitted on query create/update/delete — nothing to reconcile, nothing downstream to notify. + +## Related + +- Service-wide build/data facts: [technical.reports] +- Platform patterns: [platform.data-architecture], [platform.kafka], [platform.service-ownership] diff --git a/backend/src/main/resources/content/reports/queries/claude.md b/backend/src/main/resources/content/reports/queries/claude.md new file mode 100644 index 0000000..ee34b29 --- /dev/null +++ b/backend/src/main/resources/content/reports/queries/claude.md @@ -0,0 +1,64 @@ +--- +module: reports.queries +title: Saved Queries +tab: Claude +order: 40 +audience: internal +--- + +> Terse agent reference. Only confirmed facts. Owner service: **hiveops-reports** (all query CRUD is self-contained; no other service, no Kafka topic in this path). + +## Owns +- **Service:** `hiveops-reports` (Spring Boot). DB `hiveiq_reports`. Table **`report_queries`**. +- Nothing else reads/writes `report_queries`. No producer/consumer for queries. + +## Endpoints (all under JWT; `anyRequest().authenticated()`, no role gate) +Base: `/api/reports/templates/{templateId}/queries` — `ReportQueryController` + +| Method | Path | Action | Success | +|---|---|---|---| +| GET | `/api/reports/templates/{templateId}/queries` | list (ordered by created_at) | 200 | +| POST | `/api/reports/templates/{templateId}/queries` | create | 201 | +| PUT | `/api/reports/templates/{templateId}/queries/{queryId}` | update (partial; only non-null fields) | 200 | +| DELETE | `/api/reports/templates/{templateId}/queries/{queryId}` | delete (nulls `schedule_query_id` if it pointed here) | 204 | +| POST | `/api/reports/templates/{templateId}/queries/{queryId}/set-scheduled` | set template's scheduled query | 204 | +| DELETE | `/api/reports/templates/{templateId}/queries/scheduled` | clear scheduled query | 204 | + +Related (consume a query): `POST /api/reports/templates/{id}/run` body `{ queryId?, filterOverrides? }`. + +## Request bodies +- **Create** (`CreateQueryRequest`): `name` (required, NotBlank), `dateRangeType` (default `ALL_TIME`), `dateFrom`, `dateTo`, `atmIds: Long[]`. +- **Update** (`UpdateQueryRequest`): same fields, all optional; null field = unchanged. +- `dateRangeType` enum: `LAST_7_DAYS | LAST_30_DAYS | THIS_MONTH | LAST_MONTH | CUSTOM | ALL_TIME`. Anything else → 400 "Invalid dateRangeType". + +## DB +- Table `report_queries`: `id, uuid, template_id (FK report_templates ON DELETE CASCADE), name, date_range_type, date_from, date_to, atm_ids (JSONB), created_at, updated_at`. Index `idx_report_queries_template`. +- `report_templates.schedule_query_id` (FK → report_queries, ON DELETE SET NULL). +- `report_runs.query_id` (FK → report_queries, ON DELETE SET NULL). +- Migration: `V3__add_report_queries.sql`. DB conn = split `DB_HOST/DB_PORT/DB_NAME` (NOT `DB_URL`). + +## Kafka +- **None for queries.** (Service-level: reports produces `hiveops.reports.completed`, consumes `hiveops.device.events` — unrelated to query CRUD.) + +## Auth / access +- JWT required; principal = `ReportsPrincipal(userId, email, role, institutionKey)`. +- Access is **owner-scoped**, not role-scoped: every action does `templateRepository.findByIdAndCreatedByUserId(templateId, principal.userId())` → 404 "Report template not found" if caller isn't the template creator. +- No `MSP_ADMIN`/`BCOS_ADMIN` requirement on these endpoints. + +## Gotchas +- 404 "Report template not found" ⇒ caller isn't the template's `created_by_user_id` (or wrong id) — **not** a missing query. +- 404 "Query not found" ⇒ `findByIdAndTemplateId` miss (queryId not under that templateId). +- Deleting the scheduled query auto-nulls `schedule_query_id`; schedule then runs all-time/all-ATM. +- On run: query filters merged first, then request `filterOverrides` merged after → **overrides win**. +- Date ranges resolve at run time against `LocalDate.now(UTC)`, not save time. `CUSTOM` uses stored dates. +- Empty/null `atm_ids` ⇒ all ATMs. +- Only one scheduled query per template (`setScheduled` overwrites). +- ATM picker list comes from reports-local `device_summary` (Kafka-fed, can be stale). + +## Do NOT +- Do NOT add a role/`@PreAuthorize` claim for these endpoints — enforcement is ownership via `created_by_user_id`. +- Do NOT invent a `deviceIds` field: backend DTOs accept **only `atmIds`**. (Frontend `api.ts` types list an optional `deviceIds`, but the entity/DTO/migration have no such column — it is silently dropped.) +- Do NOT expect a Kafka event on query create/update/delete — there is none. +- Do NOT treat an empty/failed report as a query bug — data comes from the run's downstream fetch (incident/journal/fleet), not the query. +- Do NOT run DB queries against the services VM — `hiveiq_reports` is on the DB VM `10.10.10.188` (ProxyJump). +- Do NOT assume `resolveFilters` validates the query belongs to the run's template — it loads by `queryId` alone (see uncertainties). diff --git a/backend/src/main/resources/content/reports/queries/internal.md b/backend/src/main/resources/content/reports/queries/internal.md new file mode 100644 index 0000000..826a3c1 --- /dev/null +++ b/backend/src/main/resources/content/reports/queries/internal.md @@ -0,0 +1,63 @@ +--- +module: reports.queries +title: Saved Queries +tab: Internal +order: 20 +audience: internal +--- + +> **Internal ops/support view.** Grounded in `hiveops-reports` source (`ReportQueryController`, `ReportQueryService`, `ReportQuery` entity, V3 migration). Verify against code before relying on specifics. + +## What this is, operationally + +A Saved Query is a named filter preset (date range + optional ATM list) attached to **one report template**. It does two jobs: +1. A ready-made option when a user clicks **Run** on a template. +2. The filter set used for a template's **scheduled** run (`report_templates.schedule_query_id`). + +All of it lives in one service — **hiveops-reports** owns the `report_queries` table and the endpoints. There is no cross-service call, Kafka topic, or downstream dependency for query CRUD itself. + +## Who can do what (ownership, not roles) + +- Every `/api/reports/**` request just needs a **valid JWT** (`SecurityConfig` → `anyRequest().authenticated()`). There is **no `@PreAuthorize` role gate** on the queries endpoints — no `MSP_ADMIN`/`BCOS_ADMIN` requirement. +- Access is **owner-scoped**, not role-scoped. `ReportQueryService.requireTemplateOwner()` calls `templateRepository.findByIdAndCreatedByUserId(templateId, userId)`. A user can only list/create/edit/delete/schedule queries on **templates they created**. +- Consequence: a template belonging to user A is invisible to user B — including its queries. There is no admin override to manage another user's queries through this API; support must act as the owning user or touch the DB directly. + +## First things to check when it misbehaves + +| Symptom | Likely cause | Check | +|---|---|---| +| "Report template not found" (404) on any query action | Caller is **not the template creator** (or wrong template id). | `SELECT id, name, created_by_user_id FROM report_templates WHERE id = ?;` — compare to the caller's `sub` (userId) in their JWT. | +| "Query not found" (404) on edit/delete/set-scheduled | queryId not linked to that templateId (`findByIdAndTemplateId`), or query was deleted. | `SELECT id, template_id, name FROM report_queries WHERE id = ?;` | +| Scheduled report ran with no date/ATM filter | Template has `schedule_query_id = NULL`, or its query was deleted (delete auto-nulls it). | `SELECT id, schedule_enabled, schedule_query_id FROM report_templates WHERE id = ?;` | +| "Set Scheduled" button missing / schedule does nothing | Template needs a schedule turned on first; a template needs at least one query before it can run on a schedule. | Confirm `schedule_enabled = true` and at least one row in `report_queries` for that template. | +| Query saved but ATM filter ignored on run | `atm_ids` empty ⇒ **All ATMs** by design; or run-time `filterOverrides` in the Run request overrode the query (overrides win — see below). | `SELECT atm_ids FROM report_queries WHERE id = ?;` | +| "Invalid dateRangeType" (400) | Client sent a value outside the enum. | Allowed: `LAST_7_DAYS`, `LAST_30_DAYS`, `THIS_MONTH`, `LAST_MONTH`, `CUSTOM`, `ALL_TIME`. | +| ATM picker empty when creating a query | Backing device list comes from the reports `device_summary` mirror (Kafka-fed), which can go stale. | Check `hiveops.device.events` consumer lag; see [technical.reports]. | + +## Behaviour that trips people up + +- **Delete auto-clears the schedule.** Deleting the query that a template points to as `schedule_query_id` nulls that pointer (`ReportQueryService.delete`) — the template's schedule then runs with **no query filters** (all-time, all ATMs) until a new one is set. +- **Only one scheduled query per template.** `setScheduled` overwrites `schedule_query_id`; there is no multi-select. +- **Run-time overrides beat the saved query.** On a manual Run, if the caller passes `filterOverrides` (e.g. an ad-hoc ATM selection), those keys are merged **after** the query's filters and win. Scheduled runs pass no overrides, so the query is authoritative there. +- **Date ranges are computed at run time, in UTC.** `LAST_30_DAYS` etc. resolve against `LocalDate.now(UTC)` when the report runs, not when the query was saved. `CUSTOM` uses the stored `date_from`/`date_to`. +- **Queries don't fetch data.** A query only supplies `dateFrom`/`dateTo`/`atmIds` into the run's `filterOverrides`. If a report comes back empty or fails, the query is rarely the cause — look at the run and the downstream fetch (incident/journal/fleet). See [technical.reports]. + +## Direct DB peek (support) + +DB `hiveiq_reports` on the database VM (`10.10.10.188`, via ProxyJump — not the services VM): + +```sql +-- All queries for a template +SELECT id, name, date_range_type, date_from, date_to, atm_ids, created_at +FROM report_queries WHERE template_id = ? ORDER BY created_at; + +-- Which query (if any) drives the schedule +SELECT t.id, t.name, t.schedule_enabled, t.schedule_query_id, q.name AS scheduled_query +FROM report_templates t LEFT JOIN report_queries q ON q.id = t.schedule_query_id +WHERE t.id = ?; +``` + +## Related tabs + +- User-facing steps: this module's **Overview** tab. +- Build/data-flow detail: **Architect** tab and [technical.reports]. diff --git a/backend/src/main/resources/content/reports/runs/architect.md b/backend/src/main/resources/content/reports/runs/architect.md new file mode 100644 index 0000000..2328158 --- /dev/null +++ b/backend/src/main/resources/content/reports/runs/architect.md @@ -0,0 +1,79 @@ +--- +module: reports.runs +title: Run History +tab: Architect +order: 30 +audience: internal +--- + +How **Run History** is built. It is a thin CRUD/list surface over the `report_runs` table, backed by an **async executor → live downstream fetch → store → Kafka distribute** pipeline. hiveops-reports owns the run lifecycle end to end; it holds no fleet data of its own. See [platform.service-ownership]. + +## Services involved + +| Service | Role for this feature | +|---|---| +| **hiveops-reports** | Owns run records, executor, CSV export, and the runs API. | +| hiveops-incident | Data source for `INCIDENTS`, `ATM_HEALTH`, `FLEET_TASKS`, `AGENT_VERSIONS`, `AGENT_MODULES` (paginated GETs at run time). | +| hiveops-journal | Data source for `TRANSACTIONS`. | +| hiveops-messaging | Consumes the completion Kafka event and produces the in-app message to recipients (distribution). | +| hiveops-devices | Upstream of `hiveops.device.events`, which keeps the reports-local `device_summary` mirror fresh (used by other data sources, not the runs list itself). | + +## Run lifecycle (executor → store → Kafka) + +1. A run is created `PENDING` in `report_runs`, either by `ReportRunService.triggerUserRun` (from `POST /api/reports/templates/{id}/run`) or `triggerScheduledRun` (from `ReportSchedulerService`, `@Scheduled(fixedDelay=60s)`). +2. `executeAsync(runId, bearerToken)` runs on the `reportExecutor` pool (`AsyncConfig`, core=2/max=5). Status → `RUNNING`, `started_at` set. +3. The matching `ReportDataFetcher` for `template.dataSource` makes **live paginated HTTP calls** to the downstream service, forwarding a bearer token: + - **User run:** the caller's own `Authorization` header. + - **Scheduled run:** a self-minted 5-min ADMIN JWT from `InternalTokenGenerator`, HMAC-signed with `JWT_SECRET` (`Keys.hmacShaKeyFor(secret.getBytes(UTF_8))`). +4. `ReportExportService` projects columns (or `groupAndAggregate` when `groupByFields` is set). First 100 rows are stored to `report_runs.result_data` (jsonb) for preview **regardless of output format**. +5. If `outputFormat = CSV`, a CSV is written to `REPORT_UPLOAD_DIR/{uuid}.csv` and the path saved to `report_runs.csv_path`. CSV bytes never enter the DB. +6. Status → `COMPLETED`, `row_count` + `completed_at` set. On any exception the whole thing is caught: status → `FAILED`, `error_message = e.getMessage()`. +7. `distributeIfConfigured` runs post-completion when the template has `distributionConfig.recipientUserIds`. + +## Distribution data flow (Kafka) + +`ReportDistributorService.distribute` does **not** call messaging over HTTP — it publishes an event and lets messaging react. See [platform.kafka]. + +``` +run COMPLETED + → ReportDistributorService.distribute(run, recipientUserIds, token) + → ReportCompletedEventProducer.publish(ReportCompletedEvent) + → kafka topic hiveops.reports.completed (3 partitions, key = institutionKey | "global") + → one report_distribution_log row per recipient (channel=IN_APP, status=SENT|FAILED) + hiveops-messaging consumes hiveops.reports.completed → produces the in-app message +``` + +`ReportCompletedEvent` payload: run uuid, template name, row count, recipient user ids, institutionKey, triggered-by email, completedAt, source `"hiveops-reports"`. If publish throws, the run stays COMPLETED and the distribution_log rows are marked `FAILED` with the error — the run is never rolled back on a distribution failure. + +> Note: `hiveops-reports/CLAUDE.md` still describes distribution as a direct POST to `hiveops-messaging /api/v1/messages`. The current code is Kafka-only via `ReportCompletedEventProducer`; the CLAUDE.md is stale on this point. + +## DB tables (in `hiveiq_reports`) + +| Table | Read/Write for this feature | +|---|---| +| `report_runs` | Primary. Written by executor (status/rowCount/resultData/csvPath/errorMessage), read by the list/preview/download/get endpoints. Columns: `uuid`, `status`, `triggered_by`, `triggered_by_user_id`, `triggered_by_email`, `query_id`, `filter_overrides` (jsonb), `row_count`, `result_data` (jsonb), `csv_path`, `error_message`, `started_at`, `completed_at`, `created_at`. | +| `report_templates` | Read (FK from run) for name, columns, filters, output format, distribution config. | +| `report_distribution_log` | Written per recipient during distribution. | +| `device_summary` | Kafka-fed mirror (V8) — feeds other data-source fetchers, not the runs list. | + +See [platform.data-architecture]. DB connection uses split `DB_HOST`/`DB_PORT`/`DB_NAME`/`DB_USERNAME`/`DB_PASSWORD` (not `DB_URL`). H2 in-memory under the `dev` profile. + +## Key API endpoints (`ReportRunController`, base `/api/reports/runs`) + +| Method | Path | Notes | +|---|---|---| +| GET | `/api/reports/runs` | Pageable; `findByTriggeredByUserIdOrderByCreatedAtDesc` — owner-scoped. | +| GET | `/api/reports/runs/{id}` | Owner-scoped; `@Cacheable("reportRuns")` for COMPLETED/FAILED. | +| GET | `/api/reports/runs/{id}/preview` | COMPLETED only (else 409); returns ≤100 rows from `result_data`. | +| GET | `/api/reports/runs/{id}/download` | CSV stream; COMPLETED + `csv_path` on disk required. | +| DELETE | `/api/reports/runs/{id}` | Deletes CSV file then row. | +| POST | `/api/reports/runs/{id}/distribute` | Manual distribution; body `recipientUserIds`. | + +## Design characteristics + +- **Ownership = authorization.** No method-level role checks on runs; the repository's `triggeredByUserId` predicate is the only boundary. Institution isn't used for run access. +- **Fetch-at-run, not store.** No fleet data is persisted beyond the projected result snapshot — resilience and staleness both derive from the downstream services. +- **Executor is the bottleneck.** Small pool (max 5) plus synchronous downstream HTTP means throughput is downstream-latency-bound. +- **Distribution is fire-and-forget** across a Kafka boundary; run success and delivery success are independent. + +Cross-links: [platform.data-architecture] · [platform.kafka] · [platform.service-ownership] diff --git a/backend/src/main/resources/content/reports/runs/claude.md b/backend/src/main/resources/content/reports/runs/claude.md new file mode 100644 index 0000000..22fd76c --- /dev/null +++ b/backend/src/main/resources/content/reports/runs/claude.md @@ -0,0 +1,66 @@ +--- +module: reports.runs +title: Run History +tab: Claude +order: 40 +audience: internal +--- + +Terse agent reference for **reports.runs**. Act only on what's below. + +## Owner +- Service: **hiveops-reports** (owns run lifecycle, CSV, runs API). No other service owns runs. +- DB: `hiveiq_reports` · table `report_runs`. +- Ports: internal `8088`, external `8011`. NGINX `api.bcos.cloud/reports/` → `hiveiq-reports:8088/api/reports/`. + +## Endpoints (`ReportRunController`, base `/api/reports/runs`) +| METHOD | Path | Guard / precondition | Returns | +|---|---|---|---| +| GET | `/api/reports/runs` | authenticated; owner-scoped, pageable (`?page=&size=`) | Page of runs (caller's only) | +| GET | `/api/reports/runs/{id}` | owner-scoped | run or 404 | +| GET | `/api/reports/runs/{id}/preview` | must be COMPLETED (409); result_data present (400) | ≤100 rows JSON | +| GET | `/api/reports/runs/{id}/download` | COMPLETED (409); csv_path set (400); file on disk (404) | CSV stream | +| DELETE | `/api/reports/runs/{id}` | owner-scoped | 204; deletes CSV + row | +| POST | `/api/reports/runs/{id}/distribute` | COMPLETED (409); body `{"recipientUserIds":[...]}` | 204 | +| POST | `/api/reports/templates/{id}/run` | trigger a NEW run (not a runs endpoint) | run id | + +## Roles +- No `@PreAuthorize` / `hasAnyRole` on runs. `SecurityConfig` = `anyRequest().authenticated()`. +- Access boundary = **ownership**: `findByIdAndTriggeredByUserId`. Non-owner (even MSP_ADMIN/BCOS_ADMIN) → **404**, never 403. +- Scheduled runs owned by `template.createdByUserId`. + +## Tables (`hiveiq_reports`) +- `report_runs` — primary. Key cols: `uuid`, `status`, `triggered_by`, `triggered_by_user_id`, `triggered_by_email`, `query_id`, `filter_overrides`(jsonb), `row_count`, `result_data`(jsonb), `csv_path`, `error_message`, `started_at`, `completed_at`, `created_at`. +- `report_templates` — FK parent (name, columns, outputFormat, distributionConfig). +- `report_distribution_log` — per-recipient delivery (channel=IN_APP, status SENT|FAILED). +- Status enum: `PENDING → RUNNING → COMPLETED | FAILED`. TriggerSource: `USER | SCHEDULED`. + +## Kafka +| Direction | Topic | Class | +|---|---|---| +| Produced | `hiveops.reports.completed` | `ReportCompletedEventProducer` (const `ReportsKafkaConfig.TOPIC_REPORTS_COMPLETED`, 3 partitions, key=institutionKey\|"global") | +| Consumed (reports svc, not runs-specific) | `hiveops.device.events` | `DeviceEventConsumer` → `device_summary` | +- `hiveops-messaging` consumes `hiveops.reports.completed` and creates the in-app message. + +## Execution facts +- `executeAsync` on `@Async("reportExecutor")` pool: core=2, max=5. Scheduler `@Scheduled(fixedDelay=60s)`. +- User run forwards caller's `Authorization` header downstream. Scheduled run mints 5-min ADMIN JWT via `InternalTokenGenerator` (HMAC `JWT_SECRET`). +- Preview: backend caps 100 rows; frontend renders only first 50. +- CSV path: `REPORT_UPLOAD_DIR` (default `/app/uploads/reports`; OpenMetal bind `/data/hiveiq/reports/`). Not in DB. +- FAILED `error_message` = caught exception `getMessage()`; downstream (incident/journal/fleet) failure is the usual cause. + +## Gotchas +- Admin CANNOT view another user's run — 404, no override path. +- COMPLETED ≠ delivered: distribution is a separate Kafka hop; check `report_distribution_log`. +- CSV can 404 while row is COMPLETED (container recreated w/o bind mount). +- JSON-format template → no CSV → download 400. +- `hiveops-reports/CLAUDE.md` claims distribution POSTs to `messaging /api/v1/messages` — **stale**; real path is Kafka `hiveops.reports.completed`. +- DB conn uses split `DB_HOST/DB_PORT/DB_NAME`, not `DB_URL`. + +## Do NOT +- Do NOT assume role checks — none exist on runs; ownership is the gate. +- Do NOT add an admin "view all runs" path expecting it to exist. +- Do NOT treat COMPLETED as "recipient received it." +- Do NOT expect re-run on a run; re-trigger via `POST /api/reports/templates/{id}/run`. +- Do NOT look for run data in another service — hiveops-reports owns it. +- Do NOT invent a `/api/v1/messages` distribution call; distribution is Kafka-only. diff --git a/backend/src/main/resources/content/reports/runs/internal.md b/backend/src/main/resources/content/reports/runs/internal.md new file mode 100644 index 0000000..5bc6d0d --- /dev/null +++ b/backend/src/main/resources/content/reports/runs/internal.md @@ -0,0 +1,52 @@ +--- +module: reports.runs +title: Run History +tab: Internal +order: 20 +audience: internal +--- + +Ops/support view of **Run History** — the list of report executions in hiveops-reports. Each row is one `report_runs` record. Runs are **strictly owner-scoped**: a user only ever sees runs they triggered (scheduled runs show up under the template creator). Use this when a report "won't finish," "won't download," or "never arrived." + +## Access & roles + +- No role gate on the runs endpoints. `SecurityConfig` ends with `anyRequest().authenticated()` — any valid JWT works; there is **no** `@PreAuthorize("hasAnyRole('MSP_ADMIN','BCOS_ADMIN')")` on `/api/reports/runs/*`. +- **Ownership is the real ACL.** Every run lookup uses `findByIdAndTriggeredByUserId(id, userId)`. An MSP_ADMIN/BCOS_ADMIN **cannot** see, preview, download, delete, or distribute another user's run — they get a `404 Report run not found`, not a `403`. There is no admin override. If a customer says "I can't see the run my colleague made," that is by design. +- Runs are scoped by `triggered_by_user_id` (the numeric JWT `userId`), not by institution. + +## First things to check when it misbehaves + +**A run is stuck in RUNNING / never leaves PENDING** +- The executor pool is small: `@Async("reportExecutor")`, core=2 / max=5 (`AsyncConfig`). If several large reports run at once, later ones queue. +- The async job does a **live paginated HTTP fetch** to a downstream service (incident/journal/fleet). A slow/hanging downstream stalls the run. Check downstream health first. +- There is no visible per-run execution timeout in `ReportRunService.executeAsync`; a wedged downstream can hold a slot. + +**A run shows FAILED** +- Hover the **⚠ Failed** marker in the UI — it shows `error_message`, which is literally the caught exception's `getMessage()`. +- Root cause is almost always downstream: the data-source fetcher (INCIDENTS/ATM_HEALTH/TRANSACTIONS/FLEET_TASKS/AGENT_VERSIONS/AGENT_MODULES) got a non-2xx from incident/journal/fleet, or the JWT it forwarded was rejected. Reports **fetches, never stores** fleet data — downstream outages surface as FAILED runs, not empty ones. +- For **scheduled** runs the token is a self-minted 5-min ADMIN JWT (`InternalTokenGenerator`, signed with `JWT_SECRET`). If `JWT_SECRET` drifts between reports and the downstream service, scheduled runs fail auth while user-triggered ones (which forward the caller's header) may still work — a classic asymmetry to watch for. + +**Preview button does nothing / errors** +- Preview only appears on **COMPLETED** runs. Endpoint returns `409 CONFLICT` if not completed, `400` if there is no stored JSON result. +- Backend returns up to the first **100** rows (`resultData`); the frontend modal renders only the first **50** ("Showing first 50 of N rows"). This is display-only truncation, not data loss. + +**Download fails** +- CSV only exists when the template's `outputFormat` is `CSV`. A JSON-format template has no CSV → `400 This report run has no CSV output`. +- CSVs live on disk under `REPORT_UPLOAD_DIR` (default `/app/uploads/reports`; bind-mounted `/data/hiveiq/reports/` in OpenMetal), **not** in the DB. If the container was recreated without the bind mount, files are gone → `409`/`404 CSV file not found on disk` even though the row is COMPLETED. +- Deleting a run also deletes its CSV file from disk (`ReportRunService.deleteForUser`). + +**Report "completed" but recipient never got it** +- Distribution is decoupled from the run. Completion publishes a Kafka event; the in-app message is produced by hiveops-messaging downstream. A COMPLETED run with no delivery means the messaging side, not the run, failed. +- Distribution only fires if the template has `distributionConfig.recipientUserIds` (auto) or someone called the manual distribute endpoint. Check `report_distribution_log` for per-recipient `SENT` / `FAILED` rows. + +## Admin-adjacent actions + +- **Re-run**: there is no re-run on the run itself. A FAILED run is deleted from Run History and re-triggered from the **Templates** tab (`POST /api/reports/templates/{id}/run`). +- **Manual distribute** of a completed run: `POST /api/reports/runs/{id}/distribute` with `{"recipientUserIds":[...]}` — must be COMPLETED (else `409`). +- **Delete** removes the row and its CSV; irreversible. + +## Where it lives + +- Service: **hiveops-reports** (owns everything here). DB `hiveiq_reports`, table `report_runs`. +- Ports: internal `8088`, external `8011` (OpenMetal). NGINX: `api.bcos.cloud/reports/` → `hiveiq-reports:8088/api/reports/`. +- Logs: `docker compose logs hiveiq-reports` on the services VM (or Loki `localhost:3100`). Failed-run stack traces log as `Report run {id} failed`. diff --git a/backend/src/main/resources/content/reports/schedule/architect.md b/backend/src/main/resources/content/reports/schedule/architect.md new file mode 100644 index 0000000..68f9bf6 --- /dev/null +++ b/backend/src/main/resources/content/reports/schedule/architect.md @@ -0,0 +1,52 @@ +--- +module: reports.schedule +title: Scheduling & Distribution +tab: Architect +order: 30 +audience: internal +--- + +How the **schedule + distribution** feature is built. Owner: **hiveops-reports**. Two collaborators at runtime: whichever data-source service the template targets (incident/journal/fleet) and **hiveops-messaging** for delivery. See [platform.service-ownership]. + +## Services involved (this feature) +| Service | Role in this feature | +|---|---| +| hiveops-reports | Owns templates, queries, runs, the cron scheduler, CSV export, and the distribution event. DB `hiveiq_reports`. | +| hiveops-incident | Data source for `INCIDENTS`, `ATM_HEALTH`, `FLEET_TASKS`, `AGENT_VERSIONS`, `AGENT_MODULES` (live HTTP at run time). | +| hiveops-journal | Data source for `TRANSACTIONS`. | +| hiveops-messaging | Consumes the completion event and fans out in-app DIRECT messages. | + +## Data flow (executor → Kafka → delivery) +1. **Define** — user saves a template (`PUT/POST /api/reports/templates`) with `schedule_enabled`, a 6-field `schedule_cron`, and a scheduled saved query (`schedule_query_id`) for date range + ATMs. +2. **Poll** — `ReportSchedulerService.triggerDueReports()` `@Scheduled(fixedDelay=60_000)` queries `ReportTemplateRepository.findScheduledDue(now)`. +3. **Mint token** — `InternalTokenGenerator.generateForInstitution(template.institutionKey)`: HS-signed JWT via `Keys.hmacShaKeyFor(JWT_SECRET.getBytes(UTF_8))`, 300s TTL. Non-null institution → `role=MSP_ADMIN` + `institutionKey` claim; null → unscoped `role=ADMIN`. Same HMAC scheme used to validate downstream, so the token is accepted service-to-service. +4. **Execute** — `ReportRunService.triggerScheduledRun(template, token)` creates a `report_runs` row (PENDING) and runs async (`AsyncConfig` pool core=2/max=5). It fetches downstream via the fetcher for the data source, projects columns, applies groupBy/aggregations, writes CSV to `REPORT_UPLOAD_DIR`, and sets status COMPLETED (rowCount) or FAILED (error_message). User-triggered runs (`POST .../templates/{id}/run`) instead forward the caller's `Authorization` header. +5. **Advance** — scheduler recomputes `next_run_at` = `CronExpression.parse(cron).next(ZonedDateTime.now(UTC))`; a parse failure logs a warning and leaves `next_run_at = null`. +6. **Distribute** — on completion with configured recipients, `ReportDistributorService.distribute(...)` builds a `ReportCompletedEvent` and `ReportCompletedEventProducer.publish(...)` sends to Kafka topic **`hiveops.reports.completed`** (key = `institutionKey` or `"global"`, 3 partitions). One `report_distribution_log` row is written per recipient (SENT / FAILED). See [platform.kafka]. +7. **Deliver** — hiveops-messaging `ReportCompletedConsumer` (`@KafkaListener` group **`hiveops-messaging-reports`**) deserializes the event and, per `recipientUserId`, builds a `MessageEvent{ type: "DIRECT", sourceService: "hiveops-reports" }` → persisted as an in-app message ("Report ready: …"). Delivery is decoupled: reports never calls messaging over HTTP for this. + +## Kafka topics ([platform.kafka]) +| Topic | Reports role | Consumer | +|---|---|---| +| `hiveops.reports.completed` | **Produces** (`ReportsKafkaConfig.TOPIC_REPORTS_COMPLETED`) | hiveops-messaging, group `hiveops-messaging-reports` | +| `hiveops.device.events` | **Consumes** (`DeviceEventConsumer`, group `hiveops-reports-device-sync`, offset `earliest`) | keeps `device_summary` mirror for the ATM picker | + +## DB tables (`hiveiq_reports`) — read/written by this feature ([platform.data-architecture]) +| Table | Written by | Notes | +|---|---|---| +| `report_templates` | template CRUD | `schedule_enabled`, `schedule_cron`, `next_run_at`, `schedule_query_id`, `institution_key` (V7). Partial index `idx_report_templates_scheduled` (V1). | +| `report_queries` | query CRUD (V3) | saved date-range + ATM filters; one is flagged scheduled via `report_templates.schedule_query_id`. | +| `report_runs` | run executor | PENDING → RUNNING → COMPLETED/FAILED, `row_count`, `triggered_by`, `triggered_by_email`, CSV path. | +| `report_distribution_log` | distributor | per-recipient SENT/FAILED at publish time. | +| `device_summary` | Kafka device consumer (V8/V10) | mirror only; no reconciler. | + +## Key endpoints (schedule surface) +- `POST/PUT /api/reports/templates[/{id}]` — set `scheduleEnabled` + `scheduleCron`. +- `GET /api/reports/templates/{templateId}/queries`, `POST .../queries`, `POST .../queries/{queryId}/set-scheduled`, `DELETE .../queries/scheduled` — manage the scheduled query. +- `POST /api/reports/templates/{id}/run` — manual async run (202). +- `POST /api/reports/runs/{id}/distribute` — re-emit the completion event. + +## Design notes +- **Cron is Spring 6-field, UTC.** Frontend `parseCron` expects exactly 6 space-separated fields; the backend evaluates in `ZoneOffset.UTC`. No per-user timezone. +- **Poll model, not per-job cron.** One `fixedDelay=60s` sweep drives all templates; no `@Scheduled(cron=...)` per template, no quartz. Resolution is coarse (≈1 min). +- **No distributed lock** on the sweep — safe only while reports runs single-instance (relevant to the horizontal-scaling roadmap). diff --git a/backend/src/main/resources/content/reports/schedule/claude.md b/backend/src/main/resources/content/reports/schedule/claude.md new file mode 100644 index 0000000..f13c4e6 --- /dev/null +++ b/backend/src/main/resources/content/reports/schedule/claude.md @@ -0,0 +1,66 @@ +--- +module: reports.schedule +title: Scheduling & Distribution +tab: Claude +order: 40 +audience: internal +--- + +Act-without-guessing reference. Owner service: **hiveops-reports**. Delivery: **hiveops-messaging**. All facts below confirmed in code. + +## Ownership +- Templates, schedules, queries, runs, CSVs, completion event → **hiveops-reports** (DB `hiveiq_reports`, port `8088`, external `8011`, route `api.bcos.cloud/reports/`). +- In-app delivery of completed reports → **hiveops-messaging** (consumes the Kafka event; reports does NOT POST to messaging). + +## Endpoints (METHOD path) — base `/api/reports` +| Method | Path | Purpose | +|---|---|---| +| POST | `/api/reports/templates` | create template (set `scheduleEnabled`,`scheduleCron`) | +| PUT | `/api/reports/templates/{id}` | update template / schedule | +| GET | `/api/reports/templates` | list caller's templates (pageable) | +| POST | `/api/reports/templates/{id}/run` | manual async run → 202 + run id | +| GET | `/api/reports/templates/{templateId}/queries` | list saved queries | +| POST | `/api/reports/templates/{templateId}/queries` | create saved query | +| POST | `/api/reports/templates/{templateId}/queries/{queryId}/set-scheduled` | mark query as scheduled | +| DELETE | `/api/reports/templates/{templateId}/queries/scheduled` | clear scheduled query | +| GET | `/api/reports/runs` | list runs (pageable) | +| GET | `/api/reports/runs/{id}/preview` | JSON preview | +| GET | `/api/reports/runs/{id}/download` | download CSV | +| POST | `/api/reports/runs/{id}/distribute` | re-emit completion event (409 if not COMPLETED) | +| GET | `/api/devices/atms` | ATM picker from `device_summary` mirror | + +## Tables (`hiveiq_reports`) +- `report_templates` — `schedule_enabled`, `schedule_cron`, `next_run_at`, `schedule_query_id`, `institution_key` +- `report_queries` — saved date-range + ATM filters +- `report_runs` — status PENDING/RUNNING/COMPLETED/FAILED, `row_count`, `triggered_by`, `triggered_by_email` +- `report_distribution_log` — per-recipient SENT/FAILED +- `device_summary` — Kafka-fed mirror (no reconciler) + +## Kafka topics +| Topic | Reports | Other side | +|---|---|---| +| `hiveops.reports.completed` | produces (key `institutionKey`\|`global`) | messaging consumer group `hiveops-messaging-reports` → DIRECT message per recipient | +| `hiveops.device.events` | consumes group `hiveops-reports-device-sync` (offset `earliest`) | feeds `device_summary` | + +## Roles / auth +- `/api/reports/**`: `.anyRequest().authenticated()` — **JWT only, NO role check**. Scoped per-user by `userId`. +- `/api/devices/atms`: `@PreAuthorize("hasAnyRole('USER','CUSTOMER','ADMIN','MSP_ADMIN','BCOS_ADMIN')")` — the only role gate in the module. +- Scheduled runs use `InternalTokenGenerator`: 300s JWT, `role=MSP_ADMIN`+`institutionKey` when institution set, else `role=ADMIN`; signed `Keys.hmacShaKeyFor(JWT_SECRET.getBytes(UTF_8))`. + +## Gotchas +- Scheduler = single `@Scheduled(fixedDelay=60_000)` sweep (`ReportSchedulerService`), NOT per-template cron. Resolution ≈1 min. +- Cron is **Spring 6-field, evaluated in UTC**. Frontend requires exactly 6 fields. +- Invalid/blank cron → `computeNextRun` returns null → `next_run_at=NULL` → template **never** fires (silent). +- Scheduled report needs a `schedule_query_id` for date range/ATMs. +- Distribution is fire-and-forget Kafka; `report_distribution_log.status=SENT` = published, NOT delivered. +- Reports fetches downstream live per run; downstream 4xx/5xx → run FAILED, not empty. +- CSVs on disk (`REPORT_UPLOAD_DIR`, prod `/data/hiveiq/reports/`), not in DB. +- `hiveops-ai` is branded **Adoons** externally. + +## Do NOT +- Do NOT assume `MSP_ADMIN`/`BCOS_ADMIN` is required to schedule — it isn't (only `/api/devices/atms` is role-gated). +- Do NOT claim reports POSTs to messaging `/api/v1/messages` — delivery is via Kafka `hiveops.reports.completed`. (The reports/messaging CLAUDE.md mention of a direct POST is stale.) +- Do NOT expect a distributed lock — running reports on >1 replica duplicates scheduled runs. +- Do NOT treat SENT distribution-log rows as proof of delivery. +- Do NOT localize cron; there is no per-user timezone (UTC only). +- Do NOT look for scheduling data outside `hiveiq_reports` — reports owns it. diff --git a/backend/src/main/resources/content/reports/schedule/internal.md b/backend/src/main/resources/content/reports/schedule/internal.md new file mode 100644 index 0000000..776a677 --- /dev/null +++ b/backend/src/main/resources/content/reports/schedule/internal.md @@ -0,0 +1,47 @@ +--- +module: reports.schedule +title: Scheduling & Distribution +tab: Internal +order: 20 +audience: internal +--- + +Ops/support view of the report **scheduler + distribution** path. Owner service: **hiveops-reports** (DB `hiveiq_reports`, internal `8088` / OpenMetal external `8011`, NGINX `api.bcos.cloud/reports/`). Delivery hops through **hiveops-messaging** via Kafka. + +## Roles & access (read this first) +- There is **no `MSP_ADMIN`/`BCOS_ADMIN` gate on scheduling**. `SecurityConfig` is `.anyRequest().authenticated()` — every `/api/reports/**` endpoint just needs a valid JWT. Templates, queries, and runs are scoped **per-user** by `principal.userId()` (service-layer `...ForUser(id, userId)` checks), not by role. +- The **only** method-level `@PreAuthorize` in the service is on the device picker: `GET /api/devices/atms` → `hasAnyRole('USER','CUSTOMER','ADMIN','MSP_ADMIN','BCOS_ADMIN')`. A user with none of those roles can't populate the ATM list when building a schedule's saved query, but can still schedule. +- Scheduled runs execute under a **self-minted service JWT** (see below), not the owner's live session — so a scheduled report keeps running after the owner logs out. + +## How a scheduled run fires +1. `ReportSchedulerService.triggerDueReports()` runs `@Scheduled(fixedDelay = 60_000)` — one poll per instance per ~60s. +2. It calls `templateRepository.findScheduledDue(Instant.now())` (index `idx_report_templates_scheduled` on `schedule_enabled=true, next_run_at`). +3. For each due template: mint token → `runService.triggerScheduledRun(template, serviceToken)` → recompute `next_run_at` via Spring `CronExpression.next(...)` in **UTC**. +4. Run executes async, fetches from downstream, writes CSV, sets status, then (if recipients configured) distributes. + +## First things to check when it misbehaves + +**"Scheduled report never runs"** +- `report_templates.schedule_enabled = true` AND `schedule_cron` is a valid **6-field** Spring cron. `computeNextRun` catches a bad cron and sets `next_run_at = NULL` → the template is then **never** due. Symptom: schedule badge shows but Next Run is blank. +- `next_run_at` in the future / far future? Check the row. Cron is evaluated in **UTC** — a customer expecting "8am local" will see it fire at 8am UTC. +- A scheduled report needs a **scheduled saved query** (`report_templates.schedule_query_id` → `report_queries.id`) for its date range/ATMs. Set via `POST .../queries/{queryId}/set-scheduled`. +- Only **one instance** should own the 60s poll. `findScheduledDue` + save is `@Transactional` but there's no distributed lock — if reports is ever scaled to >1 replica, expect duplicate runs. + +**"Run fires but FAILS"** +- Reports **fetches, doesn't store** fleet data. A run makes live paginated HTTP calls to incident/journal/fleet; a downstream outage surfaces as a **FAILED** `report_runs` row with the error, not empty data. Check `report_runs.error_message` / logs. +- Scheduled runs use the internal token. If `JWT_SECRET` drifts between reports and the downstream service, downstream returns 401/403 → run FAILS. Confirm `JWT_SECRET` is identical everywhere. + +**"Run COMPLETED but no message delivered"** +- Distribution is Kafka, not a direct HTTP call. reports publishes `hiveops.reports.completed`; **hiveops-messaging** consumes it (group `hiveops-messaging-reports`) and creates one **DIRECT** in-app message per recipient. If Kafka is down or messaging's consumer is stopped, the report is done but silent. +- No recipients configured (`distributionConfig.recipientUserIds` empty) = nothing is published (`distribute` returns early). Expected, not a bug. +- Check `report_distribution_log`: one row per recipient, `status = SENT` (event published) or `FAILED` (publish threw). SENT only means reports handed it to Kafka — delivery still depends on messaging. + +**"Device/ATM list wrong in the schedule's query"** +- `GET /api/devices/atms` reads the local `device_summary` mirror, fed only by Kafka `hiveops.device.events` (consumer group `hiveops-reports-device-sync`). **No reconciler** — if that topic stops, ATM options go stale. Cross-check against hiveops-devices. + +## Manual levers +- Re-run now (bypass schedule): `POST /api/reports/templates/{id}/run` (202 + run id) — forwards the caller's JWT. +- Re-send a completed run: `POST /api/reports/runs/{id}/distribute` with `{"recipientUserIds":[...]}` — 409 if the run isn't COMPLETED. +- CSVs live under `REPORT_UPLOAD_DIR` (default `/app/uploads/reports`, bind-mounted `/data/hiveiq/reports/` in OpenMetal) — **not** in the DB. Missing file = broken download even for a COMPLETED run. + +See [technical.reports] for the full service map. diff --git a/backend/src/main/resources/content/reports/templates/architect.md b/backend/src/main/resources/content/reports/templates/architect.md new file mode 100644 index 0000000..5d670f3 --- /dev/null +++ b/backend/src/main/resources/content/reports/templates/architect.md @@ -0,0 +1,92 @@ +--- +module: reports.templates +title: Report Templates +tab: Architect +order: 30 +audience: internal +--- + +> **Internal — architecture.** How the Report Templates feature is built. Owner: **hiveops-reports** (see [platform.service-ownership]). + +## Services involved +| Service | Role for this feature | +|---|---| +| **hiveops-reports** | Owns template CRUD, scheduling, run execution, export, distribution. All template state lives here. | +| hiveops-incident | Data source at **run** time (`INCIDENTS`, `ATM_HEALTH`, `FLEET_TASKS`, `AGENT_VERSIONS`, `AGENT_MODULES`, `SOFTWARE_ERRORS`) | +| hiveops-journal | Data source at run time (`TRANSACTIONS`) | +| hiveops-messaging | In-app delivery of completed scheduled reports | +| hiveops-mgmt | Recipient resolution / user context (downstream) | + +Templates themselves are **pure DB CRUD inside hiveops-reports** — no other service is touched until a template is *run*. + +## Data model (DB `hiveiq_reports`, Postgres prod / H2 dev) +See [platform.data-architecture]. Flyway `V1`–`V10`. + +| Table | Written by | Notes | +|---|---|---| +| `report_templates` | `ReportTemplateService` | The template definition. `columns`/`filters`/`group_by_fields`/`aggregations`/`distribution_config` are `jsonb`. `data_source` enum, `output_format` enum. Ownership `created_by_user_id`; `institution_key` (V7), `shared` flag (V5), `schedule_query_id` link (V3/V4). | +| `report_queries` | `ReportQueryService` | Named saved date-range + ATM/device sets attached to a template (V3). The scheduled query is referenced by `report_templates.schedule_query_id`. | +| `report_runs` | `ReportRunService` | Execution instances spawned from a template. | +| `report_distribution_log` | `ReportDistributorService` | Per-recipient delivery record. | +| `device_summary` | `DeviceEventConsumer` (Kafka) | Local device mirror (V8/V10) backing the run-time ATM picker; not part of the template row. | + +Seed: `V6` inserts a shared `SOFTWARE_ERRORS` template ("Software Error Pattern Analysis", cron Monday 08:00 UTC). + +## Template lifecycle (data flow) +``` +Create/Update (REST) + ReportTemplateController → ReportTemplateService.create/update + → validate data source via SchemaRegistry.find() + → persist report_templates (jsonb columns) + → if scheduleEnabled: next_run_at = now() ← fires on next scheduler tick + +Run — user-triggered + POST /templates/{id}/run + → ReportRunController extracts caller Bearer + → ReportRunService.triggerUserRun (loads template by owner OR shared) + → @Async: fetcher (incident/journal) with FORWARDED caller JWT + → ReportExportService: project columns → groupBy/aggregate → CSV/JSON + → report_runs status PENDING→RUNNING→COMPLETED/FAILED + +Run — scheduled + ReportSchedulerService @Scheduled(fixedDelay=60s) + → templateRepository.findScheduledDue(now) + → InternalTokenGenerator.generateForInstitution(template.institutionKey) (self-minted ADMIN JWT) + → ReportRunService.triggerScheduledRun(template, serviceToken) + → recompute next_run_at from schedule_cron (Spring CronExpression, UTC) + → on COMPLETED: ReportDistributorService delivers + emits Kafka +``` + +## Executor → Kafka → record-store pattern +The run executor (`ReportRunService`) is the async worker (pool core=2/max=5 per `AsyncConfig`). On completion, `ReportDistributorService`: +- POSTs the completed report as an in-app message to **hiveops-messaging** (`/api/v1/messages`), writing `report_distribution_log` (the record store), and +- publishes to Kafka topic **`hiveops.reports.completed`** (producer in `ReportsKafkaConfig`; key = `institutionKey` or `"global"`). + +See [platform.kafka]. + +## Kafka topics (feature-relevant) +| Topic | Direction | Purpose | +|---|---|---| +| `hiveops.reports.completed` | **Produced** | Emitted after a run (incl. scheduled template runs) completes/distributes | +| `hiveops.device.events` | **Consumed** (`DeviceEventConsumer`, group `hiveops-reports-device-sync`, offset `earliest`) | Upserts/deletes `device_summary` mirror used by the run-time ATM picker; no reconciler — if the topic stops, device list goes stale | + +Template CRUD itself produces **no** Kafka; Kafka only appears at run/distribute time. + +## Key API endpoints (all under hiveops-reports, JWT required) +| Method | Path | Purpose | +|---|---|---| +| GET | `/api/reports/templates` | List caller's templates (pageable) | +| POST | `/api/reports/templates` | Create | +| GET/PUT/DELETE | `/api/reports/templates/{id}` | Get/update/delete (owner only) | +| GET | `/api/reports/templates/shared` | Templates shared with caller | +| POST / DELETE | `/api/reports/templates/{id}/share` | Share / unshare | +| POST | `/api/reports/templates/{id}/run` | Async run → `202` + run id | +| GET/POST/PUT/DELETE | `/api/reports/templates/{templateId}/queries[...]` | Saved queries CRUD | +| POST | `.../queries/{queryId}/set-scheduled` | Mark the scheduled query | +| DELETE | `.../queries/scheduled` | Clear scheduled query | +| GET | `/api/reports/schema[/{dataSource}]` | Field/filter definitions used by the builder UI | + +## Auth model +- **User runs:** forward the caller's `Authorization: Bearer` to downstream. +- **Scheduled runs:** `InternalTokenGenerator` mints a short-lived ADMIN JWT signed with shared `JWT_SECRET` (`Keys.hmacShaKeyFor(secret.getBytes(UTF_8))`), scoped to the template's `institution_key`. +- Filter chain (`JwtAuthenticationFilter` + `SecurityConfig`) is authenticate-only; authorization is enforced in-service via ownership queries, not roles. See [platform.service-ownership]. diff --git a/backend/src/main/resources/content/reports/templates/claude.md b/backend/src/main/resources/content/reports/templates/claude.md new file mode 100644 index 0000000..f99031c --- /dev/null +++ b/backend/src/main/resources/content/reports/templates/claude.md @@ -0,0 +1,70 @@ +--- +module: reports.templates +title: Report Templates +tab: Claude +order: 40 +audience: internal +--- + +> Terse act-without-guessing reference. Owner service: **hiveops-reports**. Nothing here is guessed — all confirmed in source. + +## Ownership +- **Feature owned by `hiveops-reports`.** Template state never lives in another service. +- Data at run time is **fetched** (not owned) from incident/journal. Do not look for template rows in `hiveiq_incident`/`hiveiq_journal`. + +## Routing / ports +- Internal: `hiveiq-reports:8088` · OpenMetal external: `8011` +- NGINX: `api.bcos.cloud/reports/` → `hiveiq-reports:8088/api/reports/` +- Frontend SPA: `report.bcos.cloud` (port `5182`), component `frontend/src/components/Reports.svelte`, client `frontend/src/lib/api.ts` + +## Endpoints (METHOD path) — all require JWT Bearer +| Method | Path | +|---|---| +| GET | `/api/reports/templates` | +| POST | `/api/reports/templates` | +| GET | `/api/reports/templates/{id}` | +| PUT | `/api/reports/templates/{id}` | +| DELETE | `/api/reports/templates/{id}` | +| GET | `/api/reports/templates/shared` | +| POST | `/api/reports/templates/{id}/share` | +| DELETE | `/api/reports/templates/{id}/share` | +| POST | `/api/reports/templates/{id}/run` → `202` + run id | +| GET | `/api/reports/templates/{templateId}/queries` | +| POST | `/api/reports/templates/{templateId}/queries` | +| PUT | `/api/reports/templates/{templateId}/queries/{queryId}` | +| DELETE | `/api/reports/templates/{templateId}/queries/{queryId}` | +| POST | `/api/reports/templates/{templateId}/queries/{queryId}/set-scheduled` | +| DELETE | `/api/reports/templates/{templateId}/queries/scheduled` | +| GET | `/api/reports/schema` , `/api/reports/schema/{dataSource}` | + +## Tables (DB `hiveiq_reports`) +- `report_templates` (jsonb: `columns`,`filters`,`group_by_fields`,`aggregations`,`distribution_config`; ownership `created_by_user_id`; `institution_key`, `shared`, `schedule_cron`, `next_run_at`, `schedule_query_id`) +- `report_queries` · `report_runs` · `report_distribution_log` · `device_summary` + +## Kafka topics +- **Produced:** `hiveops.reports.completed` (on run complete/distribute; key = `institutionKey` or `"global"`) +- **Consumed:** `hiveops.device.events` (group `hiveops-reports-device-sync`, offset `earliest`) → `device_summary` +- Template **CRUD emits no Kafka**; only run/distribute does. + +## Roles / auth +- Endpoints require only a valid JWT (`SecurityConfig` = `.authenticated()`). **No** `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` gate on templates. +- Access control = **per-user ownership** (`created_by_user_id`), not role, not institution. +- User runs forward caller Bearer; scheduled runs mint ADMIN JWT via `InternalTokenGenerator` (shared `JWT_SECRET`, scoped to `institution_key`). +- Data sources (enum, validated by `SchemaRegistry`): `INCIDENTS`, `ATM_HEALTH`, `TRANSACTIONS`, `FLEET_TASKS`, `AGENT_VERSIONS`, `AGENT_MODULES`, `SOFTWARE_ERRORS`. + +## Gotchas +- Setting `scheduleEnabled=true` sets `next_run_at = now()` → fires on next 60s scheduler tick regardless of cron. +- `schedule_cron` is **6-field Spring cron, evaluated UTC**. Invalid cron → `next_run_at` becomes null → schedule silently stops. +- `getForUser` is cached (`reportTemplates`, key `id:userId`); evicted on update/delete/share only. +- Shared templates listed by `findBySharedTrueAndCreatedByUserIdNot` — **no institution filter** → visible cross-institution. +- Only the **owner** can GET/PUT/DELETE a template; a recipient of a shared template can only `.../run` it (`triggerUserRun` = owner OR sharedTrue). +- Downstream (incident/journal) outage → run FAILED with `report_runs.error_message`, not an empty template. +- `V6` seeds a shared `SOFTWARE_ERRORS` template (Mon 08:00 UTC). Frontend TS `ReportDataSource` type omits `SOFTWARE_ERRORS` though backend supports it. + +## Do NOT +- Do NOT add an endpoint/table for reports data in another service — hiveops-reports owns templates end-to-end. +- Do NOT assume an admin role gate — there is none on templates. +- Do NOT look for template CRUD Kafka events — none exist. +- Do NOT expect `institution_key` to scope visibility of **shared** templates (it does not). +- Do NOT trust cron in local time — it's UTC. +- Do NOT edit another user's template via API — ownership is enforced by `created_by_user_id`. diff --git a/backend/src/main/resources/content/reports/templates/internal.md b/backend/src/main/resources/content/reports/templates/internal.md new file mode 100644 index 0000000..e917cf1 --- /dev/null +++ b/backend/src/main/resources/content/reports/templates/internal.md @@ -0,0 +1,46 @@ +--- +module: reports.templates +title: Report Templates +tab: Internal +order: 20 +audience: internal +--- + +> **Internal — ops/support/admin.** Owner service: **hiveops-reports** (port `8088` internal / `8011` OpenMetal external). Route `api.bcos.cloud/reports/` → `hiveiq-reports:8088/api/reports/`. Frontend SPA `report.bcos.cloud` (port `5182`). + +## What this module is +A **report template** is a saved report definition: data source + columns + filters + optional groupBy/aggregations + optional schedule + distribution config. Templates live in `report_templates` (Postgres `hiveiq_reports`). Templates don't hold data — running one fetches live from incident/journal/fleet at run time. + +## Who can do what (confirmed in code) +- **No admin gate.** `SecurityConfig` only requires `.anyRequest().authenticated()` — any valid HiveIQ JWT can create/list/run templates. There is **no** `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` on template endpoints (unlike most other modules). The only method-level `@PreAuthorize` in the service is on `/api/devices/atms` (device picker), not on templates. +- **Per-user ownership, not per-institution.** Templates are scoped by `created_by_user_id`. A user sees/edits/deletes **only their own** templates (`findByIdAndCreatedByUserId`). "My Reports" = your templates; "Shared Reports" = templates others marked shared. +- **Role in JWT** is used only to mint downstream tokens; missing role defaults to `ROLE_USER`. + +## First things to check when it misbehaves +1. **"My template disappeared / 404 on edit."** Ownership is by `created_by_user_id`. If the user logged in as a different account (different `userId`), the template won't be visible. Confirm the `sub` claim in their JWT matches `report_templates.created_by_user_id`. +2. **Stale template after edit.** `getForUser` is `@Cacheable("reportTemplates", key="#id+':'+#userId")`. Update/delete/share evict that key — but a same-object read from another key won't. If a template looks stale, bounce the pod or confirm the write actually committed. +3. **Scheduled template fires immediately after create/enable.** On create with `scheduleEnabled=true` (or on enabling it), `next_run_at` is set to `Instant.now()`, so the 60s scheduler picks it up on the next tick regardless of cron. Expected behavior — not a bug. +4. **Schedule never fires.** Scheduler is `@Scheduled(fixedDelay=60s)` `findScheduledDue(now)`. Check: (a) `schedule_enabled=true`, (b) `schedule_cron` is a valid **6-field Spring cron** (bad cron → `computeNextRun` returns null and it stops re-arming), (c) `next_run_at` is not null/in the future. Cron is evaluated in **UTC**. +5. **Scheduled run has no data / auth 401 downstream.** Scheduled runs use a **self-minted ADMIN JWT** (`InternalTokenGenerator.generateForInstitution(template.institutionKey)`, signed with `JWT_SECRET`). If `JWT_SECRET` drifted between reports and incident/journal/fleet, scheduled runs fail while user-triggered runs (which forward the caller's bearer) still work. +6. **Data source rejected on create.** `create` validates via `SchemaRegistry.find(dataSource)` → `400 Unknown data source`. Valid sources: `INCIDENTS`, `ATM_HEALTH`, `TRANSACTIONS`, `FLEET_TASKS`, `AGENT_VERSIONS`, `AGENT_MODULES`, `SOFTWARE_ERRORS`. +7. **Empty/failed run, not empty template.** Templates only define the query. A run failing = downstream (incident/journal/fleet) outage or a bad filter — check `report_runs.error_message`, not the template. + +## Common failure modes + fixes +| Symptom | Likely cause | Fix | +|---|---|---| +| 404 editing/deleting a template | Ownership mismatch (different `userId`) | Verify JWT `sub` vs `created_by_user_id` | +| Shared template can't be edited by recipient | Only owner can edit; recipients can only **run** shared templates | By design — owner edits | +| Scheduled report never delivered | Bad cron / `next_run_at` null / secret drift | Fix cron, re-enable, confirm `JWT_SECRET` parity | +| Template runs but rows empty | Downstream service outage or filter too narrow | Check `report_runs.error_message` + downstream health | +| Stale field after PUT | Template cache | Confirm commit; evictions are keyed `id:userId` | + +## Direct DB (via ProxyJump to 10.10.10.188, DB `hiveiq_reports`) +```sql +SELECT id, name, data_source, schedule_enabled, schedule_cron, next_run_at, + created_by_user_id, institution_key, shared +FROM report_templates ORDER BY updated_at DESC LIMIT 20; +``` +Related tables: `report_queries` (saved date-range+ATM sets, one may be the scheduled query via `report_templates.schedule_query_id`), `report_runs`, `report_distribution_log`. + +## Known cross-institution caveat +Shared templates are listed by `findBySharedTrueAndCreatedByUserIdNot(userId)` — **no institution filter**. A template shared by a user in one institution is visible to users in **all** institutions. Treat "why can institution B see institution A's report" reports as this, not a data-mirror issue. See [reports.templates] Architect tab. diff --git a/backend/src/main/resources/content/transactions/browser/architect.md b/backend/src/main/resources/content/transactions/browser/architect.md new file mode 100644 index 0000000..e34c0c0 --- /dev/null +++ b/backend/src/main/resources/content/transactions/browser/architect.md @@ -0,0 +1,91 @@ +--- +module: transactions.browser +title: Transactions Browser +tab: Architect +order: 30 +audience: internal +--- + +How the transaction browser is built end to end. Three services participate; **only journal owns data**. See [platform.service-ownership]. + +## Components + +| Piece | Repo / image | Role | +|---|---|---| +| SPA | `hiveops-transactions` → `hiveiq-transactions-frontend` (Svelte + Vite, 5178) | UI only; `TransactionBrowser.svelte` (list) + `CustomerJourney.svelte` (drawer) | +| BFF proxy | `hiveops-transactions` backend (port 8098, stateless) | JWT auth, forwards `/api/journal/**`, `/api/atms`, `/api/atm-groups`, `/api/adoons/**` | +| Data owner | `hiveops-journal` → `hiveiq-journal` (upload role, 8087/prod 8010) | Owns `hiveiq_journal` DB, parsing, all `/api/journal/**` reads | +| ATM/group lookup | `hiveops-devices` | Serves `/api/atms`, `/api/atm-groups` for the filter sidebar | +| Adoons analysis | `hiveops-ai` (`IncidentAiController`) | Internal `POST /api/adoons/analyze-journey` (X-Internal-Secret), reached via the AI-URL proxy target | + +The SPA's axios client (`lib/api.ts`) base URL comes from `window.__APP_CONFIG__.apiUrl` injected by `entrypoint.sh` from `VITE_API_URL` (default `http://localhost:8098/api`). `TransactionBrowser.svelte` also issues a **relative** `fetch('/api/journal/transactions?…')`, so in production nginx routes `/api/journal/**` straight to journal. + +## Read data flow (the browser itself) + +``` +Svelte SPA ──GET /api/journal/transactions?filters──► journal (upload role) + JournalApiController.listTransactions() + → InstitutionContext.resolveScope() (JWT → institution keys | null) + → determineMode(from,to) → HOT_ONLY | ARCHIVE_ONLY | BOTH + → JournalTransactionSpecification.withFilters(...) (JPA Specification) + → journal_transactions (or _archive, or UNION via JournalArchiveQueryService) + ◄── Page +``` + +**This read path is fully synchronous and touches no Kafka.** Kafka in journal is ingest/parse-side only (see below). Filters supported: `atmId` (repeatable), `from`, `to`, `type`, `result`, `pan`, `amountMin`, `amountMax` (dollars → ×100 cents), `journalType` (`JRN_ATM`/`NH_TCR`), `foreignOnly`, `page`, `size`. + +### 90-day hot/cold split +`determineMode()` compares the range against `now - journal.archive.days (90)`: +- range entirely ≥ cutoff → `journal_transactions` (hot) +- range entirely < cutoff → `journal_transactions_archive` (cold) +- spanning → `JournalArchiveQueryService` runs a UNION over both tables. +The SPA guards this with an "Archived Data" confirm dialog (`ARCHIVE_CUTOFF_DAYS = 90`). + +## Drawer (CustomerJourney) data flow + +Opening a row loads on demand: +- `GET /api/journal/cassette-config?atmId=` — parses the latest `REPLENISHMENT` raw block to map cassette→denomination (withdrawals only). +- `GET /api/journal/cheques/by-sequence?atmId=&txnId=` then `GET /api/journal/cheques/{id}/preview` (PNG) — deposits only, from `cheque_image`. +- `GET /adoons/status` gates the button; **Ask Adoons** → `POST /adoons/analyze-journey` → result cached back via `PATCH /api/journal/transactions/{id}/adoons-analysis` (writes `adoons_analysis` JSON on the row, fire-and-forget). + +The timeline itself is built **client-side** from `rawLines` — no server call. + +## DB tables (`hiveiq_journal`) + +| Table | Read | Write (by this feature) | +|---|---|---| +| `journal_transactions` | list/detail (hot) | `adoons_analysis` via PATCH | +| `journal_transactions_archive` | list/detail (>90d) | — | +| `journal_files` | admin file list | — | +| `journal_gaps` | (gap tooling) | — | +| `atm_institution_map` | ATM→institution sync | — | +| `cheque_image` | deposit images | — | + +## Kafka (ingest/parse side — not the browser read path) + +See [platform.kafka] and [platform.data-architecture]. Journal follows an executor → Kafka → record-store pattern for *ingest*: + +- `journal.parse-requests` / `journal.parse-requests.{INSTITUTION}` — upload role produces parse requests; **worker-role** containers consume (`JournalParseConsumer`). +- `journal.file-parsed` (`TOPIC_FILE_PARSED`) — produced after a file parses; consumed by `CassetteInventoryService` and `JournalGapDetectionService`. +- `hiveops.journal.transactions.recorded` (`TOPIC_TRANSACTIONS`) — per-transaction event stream for downstream consumers. +- `journal.replenishments` (`TOPIC_REPLENISHMENTS`) — replenishment events. + +Once parsed rows land in `journal_transactions`, the browser reads them directly over HTTP; it does not subscribe to any topic. + +## Key endpoints + +| Method | Path | Service | Auth | +|---|---|---|---| +| GET | `/api/journal/transactions` | journal | `isAuthenticated()` + institution scope | +| GET | `/api/journal/transactions/{id}` | journal | `isAuthenticated()` (+403 on scope mismatch) | +| PATCH | `/api/journal/transactions/{id}/adoons-analysis` | journal | `isAuthenticated()` | +| GET | `/api/journal/cassette-config?atmId=` | journal | `isAuthenticated()` | +| GET | `/api/journal/cheques/by-sequence?atmId=&txnId=` | journal | JWT (via `/api/**`) | +| GET | `/api/journal/cheques/{id}/preview` | journal | JWT (via `/api/**`) | +| GET | `/api/journal/admin/**` (rules, reparse, files, reconcile) | journal | `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` | +| GET | `/api/atms`, `/api/atm-groups` | devices (proxied) | JWT | +| GET/POST | `/adoons/status`, `/adoons/analyze-journey` | AI-URL target | see [platform.service-ownership] | + +## Deployment note + +Journal ships one image, two roles (`journal.role`). All browser-facing controllers are `@ConditionalOnProperty(journal.role=upload)`, so **only the upload container answers this SPA**; worker containers only run the parse listener. diff --git a/backend/src/main/resources/content/transactions/browser/claude.md b/backend/src/main/resources/content/transactions/browser/claude.md new file mode 100644 index 0000000..658a071 --- /dev/null +++ b/backend/src/main/resources/content/transactions/browser/claude.md @@ -0,0 +1,75 @@ +--- +module: transactions.browser +title: Transactions Browser +tab: Claude +order: 40 +audience: internal +--- + +Act-without-guessing reference. Confirmed from source unless marked `(unverified)`. + +## Ownership + +- **Data owner:** `hiveops-journal` (DB `hiveiq_journal`). All transaction reads/writes live here. +- **Frontend:** `hiveops-transactions` → image `hiveiq-transactions-frontend`, port `5178`, `transactions.bcos.cloud`. Svelte SPA, no DB, no Kafka. +- **BFF proxy:** `hiveops-transactions` backend, port `8098`, stateless, JWT-auth, forwards to journal/devices/AI. +- **ATM + group lists:** `hiveops-devices` (via proxy `/api/atms`, `/api/atm-groups`). +- **Adoons analyze:** real impl in `hiveops-ai` `IncidentAiController` (`POST /api/adoons/analyze-journey`, `X-Internal-Secret`, not user JWT). + +## Endpoints (METHOD path — auth) + +| METHOD | Path | Auth | +|---|---|---| +| GET | `/api/journal/transactions` | `isAuthenticated()` + institution scope | +| GET | `/api/journal/transactions/{id}` | `isAuthenticated()` (403 on scope mismatch) | +| PATCH | `/api/journal/transactions/{id}/adoons-analysis` | `isAuthenticated()` | +| GET | `/api/journal/cassette-config?atmId=` | `isAuthenticated()` | +| GET | `/api/journal/cheques/by-sequence?atmId=&txnId=` | JWT (`/api/**`) | +| GET | `/api/journal/cheques/{id}/preview` | JWT → PNG bytes | +| GET | `/api/journal/files?atmId=&page=&size=&journalType=` | `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` | +| GET/PUT | `/api/journal/admin/rules` (+`/rules/structured`, `/tcr-rules`, `/tcr-rules/raw`) | `MSP_ADMIN`/`BCOS_ADMIN` | +| POST | `/api/journal/admin/reload-rules`, `/reparse-all`, `/tcr-rules/reload`, `/reconcile`, `/archive-now` | `MSP_ADMIN`/`BCOS_ADMIN` | +| GET/POST | `/adoons/status`, `/adoons/analyze-journey` (frontend calls; routed via BFF/nginx) | `(unverified)` — see gotchas | + +`/api/journal/transactions` params: `atmId` (repeatable), `from`, `to` (YYYY-MM-DD), `type`, `result` (`SUCCESS`/`FAIL`/`TIMEOUT`), `pan`, `amountMin`, `amountMax` (dollars, ×100→cents server-side), `journalType` (`JRN_ATM`|`NH_TCR`), `foreignOnly`, `page`, `size`. + +## Tables (`hiveiq_journal`) + +- `journal_transactions` — hot (<90d) +- `journal_transactions_archive` — cold (>90d) +- `journal_files`, `journal_gaps`, `atm_institution_map`, `cheque_image` + +## Kafka topics (owned by journal; NOT used by the browser read path) + +- `journal.parse-requests` / `journal.parse-requests.{INSTITUTION}` +- `journal.file-parsed` +- `hiveops.journal.transactions.recorded` +- `journal.replenishments` + +## Scoping (`InstitutionContext.resolveScope()`) + +- `BCOS_ADMIN` / `ADMIN` / `USER` → `null` = all institutions. +- `CUSTOMER` → single institution key. +- `MSP_ADMIN` → granted key list (from JWT credentials). +- Filter applied on `journal_transactions.institution`. + +## Gotchas + +- Only the journal **upload-role** container serves these endpoints (`@ConditionalOnProperty journal.role=upload`); worker containers return nothing/404 for `/api/journal/**`. +- Ranges >90 days read the archive table + trigger a client confirm dialog (`ARCHIVE_CUTOFF_DAYS=90`, `journal.archive.days=90`). +- `journalType` (ATM/TCR) is only sent when **no single ATM** is selected. +- `SEQ #` `"0"` stored null, rendered `-`. Amounts are cents (`amountCents`), displayed `/100`. +- `foreignOnly=true` = surcharge/fee > $0. +- After any journal redeploy: `POST /api/journal/admin/reconcile` or PENDING files stay stranded. +- `amountMin`/`amountMax` are `Long` (whole dollars) server-side; the SPA input allows `step=0.01` — a decimal value produces a 500 (bug, see uncertainties). +- The SPA fires a transaction reload on every `pan` keystroke (no debounce). +- Adoons `analyze-journey` real handler (`hiveops-ai`) is `X-Internal-Secret`-gated, not user-JWT — the frontend cannot hit it directly; it goes through the AI-URL proxy target (default `hiveiq-incident:8081`). + +## Do NOT + +- Do NOT add transaction endpoints/tables to `hiveops-transactions` or incident — transaction data is journal-owned. +- Do NOT expect Kafka in the browser read path; it is synchronous JPA over `journal_transactions`. +- Do NOT assume `MSP_ADMIN` sees all institutions — it is scoped; only `BCOS_ADMIN`/`ADMIN`/`USER` are unscoped. +- Do NOT point admin rule/reparse calls at a worker container — upload role only. +- Do NOT call `hiveops-ai` `/api/adoons/analyze-journey` with a user JWT — it needs `X-Internal-Secret`. +- Do NOT brand `hiveops-ai` as anything but **Adoons** in user-facing copy. diff --git a/backend/src/main/resources/content/transactions/browser/internal.md b/backend/src/main/resources/content/transactions/browser/internal.md new file mode 100644 index 0000000..33e1c14 --- /dev/null +++ b/backend/src/main/resources/content/transactions/browser/internal.md @@ -0,0 +1,55 @@ +--- +module: transactions.browser +title: Transactions Browser +tab: Internal +order: 20 +audience: internal +--- + +Ops / support / admin view of the fleet-wide transaction browser (`transactions.bcos.cloud`, port 5178). This screen is a thin Svelte SPA; **all transaction data is owned by `hiveops-journal`** (DB `hiveiq_journal`). If a customer says "no transactions" or "wrong data," the problem is almost always in journal ingest/parsing or institution scoping — not in this frontend. + +## Who can see what (roles) + +Reads are gated by `@PreAuthorize("isAuthenticated()")` on the journal controller — **any valid JWT can list transactions**, but results are scoped by institution: + +- **BCOS_ADMIN / ADMIN / USER** → `InstitutionContext.resolveScope()` returns `null` → sees **all institutions**. +- **CUSTOMER** → scoped to their **single** institution key. +- **MSP_ADMIN** → scoped to the list of institution keys granted in the JWT credentials. + +So an MSP admin who "can't see an ATM's transactions" is usually missing that institution key in their token, or the ATM's rows carry a different `institution` value. + +## Admin-only actions (from the Settings gear) + +Journal rule/parse management lives behind `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` (journal `AdminController`, base `/api/journal/admin`): + +- **Journal Parsing / Processing Rules** — `GET/PUT /rules`, `POST /reload-rules`, `GET /rules/structured`, `PUT /rules/structured`, `GET /types`. +- **TCR Parsing / Rules** — `GET/PUT /tcr-rules`, `GET/PUT /tcr-rules/raw`, `POST /tcr-rules/reload`. +- **Reparse** — `POST /reparse-all?atmId=&journalType=` re-queues files through the parser (use after a rule change). +- **File list** — `GET /api/journal/files?atmId=&page=&size=&journalType=` shows `parseStatus` / `parseError` per uploaded file. + +## First things to check when it misbehaves + +1. **Empty list for one user only** → institution scope. Check the JWT role + granted institution keys vs the `institution` column on `journal_transactions`. MSP_ADMIN/CUSTOMER are scoped; BCOS_ADMIN is not. +2. **Empty list for everyone on an ATM** → journal parsing. Look at `GET /api/journal/files?atmId=…` for `parseStatus=FAILED` / `parseError`, or files stuck `PENDING`. After any journal redeploy run the reconcile: `POST /api/journal/admin/reconcile` (re-queues stranded files). +3. **"Journal service unavailable" banner** → the frontend got a 502/503. The journal **upload-role** container is down (only `journal.role=upload` serves these controllers — worker containers do not). Check `hiveiq-journal`. +4. **Old data won't load / "Archived Data" dialog** → queries older than **90 days** hit the cold `journal_transactions_archive` table and prompt for confirmation. This is expected and can be slow. +5. **Wrong transaction type / amounts** → parsing rules, not the UI. Amounts are stored in **cents** (`amountCents`) and divided by 100 for display; `SEQ #` of `0` is intentionally stored null and rendered `-`. +6. **Adoons "Analyze" fails / button hidden** → the frontend probes `/adoons/status`; a 503 or unavailable AI backend hides/disables it. This is non-fatal to the browser itself. + +## Common failure modes + fixes + +| Symptom | Likely cause | Fix | +|---|---|---| +| One institution/ATM shows nothing | JWT institution scope mismatch | Verify granted keys vs `journal_transactions.institution` | +| Everyone sees nothing for an ATM | File not parsed / stranded PENDING | Check `/api/journal/files`; `POST /api/journal/admin/reconcile` | +| 502/503 banner | Journal upload container down | Restart `hiveiq-journal` (upload role) | +| Amount filter returns 500 | Non-integer dollars in Min/Max | Enter whole-dollar amounts (see Architect notes) | +| TCR rows missing under ATM filter | `journalType` only applied when no single ATM selected | Use Device Type = TCR with "All ATMs" | +| Cheque images blank on a deposit | No `cheque_image` rows for that seq | Confirm agent uploaded cheque metadata for that ATM | + +## Good to know + +- **Auto-refresh** re-pulls every 60s (toggle persisted in `localStorage`); the manual refresh button forces a reload. +- **Device Type** ATM vs TCR maps to `journalType=JRN_ATM` / `NH_TCR`, but only when no specific ATM is chosen. +- **Foreign only** = surcharge/fee > $0. +- The browser never writes transaction data — the only write it makes is caching an Adoons analysis back onto the transaction (`PATCH …/adoons-analysis`). diff --git a/backend/src/main/resources/content/transactions/customer-journey/architect.md b/backend/src/main/resources/content/transactions/customer-journey/architect.md new file mode 100644 index 0000000..2fc15e3 --- /dev/null +++ b/backend/src/main/resources/content/transactions/customer-journey/architect.md @@ -0,0 +1,72 @@ +--- +module: transactions.customer-journey +title: Customer Journey +tab: Architect +order: 30 +audience: internal +--- + +How the Customer Journey drawer is built. It is a **read-mostly composition across four services**, orchestrated entirely by the Svelte component `CustomerJourney.svelte` — there is no single "journey" backend endpoint. See [platform.service-ownership]. + +## Services involved + +| Service | Role in this feature | +|---------|----------------------| +| **hiveops-transactions** (port 8098) | Stateless BFF proxy. Owns nothing; authenticates the JWT, forwards `/api/**` to journal / devices / incident. Only first-party route is `/api/users/me`. | +| **hiveops-journal** (port 8087, DB `hiveiq_journal`) | Source of truth for the transaction, its raw journal block, cheque images, cassette config, and the persisted Adoons verdict. | +| **hiveops-devices** | Supplies the ATM list (`/atms`) used only to resolve `atmVendor` (make) for the Adoons prompt. | +| **hiveops-incident** (port 8080) | Adoons gateway: validates the user JWT, then proxies to hiveops-ai with an `X-Internal-Secret`. Owns no journey data. | +| **hiveops-ai** ("Adoons") | Computes the verdict/narrative/steps from the raw journal block. | + +## Data flow + +**Everything is synchronous request/response. No Kafka is on the Customer Journey read or write path.** (hiveops-journal *does* run Kafka producers — file-parsed, transaction, replenishment events — during ingest/parsing, but the drawer reads already-persisted rows. See [platform.kafka].) + +Open drawer: +1. `CustomerJourney.svelte` receives a `JournalTransaction` (already fetched by the transaction list) — includes `rawLines`, `amountCents`, `hostResult`, `sequenceNumber`, `sessionStartedAt/EndedAt`, `adoonsAnalysis`. +2. On mount → `GET /adoons/status` (button gating) and, conditionally, cheque/cassette lookups. +3. **Timeline is parsed in the browser** from `rawLines` (`buildSemanticTimeline`, `parseSessions` — Nautilus `********` vs Hyosung `[TRANSACTION RECORD]`/`++++` JPR delimiters). No backend involvement. + +Ask Adoons (compute + persist): +``` +Browser POST /adoons/analyze-journey {transactionId,rawLines,atmId,atmVendor} + → transactions-proxy (services.ai.url → incident) + → incident AdoonsController (validates JWT, builds request) + → hiveops-ai POST /api/adoons/analyze-journey (header X-Internal-Secret, 270s read timeout) + → returns {verdict, narrative, steps} +Browser then PATCH /journal/transactions/{id}/adoons-analysis (fire-and-forget save) + → journal writes adoonsAnalysis (JSON string) onto journal_transactions row +``` +This mirrors the platform's proxy→compute→record-store pattern: incident is the JWT-boundary proxy, hiveops-ai is the executor, journal is the record store. See [platform.data-architecture]. + +## DB tables (hiveiq_journal) + +| Table | Read | Written | By | +|-------|------|---------|----| +| `journal_transactions` | transaction row + `rawLines` | `adoonsAnalysis` column (JSON) | journal `JournalApiController` | +| `journal_transactions_archive` | 90-day cold copy (same shape) | — | archival job | +| `cheque_image` | deposit cheque metadata + PNG file path | — | journal `JournalImageQueryController` / `ChequeImageService` | + +Cassette config is **not a table** — `GET /api/journal/cassette-config` reads the ATM's most recent `REPLENISHMENT` transaction and regex-parses its last `# CUR DENO` block on the fly. + +## Key API endpoints + +Frontend paths are relative to the transactions proxy base (`…/api`); the proxy forwards path + query verbatim (only `Authorization` + `Content-Type` headers survive). + +- `GET /adoons/status` → incident `GET /api/adoons/status` (probes ai `/actuator/health`) +- `POST /adoons/analyze-journey` → incident `/api/adoons/analyze-journey` → ai `/api/adoons/analyze-journey` +- `PATCH /journal/transactions/{id}/adoons-analysis` → journal (institution-scoped; 403 if out of scope) +- `GET /journal/cheques/by-sequence?atmId&txnId` → journal (`ChequeImageService.listByTxnId`, institution-scoped) +- `GET /journal/cheques/{id}/preview` → journal (PNG bytes; `verifyImageAccess` enforces scope) +- `GET /journal/cassette-config?atmId` → journal (parses last REPLENISHMENT raw block) +- `GET /atms` → devices (vendor/make lookup) + +## Scoping / security notes + +- All journal endpoints here are `@PreAuthorize("isAuthenticated()")`; institution isolation is enforced in the **service layer** via `InstitutionContext.resolveScope()`, not by role. +- The Adoons-save PATCH validates the transaction's `institution` is in the caller's scope before writing. +- The incident→ai hop is authenticated by `X-Internal-Secret`, not the user JWT — incident is the JWT trust boundary. + +## Denomination inference (client-side, `parseDenominationsFromRaw`) + +Precedence: (1) `DISPENSED NOTES` / `Dispense Count` cassette counts → (2) per-cassette `JB` sub-record amounts → (3) `cassette-config` denomination map → (4) single-denomination inference from `amountCents / notes`. Falls back to raw note counts when none resolve. All of this runs in the browser; the backend only supplies the raw block and the cassette map. diff --git a/backend/src/main/resources/content/transactions/customer-journey/claude.md b/backend/src/main/resources/content/transactions/customer-journey/claude.md new file mode 100644 index 0000000..ee522c8 --- /dev/null +++ b/backend/src/main/resources/content/transactions/customer-journey/claude.md @@ -0,0 +1,68 @@ +--- +module: transactions.customer-journey +title: Customer Journey +tab: Claude +order: 40 +audience: internal +--- + +Terse act-without-guessing reference. Component: `hiveops-transactions/frontend/src/components/Transactions/CustomerJourney.svelte`. + +## Ownership + +- **Frontend + proxy:** `hiveops-transactions` (stateless BFF, port 8098, no DB, no Kafka). +- **Data owner:** `hiveops-journal` (DB `hiveiq_journal`) — transactions, cheques, cassette config, persisted Adoons verdict. +- **AI verdict:** `hiveops-ai` (Adoons), reached **through** `hiveops-incident` (`AdoonsController`), not directly. +- **ATM/vendor list:** `hiveops-devices`. + +## Endpoints (frontend path via proxy base `…/api` → real target) + +| Method + frontend path | Target service · real path | +|------------------------|----------------------------| +| `GET /adoons/status` | incident `GET /api/adoons/status` (probes ai `/actuator/health`) | +| `POST /adoons/analyze-journey` | incident `/api/adoons/analyze-journey` → ai `/api/adoons/analyze-journey` | +| `PATCH /journal/transactions/{id}/adoons-analysis` | journal `PATCH /api/journal/transactions/{id}/adoons-analysis` | +| `GET /journal/cheques/by-sequence?atmId&txnId` | journal `GET /api/journal/cheques/by-sequence` | +| `GET /journal/cheques/{id}/preview` | journal `GET /api/journal/cheques/{id}/preview` (PNG) | +| `GET /journal/cassette-config?atmId` | journal `GET /api/journal/cassette-config` | +| `GET /atms` | devices | + +Request body for analyze: `{transactionId, rawLines, atmId, atmVendor}`. Save PATCH body = `JourneyAnalysis` `{verdict, narrative, steps[]}`. + +## Tables (hiveiq_journal) + +- `journal_transactions` — read row + `rawLines`; write `adoonsAnalysis` (JSON string column). +- `journal_transactions_archive` — cold 90-day copy, same shape. +- `cheque_image` — deposit cheque metadata + PNG file path. +- Cassette config = **no table**; parsed live from latest `REPLENISHMENT` tx `# CUR DENO` block. + +## Topics + +- **None on this path.** Customer Journey is fully synchronous HTTP. (journal has ingest-side producers, irrelevant here.) + +## Roles + +- Proxy `/api/**`: `hasAnyRole('USER','CUSTOMER','ADMIN','MSP_ADMIN','QDS_ADMIN','BCOS_ADMIN')`. +- Journal endpoints: `@PreAuthorize("isAuthenticated()")`; institution isolation via `InstitutionContext.resolveScope()` in service layer. +- incident→ai hop: `X-Internal-Secret`, not user JWT. + +## Gotchas + +- Timeline is built **in the browser** from `rawLines`; there is no backend "journey" endpoint. Empty rawLines → "No event timeline found". +- Adoons button hidden unless `GET /adoons/status` → `{available:true}` (i.e. ai `/actuator/health` reachable from incident). +- `analyze-journey` read timeout = 270s in incident; failure → HTTP 503 → UI "Adoons is currently unavailable." +- Verdict save is **fire-and-forget** (`.catch(()=>{})`); persistence failures are invisible in the UI — check journal logs for `Error saving Adoons analysis for transaction {id}`. +- Adoons-save PATCH returns **403** if the tx's institution isn't in caller scope. `cassette-config` is **not** institution-scoped. +- Cheques load only for `transactionType==='DEPOSIT'` with a non-null `sequenceNumber`; lookup key is `atmId`+`sequenceNumber` (as `txnId`). SEQ `0` is stored null → no match. +- Denomination display resolved client-side (`parseDenominationsFromRaw`): JB records → `cassette-config` map → single-denom inference; falls back to raw note counts. +- Proxy forwards only `Authorization` + `Content-Type`; strips other headers/cookies. +- `adoonsAnalysis` is stored as a JSON **string**; frontend `JSON.parse`s when the API returns a string. + +## Do NOT + +- Do NOT add a journey/timeline endpoint to `hiveops-transactions` — it owns no data; put reads in journal. +- Do NOT call `hiveops-ai` directly for Adoons from the frontend — always go proxy → incident → ai (JWT boundary + `X-Internal-Secret`). +- Do NOT write transaction/cheque/cassette data via the transactions service — it is a pure proxy. +- Do NOT assume the verdict persisted just because the UI shows it — the save can fail silently. +- Do NOT drop `InstitutionContext` checks when touching journal journey endpoints — that scope is the only cross-institution guard. +- Do NOT expect Kafka involvement in read/save of a journey. diff --git a/backend/src/main/resources/content/transactions/customer-journey/internal.md b/backend/src/main/resources/content/transactions/customer-journey/internal.md new file mode 100644 index 0000000..3e88753 --- /dev/null +++ b/backend/src/main/resources/content/transactions/customer-journey/internal.md @@ -0,0 +1,49 @@ +--- +module: transactions.customer-journey +title: Customer Journey +tab: Internal +order: 20 +audience: internal +--- + +Ops/support view of the Customer Journey drawer (the per-transaction replay in the Transactions app). This feature reads and writes **no data of its own** — the `hiveops-transactions` backend is a stateless proxy; the real data lives in **hiveops-journal**, and the AI verdict is computed by **hiveops-ai** (Adoons) via **hiveops-incident**. + +## Who can open it + +- Any authenticated Transactions user. The proxy (`hiveops-transactions` `SecurityConfig`) gates `/api/**` with `hasAnyRole('USER','CUSTOMER','ADMIN','MSP_ADMIN','QDS_ADMIN','BCOS_ADMIN')` — **not** the `MSP_ADMIN`/`BCOS_ADMIN`-only pattern used elsewhere. +- Journal endpoints behind it require only `isAuthenticated()`. +- **Institution scoping is enforced downstream** in journal: cheque lookups (`ChequeImageService.listByTxnId`) and the Adoons-save PATCH filter by the caller's institution scope. A user only sees journeys/cheques for their own institution(s). + +## What the drawer actually calls (all via the transactions proxy, base `…/api`) + +| UI action | Frontend call | Lands on | +|-----------|---------------|----------| +| Open drawer (vendor lookup) | `GET /atms` | devices service | +| Adoons button visibility | `GET /adoons/status` | incident → checks hiveops-ai `/actuator/health` | +| Ask/Re-analyze Adoons | `POST /adoons/analyze-journey` | incident → hiveops-ai | +| Save verdict | `PATCH /journal/transactions/{id}/adoons-analysis` | journal (`journal_transactions`) | +| Cheque list (deposits) | `GET /journal/cheques/by-sequence?atmId&txnId` | journal (`cheque_image`) | +| Cheque image | `GET /journal/cheques/{id}/preview` | journal | +| Note denominations (withdrawals) | `GET /journal/cassette-config?atmId` | journal | + +## First things to check when it misbehaves + +1. **Whole drawer empty / "No event timeline found"** — the timeline is built **client-side** from `transaction.rawLines`. If rawLines is empty, the transaction row parsed without a raw block. Check the source journal file in journal (`GET /journal/files`, then `/files/{id}/raw`) and re-parse if needed. +2. **"Ask Adoons" button missing** — `GET /adoons/status` returned `{available:false}`. That means hiveops-ai's `/actuator/health` was unreachable *from incident*. Check hiveops-ai is up and the incident→ai URL (`hiveops.ai.url`, default `http://hiveiq-ai:8085`) is correct. +3. **"Adoons is currently unavailable."** — `POST /adoons/analyze-journey` returned 503. Causes: hiveops-ai down, missing/incorrect `X-Internal-Secret` between incident and ai, or the model call exceeded the 270s read timeout in incident's `AdoonsController`. +4. **"Failed to analyze transaction."** — any non-503 error from the analyze call (bad payload, proxy/auth failure). +5. **Verdict shows but doesn't persist / reappears blank on reopen** — the save is **fire-and-forget** in the frontend (`saveAdoonsAnalysis(...).catch(() => {})`); save errors are swallowed and never surfaced. If a verdict won't stick, check journal logs for the PATCH (`Error saving Adoons analysis for transaction {id}`) — a 403 means the transaction's institution isn't in the caller's scope. +6. **No cheque images on a deposit** — images only load when `transactionType === 'DEPOSIT'` **and** `sequenceNumber` is present, and the lookup keys on `atmId` + `sequenceNumber` (as `txnId`). SEQ `0` is stored as null by the parser, so those deposits never match. Also confirm the agent actually uploaded cheque images for that ATM. +7. **Denominations show as raw "Cassette2 = 5 notes" instead of "$20 × 5"** — denomination inference failed. Order tried: JB sub-records in raw → `cassette-config` map (from the ATM's last REPLENISHMENT) → single-denomination inference. If `cassette-config` returns `[]`, that ATM has no parseable `# CUR DENO` block in its most recent REPLENISHMENT transaction. + +## Common failure modes → fix + +- **502/CORS on the whole app** — nginx is the CORS authority; the proxy disables app-level CORS. Check the `transactions.bcos.cloud` server block and the shared cors include, not the app. +- **All journeys 401** — proxy forwards only `Authorization` + `Content-Type` headers; a stale/expired JWT or a dropped Authorization header breaks every downstream call. +- **Cassette config right for one ATM, wrong for another** — it derives from that ATM's *latest* REPLENISHMENT only; a stale or mis-parsed replenishment yields wrong denominations. + +## Escalate to engineering (see [architect] tab) when + +- Adoons verdicts consistently 503 across ATMs (ai/incident wiring). +- Cheque/PAN data appears for the wrong institution (scoping regression in journal). +- rawLines present but timeline parsing wrong for a vendor format (Nautilus vs Hyosung session parsing). diff --git a/backend/src/main/resources/content/transactions/journal-parsing/architect.md b/backend/src/main/resources/content/transactions/journal-parsing/architect.md new file mode 100644 index 0000000..d10b247 --- /dev/null +++ b/backend/src/main/resources/content/transactions/journal-parsing/architect.md @@ -0,0 +1,69 @@ +--- +module: transactions.journal-parsing +title: Journal Parsing Rules +tab: Architect +order: 30 +audience: internal +--- + +How the Journal Parsing feature is actually built. The UI lives in the Transactions SPA, but the entire domain — rules, files, transactions, parsing — is owned by **hiveops-journal**. See [platform.service-ownership] and [platform.data-architecture]. + +## Services involved + +- **hiveops-transactions** — the Svelte SPA (`JournalParsing.svelte`, `JournalReparse.svelte`) plus a thin stateless proxy. It owns no data. Its `JournalProxyController` forwards any `/api/journal/**` request verbatim to the journal service (`services.journal.url`, default `http://hiveiq-journal:8087`). Only `Authorization` + `Content-Type` headers survive the proxy. See [technical.transactions]. +- **hiveops-journal** — owns rules, files, transactions, parsing, and Kafka. Split into two runtime roles from one image via `journal.role`: + - **upload role** (`journal.role=upload`, default) — runs HTTP controllers (`AdminController`, `JournalApiController`), scheduled jobs, and the Kafka **producer**. + - **worker role** (`journal.role=worker`) — runs only `JournalParseConsumer` (the Kafka **consumer**) + actuator. `AdminController` is `@ConditionalOnProperty(journal.role=upload)`, so admin endpoints exist only on the upload container. + +## Data flow — rules edit + +1. `JournalParsing.svelte` → `journalAdminAPI.getRules()` → `GET /api/journal/admin/rules` (text/plain YAML). +2. Edit → `saveRules()` → `PUT /api/journal/admin/rules` (text/plain body) → `JournalRulesService.saveRulesContent()` writes `journal-rules.yml` and hot-reloads in place, returning the new `version`. +3. `reloadRules()` → `POST /api/journal/admin/reload-rules` re-reads the file without writing. +4. Vendor types → `GET /api/journal/admin/types` → `JournalRulesService.getAvailableTypes()` (names under `journal_types:`). +5. TCR variant uses a parallel `tcr-rules.yml` via `TcrRulesService` (`/api/journal/admin/tcr-rules[/raw|/reload]`). + +No database writes for rules — they live in a YAML file on the journal container, loaded into an in-memory `JournalRules` model. + +## Data flow — reparse (executor → Kafka → worker → record-store) + +1. UI → `journalAdminAPI.reparseAll(atmId?, journalType?)` → `POST /api/journal/admin/reparse-all`. +2. `AdminController.reparseAll` selects `JournalFile` rows via `JournalFileRepository` (`findByAtmId` / `findByJournalType` / `...JournalTypeNot`, filtered by `NH_TCR` for the ATM vs TCR split). +3. For each file it: `transactionRepository.deleteByJournalFile(jf)` → sets `parseStatus=PENDING`, clears `transactionCount`/`parseError`/`lastParsedSize` → saves → calls `parsingService.parseJournalFileAsync(jf)`. +4. `JournalParsingService.parseJournalFileAsync` resolves the file's institution (persisting it if newly resolved) and **produces a Kafka message**: `kafkaTemplate.send(topic, atmId /*key*/, String.valueOf(fileId))`. +5. Topic is per-institution: `TOPIC_PARSE_PREFIX` = `journal.parse-requests.` + `INSTITUTION` (uppercase), or `journal.parse-requests.UNASSIGNED` when the institution isn't in configured `journal.institutions`. See [platform.kafka]. +6. `JournalParseConsumer` (`@KafkaListener(topics = "#{@parseTopicName}")`, worker role) receives the file id and calls `JournalParser.parseJournalFile(jf)`, which rebuilds `journal_transactions` rows from the original file on disk. + +`reconcileStranded()` (upload role, on startup and via `POST /api/journal/admin/reconcile`) re-publishes PENDING/PARSING files that a restart left un-consumed. + +## DB tables (hiveiq_journal) + +- **`journal_files`** — read + written by reparse (status, counts, institution, `lastParsedSize`). Entity `JournalFile`, repo `JournalFileRepository`. +- **`journal_transactions`** — deleted then rebuilt on reparse; the parser writes rows here. Repo `JournalTransactionRepository` (`deleteByJournalFile`). +- Related (not edited by this screen): `journal_transactions_archive`, `journal_gaps`, `atm_institution_map`. + +Transactions has **no database**; devices/ATM identity lives elsewhere. See [platform.data-architecture]. + +## Key API endpoints (all under the transactions `/api/journal/**` proxy → journal) + +| Method | Path | Purpose | +|--------|------|---------| +| GET | `/api/journal/admin/rules` | Raw YAML rules (text/plain) | +| PUT | `/api/journal/admin/rules` | Save + hot-reload YAML | +| POST | `/api/journal/admin/reload-rules` | Reload rules from disk | +| GET | `/api/journal/admin/types` | Vendor/journal type names | +| GET/PUT | `/api/journal/admin/rules/structured` | Structured (JSON) rules | +| GET/PUT | `/api/journal/admin/tcr-rules` + `/tcr-rules/raw`, POST `/tcr-rules/reload` | TCR rules | +| POST | `/api/journal/admin/reparse-all?atmId=&journalType=` | Bulk reparse | +| GET | `/api/journal/files?atmId=&journalType=&page=&size=` | File parse status | + +## Kafka topics + +- Producer: upload role → `journal.parse-requests.` (keyed by `atmId`), fallback `journal.parse-requests.UNASSIGNED`. Constant `JournalKafkaConfig.TOPIC_PARSE_PREFIX`. +- Consumer: worker role `JournalParseConsumer`, one worker per institution topic. See [platform.kafka]. + +## Roles / security + +- Admin endpoints: `@PreAuthorize("hasAnyRole('MSP_ADMIN','BCOS_ADMIN')")`. +- `GET /api/journal/files`: `@PreAuthorize("isAuthenticated()")`, scoped by `InstitutionContext.resolveScope()` in the default branch. +- The transactions proxy gates `/api/**` broadly (`USER,CUSTOMER,ADMIN,MSP_ADMIN,QDS_ADMIN,BCOS_ADMIN`); the real authorization is journal's `@PreAuthorize`. See [platform.service-ownership]. diff --git a/backend/src/main/resources/content/transactions/journal-parsing/claude.md b/backend/src/main/resources/content/transactions/journal-parsing/claude.md new file mode 100644 index 0000000..09ea7da --- /dev/null +++ b/backend/src/main/resources/content/transactions/journal-parsing/claude.md @@ -0,0 +1,60 @@ +--- +module: transactions.journal-parsing +title: Journal Parsing Rules +tab: Claude +order: 40 +audience: internal +--- + +## Ownership +- **Domain owner:** `hiveops-journal`. `hiveops-transactions` is a stateless proxy only (no DB, no rules). +- UI: `hiveops-transactions/frontend/src/components/Settings/JournalParsing.svelte` + `JournalReparse.svelte`. +- API client: `hiveops-transactions/frontend/src/lib/api.ts` → `journalAdminAPI`. +- Backend controllers: `AdminController` + `JournalApiController` in `hiveops-journal`. + +## Endpoints (path as seen by frontend; proxied `/api/journal/**` → journal :8087) +| Method | Path | Role | +|--------|------|------| +| GET | `/api/journal/admin/rules` (text/plain YAML) | MSP_ADMIN/BCOS_ADMIN | +| PUT | `/api/journal/admin/rules` (Content-Type: text/plain) | MSP_ADMIN/BCOS_ADMIN | +| POST | `/api/journal/admin/reload-rules` | MSP_ADMIN/BCOS_ADMIN | +| GET | `/api/journal/admin/types` | MSP_ADMIN/BCOS_ADMIN | +| GET/PUT | `/api/journal/admin/rules/structured` | MSP_ADMIN/BCOS_ADMIN | +| GET/PUT | `/api/journal/admin/tcr-rules` | MSP_ADMIN/BCOS_ADMIN | +| GET/PUT | `/api/journal/admin/tcr-rules/raw` (text/plain) | MSP_ADMIN/BCOS_ADMIN | +| POST | `/api/journal/admin/tcr-rules/reload` | MSP_ADMIN/BCOS_ADMIN | +| POST | `/api/journal/admin/reparse-all?atmId=&journalType=` | MSP_ADMIN/BCOS_ADMIN | +| POST | `/api/journal/admin/reconcile` | MSP_ADMIN/BCOS_ADMIN | +| GET | `/api/journal/files?atmId=&journalType=&page=&size=` | isAuthenticated | + +- `journalType` values: `NH_TCR` (TCR) or `ATM` (excludes NH_TCR). Omit = all. +- Return shapes: rules save/reload → `{message, version}`; reparse → `{message, queued, total}`; files → Spring `PageResponse`. + +## Tables (DB `hiveiq_journal`, user `hiveops`) +- `journal_files` — read+written by reparse (status/counts/institution). +- `journal_transactions` — deleted + rebuilt on reparse. +- Related: `journal_transactions_archive`, `journal_gaps`, `atm_institution_map`. + +## Kafka +- Producer (upload role): topic `journal.parse-requests.`; fallback `journal.parse-requests.UNASSIGNED`. Key = `atmId`, value = `fileId`. +- Consumer (worker role): `JournalParseConsumer` `@KafkaListener` per topic. +- Prefix constant: `JournalKafkaConfig.TOPIC_PARSE_PREFIX = "journal.parse-requests."`. + +## Runtime roles (one image, `journal.role`) +- `upload` (default): controllers + producer + scheduled jobs. `AdminController` only exists here. +- `worker`: consumer only. No admin endpoints. + +## Gotchas +- Rules are a **YAML file** on the journal container, hot-reloaded in memory — NOT a DB table. +- Reparse is async: 200 `{queued}` only means enqueued to Kafka. Parsing happens on workers. +- Stranded PENDING after deploy → `POST /api/journal/admin/reconcile` (not reload-rules). +- Institution not in `JOURNAL_INSTITUTIONS` → routes to `UNASSIGNED` topic/worker (still parses). +- Transactions proxy forwards ONLY `Authorization` + `Content-Type`; other headers/cookies dropped. +- `saveRules` sends `Content-Type: text/plain` — required, endpoint `consumes=TEXT_PLAIN_VALUE`. + +## Do NOT +- Do NOT add rules/reparse endpoints or tables to `hiveops-transactions` — journal owns them. +- Do NOT expect admin endpoints on worker containers (conditional on `journal.role=upload`). +- Do NOT trigger reparse to "fix stranded PENDING" — use `/reconcile`; reparse deletes transactions first. +- Do NOT call journal directly assuming custom `X-*` headers pass through the transactions proxy. +- Do NOT confuse Adoons (`/api/adoons/**`, AI journey narrative) with parsing — unrelated. diff --git a/backend/src/main/resources/content/transactions/journal-parsing/internal.md b/backend/src/main/resources/content/transactions/journal-parsing/internal.md new file mode 100644 index 0000000..6e233da --- /dev/null +++ b/backend/src/main/resources/content/transactions/journal-parsing/internal.md @@ -0,0 +1,45 @@ +--- +module: transactions.journal-parsing +title: Journal Parsing Rules +tab: Internal +order: 20 +audience: internal +--- + +Ops/support view of the **Journal Parsing** screen in the Transactions SPA (`transactions.bcos.cloud`). The screen edits the journal parsing rules and re-processes uploaded journal files. All the data and actions belong to the **hiveops-journal** service — the transactions backend only proxies the calls. See [technical.transactions]. + +## Who can use it + +- Every admin action on this screen requires `hasAnyRole('MSP_ADMIN', 'BCOS_ADMIN')` — enforced in `AdminController` (hiveops-journal). A normal `USER`/`CUSTOMER` JWT gets **403** on rules edit, reload, types, and reparse. +- The file-status list (`GET /api/journal/files`) only requires `isAuthenticated()`, so a non-admin can still see file parse status but cannot act on it. +- The request path is: browser → `transactions` proxy (`/api/journal/**`) → `hiveops-journal`. The proxy forwards only `Authorization` + `Content-Type`, so the JWT reaches journal intact. + +## What each panel does (and what it touches) + +- **Journal Parsing Rules** — reads/writes the raw `journal-rules.yml` on the journal container. "Edit Rules" → "Save & Apply" hot-reloads the rules in memory (no restart). "Reload Rules" re-reads the file from disk. Both return the new rule **version**; a bumped version confirms the change took effect. +- **Journal / Vendor Types** — lists the vendor rule-set names defined under `journal_types:` in the YAML. Read-only here; you add types by editing the YAML. +- **Reparse Journals** — the destructive one. It **deletes the existing parsed transactions** for the matched files, resets them to `PENDING`, and re-queues them for async parsing. Blank ATM ID = every ATM. The reparse panel also exists as a fuller "Journal Reparse" view with an ATM/TCR tab, live parse-status pills, and a confirm modal. + +## First things to check when it misbehaves + +1. **403 on any button** — the JWT isn't `MSP_ADMIN`/`BCOS_ADMIN`. Confirm the logged-in user's role, not just that they're logged in. +2. **Rules won't load / save fails** — the error text comes straight from journal. A YAML validation failure returns **400** with `{"error": "..."}`. Fix the YAML and re-save; the version only bumps on success. +3. **Reparse says "Queued N" but files stay PENDING** — parsing is async and runs on the **worker** containers via Kafka. If workers are down or a deploy stranded files, they sit in PENDING. Run the journal **reconcile** endpoint (`POST /api/journal/admin/reconcile`, MSP_ADMIN) to re-publish stranded PENDING/PARSING files. This is the same post-deploy step documented for hiveops-journal. +4. **A new institution's files never parse** — files for an institution not listed in the journal `JOURNAL_INSTITUTIONS` env route to the `UNASSIGNED` topic/worker. They still parse (no data loss) but via the fallback worker. Onboard the institution properly if this is persistent. +5. **TCR files not showing / not reparsing** — the reparse and file-status views split ATM vs TCR by `journalType=NH_TCR`. Make sure you're on the correct tab; the "ATM" tab explicitly excludes `NH_TCR` files. +6. **Version didn't change after save** — a save that returns 200 always bumps the version. If it didn't, the request likely hit the wrong service instance or a cached rules object; reload the page and re-check. + +## Common failure modes → fix + +| Symptom | Likely cause | Fix | +|---------|-------------|-----| +| 403 on edit/reload/reparse | Non-admin JWT | Log in as `MSP_ADMIN`/`BCOS_ADMIN` | +| 400 "Invalid YAML or save failed" | Malformed rules YAML | Correct YAML, Save & Apply again | +| Reparse queued but PENDING sticks | Worker down / stranded after deploy | `POST /api/journal/admin/reload-rules`? no — run `/api/journal/admin/reconcile` | +| Files parse via unassigned worker | Institution not in `JOURNAL_INSTITUTIONS` | Add key + worker, redeploy journal | +| Old transactions still wrong after rule change | Rules changed but not reparsed | Run Reparse (blank ATM = all) after Save & Apply | + +## Notes + +- Reparsing rebuilds transactions from the **original journal files on disk**, so it is safe to re-run — but it clears current parsed rows first, so there is a brief window where transactions for those files are gone until parsing finishes. +- Adoons (the AI journey narrative) is unrelated to parsing rules; do not chase parsing bugs through Adoons. diff --git a/backend/src/main/resources/content/transactions/processing-rules/architect.md b/backend/src/main/resources/content/transactions/processing-rules/architect.md new file mode 100644 index 0000000..9850ac8 --- /dev/null +++ b/backend/src/main/resources/content/transactions/processing-rules/architect.md @@ -0,0 +1,77 @@ +--- +module: transactions.processing-rules +title: Journal Processing Rules +tab: Architect +order: 30 +audience: internal +--- + +How the **Journal Processing Rules** feature is built. The screen is served by the Transactions SPA, but the feature is **owned end-to-end by hiveops-journal**. See [platform.service-ownership]. + +## Services involved + +| Concern | Service | Notes | +|---------|---------|-------| +| UI (`ProcessingRules.svelte`) | hiveops-transactions (frontend, port 5178) | Settings screen; also a sibling `TcrProcessingRules.svelte` for TCR | +| Request routing | Transactions BFF (hiveops-transactions backend, port 8098) / nginx | Proxies `/api/journal/**` verbatim to journal; forwards only `Authorization` + `Content-Type` | +| Rules storage + parser | **hiveops-journal** (upload role, port 8087) | `AdminController` + `JournalRulesService` + `JournalParser` | +| Downstream consumers | devices, recon, analytics | Consume journal Kafka events (not the rules themselves) | + +The Transactions backend holds **no** data for this feature — it is a stateless proxy (see [technical.transactions]). All state is the YAML file plus parsed rows in the journal DB. + +## Where the rules live + +- **File, not a table.** Rules are persisted to `journal-rules.yml` at `JOURNAL_RULES_PATH` (`journal.rules.path`). If unset, the classpath copy `src/main/resources/journal-rules.yml` is read-only (writes throw `IllegalStateException`). +- The file is parsed into a `JournalRules` model, cached in-memory (`cachedRules`), and compiled into per-journal-type `CompiledRules` (regex patterns) on first use. `reloadRules()` clears both caches. See [platform.data-architecture]. +- YAML shape: `transaction.transaction_types` is a map of `NAME → { jpr_contains, op_code_matches[] }`; `system_events` is a list of `{ pattern, event_type }`. Per-journal-type overrides live under `journal_types..system_events` etc. + +## Read/write path (the config screen) + +``` +ProcessingRules.svelte + GET /api/journal/admin/rules/structured → JournalRulesService.getStructuredRules() → StructuredRulesDTO {version, transactionTypes[], systemEvents[]} + PUT /api/journal/admin/rules/structured → JournalRulesService.saveStructuredRules(dto) + → replaces transaction_types map + system_events list in JournalRules + → serializes to YAML (NON_NULL, no doc-start marker), writes JOURNAL_RULES_PATH + → reloadRules() (cache + compiled-pattern flush) → returns new version +``` + +Both endpoints are `@PreAuthorize("hasAnyRole('MSP_ADMIN','BCOS_ADMIN')")`. Save is a **full replace**, not a merge — the DTO's ordering becomes the on-disk ordering, which is the parser's match priority (first match wins). + +Adjacent admin endpoints on the same controller: raw YAML (`GET/PUT /api/journal/admin/rules`), `POST /reload-rules`, `GET /types`, `POST /reparse-all`, plus the TCR variants under `/tcr-rules*`. + +## Runtime: how rules are applied to journals + +The rules configured here drive the parse pipeline, which follows the executor → Kafka → record-store pattern (see [platform.kafka]): + +1. Agent uploads a journal file (agent-facing, no JWT) → row in `journal_files`. +2. `JournalParsingService.parseJournalFileAsync()` resolves the ATM's institution and publishes a parse request keyed by ATM to a per-institution topic `journal.parse-requests.` (prefix `journal.parse-requests.`; legacy single topic `journal.parse-requests` during transition). +3. A **worker-role** container (`@KafkaListener` in `JournalParseConsumer`) consumes it and runs `JournalParser.parseJournalFile()` using `CompiledRules`: + - **Transaction Types** — inside `TRANSACTION START…END` blocks, classify via `jpr_contains` / `op_code_matches`; write `journal_transactions` rows. + - **System Events** — outside transaction blocks, each `system_events` pattern match increments `event_count` on the file. (It is counted only — no per-event record/emit; see the Internal tab's known-gap note.) +4. After parsing, the consumer emits Kafka events: + - `hiveops.journal.transactions.recorded` (`TOPIC_TRANSACTIONS`) — for `WITHDRAWAL`/`DEPOSIT`/`REVERSAL` only. + - `journal.replenishments` — replenishment transactions. + - `journal.file-parsed` — one per file; consumed internally for gap detection and by analytics. + +## DB tables (journal DB `hiveiq_journal`) + +- **Written by parsing:** `journal_files` (status, counts), `journal_transactions`. +- **Read for config:** none — the config screen is file-backed, no table. +- `journal_events` exists in the **incident** DB and is written only by `CassetteInventoryService` (CASSETTE_INVENTORY backfill), not by the system-event rules on this screen. + +## Kafka topics (see [platform.kafka]) + +| Topic | Direction | Purpose | +|-------|-----------|---------| +| `journal.parse-requests.` | journal upload → journal worker | dispatch parse work per institution | +| `hiveops.journal.transactions.recorded` | journal worker → recon/analytics/devices | financial transactions | +| `journal.replenishments` | journal worker → consumers | replenishment events | +| `journal.file-parsed` | journal worker → gap detection / analytics | file parsed marker | + +## Cross-references + +- [platform.data-architecture] — file-backed config vs DB-backed transaction store +- [platform.kafka] — per-institution parse dispatch, executor→Kafka→record-store +- [platform.service-ownership] — journal owns rules + parsing; transactions is a proxy +- [technical.transactions] — the stateless BFF that fronts this screen diff --git a/backend/src/main/resources/content/transactions/processing-rules/claude.md b/backend/src/main/resources/content/transactions/processing-rules/claude.md new file mode 100644 index 0000000..6a130b9 --- /dev/null +++ b/backend/src/main/resources/content/transactions/processing-rules/claude.md @@ -0,0 +1,63 @@ +--- +module: transactions.processing-rules +title: Journal Processing Rules +tab: Claude +order: 40 +audience: internal +--- + +Terse act-without-guessing reference. Owned by **hiveops-journal** (upload role). Transactions app is only the UI + proxy. + +## Ownership + +- Feature owner service: **hiveops-journal** (`AdminController`, `JournalRulesService`, `JournalParser`). +- UI: `hiveops-transactions/frontend/src/components/Settings/ProcessingRules.svelte` (calls `journalAdminAPI` in `lib/api.ts`). +- Data lives in a **YAML file** (`JOURNAL_RULES_PATH` → `journal-rules.yml`), NOT a DB table. + +## Endpoints (all on hiveops-journal, all `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')`) + +| Method | Path | Purpose | +|--------|------|---------| +| GET | `/api/journal/admin/rules/structured` | Get `{version, transactionTypes[], systemEvents[]}` | +| PUT | `/api/journal/admin/rules/structured` | Full-replace rules from DTO, write YAML, hot-reload | +| GET | `/api/journal/admin/rules` | Raw YAML (text/plain) | +| PUT | `/api/journal/admin/rules` | Save raw YAML (text/plain), hot-reload | +| POST | `/api/journal/admin/reload-rules` | Reload YAML from disk | +| GET | `/api/journal/admin/types` | Journal type names | +| POST | `/api/journal/admin/reparse-all` | Reparse files; opt `?atmId=` `?journalType=` (`ATM`/`NH_TCR`) | +| GET/PUT | `/api/journal/admin/tcr-rules`, `/tcr-rules/raw`, POST `/tcr-rules/reload` | TCR variant (separate `tcr-rules.yml`) | + +- Frontend base: `window.__APP_CONFIG__.apiUrl` (dev default `http://localhost:8098/api` → Transactions BFF), paths prefixed `/journal/...`. BFF/nginx proxies `/api/journal/**` to journal service verbatim. + +## DTO (`StructuredRulesDTO`) + +- `version:int`, `transactionTypes:[{name, jprContains, opCodeMatches:[]}]`, `systemEvents:[{pattern, eventType}]`. +- Save is a **FULL REPLACE** of `transaction_types` map + `system_events` list. Array order = match priority (first match wins). +- YAML keys: `jpr_contains`, `op_code_matches`, `system_events[].pattern`, `system_events[].event_type`. + +## Tables & topics + +- YAML file: `journal-rules.yml` at `JOURNAL_RULES_PATH` (`journal.rules.path`). +- Tables (DB `hiveiq_journal`): `journal_files`, `journal_transactions` (written by parser). Config screen touches NO table. +- `journal_events` = **incident DB**, written only by `CassetteInventoryService`, not by these rules. +- Topics: `journal.parse-requests.` (dispatch), `hiveops.journal.transactions.recorded` (WITHDRAWAL/DEPOSIT/REVERSAL only), `journal.replenishments`, `journal.file-parsed`. +- Event-type picklist (13) is hard-coded in `ProcessingRules.svelte` `EVENT_TYPES`, not from backend. + +## Gotchas + +- `AdminController` is `@ConditionalOnProperty(journal.role=upload, matchIfMissing=true)` → endpoints exist ONLY on upload container (`hiveiq-journal`), 404 on `hiveiq-journal-worker-*`. +- PUT structured/raw throws `IllegalStateException`→HTTP 400 if `JOURNAL_RULES_PATH` blank ("cannot save rules"). Reads still work. +- Hot-reload affects only NEW parses. Historical data needs `POST /reparse-all` (deletes + re-queues matching files — heavy). +- System-event match only increments `event_count`; it does NOT create an incident/`journal_events` row. UI copy ("generates an incident event") is aspirational, not current behavior. +- Save = full replace: omitting an existing type/event deletes it from YAML. +- Adoons = branding for hiveops-ai; unrelated to this screen. + +## Do NOT + +- Do NOT look for a `processing_rules` table — it's a YAML file. +- Do NOT hit worker containers for admin ops. +- Do NOT expect a JWT below MSP_ADMIN/BCOS_ADMIN to work (403 at journal even if BFF passes it). +- Do NOT assume system-event patterns raise incidents — they only bump a counter today. +- Do NOT PATCH/merge — the API only supports full-object replace via PUT. +- Do NOT edit `journal-rules.yml` and forget hot-reload/reparse for existing files. +- Do NOT confuse this with TCR rules (`/tcr-rules*`, `tcr-rules.yml`, `TcrProcessingRules.svelte`). diff --git a/backend/src/main/resources/content/transactions/processing-rules/internal.md b/backend/src/main/resources/content/transactions/processing-rules/internal.md new file mode 100644 index 0000000..1de2381 --- /dev/null +++ b/backend/src/main/resources/content/transactions/processing-rules/internal.md @@ -0,0 +1,45 @@ +--- +module: transactions.processing-rules +title: Journal Processing Rules +tab: Internal +order: 20 +audience: internal +--- + +Ops/support view of the **Journal Processing Rules** screen (Transactions app › Settings). The screen lives in the Transactions SPA but edits data owned by **hiveops-journal** — it reads and writes the `journal-rules.yml` file that drives the journal parser. It does **not** touch a database table. + +## Who can use it + +- Every admin endpoint behind this screen is gated by `@PreAuthorize("hasAnyRole('MSP_ADMIN','BCOS_ADMIN')")` on `AdminController` (hiveops-journal). +- A non-admin JWT gets **403** from the journal service even though the Transactions BFF let the request through (the BFF role check is broad; journal enforces the real gate). If a user sees an empty grid or a load error, check their role first. + +## What Save & Apply actually does + +1. Frontend PUTs the full rule set to `PUT /api/journal/admin/rules/structured`. +2. `JournalRulesService.saveStructuredRules()` rewrites the **entire** `transaction_types` map and `system_events` list into the YAML file at `JOURNAL_RULES_PATH`, then hot-reloads (`reloadRules()` clears the rules cache + all compiled patterns). +3. Response returns a new `version`; the UI shows `Saved and applied (vN)`. + +Hot-reload takes effect immediately, no restart — **but only for journals parsed after the save**. Already-parsed files are not re-classified automatically (see below). + +## First things to check when it misbehaves + +- **"Save failed" with a message about `JOURNAL_RULES_PATH is not configured"`** — the upload container is running classpath-only. `saveStructuredRules`/`saveRulesContent` throw `IllegalStateException` (HTTP 400) when `JOURNAL_RULES_PATH` / `journal.rules.path` is blank. Fix: set the env var to a writable file path and redeploy; reads still work without it, only writes fail. +- **Grid loads empty / 500 on load** — `GET /api/journal/admin/rules/structured` failed. Usually the YAML on disk is malformed. Check `docker logs hiveiq-journal` for "Error loading structured rules". Fall back to the raw-YAML endpoints (`GET/PUT /api/journal/admin/rules`) to inspect/repair. +- **404 on any admin endpoint** — you hit a **worker** container, not the upload container. `AdminController` is `@ConditionalOnProperty(journal.role=upload, matchIfMissing=true)` — it only exists on the upload (front-end) role. Route admin traffic to `hiveiq-journal`, never `hiveiq-journal-worker-*`. +- **Changes don't show up in old data** — expected. Rule edits only affect new parses. To apply new rules to historical files, run a reparse (`POST /api/journal/admin/reparse-all`, optional `atmId` / `journalType`). This deletes existing transactions for those files and re-queues them — heavy operation, use deliberately. +- **A transaction is labelled wrong** — remember **first match wins, top to bottom**. A broad rule sitting above a specific one will swallow it. Reorder with the ↑/↓ arrows and Save. + +## Transaction Types vs System Events — practical differences + +- **Transaction Types** classify each transaction block. A rule matches on `JPR Contains` (text inside the JPR block) and/or `Op Code Matches` (one or more op codes). Blank `JPR Contains` = match any content; blank op codes = skip op-code matching. Only `WITHDRAWAL`, `DEPOSIT`, `REVERSAL` types are re-published downstream as Kafka transaction events (see Architect tab). +- **System Events** are pattern rules matched against journal lines **outside** transaction blocks. The picklist of event types is hard-coded in the frontend (`EVENT_TYPES`, 13 values: `NETWORK_CONNECTED/DISCONNECTED`, `POWER_ON/OFF`, `ATM_IN/OUT_OF_SERVICE`, `SUPERVISOR_ENTER/EXIT`, `OPERATOR_ACTION`, `CASSETTE_STATUS`, `CARD_READER_FAIL`, `CASSETTE_LOW`, `DISPENSER_JAM`). Adding a new event type means editing the Svelte component, not just the YAML. + +> ⚠️ **Known gap (verify before promising a customer):** in the current parser, a System-Event match only increments the file's `event_count` — it does **not** create an incident event or a `journal_events` row. The UI copy ("Each match generates an incident event") overstates today's behavior. Don't debug a "missing incident from a system-event pattern" as a config problem; it's not wired to emit. Cassette-inventory events are the exception — those are written by a separate service path, not by this screen. + +## Safe-change checklist + +1. Confirm you're editing on the right environment (this screen writes to whichever `hiveiq-journal` upload container the API is pointed at). +2. Put specific rules above broad ones. +3. Save & Apply, watch for the `vN` bump. +4. If historical data must reflect the change, schedule a `reparse-all` (off-peak; it re-queues every matched file). +5. Verify with a fresh journal upload for a test ATM, then check the Transactions browser. diff --git a/backend/src/main/resources/content/transactions/reparse/architect.md b/backend/src/main/resources/content/transactions/reparse/architect.md new file mode 100644 index 0000000..cc5d768 --- /dev/null +++ b/backend/src/main/resources/content/transactions/reparse/architect.md @@ -0,0 +1,98 @@ +--- +module: transactions.reparse +title: Journal Reparse +tab: Architect +order: 30 +audience: internal +--- + +How **Journal Reparse** is built end to end. The feature spans two services: the **transactions** SPA/proxy (UI + auth pass-through) and **hiveops-journal** (the actual owner of files, transactions, and the reparse action). See [platform.service-ownership]. + +## Services involved + +| Service | Role in this feature | +|---------|----------------------| +| `hiveops-transactions` (frontend + proxy, port 8098) | Serves `JournalReparse.svelte`; proxies `/api/journal/**` to journal. Owns **no** data — stateless BFF. See [platform.service-ownership]. | +| `hiveops-journal` **upload role** (`JOURNAL_ROLE=upload`, port 8087) | Hosts `AdminController` + `JournalApiController`; resets file state; **produces** parse-request messages to Kafka. | +| `hiveops-journal` **worker role** (`JOURNAL_ROLE=worker`) | Consumes per-institution parse-request topics; runs the actual parse; writes transactions; emits `journal.file-parsed`. | +| Kafka | Decouples the reset/enqueue (upload) from the CPU-heavy parse (worker). See [platform.kafka]. | +| PostgreSQL `hiveiq_journal` | Stores `journal_files` and `journal_transactions`. See [platform.data-architecture]. | + +## Request path (executor → Kafka → record-store pattern) + +``` +JournalReparse.svelte + └─ journalAdminAPI.reparseAll(atmId?, journalType) [axios, base = transactions proxy /api] + └─ POST /api/journal/admin/reparse-all?atmId=&journalType= + └─ transactions proxy (JournalProxyController.forward, JOURNAL_URL) + └─ hiveops-journal AdminController.reparseAll [MSP_ADMIN/BCOS_ADMIN] + ├─ select journal_files (by atmId / journalType filter) + ├─ FOR EACH file: + │ ├─ DELETE journal_transactions WHERE file (deleteByJournalFile) + │ ├─ UPDATE journal_files SET parse_status=PENDING, + │ │ transaction_count=NULL, parse_error=NULL, last_parsed_size=NULL + │ └─ JournalParsingService.parseJournalFileAsync(file) + │ └─ kafkaTemplate.send("journal.parse-requests.{KEY}", atmId, fileId) + └─ return { message, queued, total } +``` + +Then, asynchronously on the **worker**: + +``` +JournalParseConsumer @KafkaListener(topics = "#{@parseTopicName}") // one topic per worker + ├─ UPDATE journal_files SET parse_status=PARSING + ├─ JournalParser.parseJournalFile(file) → INSERT journal_transactions + ├─ UPDATE journal_files SET parse_status=PARSED (transaction_count, parsed_at, …) + │ └─ on failure: parse_status=ERROR, parse_error= + └─ FileParsedEventProducer → kafkaTemplate.send("journal.file-parsed", atmId, event) + ├─ CassetteInventoryService @KafkaListener(journal.file-parsed) // cassette backfill + └─ JournalGapDetectionService @KafkaListener(journal.file-parsed) // gap re-check +``` + +## Institution → topic routing + +`JournalParsingService.resolveTopic(institution)` decides the parse-request topic: +1. `jf.getInstitution()` if already set (uppercase key, e.g. `AICU`). +2. else `AtmInstitutionSyncService.resolve(atmId)` — local map synced from the incident DB. +3. else `"unassigned"`. + +- Known institution (in `JOURNAL_INSTITUTIONS`) → `journal.parse-requests.{KEY}` (uppercased). +- Unknown/blank → `journal.parse-requests.UNASSIGNED`. + +Each worker container listens to exactly one topic via the Spring bean `@parseTopicName` (derived from `JOURNAL_INSTITUTION`). This is how parsing scales horizontally per institution. See [platform.kafka]. + +## Data flow — DB tables + +| Table (DB `hiveiq_journal`) | Read | Written by reparse | +|-----------------------------|------|--------------------| +| `journal_files` (`@Table journal_files`) | file list + status (`GET /files`), reparse selection | status reset (upload), PARSING/PARSED/ERROR (worker) | +| `journal_transactions` (`@Table journal_transactions`) | transaction counts | **deleted** at reparse start, re-inserted by worker | + +`GET /api/journal/files` returns a `PageResponse` (`atmId`, `journalDate`, `parseStatus`, `transactionCount`, `parseError`, …), rendered as status pills + table. See [platform.data-architecture]. + +## Kafka topics + +| Topic | Producer | Consumer | Purpose | +|-------|----------|----------|---------| +| `journal.parse-requests.{KEY}` / `.UNASSIGNED` | upload (`parseJournalFileAsync`) | worker (`JournalParseConsumer`) | one parse job = one file id, keyed by atmId | +| `journal.file-parsed` | worker (`FileParsedEventProducer`) | `CassetteInventoryService`, `JournalGapDetectionService` | fan-out after a file finishes parsing | + +Static topics `journal.parse-requests` (base) and `journal.replenishments` are also declared but not on the reparse path. See [platform.kafka]. + +## Resilience + +Because reset (upload) and parse (worker) are decoupled, lost messages are recovered by `JournalParsingService`: +- `reconcileStranded()` — `@EventListener(ApplicationReadyEvent)` on upload startup, and on-demand via `POST /api/journal/admin/reconcile`; re-queues all PENDING/PARSING. +- `recoverStaleParsing()` — `@Scheduled` every 30 min; re-queues files stuck in PARSING > 30 min (worker died mid-parse); a companion sweep re-queues PENDING > 30 min (Kafka send failed silently). + +## API endpoints (this feature) + +| Method | Path (journal svc) | Controller | Role | +|--------|--------------------|------------|------| +| POST | `/api/journal/admin/reparse-all?atmId=&journalType=` | `AdminController` | `MSP_ADMIN`/`BCOS_ADMIN` | +| GET | `/api/journal/files?atmId=&journalType=&page=&size=` | `JournalApiController` | `isAuthenticated()` | +| POST | `/api/journal/files/{id}/reparse` | `JournalApiController` | `isAuthenticated()` | +| POST | `/api/journal/admin/reconcile` | `AdminController` | `MSP_ADMIN`/`BCOS_ADMIN` | +| POST | `/api/journal/admin/parse-sync?fileId=` | `AdminController` | `MSP_ADMIN`/`BCOS_ADMIN` (diagnostics, synchronous) | + +All reach journal through the transactions proxy (`JournalProxyController` → `JOURNAL_URL`, default `http://hiveiq-journal:8087`), which forwards only `Authorization` + `Content-Type`. diff --git a/backend/src/main/resources/content/transactions/reparse/claude.md b/backend/src/main/resources/content/transactions/reparse/claude.md new file mode 100644 index 0000000..a9e3f21 --- /dev/null +++ b/backend/src/main/resources/content/transactions/reparse/claude.md @@ -0,0 +1,58 @@ +--- +module: transactions.reparse +title: Journal Reparse +tab: Claude +order: 40 +audience: internal +--- + +## Ownership +- **Owner service: `hiveops-journal`** (NOT transactions). Transactions is a stateless proxy only. +- UI: `hiveops-transactions/frontend/src/components/Settings/JournalReparse.svelte`; API layer `journalAdminAPI` in `frontend/src/lib/api.ts`. +- Backend controllers: `AdminController` (reparse/admin), `JournalApiController` (files) in `hiveops-journal`. + +## Endpoints (METHOD + path, journal service; hit via transactions proxy `/api/journal/**`) +| Method | Path | Role | Notes | +|--------|------|------|-------| +| POST | `/api/journal/admin/reparse-all?atmId=&journalType=` | `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` | returns `{message,queued,total}` | +| GET | `/api/journal/files?atmId=&journalType=&page=&size=` | `isAuthenticated()` | `PageResponse` | +| POST | `/api/journal/files/{id}/reparse` | `isAuthenticated()` | single file | +| POST | `/api/journal/admin/reconcile` | `MSP_ADMIN`/`BCOS_ADMIN` | re-queue PENDING/PARSING | +| POST | `/api/journal/admin/parse-sync?fileId=` | `MSP_ADMIN`/`BCOS_ADMIN` | synchronous, diagnostics only | + +- `journalType` values: `ATM` (→ `findByJournalTypeNot("NH_TCR")`) or `NH_TCR` (→ TCR only). Blank = all files. +- `atmId` is uppercased server-side before matching. +- Proxy forwards ONLY `Authorization` + `Content-Type` headers; strips others. Proxy `/api/**` role is broad; real gate is in journal. + +## Tables (DB `hiveiq_journal`) +- `journal_files` — status reset here (`parse_status`, `transaction_count`, `parse_error`, `last_parsed_size`). +- `journal_transactions` — DELETED at reparse start, re-inserted by worker. +- (archive: `journal_transactions_archive` — not on reparse path.) + +## Kafka topics +- `journal.parse-requests.{KEY}` and `journal.parse-requests.UNASSIGNED` — parse jobs; produced by upload role, consumed by worker role (`JournalParseConsumer`, one topic per worker). +- `journal.file-parsed` — emitted after parse; consumed by `CassetteInventoryService` + `JournalGapDetectionService`. + +## Flow (terse) +1. `reparse-all` (upload role): select files → per file `deleteByJournalFile` + set PENDING + `kafkaTemplate.send(journal.parse-requests.{KEY}, atmId, fileId)`. +2. worker `JournalParseConsumer`: PARSING → parse → PARSED (or ERROR) → publish `journal.file-parsed`. +3. Status transitions PENDING→PARSING→PARSED are async; UI polls `GET /files` every 15s. + +## Roles +- reparse/reconcile/parse-sync/all `AdminController` = `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')`. +- `GET /files`, per-file `/reparse` = `isAuthenticated()`. + +## Gotchas +- Reparse is **delete-then-rebuild**: transactions gone until a worker re-parses. If the institution's worker is down, files sit PENDING with 0 transactions indefinitely. +- Upload role only PRODUCES to Kafka; worker role PARSES. Missing/stopped worker = nothing advances. +- Files with no institution mapping route to `.UNASSIGNED` → need `hiveiq-journal-worker-unassigned` running. +- Self-heal: startup `reconcileStranded` + on-demand `/reconcile` + `@Scheduled` 30-min recovery for stuck PENDING/PARSING. Wait ≤30 min OR call `/reconcile` before deeper debugging. +- `parseJournalFileAsync` swallows Kafka send failures (logs warn) — file already reset to PENDING; `queued` counter may overstate what actually enqueued when Kafka is down. +- ATM vs TCR is a UI tab → `journalType` param; wrong tab = "no files" confusion. + +## Do NOT +- Do NOT treat transactions service as the backend — it owns no data; go to `hiveops-journal`. +- Do NOT invent a per-file reparse under `/admin` — single file is `POST /api/journal/files/{id}/reparse`. +- Do NOT run fleet-wide reparse-all (blank atmId) casually — deletes all transactions of that type first; thousands of files, minutes. +- Do NOT expect synchronous results from `reparse-all` — it only enqueues. Use `parse-sync` (single file) for immediate parse. +- Do NOT add auth assumptions from the proxy — journal's `@PreAuthorize` is the source of truth (`MSP_ADMIN`/`BCOS_ADMIN` for reparse). diff --git a/backend/src/main/resources/content/transactions/reparse/internal.md b/backend/src/main/resources/content/transactions/reparse/internal.md new file mode 100644 index 0000000..5b5732e --- /dev/null +++ b/backend/src/main/resources/content/transactions/reparse/internal.md @@ -0,0 +1,62 @@ +--- +module: transactions.reparse +title: Journal Reparse +tab: Internal +order: 20 +audience: internal +--- + +Ops / support / admin view of **Journal Reparse** (Transactions SPA → Settings → *Journal Reparse*, `JournalReparse.svelte`). The screen has two tabs — **ATM Journals** and **TCR Journals** — that show file parse status and trigger a bulk re-parse. The data does **not** come from the transactions service; transactions is a stateless proxy that forwards to **hiveops-journal**, which owns the files, the parsing, and the reparse action. + +## Who can do what + +| Action | UI | Real endpoint (journal svc) | Role enforced | +|--------|----|-----------------------------|---------------| +| View parse status / file list | Refresh, tab switch, ATM filter | `GET /api/journal/files` | `isAuthenticated()` — any logged-in user | +| Trigger bulk reparse | **Reparse All** button | `POST /api/journal/admin/reparse-all` | **`hasAnyRole('MSP_ADMIN','BCOS_ADMIN')`** | + +So a plain user can *see* status but only **MSP_ADMIN / BCOS_ADMIN** can trigger a reparse. If a non-admin clicks Reparse All they get a 403 from the journal service (surfaced as the red error alert). The transactions proxy in front is deliberately loose (`hasAnyRole(USER,CUSTOMER,ADMIN,MSP_ADMIN,QDS_ADMIN,BCOS_ADMIN)` on `/api/**`); the real authorization is enforced downstream by journal's `AdminController`. + +## What "Reparse All" actually does + +For each matched `journal_files` row (`AdminController.reparseAll`): +1. Deletes all rows in `journal_transactions` for that file (`deleteByJournalFile`). +2. Resets the file: `parse_status = PENDING`, `transaction_count = null`, `parse_error = null`, `last_parsed_size = null` (the null size forces a re-parse even if the file bytes are unchanged). +3. Publishes an async parse request to Kafka; increments the `queued` counter. + +Returns `{ message, queued, total }` → UI shows *"Queued X of Y files"*. Files then move **PENDING → PARSING → PARSED** (or **ERROR**) on their own as the per-institution worker picks them up. Nothing is parsed inline in the request. + +**Scope of the buttons:** +- **ATM tab** → `journalType=ATM` → matches all files *except* `NH_TCR` (`findByJournalTypeNot("NH_TCR")`). +- **TCR tab** → `journalType=NH_TCR` → matches only TCR files. +- **ATM ID blank** → every ATM of that type (can be thousands of files / several minutes). +- **ATM ID set** → uppercased and matched exactly (`findByAtmIdAndJournalType…`). + +## First things to check when it misbehaves + +1. **Files stuck in PENDING and never moving to PARSED.** This is the #1 failure mode. Parsing is done by the **worker** containers (`JOURNAL_ROLE=worker`) consuming per-institution Kafka topics. If the worker for that institution isn't running, its files sit PENDING forever. Check the worker container for the institution is up. +2. **PENDING right after a deploy.** The upload service only *produces* Kafka messages; a restart can strand in-flight files. Run the reconcile endpoint (re-queues all PENDING/PARSING): + ``` + POST /api/journal/admin/reconcile (MSP_ADMIN/BCOS_ADMIN) + ``` + It also runs automatically on startup and every 30 min (`recoverStaleParsing` re-queues PARSING > 30 min; a separate sweep re-queues PENDING > 30 min). So most stuck files self-heal within ~30 min — if they don't, the worker is down or the institution has no worker. +3. **"Unassigned" institution.** If a file's ATM has no institution mapping (`jf.getInstitution()` null and `AtmInstitutionSyncService` can't resolve it), it routes to the `journal.parse-requests.UNASSIGNED` topic and is parsed by `hiveiq-journal-worker-unassigned`. If that worker is down, those files hang. Fix the ATM→institution mapping in devices/incident and re-run reparse. +4. **ERROR status with a message.** The **Error** column shows `parse_error`. Usually a rules problem (bad/renamed journal format) — check the rules for that vendor type before re-running. TCR files use a separate ruleset (`tcr-rules.yml`). +5. **Kafka down.** `parseJournalFileAsync` swallows send failures (logs a warn) and the file is already reset to PENDING with its transactions deleted — the 30-min PENDING sweep or a manual `/reconcile` will re-queue once the broker is back. + +## Common failure modes → fix + +| Symptom | Likely cause | Fix | +|---------|-------------|-----| +| Reparse All → 403 / red error | User is not MSP_ADMIN or BCOS_ADMIN | Use an admin account | +| Files parsed=PENDING, never advance | Institution worker container down / not deployed | Bring up `hiveiq-journal-worker-{KEY}`; then `/reconcile` | +| Files PENDING after deploy | Kafka messages stranded on restart | `POST /api/journal/admin/reconcile` (or wait ≤30 min) | +| Transactions dropped to 0 and stay 0 | Reparse deleted rows but no worker re-parsed | Same as above — reparse **deletes first**, so a missing worker leaves data empty until it runs | +| Status pills/table empty | Wrong tab (ATM vs TCR) or ATM filter typo | Clear filter, confirm tab matches file type | +| Single file needs re-parse, not the whole fleet | — | `POST /api/journal/files/{id}/reparse` (per-file, `isAuthenticated()`); diagnostics-only sync: `POST /api/journal/admin/parse-sync?fileId=X` | + +## Good to know + +- **Reparse is destructive-then-rebuild:** transactions are deleted *before* re-parsing. During a fleet-wide reparse, affected transactions are temporarily gone from the Transactions browser until workers finish. +- **Auto-refresh** in the UI polls `GET /api/journal/files` every 15s (client-side only; toggle persisted in `localStorage`). It does not drive parsing. +- Downstream of a successful parse, journal emits `journal.file-parsed`, which re-triggers **gap detection** and **cassette inventory** — a large reparse also regenerates those. diff --git a/backend/src/main/resources/content/vault/cit-visits/architect.md b/backend/src/main/resources/content/vault/cit-visits/architect.md new file mode 100644 index 0000000..5d9a84f --- /dev/null +++ b/backend/src/main/resources/content/vault/cit-visits/architect.md @@ -0,0 +1,86 @@ +--- +module: vault.cit-visits +title: CIT Visits +tab: Architect +order: 30 +audience: internal +--- + +How the CIT Visits feature is built. See [platform.service-ownership] — **hiveops-vault owns CIT visit data end to end**; it holds only a read-only mirror of device metadata. + +## Services involved + +| Service | Role for this feature | +|---------|----------------------| +| **hiveops-vault** (backend, port 8094) | Owns CIT visits, cassette counts, removals, CSV imports. Serves the whole `/api/vault` surface. | +| **hiveops-vault** (frontend, port 5185, `vault.bcos.cloud`) | Svelte SPA. `VisitsView` (list+filters), `VisitDetailPanel` (per-cassette + removals, confirm/dispute), `ImportView` (CSV upload), `DashboardView` (stats). Axios client `frontend/src/lib/api.ts`. | +| **hiveops-devices** | Upstream owner of device metadata. Publishes `hiveops.device.events`; vault only consumes. | +| **hiveops-auth / mgmt** | Issue the JWT (claims `sub`, `email`, `role`, `institutionKey`) that vault validates. | +| **NGINX** | Edge routing + CORS authority. Maps `api.bcos.cloud/vault/` → `hiveiq-vault:8094/api/vault/`. | + +Single combined repo: `hiveops-vault/backend` + `hiveops-vault/frontend`. + +## Request path (auth + scoping) + +1. SPA sends `Authorization: Bearer `. +2. `JwtAuthenticationFilter` validates via shared `com.hiveops.security.JwtTokenValidator`, builds a `VaultPrincipal(userId, email, role, institutionKey)`, sets it as the authentication principal. +3. `SecurityConfig` — stateless, CSRF off, **Spring CORS disabled** (NGINX handles CORS), `anyRequest().authenticated()`. No role checks. +4. Controllers inject `@AuthenticationPrincipal VaultPrincipal` and scope every query by `institutionKey`. + +See [platform.data-architecture] for the institution-scoping pattern. + +## Data flow — CIT visits (owned data) + +- **Manual create/update/delete** and **status transitions** (`PENDING → CONFIRMED / DISPUTED`) go straight through `CitVisitController` → `CitVisitService` → JPA repositories → `hiveiq_vault`. Synchronous; no Kafka, no executor. +- **CSV bulk import** follows an async ingest pattern: + 1. `POST /api/vault/imports` → `ImportController` saves a `cit_imports` row (status `PROCESSING`) and returns **202** immediately. + 2. `CsvImportService.processAsync(...)` runs on the `importExecutor` `ThreadPoolTaskExecutor` (core 2 / max 4 / queue 50, thread prefix `vault-import-`; `AsyncConfig`). + 3. Parser groups rows by composite key `atm_id|visit_date|vendor|reference` into one `CitVisit` with child cassette counts / removals, then sets `cit_imports.status` = `COMPLETED` (+ `recordCount`) or `FAILED` (+ `errorMessage`). + 4. Client polls `GET /api/vault/imports/{id}` for the terminal state. + +This is an **executor → record-store** pattern (async work tracked by a status row) rather than executor → Kafka; vault produces **no** Kafka messages. + +## Data flow — device metadata (mirror) + +Vault keeps `device_summary` in sync from Kafka. See [platform.kafka]. + +- **Topic consumed:** `hiveops.device.events` +- **Consumer:** `DeviceEventConsumer` (`@KafkaListener`), group `hiveops-vault-device-sync`, offset reset `earliest`, container factory `deviceEventKafkaListenerContainerFactory` (`DeviceEventKafkaConfig`). +- **Payload:** `DeviceEventMessage` (JSON; trusted packages include `com.hiveops.devices.kafka`, `com.hiveops.fleet.kafka`, `com.hiveops.vault.kafka`). +- **Behavior:** `DEVICE_DELETED` → delete the `device_summary` row by `atmId`. Otherwise upsert by `atmId`. On **insert**, a missing `deviceId` is skipped with a warning; a missing `institutionKey` defaults to `"BCOS"`. +- **Produced topics:** none. + +`device_summary` feeds `GET /api/vault/atms` and the MSP-admin institution-derivation on visit create. It is never authored by vault — a stale mirror is an upstream `hiveops-devices` publish gap. + +## DB tables (database `hiveiq_vault`) + +| Table | Written by | Read by | +|-------|-----------|---------| +| `cit_visits` | visit CRUD, CSV import | list/detail/recent/by-atm, stats | +| `cit_cassette_counts` | visit create, CSV import | visit detail | +| `cit_removals` | visit create, CSV import | visit detail | +| `cit_imports` | import upload + async job | import list/detail | +| `device_summary` | Kafka consumer only | `/atms`, create-visit institution derivation | + +Schema from Flyway `V1__create_vault_schema.sql`; `device_summary` added/altered in `V3`/`V5`. Prod = PostgreSQL (`ddl-auto: validate` + Flyway); dev = H2 `create-drop`, Flyway disabled. Env vars are the split form `DB_HOST` / `DB_PORT` / `DB_NAME` (default `hiveiq_vault`) — not `DB_URL`. + +## Key API endpoints + +Base `/api/vault` (all authenticated, institution-scoped): + +| Method | Path | Purpose | +|--------|------|---------| +| GET | `/visits` | List, paginated (default size 25, sort `visitDate` DESC), filters `atmId`,`vendor`,`status`,`from`,`to` | +| GET | `/visits/{id}` | Detail (cassette counts + removals) | +| POST | `/visits` | Create (201) | +| PUT | `/visits/{id}` | Update status/notes/vendor/reference | +| DELETE | `/visits/{id}` | Delete (204) | +| GET | `/visits/recent` | Recent for dashboard | +| GET | `/atm/{atmId}/visits` | All visits for one ATM | +| GET | `/atms` | Device list from `device_summary` mirror | +| POST | `/imports` | CSV upload (202, async) | +| GET | `/imports` · `/imports/{id}` | Import history / poll status | +| GET | `/stats` | Dashboard aggregates | +| GET | `/users/me` | Decoded JWT identity | + +Cross-links: [platform.data-architecture] · [platform.kafka] · [platform.service-ownership] · [technical.vault]. diff --git a/backend/src/main/resources/content/vault/cit-visits/claude.md b/backend/src/main/resources/content/vault/cit-visits/claude.md new file mode 100644 index 0000000..f027b1b --- /dev/null +++ b/backend/src/main/resources/content/vault/cit-visits/claude.md @@ -0,0 +1,64 @@ +--- +module: vault.cit-visits +title: CIT Visits +tab: Claude +order: 40 +audience: internal +--- + +Terse act-without-guessing reference. All facts grounded in `hiveops-vault` source. + +## Ownership +- **Owner service:** `hiveops-vault` (backend port 8094, frontend port 5185, `vault.bcos.cloud`). +- **Owns:** CIT visits, cassette counts, removals, CSV imports (full CRUD). +- **Mirrors (read-only):** `device_summary` via Kafka from `hiveops-devices`. Vault never authors device data. +- **DB:** `hiveiq_vault` (prod PostgreSQL; dev H2 in-memory). Env: `DB_HOST`/`DB_PORT`/`DB_NAME` (not `DB_URL`). + +## Endpoints (base `/api/vault`, edge `api.bcos.cloud/vault/...`) +| METHOD | Path | +|--------|------| +| GET | `/api/vault/visits` (params: `atmId`,`vendor`,`status`,`from`,`to`,`page`,`size`) | +| GET | `/api/vault/visits/{id}` (id = UUID) | +| POST | `/api/vault/visits` → 201 | +| PUT | `/api/vault/visits/{id}` (`status`/`notes`/`vendorName`/`referenceNumber`) | +| DELETE | `/api/vault/visits/{id}` → 204 | +| GET | `/api/vault/visits/recent` | +| GET | `/api/vault/atm/{atmId}/visits` | +| GET | `/api/vault/atms` | +| POST | `/api/vault/imports` (multipart `file`) → 202 async | +| GET | `/api/vault/imports` · `/api/vault/imports/{id}` | +| GET | `/api/vault/stats` | +| GET | `/api/vault/users/me` | + +## Tables (`hiveiq_vault`) +- `cit_visits` (status `PENDING`/`CONFIRMED`/`DISPUTED`; `institution_key`) +- `cit_cassette_counts` (`cassette_type` DISPENSER/RECYCLER/RETRACT/MIX) +- `cit_removals` (`removal_type` CHECK/DEPOSIT/REJECTED_NOTE) +- `cit_imports` (status `PROCESSING`/`COMPLETED`/`FAILED`) +- `device_summary` (Kafka-fed mirror) + +## Kafka +- **Consumes:** `hiveops.device.events` → `DeviceEventConsumer`, group `hiveops-vault-device-sync`, offset `earliest`, payload `DeviceEventMessage`. `DEVICE_DELETED` deletes; else upsert by `atmId`. +- **Produces:** NONE. + +## Auth / roles +- JWT Bearer only (`Authorization: Bearer`). Validated by `JwtTokenValidator` → `VaultPrincipal(userId,email,role,institutionKey)`. +- **No role gating.** `SecurityConfig` = `anyRequest().authenticated()`. There is NO `hasAnyRole(...)` in vault. Access = institution scoping via `institutionKey` claim. + +## Gotchas +- Every read filters by `principal.institutionKey()`. Wrong/missing claim → empty page, **no error**. +- MSP admin = null `institutionKey`: only **create-visit** (derives institution from ATM's `device_summary`) and **`/atms`** (returns `findAll()`) handle null. `/imports`, `/stats`, visit list/recent pass null through → typically empty. +- CSV upload returns **202 immediately**; work runs on `importExecutor` (`vault-import-*`, core2/max4/queue50). **Must poll** `/imports/{id}` for `COMPLETED`/`FAILED`. +- Upload content-type must contain `csv` or `text`, non-empty, else 400. +- CSV parser groups rows by `atm_id|visit_date|vendor|reference`; `REMOVAL` in slot col = removal row. +- On device event: missing `deviceId` skips insert; missing `institutionKey` defaults to `"BCOS"`. +- Spring CORS **disabled**; NGINX is CORS authority + routes `/vault/` → `hiveiq-vault:8094/api/vault/`. +- `visitDate`/`from`/`to` are `Instant` (ISO date-time), not date-only. + +## Do NOT +- Do NOT expect role checks or add `hasAnyRole` claims to test access — it's institution-scoped only. +- Do NOT write `device_summary` from vault or treat a missing device as a vault bug — fix upstream `hiveops-devices` publish. +- Do NOT treat a 202 import as done — poll `/imports/{id}`. +- Do NOT look for vault-produced Kafka topics — there are none. +- Do NOT use `DB_URL` env for vault — it uses `DB_HOST`/`DB_PORT`/`DB_NAME`. +- Do NOT add CIT-visit endpoints to another service — vault owns this domain ([platform.service-ownership]). diff --git a/backend/src/main/resources/content/vault/cit-visits/internal.md b/backend/src/main/resources/content/vault/cit-visits/internal.md new file mode 100644 index 0000000..9bd7757 --- /dev/null +++ b/backend/src/main/resources/content/vault/cit-visits/internal.md @@ -0,0 +1,54 @@ +--- +module: vault.cit-visits +title: CIT Visits +tab: Internal +order: 20 +audience: internal +--- + +Ops/support view for the CIT Visits screen (`vault.bcos.cloud`). Served by **hiveops-vault** (Spring Boot, port 8094; NGINX maps `api.bcos.cloud/vault/` → `hiveiq-vault:8094/api/vault/`). + +## Who can do what (roles) + +There is **no role gating** in this module. `SecurityConfig` requires only `anyRequest().authenticated()` — any valid JWT gets in. There is **no** `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` anywhere in vault. Access is controlled purely by **institution scoping**: every query filters on the caller's `institutionKey` (from the JWT claim). + +- **Institution user** (JWT has `institutionKey`) → sees/edits only their own institution's visits, imports, ATMs, stats. +- **MSP admin** (JWT `institutionKey` is null) → on **create**, the institution is derived from the visited ATM's `device_summary` row. On **list ATMs** (`/atms`) a null institution returns `findAll()` (all devices). See the null-institution caveat below. + +## Admin / operator actions + +All driven from the visit detail panel and import screen — no separate admin console: + +- **Confirm / Dispute a visit** — `PUT /api/vault/visits/{id}` with `{ "status": "CONFIRMED" | "DISPUTED" | "PENDING" }`. Buttons live in `VisitDetailPanel.svelte` (`setStatus`). +- **Edit notes / vendor / reference** — same `PUT`, body `{ notes, vendorName, referenceNumber }`. +- **Create a visit manually** — `POST /api/vault/visits` (201). +- **Delete a visit** — `DELETE /api/vault/visits/{id}` (204). +- **Bulk CSV import** — `POST /api/vault/imports` (multipart `file`); returns **202 Accepted** immediately, processes asynchronously. + +## First things to check when it misbehaves + +1. **Empty visit list / "no data"** — confirm the JWT actually carries `institutionKey`. Everything is institution-scoped; a token with the wrong or missing institution silently returns an empty page (no error). +2. **A device is missing from the ATM dropdown / `/atms`** — `device_summary` is a **read-only Kafka mirror**, not owned by vault. If a device is missing, it's an **upstream publish gap** from `hiveops-devices`, not a vault bug. Check the `hiveops.device.events` topic and the `DeviceEventConsumer` logs (`Upserted device_summary for {atmId}` / `has no deviceId, skipping insert`). +3. **CSV import stuck on PROCESSING** — the upload returns 202 and work runs on the `importExecutor` thread pool (`vault-import-*`). The client must **poll** `GET /api/vault/imports/{id}` for terminal `COMPLETED` / `FAILED`. If it never leaves PROCESSING, check vault logs for `Import {id} failed`. +4. **Import rejected at upload** — content-type must contain `csv` or `text`, and the file must be non-empty, else `400`. Odd browser content-types can trip this. +5. **`502` on the whole app** — vault CORS is disabled at Spring level (NGINX is the CORS authority). A CORS/`502` symptom is an NGINX/backend routing issue, not vault code. + +## Common failure modes + fixes + +| Symptom | Likely cause | Fix | +|---------|-------------|-----| +| Visits list empty for a real institution | JWT missing/wrong `institutionKey` | Re-issue token via auth; verify claim | +| Device absent from `/atms` list | Stale `device_summary` mirror | Have `hiveops-devices` re-publish `hiveops.device.events`; check consumer log | +| Import never completes | Async job errored | `GET /imports/{id}` → read `errorMessage`; check vault logs | +| Import 400 "Only CSV files are accepted" | Content-type not `csv`/`text` | Re-upload as `.csv` / `text/csv` | +| MSP admin sees no imports/stats | Null `institutionKey` queries (see caveat) | Query per-institution with a scoped token | + +## Null-institution caveat (MSP admin) + +`ImportController`, `StatsController`, and the visit list/recent queries pass `principal.institutionKey()` straight through. For an MSP admin whose token has **no** `institutionKey`, imports and stats are queried with `null` and will typically come back empty. Only **create-visit** and **`/atms`** have explicit null handling. If an MSP admin reports "no imports/stats," this is expected — use an institution-scoped token. + +## Data (for support lookups) + +Database **`hiveiq_vault`** (prod PostgreSQL on 10.10.10.188 via ProxyJump; dev = H2 in-memory). Tables: `cit_visits`, `cit_cassette_counts`, `cit_removals`, `cit_imports`, `device_summary`. All CIT rows carry `institution_key`. + +Related: [vault.cit-visits] Overview (customer view) · [technical.vault] service overview. diff --git a/backend/src/main/resources/content/vault/dashboard/architect.md b/backend/src/main/resources/content/vault/dashboard/architect.md new file mode 100644 index 0000000..3f1e2c0 --- /dev/null +++ b/backend/src/main/resources/content/vault/dashboard/architect.md @@ -0,0 +1,76 @@ +--- +module: vault.dashboard +title: Dashboard +tab: Architect +order: 30 +audience: internal +--- + +How the Vault Dashboard is built. It is a thin read view over `hiveops-vault` — no cross-service calls at request time, no executor/Kafka on the read path. See [platform.service-ownership]. + +## Services involved (for THIS feature) + +Only **one** service participates on the request path: `hiveops-vault` (Spring Boot, port 8094; frontend Svelte SPA, port 5185). The dashboard does **not** call incident/journal/devices/mgmt at runtime. Vault owns the CIT domain end-to-end. See [platform.service-ownership]. + +- Frontend `DashboardView.svelte` → NGINX (`api.bcos.cloud/vault/` → `hiveiq-vault:8094/api/vault/`) → vault controllers → JPA repositories → PostgreSQL `hiveiq_vault`. + +## Data flow (read path) + +``` +DashboardView.onMount + ├─ statsAPI.get() → GET /api/vault/stats → StatsController + │ → VaultStatsService.getStats(key) + │ → CitVisitRepository aggregate queries + │ → CitImportRepository.countCompleted + └─ visitAPI.recent() → GET /api/vault/visits/recent → CitVisitController.getRecentVisits + → CitVisitService.getRecent(key) + → CitVisitRepository.findTop10... +``` + +Both calls resolve via `Promise.all`; `institutionKey` is taken from the JWT-derived `VaultPrincipal`, never from the request body/query. + +## What each stat is computed from + +`VaultStatsService.getStats(institutionKey)` builds `VaultStatsResponse` from five queries (windows in **UTC**, `ZoneOffset.UTC`): + +| Field | Query | Source table(s) | +|---|---|---| +| `visitsThisMonth` | `countByInstitutionAndDateRange(key, monthStart, monthEnd)` | `cit_visits` | +| `visitsPending` | `countPendingByInstitution(key)` (`status = 'PENDING'`) | `cit_visits` | +| `totalCashAddedCentsYtd` | `sumCashAddedCents(key, ytdStart)` = `SUM(added_count * denomination)` | `cit_cassette_counts` JOIN `cit_visits` | +| `totalRemovalAmountCentsYtd` | `sumRemovalAmountCents(key, ytdStart)` = `SUM(amount)` | `cit_removals` JOIN `cit_visits` | +| `totalImportsCompleted` | `countCompletedByInstitution(key)` | `cit_imports` | + +Dashboard cards render only the first four; `totalImportsCompleted` is returned but unused by this view. Recent Visits = `findTop10ByInstitutionKeyOrderByVisitDateDesc` (or `findTop10ByOrderByVisitDateDesc` when key is null). + +## DB tables — read/written by the dashboard + +Reads only (all in `hiveiq_vault`, all scoped by `institution_key`): + +| Table | Role on dashboard | +|---|---| +| `cit_visits` | Visit headers — counts, pending, recent list | +| `cit_cassette_counts` | Per-slot counts — source of Cash Added YTD | +| `cit_removals` | Non-cash removals — source of Removals YTD | +| `cit_imports` | Completed-import count (returned, not shown) | + +The dashboard **writes nothing**. Writes to `cit_*` happen elsewhere (manual `POST /visits`, async CSV import). See [platform.data-architecture]. + +## Kafka (not on the dashboard path) + +Vault **consumes** `hiveops.device.events` via `DeviceEventConsumer` (group `hiveops-vault-device-sync`, offset reset `earliest`) to maintain the `device_summary` mirror. Vault **produces no topics** (no `KafkaTemplate`/`StreamBridge`). The dashboard's stats/recent queries do **not** read `device_summary`, so the executor→Kafka→record-store mirror pattern is out of band for this feature — a stale device mirror does not affect dashboard numbers. See [platform.kafka]. + +## Key API endpoints (dashboard) + +| Method | Path | Handler | +|---|---|---| +| GET | `/api/vault/stats` | `StatsController.getStats` | +| GET | `/api/vault/visits/recent` | `CitVisitController.getRecentVisits` | + +Both require a JWT (`JwtAuthenticationFilter` → `VaultPrincipal`); no role annotation — authorization is institution-scoping only. + +## Runtime / deploy + +- Frontend config injected at container start (`frontend/entrypoint.sh`): `VITE_API_URL=https://api.bcos.cloud/vault`, `VITE_AUTH_URL=https://api.bcos.cloud/auth`. +- Spring CORS disabled in vault; NGINX is the CORS authority for `vault.bcos.cloud`. +- Prod DB via split env vars `DB_HOST`/`DB_PORT`/`DB_NAME=hiveiq_vault`/`DB_USERNAME`/`DB_PASSWORD` (not the `DB_URL` form); `ddl-auto: validate` + Flyway. Dev = H2 in-memory. diff --git a/backend/src/main/resources/content/vault/dashboard/claude.md b/backend/src/main/resources/content/vault/dashboard/claude.md new file mode 100644 index 0000000..9a81f3c --- /dev/null +++ b/backend/src/main/resources/content/vault/dashboard/claude.md @@ -0,0 +1,60 @@ +--- +module: vault.dashboard +title: Dashboard +tab: Claude +order: 40 +audience: internal +--- + +Terse act-without-guessing reference. Owner: **`hiveops-vault`** (port 8094, DB `hiveiq_vault`). Frontend view `hiveops-vault/frontend/src/components/Dashboard/DashboardView.svelte`. + +## Endpoints this view calls (exactly two) + +| Method | Path | Handler | Returns | +|---|---|---|---| +| GET | `/api/vault/stats` | `StatsController.getStats` | `VaultStatsResponse` | +| GET | `/api/vault/visits/recent` | `CitVisitController.getRecentVisits` | `List` (top 10, `visitDate` DESC) | + +- External base (via NGINX): `https://api.bcos.cloud/vault/api/vault/...` +- Frontend `VITE_API_URL=https://api.bcos.cloud/vault` (client prefixes `/stats`, `/visits/recent`). +- Both require JWT Bearer. Result scoped to token's `institutionKey`. + +## Stats response fields + +- `visitsThisMonth` (UTC month), `visitsPending` (`status='PENDING'`), `totalCashAddedCentsYtd`, `totalRemovalAmountCentsYtd`, `totalImportsCompleted` (returned, NOT rendered). Money is **cents**. + +## Tables (all in `hiveiq_vault`, scoped by `institution_key`) + +- `cit_visits` — headers; recent list + monthly/pending counts +- `cit_cassette_counts` — Cash Added YTD = `SUM(added_count * denomination)` +- `cit_removals` — Removals YTD = `SUM(amount)` +- `cit_imports` — completed-import count +- `device_summary` — Kafka mirror; **NOT used** by dashboard stats/recent + +## Kafka + +- Consumes: `hiveops.device.events` (group `hiveops-vault-device-sync`) → maintains `device_summary` only. +- Produces: **none**. + +## Roles / auth + +- No role gate. `SecurityConfig` = `authenticated()` only. **No** `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` in vault. +- Authorization = institution scoping via `VaultPrincipal.institutionKey()` from JWT. + +## Gotchas + +- MSP admin (JWT `institutionKey == null`): **Recent Visits shows ALL institutions** (`findTop10ByOrderByVisitDateDesc`), but **all stat cards read 0** (`institutionKey = null` matches no rows). Populated table under zeroed cards is expected behavior. +- All date windows are **UTC** (`ZoneOffset.UTC`) — month/YTD boundaries are UTC, not local. +- YTD totals come from child rows; a visit with no cassette/removal rows contributes 0. +- Dashboard error = single toast "Failed to load dashboard"; no per-card errors. +- CSV import is `@Async` (POST `/imports` → 202); poll `GET /api/vault/imports/{id}` before blaming the dashboard for missing visits. +- Spring CORS disabled; NGINX owns CORS. Prod DB uses split `DB_HOST/DB_PORT/DB_NAME` env, not `DB_URL`. + +## Do NOT + +- Do NOT add role checks expecting them to already exist — none do. +- Do NOT invent write endpoints for this view — dashboard is read-only. +- Do NOT read `device_summary` to explain wrong stat numbers — stats come from `cit_*`. +- Do NOT trust MSP-admin cards as authoritative (they read 0). +- Do NOT assume local-time day boundaries — everything is UTC. +- Do NOT expect a `/visits/recent` limit param — it's a fixed top-10. diff --git a/backend/src/main/resources/content/vault/dashboard/internal.md b/backend/src/main/resources/content/vault/dashboard/internal.md new file mode 100644 index 0000000..7afe39e --- /dev/null +++ b/backend/src/main/resources/content/vault/dashboard/internal.md @@ -0,0 +1,63 @@ +--- +module: vault.dashboard +title: Dashboard +tab: Internal +order: 20 +audience: internal +--- + +Ops/support view of the HiveIQ **Vault Dashboard** — the landing view of `vault.bcos.cloud`. It renders four summary stat cards plus a "Recent Visits" table of CIT (Cash-in-Transit) service calls. Owned entirely by `hiveops-vault` (port 8094, DB `hiveiq_vault`). + +## What the dashboard actually calls + +On mount, `DashboardView.svelte` fires **two** requests in parallel (`Promise.all`): + +| UI element | Endpoint | Backing service/method | +|---|---|---| +| 4 stat cards | `GET /api/vault/stats` | `VaultStatsService.getStats(institutionKey)` | +| Recent Visits table | `GET /api/vault/visits/recent` | `CitVisitService.getRecent(institutionKey)` (top 10 by `visitDate` DESC) | + +If either call throws, the whole view shows a single toast: **"Failed to load dashboard"** and the cards fall back to `0` / the table shows "No visits recorded yet." There is no per-card error state — a red toast on this page almost always means one of those two GETs returned non-2xx. + +## Roles required + +**None beyond a valid JWT.** `SecurityConfig` enforces only `authenticated()` on `anyRequest()`. There is **no** `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` gate anywhere in vault. Access is controlled purely by JWT validity + **institution scoping** (`VaultPrincipal.institutionKey()` decoded from the token). Any authenticated HiveIQ user with the Vault module can open this page and see their own institution's data. + +## First things to check when it misbehaves + +1. **"Failed to load dashboard" toast** — hit both endpoints directly with the user's JWT: + - `GET https://api.bcos.cloud/vault/api/vault/stats` + - `GET https://api.bcos.cloud/vault/api/vault/visits/recent` + A 401 = expired/bad token (auth issue, not vault). A 403/CORS = NGINX routing. A 500 = vault backend — check `docker compose logs hiveiq-vault`. +2. **Cards all show 0 but the user has visits** — check whether the JWT carries an `institutionKey`. Stats queries filter `WHERE v.institutionKey = :key`; a **null** key matches nothing, so every card reads 0. (See MSP-admin gotcha below.) +3. **Cash Added / Removals YTD look wrong** — these are computed from child rows, not the visit header: + - *Cash Added YTD* = `SUM(cit_cassette_counts.added_count * denomination)` for visits with `visit_date >= Jan 1 (UTC)`. + - *Removals YTD* = `SUM(cit_removals.amount)` over the same window. + A visit with a header but no cassette/removal child rows contributes 0. Values are in **cents**; the frontend formats them. +4. **Empty table / stale numbers after a CSV import** — imports are `@Async` (POST `/imports` returns 202). If a visit isn't showing, confirm the import reached `COMPLETED` via `GET /api/vault/imports/{id}` before assuming a dashboard bug. +5. **Wrong ATM's institution / device metadata off** — dashboard stats and recent visits read `cit_*` tables directly and do **not** depend on the `device_summary` mirror, so a stale device mirror does **not** corrupt these numbers. (The mirror only matters for the ATM picker/list elsewhere.) + +## Common failure modes → fix + +| Symptom | Likely cause | Fix | +|---|---|---| +| All requests 401 | Expired/invalid JWT | Re-login; verify `JWT_SECRET` matches the shared platform secret | +| 403 / CORS error in console | NGINX not routing `api.bcos.cloud/vault/` | Vault disables Spring CORS on purpose — CORS lives in NGINX; check the vault server block | +| 500 on `/stats` or `/visits/recent` | Backend down / DB unreachable | `docker compose logs hiveiq-vault`; check `DB_HOST`/`DB_PORT`/`DB_NAME=hiveiq_vault` connectivity | +| Cards = 0 for an MSP admin | Null `institutionKey` on token | Known asymmetry (below); not a data-loss bug | +| Times/YTD boundary look off by a few hours | All windows computed in **UTC** (`ZoneOffset.UTC`) | Expected — month/YTD boundaries are UTC, not local | + +## MSP-admin gotcha (important) + +For a multi-institution admin whose JWT has **no** `institutionKey`: +- **Recent Visits** returns `findTop10ByOrderByVisitDateDesc()` — i.e. visits across **all** institutions. +- **Stats cards** filter `institutionKey = null`, which matches nothing → **all four cards read 0**. + +So an MSP admin sees a populated table under empty (0) cards. That inconsistency is a real code behavior, not a config problem — flag it rather than "fixing data." See the Architect tab and uncertainties. + +## Where things live + +- Frontend view: `hiveops-vault/frontend/src/components/Dashboard/DashboardView.svelte` +- API client: `hiveops-vault/frontend/src/lib/api.ts` (`statsAPI.get`, `visitAPI.recent`) +- Backend: `StatsController` / `VaultStatsService`, `CitVisitController#getRecentVisits` / `CitVisitService#getRecent` +- Runtime config injected by `frontend/entrypoint.sh` → `VITE_API_URL = https://api.bcos.cloud/vault` diff --git a/backend/src/main/resources/content/vault/import/architect.md b/backend/src/main/resources/content/vault/import/architect.md new file mode 100644 index 0000000..5846ac0 --- /dev/null +++ b/backend/src/main/resources/content/vault/import/architect.md @@ -0,0 +1,71 @@ +--- +module: vault.import +title: Import +tab: Architect +order: 30 +audience: internal +--- + +How the CIT CSV import feature is built. Single owning service — no cross-service or event fan-out on this path. See [platform.service-ownership]. + +## Services involved + +- **`hiveops-vault` backend** (Spring Boot, port 8094, image `hiveiq-vault`) — owns the entire import path end to end. +- **`hiveops-vault` frontend** (Svelte SPA, port 5185, `vault.bcos.cloud`) — `components/Import/ImportView.svelte` drives upload + polling via `importAPI` in `lib/api.ts`. +- No other service participates. There is **no Kafka on the import path** (vault produces nothing; it only *consumes* `hiveops.device.events` to keep its `device_summary` mirror, unrelated to import). See [platform.kafka]. + +## Data flow + +``` +ImportView.svelte + └─ importAPI.upload(file) POST /api/vault/imports (multipart "file") + └─ ImportController.uploadCsv() + 1. validate: reject empty; reject content-type without "csv"/"text" + 2. save CitImport row (status=PROCESSING, importedBy=principal.email, format=CSV) + 3. CsvImportService.processAsync(id, file.getInputStream(), institutionKey, email) + 4. return 202 ACCEPTED + ImportResponse(PROCESSING) ← request ends here + └─ startPolling(id): every 2s GET /api/vault/imports/{id} + └─ until status == COMPLETED | FAILED + +CsvImportService.processAsync @Async("importExecutor") @Transactional + ├─ reload CitImport by id + ├─ parse(): read header, group data rows by atm_id|visit_date|vendor|reference + │ • cassette row (11 cols) → CitCassetteCount builder + │ • REMOVAL row (8 cols, slot="REMOVAL") → CitRemoval builder + ├─ per group: save CitVisit (+ nested cassette counts + removals) via CitVisitRepository + └─ set status COMPLETED + recordCount(#visits) | on any exception → FAILED + errorMessage +``` + +The upload is fire-and-forget from the client's perspective: `POST` returns `202` immediately and the browser polls the detail endpoint until terminal. This is the **executor → async worker → record-store** pattern; here the "record store" is Postgres directly (no Kafka intermediary — contrast with fleet's executor→Kafka→record-store). See [platform.data-architecture]. + +## Async execution + +- `CsvImportService.processAsync` is annotated `@Async("importExecutor")` (thread pool defined in `AsyncConfig`) and `@Transactional` — the **entire file parse + all visit saves are one transaction**. Any thrown exception (e.g. `Instant.parse` on a bad date) rolls back every visit and marks the import `FAILED`. +- The controller's `CitImport` save commits in its own transaction before the async worker reloads it by id, so the worker always finds the `PROCESSING` row. + +## DB tables (all in `hiveiq_vault`, all scoped by `institution_key`) + +| Table | Written by import | Notes | +|-------|-------------------|-------| +| `cit_imports` | ✅ insert (PROCESSING) then update (COMPLETED/FAILED, `record_count`, `error_message`) | audit row per upload; `imported_by` = JWT email, `format` = `CSV` | +| `cit_visits` | ✅ one row per `atm_id+visit_date+vendor+reference` group | FK `cit_import` back to `cit_imports`; `created_by` = importer email | +| `cit_cassette_counts` | ✅ one row per cassette CSV line | slot, denomination, `cassette_type`, opening/added/removed/closing | +| `cit_removals` | ✅ one row per REMOVAL CSV line | `removal_type` (CHECK/DEPOSIT/REJECTED_NOTE), count, amount | +| `device_summary` | ❌ read-only mirror (not touched by import) | Kafka-fed from `hiveops.device.events`; only relevant if you cross-check ATM validity | + +## Key API endpoints + +Base `/api/vault/imports` (`ImportController`). JWT Bearer required; results scoped to `principal.institutionKey()`. + +| Method | Path | Purpose | +|--------|------|---------| +| POST | `/api/vault/imports` | multipart `file` → save PROCESSING row, kick async, return **202** | +| GET | `/api/vault/imports` | paginated history, sorted `importedAt` DESC (`?page=&size=`, default size 25) | +| GET | `/api/vault/imports/{id}` | single import status (polled by UI); 404 if not in caller's institution | + +## Security & routing + +- `SecurityConfig` = JWT filter (`JwtAuthenticationFilter` → `VaultPrincipal`) + `anyRequest().authenticated()`. **No role checks** — authorization is institution-scoping only. +- Spring CORS is disabled; NGINX is the CORS authority and maps `api.bcos.cloud/vault/` → `hiveiq-vault:8094/api/vault/`. + +Cross-links: [platform.data-architecture] · [platform.kafka] · [platform.service-ownership] · [technical.vault] diff --git a/backend/src/main/resources/content/vault/import/claude.md b/backend/src/main/resources/content/vault/import/claude.md new file mode 100644 index 0000000..985392a --- /dev/null +++ b/backend/src/main/resources/content/vault/import/claude.md @@ -0,0 +1,64 @@ +--- +module: vault.import +title: Import +tab: Claude +order: 40 +audience: internal +--- + +Terse agent reference for CIT CSV import. **Owner: `hiveops-vault`** (port 8094, image `hiveiq-vault`, DB `hiveiq_vault`). Frontend: `hiveops-vault/frontend` `components/Import/ImportView.svelte`. + +## Endpoints (JWT Bearer; scoped to `principal.institutionKey()`) + +| METHOD | Path (internal) | Prod URL prefix | Notes | +|--------|-----------------|-----------------|-------| +| POST | `/api/vault/imports` | `api.bcos.cloud/vault/imports` | multipart field name **`file`**; returns **202** + PROCESSING record | +| GET | `/api/vault/imports?page=&size=` | same | history, `importedAt` DESC, default size 25 | +| GET | `/api/vault/imports/{id}` | same | poll for status; 404 if wrong institution | + +Controller: `controller/ImportController.java`. Async worker: `service/CsvImportService.java` (`processAsync`, `@Async("importExecutor")`, `@Transactional`). + +## Tables (DB `hiveiq_vault`, all have `institution_key`) + +- `cit_imports` — audit; status `PROCESSING → COMPLETED | FAILED`; `record_count`, `error_message`, `imported_by`(=JWT email), `format`=`CSV` +- `cit_visits` — one per `atm_id|visit_date|vendor|reference`; FK `cit_import` +- `cit_cassette_counts` — per cassette row; `cassette_type` DISPENSER/RECYCLER/RETRACT/MIX +- `cit_removals` — per REMOVAL row; `removal_type` CHECK/DEPOSIT/REJECTED_NOTE +- `device_summary` — read-only mirror, **not** written by import + +## Kafka + +- **Produced by import: none.** +- Vault consumes `hiveops.device.events` (group `hiveops-vault-device-sync`) only to maintain `device_summary` — unrelated to import. + +## Roles + +- **No role checks.** `SecurityConfig` = `anyRequest().authenticated()`. No `hasAnyRole(...)`. Authorization = institution-scoping via `VaultPrincipal.institutionKey()`. + +## CSV shape + +- Header row required. Group key = `atm_id + visit_date(ISO-8601, `Z` appended if missing) + vendor + reference`. +- Cassette row = 11 cols: `atm_id,visit_date,vendor,reference,slot,denomination(cents),cassette_type,opening,added,removed,closing` +- Removal row = 8 cols, slot literal `REMOVAL`: `atm_id,visit_date,vendor,reference,REMOVAL,removal_type,count,amount(cents)` +- `record_count` = **#visits** (groups), not #rows. + +## Gotchas + +- Upload validation: rejects empty file; rejects content-type only if it contains neither `csv` nor `text`. **Null content-type passes** the check. +- Whole file parses in one `@Transactional` — one bad `visit_date` (`Instant.parse` throws) → status FAILED, **zero** visits saved. +- Malformed values silently coerced (NOT failed): bad int/long → `0`; unknown `cassette_type` → `DISPENSER`; unknown `removal_type` → `REJECTED_NOTE`. +- Short rows silently skipped: `<7` cols dropped; cassette needs 11, removal needs 8. +- No dedup/idempotency — re-uploading the same file creates duplicate visits. +- MSP admin with null `institutionKey`: import path uses the principal key directly (does NOT derive institution from ATM like manual visit-create does) → visits tagged `institution_key = null`. +- Async: POST returns 202 before processing; must poll `/imports/{id}` for terminal state. UI polls every 2s. +- Uploads dir `UPLOADS_DIR` default `/tmp/vault-uploads`; multipart max 20MB. + +## Do NOT + +- Do NOT expect a synchronous result from POST — it's 202 + async; read status from `GET /imports/{id}`. +- Do NOT add role gating assumptions — there are none in vault. +- Do NOT look for a Kafka topic/consumer for import — the import path is pure DB. +- Do NOT treat `record_count` as CSV line count. +- Do NOT assume a COMPLETED import is clean — bad cells become 0/default silently. +- Do NOT write `device_summary` from anything but the `hiveops.device.events` consumer. +- Do NOT re-upload to "retry" without deleting prior visits — no idempotency. diff --git a/backend/src/main/resources/content/vault/import/internal.md b/backend/src/main/resources/content/vault/import/internal.md new file mode 100644 index 0000000..a54272c --- /dev/null +++ b/backend/src/main/resources/content/vault/import/internal.md @@ -0,0 +1,55 @@ +--- +module: vault.import +title: Import +tab: Internal +order: 20 +audience: internal +--- + +Ops/support view of the CIT CSV import screen. Backend is **`hiveops-vault`** (port 8094, `hiveiq_vault` DB). Endpoints live under `/api/vault/imports`, reached in prod via `api.bcos.cloud/vault/imports`. + +## Who can use it + +- **No role gating.** `SecurityConfig` only requires `authenticated()` on `anyRequest()` — there is **no** `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` check. Any user with a valid JWT can upload and list imports. +- Access is **institution-scoped**, not role-scoped: list/detail/upload all use `principal.institutionKey()`. A user only sees imports (and creates visits) for their own institution. +- `importedBy` is stamped from the JWT **email** (`principal.email()`), shown in the **By** column. + +## First things to check when it misbehaves + +1. **Import stuck on "Processing" forever** — the frontend polls `GET /imports/{id}` every 2s and only stops on `COMPLETED`/`FAILED`. If it never flips, the `@Async("importExecutor")` job died or the pool is starved. Check vault logs for `Import {id} failed` / `Import record {id} not found`, and verify the `importExecutor` thread pool (`AsyncConfig`) isn't exhausted. +2. **"Failed" with an error message** — the **Error** column shows the exception message. Most common: a bad `visit_date` — `Instant.parse` throws and fails the **whole** file (the parse runs in one `@Transactional` method, so one bad date = zero visits saved). +3. **Upload rejected before it starts** — the POST returns `400` for: empty file (`"File is empty"`) or a content-type that contains neither `csv` nor `text` (`"Only CSV files are accepted"`). The Svelte view also blocks non-`.csv` by filename client-side. +4. **Import "Completed" but Records looks wrong / too low** — `recordCount` counts **visits** (unique `atm_id|visit_date|vendor|reference` groups), not CSV rows. Multiple cassette/removal rows collapse into one visit. Also: rows shorter than the required column count are silently **skipped** (cassette needs 11 cols, removal needs 8, anything `< 7` cols dropped). +5. **Data imported but numbers are zero / types wrong** — malformed values are silently coerced, not failed: bad integer/long → `0`; unknown `cassette_type` → `DISPENSER`; unknown removal type → `REJECTED_NOTE`. A "Completed" import can still contain garbage counts. Verify the source CSV, not the app. +6. **Nothing shows in history for an MSP admin** — a multi-institution admin whose JWT has a null `institutionKey` will write imports/visits tagged `institutionKey = null` (the CSV path uses the principal key directly and does **not** derive institution from the ATM the way manual visit-create does). They then won't line up under any real institution. Upload as an institution-scoped user. + +## Common failure modes → fix + +| Symptom | Likely cause | Fix | +|---------|-------------|-----| +| `400 File is empty` | Zero-byte upload | Re-export the CSV | +| `400 Only CSV files are accepted` | Content-type not `*csv*`/`*text*` | Send with `text/csv`; note null content-type is **not** rejected by the API | +| Status `FAILED`, error mentions parse/`Instant` | Bad `visit_date` (not ISO-8601) | Fix date column; `Z`/UTC is appended if missing | +| Status `FAILED`, "CSV file is empty" | Header-only or truly empty body | File must have a header row **plus** data rows | +| Completed but half the rows missing | Short rows silently skipped | Ensure cassette rows have 11 cols, removal rows 8 | +| Counts all `0` / wrong cassette type | Silent coercion of bad values | Correct numeric/enum columns at source | +| Duplicate visits after re-upload | No dedup/idempotency on re-import | Don't re-upload the same file; delete dup visits via `DELETE /api/vault/visits/{id}` | + +## Manual poke (with a real JWT) + +```bash +# List import history for the caller's institution +curl -s -H "Authorization: Bearer $JWT" \ + "https://api.bcos.cloud/vault/imports?page=0&size=25" + +# Single import status (what the UI polls) +curl -s -H "Authorization: Bearer $JWT" \ + "https://api.bcos.cloud/vault/imports/" + +# Upload a CSV (returns 202 + PROCESSING record, then poll the id above) +curl -s -H "Authorization: Bearer $JWT" \ + -F "file=@cit.csv;type=text/csv" \ + "https://api.bcos.cloud/vault/imports" +``` + +Uploads land in `UPLOADS_DIR` (default `/tmp/vault-uploads`); multipart max is 20MB. See [technical.vault] for the full service picture.