release(guide): develop -> main — markdown Guide + audience cap, for production deploy #27

Merged
hiveiq merged 84 commits from develop into main 2026-07-16 15:24:26 -04:00
451 changed files with 28527 additions and 942 deletions
+117
View File
@@ -0,0 +1,117 @@
name: CD - Develop (guide → bcos.dev)
# Guide-specific CI (issue #5). The guide bundles markdown INTO the backend jar at build time,
# so a running instance must be rebuilt + redeployed to pick up content changes — a git-pull is
# not enough. This automates that on every push to develop: build → push :dev → deploy to bcos.dev.
#
# Auto-deploy is intentional and scoped to the DEV environment only (docs, low blast radius).
# Audience filtering is runtime (GUIDE_SERVED_AUDIENCES). The app default is `customer` (fail-safe);
# bcos.dev opts UP to all four tiers explicitly in its compose. Production leaves it unset and so
# serves customer only — internal/dev/bcos content never leaves dev, even if a config line is missed.
# Auto-deploy live: BCOS_DEV_DEPLOY_KEY provisioned (scoped forced-command key on .251).
on:
push:
branches:
- develop
paths:
- 'backend/**'
- '.gitea/workflows/cd-develop.yml'
jobs:
build-and-deploy:
runs-on: ubuntu-latest
env:
GIT_SSL_NO_VERIFY: "true"
MAVEN_OPTS: "-Djava.net.preferIPv4Stack=true -Dmaven.wagon.http.ssl.insecure=true -Dmaven.wagon.http.ssl.allowall=true"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure Maven settings (Gitea package registry)
run: |
mkdir -p ~/.m2
cat > ~/.m2/settings.xml << 'EOF'
<settings>
<servers>
<server><id>gitea</id><username>hiveiq</username><password>${{ secrets.MAVEN_TOKEN }}</password></server>
</servers>
<profiles>
<profile>
<id>gitea-registry</id>
<repositories>
<repository>
<id>gitea</id>
<url>https://hiveiq-gitea.directlx.dev/api/packages/hiveops/maven</url>
<releases><enabled>true</enabled></releases><snapshots><enabled>false</enabled></snapshots>
</repository>
</repositories>
</profile>
</profiles>
<activeProfiles><activeProfile>gitea-registry</activeProfile></activeProfiles>
</settings>
EOF
# mvn package FIRST — the Dockerfile does `COPY target/*.jar`, so skipping this ships stale
# content (the exact trap that makes the manual process error-prone).
- name: Build backend JAR (bakes in content)
run: cd backend && mvn clean package -DskipTests -B
- name: Build + push image
run: |
echo "${{ secrets.DEV_REGISTRY_PASSWORD }}" | docker login ${{ secrets.DEV_REGISTRY_URL }} -u ${{ secrets.DEV_REGISTRY_USERNAME }} --password-stdin
docker build -t ${{ secrets.DEV_REGISTRY_URL }}/hiveiq-guide-backend:dev backend/
docker push ${{ secrets.DEV_REGISTRY_URL }}/hiveiq-guide-backend:dev
# DEPENDENCY (issue #5, coordinate with CI/runner session): needs BCOS_DEV_DEPLOY_KEY secret
# (SSH key for bcosadmin@.251) + runner network reachability to 173.231.195.251.
- name: Deploy to bcos.dev
run: |
mkdir -p ~/.ssh && echo "${{ secrets.BCOS_DEV_DEPLOY_KEY }}" > ~/.ssh/dk && chmod 600 ~/.ssh/dk
ssh -i ~/.ssh/dk -o StrictHostKeyChecking=no bcosadmin@173.231.195.251 \
'cd ~/hiveiq/hiveops-openmetal/hiveops/instances/dev && docker compose pull hiveiq-guide-backend && docker compose up -d hiveiq-guide-backend'
- name: Smoke — guide backend healthy + content served
run: |
# `compose up -d` restarts the Spring Boot backend, which needs well over 60s to warm
# up and report healthy — poll for ~5 min so a normal restart isn't a false failure.
for i in $(seq 1 30); do
s=$(curl -sk -o /dev/null -w '%{http_code}' https://api.bcos.dev/guide/actuator/health || echo 000)
[ "$s" = "200" ] && { echo "guide healthy (attempt $i)"; exit 0; }
echo "attempt $i/30: health=$s — waiting 10s"
sleep 10
done
echo "::error::guide did not report healthy 5 min after deploy"; exit 1
# Posts a ✅/🔴 card to #hiveops-dev-deploy on every run (deploy result). Runs even on
# failure so a broken build/deploy/smoke is announced, not silent. SLACK_WEBHOOK_URL is an
# org secret; if unset the step no-ops so forks/dry-runs don't error.
- name: Notify Slack (#hiveops-dev-deploy)
if: always()
continue-on-error: true # a notification must NEVER fail the deploy
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
JOB_STATUS: ${{ job.status }}
ACTOR: ${{ github.actor }}
REF_NAME: ${{ github.ref_name }}
SHA: ${{ github.sha }}
RUN_NUMBER: ${{ github.run_number }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
COMMIT_MSG: ${{ github.event.head_commit.message }}
run: |
[ -z "$SLACK_WEBHOOK_URL" ] && { echo "no SLACK_WEBHOOK_URL secret — skipping"; exit 0; }
SHORT="${SHA:0:8}"
FIRST="${COMMIT_MSG%%$'\n'*}" # first line via param-expansion (no pipe)
SUBJECT="$(printf '%s' "$FIRST" | tr -d '"\\' | cut -c1-100)"
if [ "$JOB_STATUS" = "success" ]; then
COLOR="#2eb67d"; TITLE=":white_check_mark: guide → bcos.dev deployed"
else
COLOR="#e01e5a"; TITLE=":red_circle: guide → bcos.dev deploy FAILED ($JOB_STATUS)"
fi
PAYLOAD=$(printf '{"attachments":[{"color":"%s","blocks":[{"type":"section","text":{"type":"mrkdwn","text":"*%s*"}},{"type":"section","fields":[{"type":"mrkdwn","text":"*Commit*\\n`%s` %s"},{"type":"mrkdwn","text":"*Branch*\\n%s"},{"type":"mrkdwn","text":"*By*\\n%s"},{"type":"mrkdwn","text":"*Run*\\n#%s"}]},{"type":"actions","elements":[{"type":"button","text":{"type":"plain_text","text":"View run"},"url":"%s"}]}]}]}' \
"$COLOR" "$TITLE" "$SHORT" "$SUBJECT" "$REF_NAME" "$ACTOR" "$RUN_NUMBER" "$RUN_URL")
# Non-fatal: capture the HTTP code, never let curl's exit fail the step.
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 -X POST -H 'Content-type: application/json' --data "$PAYLOAD" "$SLACK_WEBHOOK_URL" || echo "curl-error")
echo "slack notify HTTP: $code (200 = card posted; anything else = runner could not reach Slack)"
+48
View File
@@ -0,0 +1,48 @@
# hiveops-guide — Design (approved 2026-06-30)
One knowledge base that (a) teaches customers how each module works and (b) is the
source-of-truth for module behavior (for admins + Claude). Markdown-authored, served by the
backend API, read in a tabbed slide-out.
## Decisions (Johannes, 2026-06-30)
1. Content = **markdown files in git**, still served via **backend API calls** (not bundled in
the frontend).
2. **Start over** — greenfield. No backward-compat with the old `content_json` guides or the
`/api/v1/guides/{pageKey}` contract; the Fleet "Guide" buttons get re-wired to the new API.
3. **Retire the Guide Admin CRUD app.** The guide keeps **internal docs** (technical guides for
admins/Claude, `audience: internal`).
## Content model — markdown in the repo
`content/<app>/<module>/*.md` — folder per module, **one file per tab**. Frontmatter:
```yaml
---
module: devices.sw-deploy
title: Software Deployment
tab: The Data # the tab label in the slide-out
order: 20 # tab order
audience: customer # or "internal" (hidden from customers)
---
<markdown body>
```
Cross-cutting technical guides: `content/technical/*.md` (e.g. fleet→Kafka→devices, service map).
## Backend (Spring Boot service kept; model replaced)
- Scans `content/**/*.md` at startup (cached), parses frontmatter + body.
- API (JWT-gated):
- `GET /api/v1/guide/nav?audience=` → apps → modules → tabs tree
- `GET /api/v1/guide/modules/{module}?audience=` → module + its tabs (markdown bodies)
- Remove the DB `content_json` model + admin CRUD endpoints. `audience` gates customer vs internal.
## Frontend
- New **tabbed slide-out reader** (markdown rendered with `marked`); nav + tabs from the API.
- Retire the Guide Admin CRUD app.
## Consumers
- Re-wire the Fleet "Guide" buttons (and future in-app guide entry points) to the new API.
## Phases
1. **Backend** — markdown content model + `nav`/`modules` API; retire DB/CRUD.
2. **Frontend** — tabbed slide-out reader.
3. **Content** — author `devices.sw-deploy` + `fleet.tasks` as the pattern (Overview / Data /
How-to + an internal Technical tab), then fan out to all modules.
4. **Technical guides** (internal) — fleet→Kafka→devices, service map, etc.
+1 -20
View File
@@ -14,7 +14,7 @@
<parent>
<groupId>com.hiveops</groupId>
<artifactId>hiveops-bom</artifactId>
<version>1.0.2</version>
<version>1.0.7</version>
<relativePath/>
</parent>
@@ -29,10 +29,6 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
@@ -46,21 +42,6 @@
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
<groupId>com.hiveops</groupId>
<artifactId>hiveops-security-common</artifactId>
@@ -35,11 +35,9 @@ public class SecurityConfig {
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health", "/error").permitAll()
// Consumer: any authenticated customer, user, or admin
.requestMatchers(HttpMethod.GET, "/api/v1/guides/**")
.hasAnyRole("USER", "CUSTOMER", "ADMIN", "MSP_ADMIN", "QDS_ADMIN", "BCOS_ADMIN")
// Admin CRUD — handled by @PreAuthorize on the controller
.requestMatchers("/api/v1/admin/guides/**").authenticated()
// Read-only guide API: any authenticated user (audience gating done in the controller)
.requestMatchers(HttpMethod.GET, "/api/v1/guide/**")
.hasAnyRole("USER", "CUSTOMER", "ADMIN", "MSP_ADMIN", "BCOS_ADMIN")
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
@@ -0,0 +1,34 @@
package com.hiveops.guide.controller;
import com.hiveops.guide.service.GiteaFeedbackService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/** In-guide "report a bug / suggest an enhancement" → creates a Gitea issue. Requires a logged-in user. */
@RestController
@RequestMapping("/api/v1/guide/feedback")
@RequiredArgsConstructor
public class FeedbackController {
private final GiteaFeedbackService service;
public record FeedbackRequest(String type, String area, String title, String description, String module) {}
@PostMapping
public ResponseEntity<?> submit(@RequestBody FeedbackRequest r, Authentication auth) {
String email = auth != null ? auth.getName() : null;
String role = auth != null
? auth.getAuthorities().stream().findFirst()
.map(a -> a.getAuthority().replaceFirst("^ROLE_", "")).orElse(null)
: null;
GiteaFeedbackService.Result res = service.create(
r.type(), r.area(), r.title(), r.description(), r.module(), email, role);
return res.success()
? ResponseEntity.ok(Map.of("number", res.number(), "url", res.url()))
: ResponseEntity.badRequest().body(Map.of("error", res.error() == null ? "Failed" : res.error()));
}
}
@@ -1,86 +1,50 @@
package com.hiveops.guide.controller;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.hiveops.guide.entity.Guide;
import com.hiveops.guide.service.GuideService;
import com.hiveops.guide.dto.GuideDtos.ModuleView;
import com.hiveops.guide.dto.GuideDtos.NavApp;
import com.hiveops.guide.service.MarkdownGuideService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Read-only guide API backed by markdown files. Internal-audience content is only
* returned to admin roles (MSP_ADMIN / BCOS_ADMIN); everyone else sees customer content.
*/
@RestController
@RequestMapping("/api/v1/guide")
@RequiredArgsConstructor
@Slf4j
public class GuideController {
private final GuideService guideService;
private final ObjectMapper objectMapper;
private final MarkdownGuideService service;
// ── Consumer: any authenticated user (called by SPAs via GuidePanel) ─────
@GetMapping("/api/v1/guides/{pageKey}")
public ResponseEntity<Object> getGuide(@PathVariable String pageKey) {
Optional<Guide> found = guideService.findByPageKey(pageKey);
if (found.isEmpty()) return ResponseEntity.notFound().build();
try {
Object content = objectMapper.readValue(found.get().getContentJson(), Object.class);
return ResponseEntity.ok(content);
} catch (JsonProcessingException e) {
log.error("Failed to parse guide content for pageKey: {}", pageKey, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
@GetMapping("/nav")
public List<NavApp> nav(Authentication auth) {
return service.nav(isInternal(auth), isBcos(auth));
}
// ── Admin: list + CRUD (MSP_ADMIN or BCOS_ADMIN) ─────────────────────────
@GetMapping("/api/v1/admin/guides")
@PreAuthorize("hasAnyRole('MSP_ADMIN','BCOS_ADMIN')")
public ResponseEntity<List<Guide>> listGuides() {
return ResponseEntity.ok(guideService.findAll());
@GetMapping("/modules/{module}")
public ResponseEntity<ModuleView> module(@PathVariable String module, Authentication auth) {
return service.module(module, isInternal(auth), isBcos(auth))
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
@PostMapping("/api/v1/admin/guides")
@PreAuthorize("hasAnyRole('MSP_ADMIN','BCOS_ADMIN')")
public ResponseEntity<Guide> createGuide(@RequestBody Map<String, Object> body, Authentication auth) {
String pageKey = requireString(body, "pageKey");
String contentJson = requireString(body, "contentJson");
Boolean enabled = body.get("enabled") instanceof Boolean b ? b : Boolean.TRUE;
Guide created = guideService.create(pageKey, contentJson, enabled, auth != null ? auth.getName() : "system");
return ResponseEntity.status(HttpStatus.CREATED).body(created);
private boolean isInternal(Authentication auth) {
if (auth == null) return false;
return auth.getAuthorities().stream()
.map(a -> a.getAuthority())
.anyMatch(a -> a.equals("ROLE_MSP_ADMIN") || a.equals("ROLE_BCOS_ADMIN"));
}
@PutMapping("/api/v1/admin/guides/{pageKey}")
@PreAuthorize("hasAnyRole('MSP_ADMIN','BCOS_ADMIN')")
public ResponseEntity<Guide> updateGuide(@PathVariable String pageKey,
@RequestBody Map<String, Object> body,
Authentication auth) {
String contentJson = requireString(body, "contentJson");
Boolean enabled = body.get("enabled") instanceof Boolean b ? b : null;
Guide updated = guideService.update(pageKey, contentJson, enabled, auth != null ? auth.getName() : "system");
return ResponseEntity.ok(updated);
}
@DeleteMapping("/api/v1/admin/guides/{pageKey}")
@PreAuthorize("hasRole('BCOS_ADMIN')")
public ResponseEntity<Void> deleteGuide(@PathVariable String pageKey) {
guideService.delete(pageKey);
return ResponseEntity.noContent().build();
}
private String requireString(Map<String, Object> body, String key) {
Object val = body.get(key);
if (!(val instanceof String s) || s.isBlank()) {
throw new org.springframework.web.server.ResponseStatusException(
HttpStatus.BAD_REQUEST, key + " is required");
}
return s;
/** BCOS_ADMIN only — gates the strictest tier (audience: bcos), e.g. the Processes section. */
private boolean isBcos(Authentication auth) {
if (auth == null) return false;
return auth.getAuthorities().stream()
.map(a -> a.getAuthority())
.anyMatch(a -> a.equals("ROLE_BCOS_ADMIN"));
}
}
@@ -0,0 +1,18 @@
package com.hiveops.guide.dto;
import java.util.List;
/** API response shapes for the guide. */
public final class GuideDtos {
/** nav: apps → modules → tabs (metadata only, no bodies). */
public record NavTab(String tab, int order, String audience) {}
public record NavModule(String module, String title, List<NavTab> tabs) {}
public record NavApp(String app, List<NavModule> modules) {}
/** module view: one module with its tabs, each carrying the markdown body. */
public record ModuleTab(String tab, int order, String audience, String body) {}
public record ModuleView(String module, String title, List<ModuleTab> tabs) {}
private GuideDtos() {}
}
@@ -1,43 +0,0 @@
package com.hiveops.guide.entity;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.time.Instant;
@Entity
@Table(name = "guides")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Guide {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "page_key", unique = true, nullable = false, length = 100)
private String pageKey;
@Column(name = "content_json", nullable = false, columnDefinition = "TEXT")
private String contentJson;
@Column(nullable = false)
@Builder.Default
private Boolean enabled = true;
@Column(name = "updated_by", length = 255)
private String updatedBy;
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@UpdateTimestamp
@Column(name = "updated_at")
private Instant updatedAt;
}
@@ -2,7 +2,6 @@ package com.hiveops.guide.exception;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
@@ -29,13 +28,6 @@ public class GlobalExceptionHandler {
.body(Map.of("error", ex.getMessage() != null ? ex.getMessage() : "Invalid request"));
}
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<Map<String, String>> handleDataIntegrity(DataIntegrityViolationException ex) {
logger.error("Data constraint violation: {}", ex.getMessage());
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "A guide with this page key already exists."));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, String>> handleAll(Exception ex) {
logger.error("Unhandled error: {}", ex.getMessage(), ex);
@@ -0,0 +1,15 @@
package com.hiveops.guide.model;
/**
* One parsed guide markdown file = one tab of a module.
* Source of truth is the frontmatter; folder layout is just organisation.
*/
public record GuideDoc(
String app, // derived from module prefix, e.g. "devices"
String module, // full module key, e.g. "devices.sw-deploy"
String title, // module title (shared across its tabs)
String tab, // tab label, e.g. "Overview"
int order, // tab order within the module
String audience, // "customer" | "internal"
String body // raw markdown (rendered client-side)
) {}
@@ -1,16 +0,0 @@
package com.hiveops.guide.repository;
import com.hiveops.guide.entity.Guide;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface GuideRepository extends JpaRepository<Guide, Long> {
Optional<Guide> findByPageKeyAndEnabledTrue(String pageKey);
Optional<Guide> findByPageKey(String pageKey);
List<Guide> findAllByOrderByPageKeyAsc();
boolean existsByPageKey(String pageKey);
}
@@ -0,0 +1,116 @@
package com.hiveops.guide.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;
/**
* Creates Gitea Bug/Enhancement issues from in-guide feedback. Mirrors the hiveops-browser
* feedback flow but keeps the token server-side. Reaches Gitea over the public (Cloudflare) route.
*/
@Service
@Slf4j
public class GiteaFeedbackService {
private final ObjectMapper mapper = new ObjectMapper();
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
@Value("${gitea.base-url:https://hiveiq-gitea.directlx.dev}")
private String baseUrl;
@Value("${gitea.feedback.token:}")
private String token;
private static final String ORG = "hiveiq-src";
/** area key (= guide module app-prefix, or explicit) → repo name. Default: the guide itself. */
private static final Map<String, String> REPOS = Map.ofEntries(
Map.entry("fleet", "hiveops-fleet"), Map.entry("devices", "hiveops-devices"),
Map.entry("incident", "hiveops-incident"), Map.entry("transactions", "hiveops-transactions"),
Map.entry("analytics", "hiveops-analytics"), Map.entry("reports", "hiveops-reports"),
Map.entry("messaging", "hiveops-messaging"), Map.entry("claims", "hiveops-claims"),
Map.entry("recon", "hiveops-recon"), Map.entry("vault", "hiveops-vault"),
Map.entry("aria", "hiveops-aria"), Map.entry("msp", "hiveops-msp"),
Map.entry("dashboard", "hiveops-dashboard"), Map.entry("profile", "hiveops-profile"),
Map.entry("mobile", "hiveops-mobile"), Map.entry("agent", "hiveops-agent"),
Map.entry("guide", "hiveops-guide"));
public record Result(boolean success, Integer number, String url, String error) {}
public Result create(String type, String area, String title, String description,
String module, String email, String role) {
if (token == null || token.isBlank())
return new Result(false, null, null, "Feedback is not configured.");
if (title == null || title.isBlank() || description == null || description.isBlank())
return new Result(false, null, null, "Title and description are required.");
String labelName = "enhancement".equalsIgnoreCase(type) ? "Enhancement" : "Bug";
String repoName = REPOS.getOrDefault(area == null ? "" : area.toLowerCase(), "hiveops-guide");
String repo = ORG + "/" + repoName;
try {
Integer labelId = findLabel(repo, labelName);
if (labelId == null) labelId = createLabel(repo, labelName);
String body = description + "\n\n---\n"
+ "**Submitted via:** guide.bcos.dev\n"
+ "**Area:** " + repoName + (module != null && !module.isBlank() ? " · " + module : "") + "\n"
+ "**Reporter:** " + (email == null ? "unknown" : email)
+ (role != null ? " (" + role + ")" : "") + "\n";
ObjectNode payload = mapper.createObjectNode();
payload.put("title", title.trim());
payload.put("body", body);
if (labelId != null) payload.putArray("labels").add(labelId.intValue());
HttpResponse<String> resp = http.send(
req(repo, "/issues").POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(payload))).build(),
HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() >= 200 && resp.statusCode() < 300) {
JsonNode n = mapper.readTree(resp.body());
log.info("Feedback issue created: {} #{}", repo, n.path("number").asInt());
return new Result(true, n.path("number").asInt(), n.path("html_url").asText(), null);
}
log.warn("Gitea issue create failed {}: {}", resp.statusCode(), resp.body());
return new Result(false, null, null, "Gitea returned " + resp.statusCode());
} catch (Exception e) {
log.warn("Feedback submit failed: {}", e.getMessage());
return new Result(false, null, null, e.getMessage());
}
}
private Integer findLabel(String repo, String name) throws Exception {
HttpResponse<String> r = http.send(req(repo, "/labels?limit=100").GET().build(),
HttpResponse.BodyHandlers.ofString());
if (r.statusCode() != 200) return null;
for (JsonNode l : mapper.readTree(r.body()))
if (l.path("name").asText().equalsIgnoreCase(name)) return l.path("id").asInt();
return null;
}
private Integer createLabel(String repo, String name) throws Exception {
ObjectNode p = mapper.createObjectNode();
p.put("name", name);
p.put("color", name.equalsIgnoreCase("Enhancement") ? "#a2eeef" : "#d73a4a");
HttpResponse<String> r = http.send(
req(repo, "/labels").POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(p))).build(),
HttpResponse.BodyHandlers.ofString());
return (r.statusCode() >= 200 && r.statusCode() < 300) ? mapper.readTree(r.body()).path("id").asInt() : null;
}
private HttpRequest.Builder req(String repo, String path) {
return HttpRequest.newBuilder(URI.create(baseUrl + "/api/v1/repos/" + repo + path))
.header("Authorization", "token " + token)
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(20));
}
}
@@ -1,75 +0,0 @@
package com.hiveops.guide.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.hiveops.guide.entity.Guide;
import com.hiveops.guide.repository.GuideRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Optional;
@Service
@RequiredArgsConstructor
@Slf4j
public class GuideService {
private final GuideRepository guideRepository;
private final ObjectMapper objectMapper;
@Transactional(readOnly = true)
public Optional<Guide> findByPageKey(String pageKey) {
return guideRepository.findByPageKeyAndEnabledTrue(pageKey);
}
@Transactional(readOnly = true)
public List<Guide> findAll() {
return guideRepository.findAllByOrderByPageKeyAsc();
}
@Transactional
public Guide create(String pageKey, String contentJson, Boolean enabled, String updatedBy) {
if (guideRepository.existsByPageKey(pageKey)) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Guide already exists for pageKey: " + pageKey);
}
validateJson(contentJson);
Guide guide = Guide.builder()
.pageKey(pageKey)
.contentJson(contentJson)
.enabled(enabled != null ? enabled : Boolean.TRUE)
.updatedBy(updatedBy)
.build();
return guideRepository.save(guide);
}
@Transactional
public Guide update(String pageKey, String contentJson, Boolean enabled, String updatedBy) {
Guide guide = guideRepository.findByPageKey(pageKey)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Guide not found: " + pageKey));
validateJson(contentJson);
guide.setContentJson(contentJson);
if (enabled != null) guide.setEnabled(enabled);
guide.setUpdatedBy(updatedBy);
return guideRepository.save(guide);
}
@Transactional
public void delete(String pageKey) {
Guide guide = guideRepository.findByPageKey(pageKey)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Guide not found: " + pageKey));
guideRepository.delete(guide);
}
private void validateJson(String json) {
try {
objectMapper.readTree(json);
} catch (JsonProcessingException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "contentJson is not valid JSON: " + e.getOriginalMessage());
}
}
}
@@ -0,0 +1,151 @@
package com.hiveops.guide.service;
import com.hiveops.guide.dto.GuideDtos.*;
import com.hiveops.guide.model.GuideDoc;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.*;
/**
* Loads guide content from markdown files on the classpath ({@code content/**\/*.md}) at startup,
* parses YAML-ish frontmatter, and serves the nav tree + per-module views. No database.
*/
@Service
@Slf4j
public class MarkdownGuideService {
private volatile List<GuideDoc> docs = List.of();
/** Which audience tiers this instance serves — the per-environment cap.
* bcos.dev/DLX = all; production = customer,internal. */
@org.springframework.beans.factory.annotation.Value("${guide.served-audiences:customer,internal,dev,bcos}")
private String servedRaw;
private volatile Set<String> served;
private Set<String> served() {
Set<String> s = served;
if (s == null) {
s = new HashSet<>();
for (String x : servedRaw.split(",")) {
x = x.trim().toLowerCase(Locale.ROOT);
if (!x.isEmpty()) s.add(x);
}
served = s;
log.info("Guide serving audience tiers: {}", s);
}
return s;
}
@PostConstruct
public void load() {
List<GuideDoc> loaded = new ArrayList<>();
try {
var resolver = new PathMatchingResourcePatternResolver();
Resource[] resources = resolver.getResources("classpath*:content/**/*.md");
for (Resource r : resources) {
try (var in = r.getInputStream()) {
GuideDoc doc = parse(new String(in.readAllBytes(), StandardCharsets.UTF_8));
if (doc != null) loaded.add(doc);
else log.warn("Guide file missing valid frontmatter, skipped: {}", r.getFilename());
} catch (Exception e) {
log.warn("Failed to read guide file {}: {}", r.getFilename(), e.getMessage());
}
}
} catch (IOException e) {
log.error("Failed to scan guide markdown content", e);
}
this.docs = List.copyOf(loaded);
log.info("Loaded {} guide docs across {} modules", docs.size(),
docs.stream().map(GuideDoc::module).distinct().count());
}
/** frontmatter between leading '---' fences, then the markdown body. */
private GuideDoc parse(String raw) {
String content = raw.replace("\r\n", "\n");
if (!content.startsWith("---\n")) return null;
int end = content.indexOf("\n---", 4);
if (end < 0) return null;
Map<String, String> meta = new HashMap<>();
for (String line : content.substring(4, end).split("\n")) {
int colon = line.indexOf(':');
if (colon < 0) continue;
String key = line.substring(0, colon).trim();
String val = line.substring(colon + 1).trim();
// strip surrounding quotes and trailing "# comment"
int hash = val.indexOf(" #");
if (hash >= 0) val = val.substring(0, hash).trim();
if (val.length() >= 2 && val.startsWith("\"") && val.endsWith("\"")) val = val.substring(1, val.length() - 1);
if (!key.isEmpty()) meta.put(key, val);
}
String module = meta.get("module");
String tab = meta.get("tab");
if (module == null || module.isBlank() || tab == null || tab.isBlank()) return null;
String body = content.substring(end + 4).replaceFirst("^\\n+", "").stripTrailing();
String app = module.contains(".") ? module.substring(0, module.indexOf('.')) : module;
String title = meta.getOrDefault("title", module);
String audience = meta.getOrDefault("audience", "customer").toLowerCase(Locale.ROOT);
int order = 100;
try { order = Integer.parseInt(meta.getOrDefault("order", "100")); } catch (NumberFormatException ignored) {}
return new GuideDoc(app, module, title, tab, order, audience, body);
}
/** Audience ladder: customer < internal < dev < bcos.
* First the environment cap (served tiers), then the caller's role.
* customer→all · internal→MSP or BCOS admin · dev/bcos→BCOS admin only.
* NB: 'dev' is developer/architecture content and is BCOS-only — it must NOT be
* gated at MSP (issue #18). Only 'internal' is shared with MSP admins. */
private boolean visible(GuideDoc d, boolean includeInternal, boolean includeBcos) {
String a = d.audience();
if (!served().contains(a)) return false; // environment cap (prod = customer,internal)
return switch (a) {
case "bcos", "dev" -> includeBcos; // BCOS_ADMIN only
case "internal" -> includeInternal; // MSP_ADMIN or BCOS_ADMIN
default -> true; // customer
};
}
/** apps → modules → tabs, filtered by audience. */
public List<NavApp> nav(boolean includeInternal, boolean includeBcos) {
Map<String, Map<String, List<GuideDoc>>> byApp = new TreeMap<>();
for (GuideDoc d : docs) {
if (!visible(d, includeInternal, includeBcos)) continue;
byApp.computeIfAbsent(d.app(), k -> new TreeMap<>())
.computeIfAbsent(d.module(), k -> new ArrayList<>()).add(d);
}
List<NavApp> apps = new ArrayList<>();
for (var appEntry : byApp.entrySet()) {
List<NavModule> modules = new ArrayList<>();
for (var modEntry : appEntry.getValue().entrySet()) {
List<GuideDoc> mdocs = modEntry.getValue();
mdocs.sort(Comparator.comparingInt(GuideDoc::order));
List<NavTab> tabs = mdocs.stream()
.map(d -> new NavTab(d.tab(), d.order(), d.audience())).toList();
modules.add(new NavModule(modEntry.getKey(), mdocs.get(0).title(), tabs));
}
apps.add(new NavApp(appEntry.getKey(), modules));
}
return apps;
}
/** one module with its tabs (markdown bodies), filtered by audience. */
public Optional<ModuleView> module(String moduleKey, boolean includeInternal, boolean includeBcos) {
List<GuideDoc> mdocs = docs.stream()
.filter(d -> d.module().equals(moduleKey) && visible(d, includeInternal, includeBcos))
.sorted(Comparator.comparingInt(GuideDoc::order))
.toList();
if (mdocs.isEmpty()) return Optional.empty();
List<ModuleTab> tabs = mdocs.stream()
.map(d -> new ModuleTab(d.tab(), d.order(), d.audience(), d.body())).toList();
return Optional.of(new ModuleView(moduleKey, mdocs.get(0).title(), tabs));
}
}
@@ -2,20 +2,7 @@ spring.application.name=hiveops-guide
server.port=8099
spring.datasource.url=${DB_URL:jdbc:postgresql://localhost:5432/hiveiq_guide}
spring.datasource.username=${DB_USERNAME:hiveiq}
spring.datasource.password=${DB_PASSWORD}
spring.datasource.driver-class-name=org.postgresql.Driver
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.hibernate.ddl-auto=validate
spring.flyway.enabled=true
spring.flyway.locations=classpath:db/migration
spring.flyway.baseline-on-migrate=true
spring.flyway.baseline-version=1
spring.flyway.validate-on-migrate=true
spring.jpa.show-sql=false
# Guide content is markdown files on the classpath (content/**/*.md) — no database.
logging.level.root=WARN
logging.level.com.hiveops.guide=INFO
@@ -35,3 +22,20 @@ jwt.secret=${JWT_SECRET}
internal.secret=${INTERNAL_SECRET:dev-internal-secret}
cors.allowed-origins=${CORS_ALLOWED_ORIGINS:http://localhost:5188}
# Audience tiers this instance serves (per-environment cap), checked BEFORE any role
# gating — a tier that is not served never leaves the process, whatever the caller's role.
#
# The default is deliberately the SAFEST tier, not the fullest: a missing or misspelled
# GUIDE_SERVED_AUDIENCES must never be the reason internal content reaches a customer.
# MSP_ADMIN is a customer-facing role (QDS log in as MSP_ADMIN), so a fail-open default
# would expose the Architect/Claude tiers to customers on one forgotten env line.
#
# Each environment opts UP explicitly:
# production -> customer (leave unset; the default is correct)
# bcos.dev/DLX -> customer,internal,dev,bcos (set in that instance's compose)
guide.served-audiences=${GUIDE_SERVED_AUDIENCES:customer}
# In-guide feedback → Gitea issues (server-side token; reaches Gitea via public route)
gitea.base-url=${GITEA_BASE_URL:https://hiveiq-gitea.directlx.dev}
gitea.feedback.token=${GITEA_FEEDBACK_TOKEN:}
@@ -0,0 +1,115 @@
---
module: agent.activeteller
title: Active Teller (IAT) — teller-session, core-banking & Check21 monitoring for Nautilus Hyosung ITMs
tab: Architect
order: 30
audience: bcos
---
> **Internal · architecture.** Written 2026-07-01 from `hiveops-module-activeteller` source. Verify against code before relying on specifics.
## Module shape
Package `com.hiveops.activeteller`, six classes, no controllers/DB/Kafka (this is an agent module, not a microservice — see [technical.agent]):
- **`ActiveTellerModule`** — the `AgentModule` SPI entry point (registered via `META-INF/services/com.hiveops.core.module.AgentModule`). Owns lifecycle (`initialize()` → `isEnabled()` → `start()` → `stop()`), the shared single-thread `ScheduledExecutorService` (daemon thread named `activeteller`), and constructs the three monitors + the emitter.
- **`TellerSessionMonitor`** (package-private) — parses `ActiveTellerAgent_*.log`.
- **`SymXchangeMonitor`** (package-private) — parses `SymXchange{date}.log`.
- **`CheckProcessingMonitor`** (package-private) — parses `CheckProcessingResults{date}.log`.
- **`RollingLogTailer`** (package-private) — shared tail-by-offset reader; each monitor owns one instance.
- **`IatEventEmitter`** (package-private) — thin wrapper over `IncidentEventClient` that builds the `CreateJournalEventRequest` and centralizes send-error handling.
The three monitors are plain constructed objects, not their own `AgentModule`s — `ActiveTellerModule` drives all three from a single poll loop.
## Lifecycle & config gating
`initialize(ModuleContext)` is defensive and never throws for a mis-provisioned box — it just leaves `ready=false`, which `isEnabled()` returns. It bails (module stays disabled) when any of these fail, each with a distinct log line:
1. `device.type != NH_IAT`
2. `activeteller.log.dir` unset
3. `activeteller.log.dir` is not an existing directory
4. neither `agent.endpoint` nor `incident.endpoint` is configured
Endpoint prefix is chosen the standard way: `prefix = agentEndpoint != null ? "agent" : "incident"`, then `HttpClientSettings settings = HttpClientHelper.loadSettingsFromProperties(props, prefix)`. The `IncidentEventClient` is built once with `(settings, atmName, country)` from `ModuleContext`. `isHealthy()` = scheduler alive and not terminated.
## Flow: poll → tail → detect → emit
```
ActiveTellerModule.start()
→ ScheduledExecutorService (daemon "activeteller")
├─ scheduleAtFixedRate(poll, 0, pollSec, SECONDS) // default 30s
└─ scheduleAtFixedRate(heartbeat, heartbeatSec, heartbeatSec, SECONDS) // default 900s
poll():
tellerMonitor.poll(); symXchangeMonitor.poll(); checkMonitor.poll();
heartbeat():
if any monitor isAlive() → emit IAT_HEARTBEAT with concatenated fragments
```
`poll()` wraps all three in a try/catch so one monitor's parse error can't kill the scheduler. `heartbeat()` skips entirely if no monitor has seen a line today (`isAlive()` = `tailer.currentFile() != null`), so idle machines stay quiet.
### `RollingLogTailer` — the shared tail engine
Every monitor reads through one `RollingLogTailer` constructed with `(baseLogDir, tag, FileResolver)`. On each `poll(Consumer<String>)`:
- Computes `dailyFolder = baseLogDir.resolve(yyyyMMdd)`; if it isn't a directory, no-ops.
- Calls the `FileResolver` to pick the concrete file inside that folder:
- `fixedName(prefix, suffix)` → `{folder}/{prefix}{yyyyMMdd}{suffix}` (SymXchange, CheckProcessingResults).
- `newestMatching(prefix, suffix)` → the most-recently-modified file matching prefix+suffix (session-stamped `ActiveTellerAgent_*.log`).
- Tail-by-offset: opens with `RandomAccessFile`, `seek(fileOffset)`, reads new lines to EOF, advances `fileOffset = raf.getFilePointer()`.
- **Rotation/new file:** when the resolved path changes (new day or new session file), logs `watching <path>`, sets `currentFile` and resets `fileOffset = 0` (reads the new file from the top).
- **Truncation guard:** if `raf.length() < fileOffset`, resets offset to 0.
- **Encoding fix:** `RandomAccessFile.readLine()` decodes as ISO-8859-1; each line is re-decoded to UTF-8 (`new String(raw.getBytes(ISO_8859_1), UTF_8)`) before the consumer sees it.
`RollingLogTailer` holds **no** detection logic — the monitors own all parsing.
### `TellerSessionMonitor` — session state machine
Line format: `yyyy-MM-dd HH:mm:ss <message>`. Maintains single-customer session state (an IAT serves one customer at a time): `requestAt`, `sessionAt`, `videoUp`, `currentTeller`, plus a `logClock` (timestamp of the last processed line).
- `POST TellerSessionRequest message received` → `requestAt = ts` (idle `DELETE` heartbeats are ignored).
- A payload containing `"TellerSessionRequestId"` → session assigned (extracts `"TellerName"`; dedups repeated echoes of the same payload); clears `requestAt`, resets video flags.
- `OnAdd for RemoteControlSession` (while a session is active) → `videoUp = true`.
- `Ending teller session` / `OnDelete for TellerSessionRequest` → session end; if video never came up, emit `IAT_VIDEO_SESSION_FAIL`.
- REST outcome `(https?://…) server response (\w+)` → success statuses `{OK, Created, Accepted, NoContent, NotModified, ResetContent}` reset the failure streak (and, if `IAT_SERVER_UNREACHABLE` had been reported, emit `IAT_SERVER_RECOVERED`); anything else emits `IAT_SERVER_ERROR` and, at `failureThreshold` consecutive failures, `IAT_SERVER_UNREACHABLE` (once, until a success resets it).
- A payload containing `"ModeType"` with `"ModeName":"(\w+)"` → `IAT_MODE_CHANGE` (suppressed on first observation and on unchanged mode).
- **`checkTimers()`** runs after each batch, comparing `requestAt`/`sessionAt` against **`logClock`** (not wall-clock): overdue request → `IAT_TELLER_UNANSWERED`; session with no video past `videoTimeout` → `IAT_VIDEO_SESSION_FAIL`. Driving timers off the log clock means replaying a backlog can't raise spurious timeouts.
### `SymXchangeMonitor` — header-block parsing
The SymXchange log alternates header lines (`[yyyy-MM-dd HH:mm:ss-nnn][OpName]`) and XML payloads (PANs/SSNs masked to `****` at source). Per line:
- A header sets `currentTs`/`currentOp`, resets `faultReportedForBlock`, bumps `messageCount`.
- A `CONN_ERROR`-matching line (unable to connect / no connection / refused / timed out / DNS) increments a streak; at `failureThreshold` consecutive → `IAT_CORE_UNREACHABLE` (once until reset).
- Each SymXchange message **header** clears the connection-error streak — any response (incl. a fault) proves the core is reachable — and, if `IAT_CORE_UNREACHABLE` had been reported, emits `IAT_CORE_RECOVERED`.
- A `FAULT`-matching line (`faultstring`, `<faultcode`, `:Fault>`, `<Fault>`, `<ErrorMessage>`, non-zero `<ErrorCode>`) → `IAT_CORE_TXN_FAIL` (at most once per header block). The fault/conn regexes are conservative heuristics noted for tuning against a real failure sample.
### `CheckProcessingMonitor` — pipe-delimited records
Each `Process Date:`-prefixed line is split on `|` into `Key: Value` fields. A check is flagged (→ `IAT_CHECK_IMAGE_QUALITY`, informational) when any of: `Status` doesn't start with `Processed`; `Invalidity Score` > `invalidityThreshold`; or `|Amount CAR Amount LAR| >= amountMismatchCents`. Amounts are in cents and rendered as dollars in the detail text. Clean checks emit nothing.
## How events reach the platform
All three monitors emit through the single shared `IatEventEmitter`, which wraps the same `IncidentEventClient` class used by `journal-events` and other event-reporting modules — see [technical.agent] for the full outbound API surface.
```
monitor → emitter.emit(eventType, details, eventSource, eventTime)
→ CreateJournalEventRequest.builder()
.agentAtmId(atmId) // ATM name string, resolved to numeric atmId server-side
.eventType(eventType) // free-form String, e.g. "IAT_CORE_UNREACHABLE"
.eventDetails(details)
.eventSource(eventSource) // IAT_ACTIVETELLER | IAT_SYMXCHANGE | IAT_CHECK21
.eventTime(ts.format(ISO_LOCAL_DATE_TIME) | null)
.build()
→ IncidentEventClient.sendEvent(req)
→ HTTP POST {agent.endpoint or incident.endpoint}/api/journal-events
```
`eventType` is a plain `String` on `CreateJournalEventRequest` (the `hiveops-agent-journal` DTO) — not validated client-side. Nothing stops an unregistered `IAT_*` type from being sent; recognition/labeling happens server-side against hiveops-incident's DB-backed `event_type` table. On `IOException`, `emit()` logs a warning and drops the event — no retry, no queue.
## Platform abstraction
None used directly. The module reads plain files via `java.nio.file` / `RandomAccessFile` — no `PlatformService`/`PathResolver` dependency. The Hyosung/Windows constraint is enforced by deployment convention (installed only on `device.type=NH_IAT` machines) and the configured `activeteller.log.dir`, not by a runtime OS check in this module's code.
## Cross-links
- [technical.agent] — module system, lifecycle, shared HTTP client settings, full outbound API surface
- [platform.module-map]
@@ -0,0 +1,77 @@
---
module: agent.activeteller
title: Active Teller (IAT) — teller-session, core-banking & Check21 monitoring for Nautilus Hyosung ITMs
tab: Claude
order: 40
audience: bcos
---
> **Internal · agent reference.** Terse. Confirmed against `hiveops-module-activeteller` source 2026-07-01.
## Identity
- Maven artifact: **`hiveops-module-activeteller`** (`com.hiveops`, version `1.0.0`, packaged as a standalone `jar`, parent `hiveops-agent-parent` `4.4.3`).
- Agent module id (`getName()`, and the string to use in **`modules.disabled`**): **`activeteller`**. Module `getVersion()` = `1.0.0`.
- Entry class: `com.hiveops.activeteller.ActiveTellerModule`, registered via `META-INF/services/com.hiveops.core.module.AgentModule`.
- Log package (`getLogPackage()`): `com.hiveops.activeteller`.
- Activation gate: `device.type` must equal `NH_IAT`; any other value → module inert, `isEnabled()` returns `false`.
- Classes: `ActiveTellerModule`, `TellerSessionMonitor`, `SymXchangeMonitor`, `CheckProcessingMonitor`, `RollingLogTailer`, `IatEventEmitter`.
## Config properties (exact keys)
| Key | Default | Required | Notes |
|---|---|---|---|
| `device.type` | *(none)* | **yes** | must be `NH_IAT` |
| `activeteller.log.dir` | *(none)* | **yes** | base folder holding `yyyyMMdd/` daily subfolders; must exist & be a dir or module disables (warn, no crash) |
| `activeteller.poll.interval.sec` | `30` | no | **applied** in `start()` |
| `activeteller.heartbeat.interval.sec` | `900` | no | rollup heartbeat cadence |
| `activeteller.answer.timeout.sec` | `180` | no | request→assignment deadline |
| `activeteller.video.timeout.sec` | `60` | no | session→video deadline |
| `activeteller.server.failure.threshold` | `3` | no | consecutive REST fails → `IAT_SERVER_UNREACHABLE` |
| `activeteller.core.failure.threshold` | `3` | no | consecutive SymXchange conn errors → `IAT_CORE_UNREACHABLE` |
| `activeteller.check.invalidity.threshold` | `50` | no | Check21 invalidity flag threshold |
| `activeteller.check.amount.mismatch.cents` | `500` | no | CAR/LAR gap (cents) that flags a check |
Shared (not module-specific): `agent.endpoint` / `incident.endpoint` (prefix = `agent` if `agent.endpoint` set, else `incident`), resolved via `HttpClientHelper.loadSettingsFromProperties(props, prefix)` — same bearer-token / institution-key chain as every other event module. No IAT-specific auth keys.
## Event types emitted (exact strings)
| Event type | `eventSource` | Trigger |
|---|---|---|
| `IAT_TELLER_UNANSWERED` | `IAT_ACTIVETELLER` | teller requested, no session assigned within `answer.timeout.sec` |
| `IAT_VIDEO_SESSION_FAIL` | `IAT_ACTIVETELLER` | session assigned but no video/remote-control within `video.timeout.sec`, or session ended with no video |
| `IAT_SERVER_ERROR` | `IAT_ACTIVETELLER` | Active Teller REST call returned non-2xx |
| `IAT_SERVER_UNREACHABLE` | `IAT_ACTIVETELLER` | `server.failure.threshold` consecutive REST failures |
| `IAT_SERVER_RECOVERED` | `IAT_ACTIVETELLER` | first REST success after an `IAT_SERVER_UNREACHABLE` (→ incident auto-close) |
| `IAT_MODE_CHANGE` | `IAT_ACTIVETELLER` | `"ModeType"` payload, `ModeName` changed (not first observation) |
| `IAT_CORE_TXN_FAIL` | `IAT_SYMXCHANGE` | SOAP fault / error line in SymXchange log (once per header block) |
| `IAT_CORE_UNREACHABLE` | `IAT_SYMXCHANGE` | `core.failure.threshold` consecutive connection errors |
| `IAT_CORE_RECOVERED` | `IAT_SYMXCHANGE` | next SymXchange message after an `IAT_CORE_UNREACHABLE` (→ incident auto-close) |
| `IAT_CHECK_IMAGE_QUALITY` | `IAT_CHECK21` | Check21 status≠Processed, invalidity>threshold, or CAR/LAR gap≥threshold |
| `IAT_HEARTBEAT` | `IAT_ACTIVETELLER` | every `heartbeat.interval.sec`, only if a monitor saw a line today |
- `eventType` is a free-form `String` on `CreateJournalEventRequest` (`hiveops-agent-journal` DTO) — **not validated client-side**. These `IAT_*` strings are custom (not in any agent enum) — recognition is hiveops-incident's DB-backed `event_type` table.
- `CreateJournalEventRequest` fields set: `agentAtmId`, `eventType`, `eventDetails`, `eventSource`, `eventTime` (ISO_LOCAL_DATE_TIME, or null). No cassette/card fields set.
- Transport: `POST {endpoint}/api/journal-events` via `IncidentEventClient.sendEvent()` — same client/endpoint as `journal-events`.
## Files monitored (under `{activeteller.log.dir}/{yyyyMMdd}/`)
- `ActiveTellerAgent_*.log` — newest-matching (session-stamped); `TellerSessionMonitor`, tag `agent`.
- `SymXchange{yyyyMMdd}.log` — fixed daily name; `SymXchangeMonitor`, tag `symx`.
- `CheckProcessingResults{yyyyMMdd}.log` — fixed daily name; `CheckProcessingMonitor`, tag `check`.
- All via `RollingLogTailer`: tail-by-offset (`RandomAccessFile` + `fileOffset`), reset on new-day/new-file or truncation; lines re-decoded ISO-8859-1 → UTF-8.
## Behavior facts
- Answer/video timers use the **log clock** (timestamp of last processed line), not wall-clock — backlog replay won't raise false timeouts.
- Dedup guards: `IAT_VIDEO_SESSION_FAIL`, `IAT_SERVER_UNREACHABLE`, `IAT_CORE_UNREACHABLE`, and per-block `IAT_CORE_TXN_FAIL` each fire once until state resets. When an `*_UNREACHABLE` state resets (first REST success / next SymXchange message), the matching `IAT_SERVER_RECOVERED` / `IAT_CORE_RECOVERED` is emitted.
- REST "success" whitelist: `OK, Created, Accepted, NoContent, NotModified, ResetContent`.
- SymXchange fault/conn regexes are conservative heuristics — may need tuning against a real failure sample.
## Deployment
- Delivered fleet-wide via **`INSTALL_MODULE`** fleet task (drops JAR into agent `modules/` dir, restarts agent — `ProcessInstallModule` in `hiveops-file-collection`).
- Disable without uninstalling: add `activeteller` to `modules.disabled`, push `CONFIG_UPDATE`.
- Register the eleven `IAT_*` event types in hiveops-incident → Settings → Event Types (DB `event_type` table) for correct labeling.
## Do NOT
- Do NOT expect the module to run on any `device.type` other than `NH_IAT` — inert by design elsewhere, not a bug.
- Do NOT expect retry/queueing on send failure — a failed `sendEvent()` is logged (`activeteller: failed to send ...`) and dropped.
- Do NOT assume the `IAT_*` strings exist in an agent `EventType` enum — they don't; the incident DB `event_type` table is what gates recognition.
- Do NOT point `activeteller.log.dir` at the daily `yyyyMMdd` subfolder — it must be the **base** folder; the tailer appends `{yyyyMMdd}/` itself.
- Do NOT assume `IAT_HEARTBEAT` fires on an idle/offline machine — it's suppressed unless a monitor saw a line today.
- Do NOT treat the SymXchange fault detection as authoritative — the markers are heuristics tuned without a real fault sample.
@@ -0,0 +1,68 @@
---
module: agent.activeteller
title: Active Teller (IAT) — teller-session, core-banking & Check21 monitoring for Nautilus Hyosung ITMs
tab: Internal
order: 20
audience: dev
---
> **Internal · ops/support.** Written 2026-07-01 from `hiveops-module-activeteller` source. Verify against code before relying on specifics.
## What this module is
`hiveops-module-activeteller` (Maven artifact `hiveops-module-activeteller`, agent module id **`activeteller`**) is an **agent drop-in module**, not a backend service. It runs inside the HiveIQ Agent process on **Nautilus Hyosung Active Teller Interactive/Video Teller Machines (IAT/ITM) only** — it self-disables unless the ATM's `device.type` is `NH_IAT`.
It tails three log files that the Hyosung MoniPlus2s / Active Teller software already writes to disk and turns specific lines into HiveIQ journal events (`IAT_*`) sent to the incident pipeline via agent-proxy. The module does **not** talk to the ITM hardware, the teller/video server, or the core banking host directly — it is purely a read-only log observer.
## What it monitors and why
All three logs live under the same base folder configured by `activeteller.log.dir`, inside a per-day `yyyyMMdd` subfolder (the standard MoniPlus2s convention). One scheduler tick (default 30s) polls all three:
1. **`ActiveTellerAgent_{date}_{time}.log`** (session-stamped; newest match wins) — the Active Teller client log. Reconstructs the interactive-teller session flow and reports:
- **`IAT_TELLER_UNANSWERED`** — a customer requested a teller but no teller session was assigned within `activeteller.answer.timeout.sec`.
- **`IAT_VIDEO_SESSION_FAIL`** — a teller session started but the video/remote-control session never came up (within `activeteller.video.timeout.sec`, or the session ended without video).
- **`IAT_SERVER_ERROR`** — an Active Teller REST call returned a non-2xx status.
- **`IAT_SERVER_UNREACHABLE`** — `activeteller.server.failure.threshold` consecutive REST failures.
- **`IAT_SERVER_RECOVERED`** — first successful Active Teller REST call after an `IAT_SERVER_UNREACHABLE`; lets the incident side auto-close the connectivity ticket.
- **`IAT_MODE_CHANGE`** — scheduled operating-mode transition (e.g. Standard ↔ SelfService).
2. **`SymXchange{date}.log`** (fixed daily name) — the Jack Henry **Symitar (SymXchange)** core-banking conversation. Reports:
- **`IAT_CORE_TXN_FAIL`** — a SOAP fault / error response from the core (at most once per header block).
- **`IAT_CORE_UNREACHABLE`** — `activeteller.core.failure.threshold` consecutive connection-level errors (unable to connect / timed out / refused / DNS).
- **`IAT_CORE_RECOVERED`** — next SymXchange message after an `IAT_CORE_UNREACHABLE` (a response, incl. a SOAP fault, proves the core is reachable); lets the incident side auto-close the core-banking ticket.
3. **`CheckProcessingResults{date}.log`** (fixed daily name) — Check21 image capture. Emits informational **`IAT_CHECK_IMAGE_QUALITY`** when a check deposit's invalidity score is high, the CAR/LAR (courtesy/legal amount) disagree, or the status isn't "Processed" — cases a back-office reviewer would otherwise have to catch by hand.
A rollup **`IAT_HEARTBEAT`** is emitted every `activeteller.heartbeat.interval.sec` (default 900s / 15 min), but **only if at least one log produced a line today** — it will not send an empty heartbeat on an idle/offline machine.
## Config properties it registers
| Property | Default | Meaning |
|---|---|---|
| `device.type` | *(none)* | Activation gate — must equal `NH_IAT` or the module is inert. |
| `activeteller.log.dir` | *(none — required)* | Base folder that contains the `yyyyMMdd` daily subfolders (e.g. `C:/Hyosung/MoniPlus2s/MoniPlus2Slog`). If unset or not a real directory, the module logs a warning and disables itself — it does **not** crash agent startup. |
| `activeteller.poll.interval.sec` | `30` | Log poll interval. **Applied** (unlike some sibling modules). |
| `activeteller.heartbeat.interval.sec` | `900` | Rollup-heartbeat interval. |
| `activeteller.answer.timeout.sec` | `180` | Teller-request → assignment deadline before `IAT_TELLER_UNANSWERED`. |
| `activeteller.video.timeout.sec` | `60` | Session-start → video/remote-control-up deadline before `IAT_VIDEO_SESSION_FAIL`. |
| `activeteller.server.failure.threshold` | `3` | Consecutive REST failures → `IAT_SERVER_UNREACHABLE`. |
| `activeteller.core.failure.threshold` | `3` | Consecutive SymXchange connection errors → `IAT_CORE_UNREACHABLE`. |
| `activeteller.check.invalidity.threshold` | `50` | Check21 invalidity score above which a check is flagged. |
| `activeteller.check.amount.mismatch.cents` | `500` | CAR/LAR gap (in cents) that counts as a bad read. |
It also reuses the shared agent HTTP settings — `agent.endpoint` (preferred) or `incident.endpoint` (legacy fallback), resolved through `HttpClientHelper.loadSettingsFromProperties(props, prefix)` (the same bearer-token / institution-key chain every event-reporting module uses). There are no IAT-specific auth properties.
## How it's enabled / deployed
- Activates only on ATMs configured with `device.type=NH_IAT`. On any other device type it logs `activeteller: device.type='<X>', not NH_IAT — module disabled` and does nothing — expected, not a bug.
- Delivered like any other agent module: uploaded to the fleet artifact store and pushed via an **`INSTALL_MODULE`** fleet task, which drops the module JAR into the agent's `modules/` directory and restarts the agent service (see `ProcessInstallModule` in `hiveops-file-collection`). Standard rollout order: lab ATM → pilot device → fleet.
- Can be disabled fleet-wide without removing the JAR by adding `activeteller` to the comma-separated `modules.disabled` property and pushing a `CONFIG_UPDATE` (the id is **`activeteller`**, the module's `getName()`).
- The eleven event types it emits (`IAT_HEARTBEAT`, `IAT_TELLER_UNANSWERED`, `IAT_VIDEO_SESSION_FAIL`, `IAT_SERVER_ERROR`, `IAT_SERVER_UNREACHABLE`, `IAT_SERVER_RECOVERED`, `IAT_MODE_CHANGE`, `IAT_CORE_TXN_FAIL`, `IAT_CORE_UNREACHABLE`, `IAT_CORE_RECOVERED`, `IAT_CHECK_IMAGE_QUALITY`) are `IAT_*` custom types — they must exist as rows in hiveops-incident's `event_type` table (Incident → Settings → Event Types) to be recognized/labeled correctly downstream. They are not standard shared types.
## Common failure modes / what to check
- **No IAT events ever appear for a customer ITM** — first check `device.type` on that ATM's config; if it isn't `NH_IAT` the module is intentionally inert. Not a bug.
- **Module loaded but silent** — check `activeteller.log.dir` is set and points at a real, existing base directory on that machine. A missing/wrong path disables the module at startup with a log warning (no crash). Also confirm the daily `yyyyMMdd` subfolder actually exists — the tailer resolves `{log.dir}/{yyyyMMdd}/` each poll and silently no-ops if that folder isn't there.
- **Events stopped after midnight / around log rotation** — all three monitors roll to a new `yyyyMMdd` folder (and SymXchange/Check21 to a new dated filename) at local midnight; the ActiveTeller log is session-stamped, so the newest matching file wins. If the Hyosung file-naming convention ever changes, the module watches a file that never appears and goes silent. Grep agent logs for `activeteller[...]: watching <path>` to confirm which file each monitor is tailing.
- **Spurious `IAT_TELLER_UNANSWERED` / `IAT_VIDEO_SESSION_FAIL`** — the answer/video timers are driven off the **log clock** (timestamp of the last processed line), not wall-clock, so replaying a backlog cannot raise false timeouts. If real timeouts fire wrongly, re-check `activeteller.answer.timeout.sec` / `activeteller.video.timeout.sec` against the site's actual teller-answer SLA.
- **SymXchange faults missed or over-reported** — the fault / connection-error regexes are conservative heuristics (no real SymXchange failure was in the reference capture). If a customer's real fault has a different shape, the markers may need tuning; check `IAT_CORE_TXN_FAIL` details against the raw log.
- **Events sent but not visible/categorized in Incident** — confirm the `IAT_*` types are registered in hiveops-incident's Event Types settings; these are DB-driven, not hardcoded.
- **HTTP send failures** — `IatEventEmitter` catches and logs send failures (`activeteller: failed to send ...`) but does **not** retry or queue — a transient outage silently drops that single event rather than backing it up.
@@ -0,0 +1,69 @@
---
module: agent.aria-signals
title: ARIA Security Signals — ATM threat-signal collector
tab: Architect
order: 30
audience: bcos
---
> **Internal · how it's built.** Confirmed against `hiveops-module-aria-signals` source (`com.hiveops.aria`) 2026-07-01. See [technical.agent] for agent-wide module-system context.
## Maven module
`hiveops-module-aria-signals` (artifact `hiveops-module-aria-signals`, own version `1.0.0`, parented by `hiveops-agent-parent` — currently `4.4.3`). Packaged as a standalone jar (`<finalName>${project.artifactId}</finalName>` → `hiveops-module-aria-signals.jar`). Dependencies are all **`provided`** scope (`hiveops-core`, `jackson-databind`, `log4j-api`, `commons-lang3`) — nothing is shaded into the module JAR; it relies on the agent runtime classpath. No dependency on `hiveops-journal`, `hiveops-images`, or `hiveops-file-collection`.
Registered via ServiceLoader SPI: `src/main/resources/META-INF/services/com.hiveops.core.module.AgentModule` → `com.hiveops.aria.AriaSignalModule`. This is how `ModuleLoader` discovers it from the `modules/` directory at startup.
## Classes and flow
Four runtime classes plus three DTOs:
| Class | Role |
|---|---|
| `AriaSignalModule` | `AgentModule` implementation — reads config, wires collaborators, owns the scheduler and lifecycle |
| `AriaSignalCollector` | `Runnable` — polls the Windows Security Event Log + scans staging dirs, batches, POSTs events |
| `AriaPostureReporter` | `Runnable` — builds and POSTs the device posture snapshot |
| `AriaHttpClient` | Thin `HttpURLConnection` JSON POST client to the ARIA base URL |
| `dto.AriaIngestRequest` | Envelope: `deviceAgentId`, `deviceId`, `institutionKey`, `List<AriaSignalItem> events` |
| `dto.AriaSignalItem` | One signal: `signalKey`, `eventType`, `Map<String,Object> rawPayload`, `detectedAt` (ISO-8601 string) |
| `dto.AriaPostureReportDto` | Posture snapshot (see fields below) |
### Lifecycle (`AriaSignalModule`)
`getName()` = `"aria-signals"`; `getLogPackage()` = `"com.hiveops.aria"` → the framework routes its output to `logs/modules/aria-signals.log`.
- `initialize(ModuleContext)` — reads `aria.signals.enabled`; if false, returns (leaves `ready=false`). Reads `aria.endpoint`; if blank, logs a warning and returns. Otherwise builds one `AriaHttpClient(endpoint, serviceSecret, skipSsl)`, resolves `atmName` (`context.getAtmName()`), `institutionKey` (`agent.institution.key` → `incident.institution.key`), `deviceId` (`aria.device.id`, parsed to `Long` or null), intervals and batch size, then constructs the collector and reporter and sets `ready=true`.
- `isEnabled(context)` returns the `ready` flag (so a mis-configured module self-disables rather than erroring).
- `start()` — creates a **2-thread** daemon `ScheduledExecutorService` and schedules both `Runnable`s with `scheduleAtFixedRate`: the collector at `[0, signalIntervalSec]` and the reporter at `[0, postureIntervalSec]`. So the posture report fires **immediately**; the collector's first run just **anchors** the log (see below).
- `stop()` — `scheduler.shutdownNow()`.
- `isHealthy()` — `scheduler == null || !scheduler.isTerminated()`; consulted by the framework `ModuleWatchdog`.
### Signal collection cycle (`AriaSignalCollector`, poll-based)
The trigger model is **poll**, not log-tail or hook. Each `run()`:
1. **First tick anchors, doesn't collect.** If not yet `anchored`, `anchorSecurityLog()` runs `wevtutil qe Security /rd:true /f:XML /c:1 /q:<query>` (newest first, query with no `EventRecordID` bound), parses the max `EventRecordID`, stores it in `lastSecurityRecordId`, sets `anchored=true`, and returns. This prevents replaying historical events. On non-Windows it just sets `anchored=true` and returns.
2. **Subsequent ticks:** `collectSecurityEvents()` builds a query `*[System[(EventID=1102 or … ) and EventRecordID > <lastSecurityRecordId>]]`, runs `wevtutil qe Security /rd:false /f:XML /c:<batchSize> /q:<query>`, and parses the returned XML with **hand-rolled string scanning** (`parseEvents`/`parseBlock`/`extractTag`/`extractAttr` — no XML parser dependency). Each event becomes an `AriaSignalItem` with `signalKey = <eventId>`, `eventType = "OS_EVENT"`, and `rawPayload = {eventId, recordId, provider?}`. The bookmark `lastSecurityRecordId` advances to the max record seen.
3. **Staging-dir scan** (`scanStagingDirs()`, runs on all OSes but paths are Windows `C:/...`): for each existing directory, any regular file whose name matches `SUSPICIOUS_FILENAMES` (case-insensitive) emits `AriaSignalItem(signalKey="IOC_FILENAME_MATCH", eventType="FILE_SYSTEM", payload={path, filename})`. Separately, if the directory contains **any** file at all, it emits `AriaSignalItem(signalKey="SUSPICIOUS_DIRECTORY", eventType="FILE_SYSTEM", payload={directory})`.
4. If the combined list is non-empty, wrap in `AriaIngestRequest(deviceAgentId, deviceId, institutionKey, events)` and `client.sendEvents(...)`.
Note the bookmark advances **before** the POST, so a failed send does not re-queue those Security events on the next tick.
### Posture cycle (`AriaPostureReporter`)
`buildReport()` populates `AriaPostureReportDto`: `deviceAgentId`/`deviceId`/`institutionKey`; `osVersion` (`os.name os.version`) and `osEolStatus` (`classifyOsEol` → `EOL` for Win 7/XP/Vista/8, else `SUPPORTED`/`UNKNOWN`). On Windows it adds `diskEncryptionEnabled` (BitLocker `manage-bde -status C:` → "protection on"), `auditPolicyCompliant` (`auditpol` → not "no auditing"), `softwareWhitelistEnabled` (`sadmin status` → "solid", fallback AppLocker registry `EnforcementMode 0x1`). If `aria.gold.image.hash` is set, it SHA-256-hashes `aria.image.path` (`sha256:<hex>`) into `currentImageHash` for drift comparison. Each check returns `null` on failure, and `@JsonInclude(NON_NULL)` drops null fields from the JSON.
## How events reach the platform
`AriaHttpClient` posts JSON via `HttpURLConnection` (connect 10s / read 15s) with `Content-Type: application/json; charset=UTF-8` and header `X-Service-Secret: <aria.service.secret>`:
| Method | Path (relative to `aria.endpoint`) | Body |
|---|---|---|
| `sendEvents` | `POST /api/aria/ingest/events` | `AriaIngestRequest` |
| `sendPostureReport` | `POST /api/aria/posture/report` | `AriaPostureReportDto` |
This is a **direct** agent → ARIA backend path. It does **not** traverse agent-proxy/incident and does **not** use `agent.auth.token`, `FileUploadClient`, or the fleet-task pipeline that other modules share. Non-2xx responses are logged as warnings; there is no retry/backoff.
## Platform abstraction
This module deliberately **does not** use the agent's `PlatformService`/`SystemCommands` abstraction. OS branching is done inline with `System.getProperty("os.name")` and raw `ProcessBuilder` calls to `wevtutil`, `manage-bde`, `auditpol`, `sadmin`, `reg`. Command output is read with the JVM `file.encoding` charset. Consequence: all monitoring except the OS/EOL and image-hash posture fields is effectively Windows-only.
@@ -0,0 +1,85 @@
---
module: agent.aria-signals
title: ARIA Security Signals — ATM threat-signal collector
tab: Claude
order: 40
audience: bcos
---
> **Internal · agent reference.** Terse. Confirmed against `hiveops-module-aria-signals` source (`com.hiveops.aria`) 2026-07-01.
## Identity
- Module name (`AgentModule.getName()`): **`aria-signals`**
- Maven artifact: **`hiveops-module-aria-signals`** (own version `1.0.0`; parent `hiveops-agent-parent` `4.4.3`)
- Entry class: `com.hiveops.aria.AriaSignalModule` (SPI file: `META-INF/services/com.hiveops.core.module.AgentModule`)
- Other classes: `AriaSignalCollector`, `AriaPostureReporter`, `AriaHttpClient`; DTOs `AriaIngestRequest`, `AriaSignalItem`, `AriaPostureReportDto`
- Log package (`getLogPackage()`): `com.hiveops.aria` → `logs/modules/aria-signals.log`
- Drop-in JAR: `modules/hiveops-module-aria-signals.jar` (all deps `provided`; not in fat JAR)
- **Disabled by default.** No fleet-task kind; passive scheduled collector.
## Config property keys (all in `hiveops.properties`)
| Key | Default | Notes |
|---|---|---|
| `aria.signals.enabled` | `false` | master switch |
| `aria.endpoint` | *(none, required)* | ARIA base URL; blank → module inactive |
| `aria.service.secret` | `""` | sent as `X-Service-Secret` header |
| `aria.signals.poll.interval.sec` | `60` | event/staging poll |
| `aria.posture.report.interval.sec` | `86400` | posture report (24h) |
| `aria.signals.batch.size` | `50` | `wevtutil /c:` cap per poll |
| `aria.gold.image.hash` | *(none)* | enables image-drift check when set |
| `aria.image.path` | *(none)* | file hashed (SHA-256) vs gold hash |
| `aria.ssl.skip.verify` | `false` | installs **JVM-wide** trust-all SSL |
| `aria.device.id` | *(none)* | numeric; else omitted from reports |
| `agent.institution.key` → `incident.institution.key` | *(none)* | institution key |
- `isEnabled()` == true only when `aria.signals.enabled=true` AND `aria.endpoint` non-blank.
- `deviceAgentId` = `ModuleContext.getAtmName()`. No dedicated `aria.*` interval for staging scan (shares `aria.signals.poll.interval.sec`).
## Signal strings emitted (`AriaSignalItem.signalKey` / `.eventType`)
| signalKey | eventType | Source |
|---|---|---|
| `"1102"`,`"4719"`,`"6416"`,`"2003"`,`"4663"`,`"4688"`,`"4697"` | `OS_EVENT` | Windows Security Event Log (`wevtutil`) |
| `IOC_FILENAME_MATCH` | `FILE_SYSTEM` | suspicious filename in a staging dir |
| `SUSPICIOUS_DIRECTORY` | `FILE_SYSTEM` | staging dir contains ANY file |
- Monitored Security Event IDs: `1102, 4719, 6416, 2003, 4663, 4688, 4697` (`SECURITY_EVENT_IDS`).
- Staging dirs (`STAGING_DIRS`): `C:/Temp/bin`, `C:/Windows/Temp/s`, `C:/ProgramData/s`, `C:/ProgramData/Microsoft/Wms`, `C:/Windows/system32/bin`.
- Suspicious filenames (case-insensitive): `Newage.exe`, `Color.exe`, `sdelete.exe`, `atmapp.exe`, `mainclientd.exe`, `ATMContent.exe`.
- Posture DTO fields: `diskEncryptionEnabled`, `auditPolicyCompliant`, `softwareWhitelistEnabled`, `osVersion`, `osEolStatus` (`SUPPORTED|EOL|UNKNOWN`), `goldImageHash`, `currentImageHash` (`sha256:<hex>`).
## HTTP endpoints (agent → ARIA backend, DIRECT)
| Call | Method + path (relative to `aria.endpoint`) |
|---|---|
| Events | `POST /api/aria/ingest/events` (`AriaIngestRequest`) |
| Posture | `POST /api/aria/posture/report` (`AriaPostureReportDto`) |
- Header: `X-Service-Secret: <aria.service.secret>`. Timeouts: connect 10s / read 15s. Non-2xx logged as warning, **no retry**.
- Does **NOT** go through agent-proxy/incident; does **NOT** use `agent.auth.token` or `FleetTask`/`FileUploadClient`.
## Posture check commands (Windows, raw `ProcessBuilder`)
- BitLocker: `manage-bde -status C:` → true if output contains "protection on"
- Audit policy: `auditpol /get /subcategory:"Audit Policy Change"` → true unless "no auditing"
- Whitelisting: `sadmin status` → "solid"; fallback `reg query HKLM\...\SrpV2\Exe /v EnforcementMode` → contains `0x1`
- Each returns `null` on failure; `@JsonInclude(NON_NULL)` drops nulls.
## Deployment
- Enable: `CONFIG_UPDATE` fleet task setting `aria.signals.enabled=true` + `aria.endpoint` (+ secret), then agent restart.
- Retrofit missing JAR: `INSTALL_MODULE` fleet task (drops JAR into `modules/`, restarts agent) or full agent update.
- Disable: `aria.signals.enabled=false` OR add `aria-signals` to `modules.disabled`.
## Gotchas
- **First collector tick only anchors the log** (records max `EventRecordID`, sends nothing). First real events arrive on the 2nd tick (after `aria.signals.poll.interval.sec`). Posture report fires immediately on start.
- **Windows-only in practice:** event-log poll + staging scan produce nothing on Linux. Posture report runs anywhere but only OS/EOL + image-hash fields populate off-Windows (BitLocker/audit/whitelist are Windows-guarded).
- `SUSPICIOUS_DIRECTORY` fires if the staging dir holds **any** file, not just a suspicious one → noisy.
- Record bookmark (`lastSecurityRecordId`) advances **before** the POST; a failed send loses those events (no re-queue).
- `aria.ssl.skip.verify=true` installs a trust-all `SSLContext` via `HttpsURLConnection.setDefault*` — affects the **entire JVM**, not just ARIA calls.
- XML is parsed by hand-rolled string scanning (`extractTag`/`extractAttr`), not a real XML parser.
- No `PlatformService`/`SystemCommands` usage — inline `os.name` + `ProcessBuilder`.
## Do NOT
- Do NOT expect `EventType` journal/incident events (`CARD_READER_FAIL`, `IAT_*`, etc.) — this module emits ARIA `signalKey`/`eventType` (`OS_EVENT`/`FILE_SYSTEM`) only, direct to ARIA.
- Do NOT look for these calls in agent-proxy/incident or fleet-task logs — they bypass that pipeline entirely.
- Do NOT assume `agent.auth.token` authenticates ARIA calls — it's `X-Service-Secret` only.
- Do NOT expect data on the first poll or on Linux — anchor-first + Windows-centric.
- Do NOT enable `aria.ssl.skip.verify` casually — it disables TLS verification JVM-wide.
- Do NOT assume delivery is guaranteed — best-effort, no retry, bookmark advances past failed sends.
- Do NOT invent extra `aria.*` keys — only the eleven above exist in code.
@@ -0,0 +1,82 @@
---
module: agent.aria-signals
title: ARIA Security Signals — ATM threat-signal collector
tab: Internal
order: 20
audience: dev
---
> **Internal · ops/support view.** Confirmed against `hiveops-module-aria-signals` source (`com.hiveops.aria`) 2026-07-01.
## What it does (and doesn't)
`hiveops-module-aria-signals` (module name `aria-signals`, class `AriaSignalModule`) is a **passive security-signal collector** that feeds the HiveIQ **ARIA** threat-intelligence backend. On each ATM it runs two scheduled jobs:
1. **Signal collector** (`AriaSignalCollector`, default every **60s**) — polls the Windows **Security Event Log** for a fixed set of threat-relevant Event IDs, and scans a fixed list of known malware **staging directories** for suspicious filenames. Batches whatever it finds and POSTs it to ARIA.
2. **Posture reporter** (`AriaPostureReporter`, default every **24h**) — collects a device **security posture** snapshot (BitLocker disk encryption, audit policy, software whitelisting, OS/EOL status, gold-image hash drift) and POSTs it to ARIA.
It is **disabled by default** and is **Windows-centric**: the event-log poll and staging-dir scan only produce data on Windows; on Linux the posture reporter still runs but only fills in OS/EOL and image-hash fields.
Important boundary: unlike the journal/incident modules, this module does **not** emit `EventType` journal events to hiveops-incident and does **not** go through agent-proxy. It POSTs **directly** to the ARIA base URL (`aria.endpoint`, e.g. `https://api.bcos.cloud`) authenticated with an `X-Service-Secret` header. See [architect.md] for the flow.
### What it monitors
**Security Event Log IDs** (`AriaSignalCollector.SECURITY_EVENT_IDS`):
| Event ID | Meaning |
|---|---|
| 1102 | Audit log cleared |
| 4719 | Audit policy changed |
| 6416 | New removable (USB) device recognised |
| 2003 | Network setting changed |
| 4663 | File/object access attempt |
| 4688 | New process created |
| 4697 | Service installed on system |
**Staging directories scanned** (jackpotting / Ploutus-family paths — `STAGING_DIRS`): `C:/Temp/bin`, `C:/Windows/Temp/s`, `C:/ProgramData/s`, `C:/ProgramData/Microsoft/Wms`, `C:/Windows/system32/bin`. Suspicious filenames flagged (`SUSPICIOUS_FILENAMES`, case-insensitive): `Newage.exe`, `Color.exe`, `sdelete.exe`, `atmapp.exe`, `mainclientd.exe`, `ATMContent.exe`.
**Posture checks** (Windows, via `ProcessBuilder`): BitLocker (`manage-bde -status C:`), audit policy (`auditpol /get /subcategory:"Audit Policy Change"`), software whitelisting (McAfee Solidcore/Trellix `sadmin status`, falling back to Windows AppLocker registry `SrpV2\Exe EnforcementMode`). OS/EOL classification and optional gold-image SHA-256 drift run on all platforms.
## Config properties it registers
All keys live in `hiveops.properties` and are read in `AriaSignalModule.initialize()`:
| Key | Default | Purpose |
|---|---|---|
| `aria.signals.enabled` | `false` | Master switch — module stays inactive unless `true` |
| `aria.endpoint` | *(none — required)* | ARIA base URL, e.g. `https://api.bcos.cloud`. If unset, module logs a warning and stays inactive |
| `aria.service.secret` | `""` | Value sent in the `X-Service-Secret` header on every ARIA POST |
| `aria.signals.poll.interval.sec` | `60` | Event-log / staging-dir poll interval |
| `aria.posture.report.interval.sec` | `86400` | Posture report interval (default 24h) |
| `aria.signals.batch.size` | `50` | Max Security events pulled per poll (`wevtutil /c:`) |
| `aria.gold.image.hash` | *(none)* | Expected SHA-256 for image-drift check; only checked if set |
| `aria.image.path` | *(none)* | File hashed and compared against `aria.gold.image.hash` |
| `aria.ssl.skip.verify` | `false` | Skip TLS cert verification (installs a JVM-wide trust-all — see gotchas) |
| `aria.device.id` | *(none)* | Optional numeric HiveIQ device id included in reports |
| `agent.institution.key` (falls back to `incident.institution.key`) | *(none)* | Institution key stamped on every report |
Device identity on the wire: `deviceAgentId` = the agent's ATM name (`ModuleContext.getAtmName()`); `deviceId` = `aria.device.id` if numeric, else omitted.
## Enabled / deployed to ATMs
- **Enable:** push `aria.signals.enabled=true` **and** `aria.endpoint=...` (plus `aria.service.secret`) via a `CONFIG_UPDATE` fleet task, then restart the agent so the module re-initializes. `isEnabled()` returns true only when both `aria.signals.enabled=true` and `aria.endpoint` is non-blank.
- **Shipped how:** it is a **drop-in JAR** — `modules/hiveops-module-aria-signals.jar` (own artifact version `1.0.0`, parented by `hiveops-agent-parent`). It is **not** baked into the fat JAR. Discovered at startup via ServiceLoader from the `modules/` directory.
- **Retrofit to an ATM that doesn't have the JAR yet:** deliver via a fleet **`INSTALL_MODULE`** task (drops the JAR into `modules/` and restarts the agent), or via a full agent update that includes it in the module set.
- **Disable again:** either set `aria.signals.enabled=false`, or add `aria-signals` to `modules.disabled` (comma-separated) via `CONFIG_UPDATE`.
## Common failure modes / what to check
| Symptom | Likely cause | What to check |
|---|---|---|
| Module logs "disabled via aria.signals.enabled" and does nothing | `aria.signals.enabled` not `true` | Confirm the property on that ATM after the `CONFIG_UPDATE` landed and the agent restarted |
| Module logs "aria.endpoint not configured, module inactive" | `aria.endpoint` blank even though enabled | Set `aria.endpoint` and restart |
| No events ever arrive at ARIA, no errors | ATM is Linux, or genuinely no matching events; also the **first poll only anchors** the log (no data until the 2nd tick) | Confirm OS is Windows; wait past one `aria.signals.poll.interval.sec`; check `logs/modules/aria-signals.log` for "anchored Security log at RecordID" |
| Posture report fields all null on Windows | `manage-bde` / `auditpol` / `sadmin` not on PATH or agent lacks privileges | Each check returns null on failure — run the commands manually as the agent service account |
| `ARIA POST ... returned HTTP 4xx/5xx` in log | Wrong `aria.endpoint`, bad/missing `aria.service.secret`, or ARIA backend down | Non-2xx is logged as a warning and **not retried**; verify endpoint + secret |
| TLS errors on the POST | ARIA cert not trusted | Fix the trust store; only use `aria.ssl.skip.verify=true` as a last resort (it disables verification JVM-wide) |
| Flood of `SUSPICIOUS_DIRECTORY` signals | A monitored staging dir contains *any* file — the directory is flagged even when the file itself isn't on the suspicious list | Inspect the actual `directory` path; this is expected behaviour, not a bug |
| Events dropped after a failed send | The record bookmark advances before the POST; if the POST fails those events aren't re-sent | Check network to `aria.endpoint`; treat ARIA as best-effort telemetry, not guaranteed delivery |
## Not applicable here
No journal/incident (`EventType`) integration, no fleet-task execution. Its only output is HTTP POSTs to ARIA (`/api/aria/ingest/events`, `/api/aria/posture/report`).
@@ -0,0 +1,96 @@
---
module: agent.atec-tcr-events
title: ATEC TCR Events — cassette + process monitoring for LG Teller Cash Recyclers
tab: Architect
order: 30
audience: bcos
---
> **Internal · architecture.** Written 2026-07-01 from `hiveops-module-atec-tcr-events` source. Verify against code before relying on specifics.
## Module shape
Package `com.hiveops.tcrevents`, three classes, no controllers/DB/Kafka (this is an agent module, not a microservice — see [technical.agent]):
- **`TcrEventsModule`** — the `AgentModule` SPI entry point (registered via `META-INF/services/com.hiveops.core.module.AgentModule`). Owns lifecycle (`initialize()` → `isEnabled()` → `start()` → `stop()`) and the shared `ScheduledExecutorService`.
- **`CstSnsLogMonitor`** (package-private) — parses `CST_SNR_MMDD.log`.
- **`TcrAccessLogMonitor`** (package-private) — parses `ACESSLog<MMDD>.txt` (code: `logDir.resolve("ACESSLog" + mmdd + ".txt")` — **no underscore** before the date; the class javadoc's `ACESSLog_MMDD.txt` is stale).
Both monitors are plain constructed objects (`new CstSnsLogMonitor(logDir, atmName, client)`), not their own `AgentModule`s — `TcrEventsModule` drives both from a single poll loop.
## Flow: poll → parse → detect → emit
```
TcrEventsModule.start()
→ ScheduledExecutorService (daemon thread "tcr-events", fixed-rate, every 30s — see internal.md re: config bug)
→ poll()
→ cstMonitor.poll() // CstSnsLogMonitor
→ accessMonitor.poll() // TcrAccessLogMonitor
```
Each monitor uses **tail-by-offset** log reading, the same pattern as the agent's journal readers:
- Tracks `currentFile` + `fileOffset` (byte position) per monitor.
- On each `poll()`, opens the file with `RandomAccessFile`, seeks to `fileOffset`, reads new lines to EOF, advances `fileOffset` to `raf.getFilePointer()`.
- Daily rotation: `rotateDailyFileIfNeeded()` compares `LocalDate.now()` to the last-seen date; on a new day it recomputes the filename (`CST_SNR_<MMDD>.log` / `ACESSLog<MMDD>.txt` — the date is `MMdd` via `DateTimeFormatter.ofPattern("MMdd")`) and resets `fileOffset` to 0.
- Truncation guard: if `raf.length() < fileOffset` (file was truncated/replaced mid-session), `CstSnsLogMonitor` resets offset + in-progress parse state; `TcrAccessLogMonitor` resets offset only (no block state to clear).
### `CstSnsLogMonitor` — block-based parsing
The CST_SNR log groups related lines into operation blocks:
```
>>>>> HH:MM:SS.mmm - <OP> START (OP = RP/Replenishment, TD/Teller Deposit, BC/Bill Count, ...)
...
[N_E] AH: NORM AL: EMPT BH: NORM ...
...
>>>>> HH:MM:SS.mmm - <OP> END
```
- `BLOCK_START` / `BLOCK_END` regexes toggle an `inBlock` flag and accumulate `blockLines`.
- On `BLOCK_END`, `parseBlock()` finds the single `[N_E]` line, tokenizes `SLOT: STATUS` pairs (`SLOT_TOKEN` regex), and diffs each slot against an in-memory `Map<String,String> slotStatus` — **only emits when status actually changes** (no dedup on the server side, so this in-agent memory is the only guard against event storms).
- Status → event mapping: `EMPT`→`CASSETTE_EMPTY`, `SNRF`→`TCR_SENSOR_FAULT`, `TMAN`→`TCR_MANUAL_REQUIRED`, `NORM`→ log-only (no event) recovery notice.
- A separate `MISMATCH` regex is checked on **every line independent of block state** (not gated by `inBlock`) — catches `** CST <slot> - SNR deposit count & 80 data deposit count mismatch! ... - <dataCount> - <snsCount>` and emits `TCR_SENSOR_MISMATCH` immediately.
- `slotStatus` map is per-`CstSnsLogMonitor` instance (i.e. per-agent-process lifetime), cleared on file truncation or day rollover — so a slot that was `EMPT` yesterday will re-emit `CASSETTE_EMPTY` today if it's still empty at the first block of the new day (this is intentional: state doesn't carry across the daily file rotation).
### `TcrAccessLogMonitor` — line-based parsing
Simpler: no blocks, just substring matches per line:
- `"Process restarted from abnormal termination"` → `TCR_PROCESS_RESTART`
- `"## Shutdown ##"` → `TCR_SHUTDOWN` (message: "TCR operator shutdown")
- `"## Shutdown for reboot ##"` → `TCR_SHUTDOWN` (message: "TCR shutdown for reboot" — same event type, different `eventDetails` text based on whether the line contains `"reboot"`)
No de-dup logic here — every matching line emits, every poll cycle it appears in the unread tail.
## How events reach the platform
Both monitors emit through the same shared `IncidentEventClient` instance (constructed once in `TcrEventsModule.initialize()`), which is the same client class used by `journal-events` and other event-reporting modules — see [technical.agent] for the full outbound API surface.
```
CstSnsLogMonitor/TcrAccessLogMonitor.emit(eventType, details, ...)
→ CreateJournalEventRequest.builder()
.agentAtmId(atmId) // ATM name string, not incident's numeric atmId — resolved server-side
.eventType(eventType) // free-form String, e.g. "TCR_SENSOR_FAULT" — not validated client-side
.eventDetails(details)
.cassettePosition(slot) // CstSnsLogMonitor only; null for TcrAccessLogMonitor events
.eventSource("TCR_CST_SNR" | "TCR_ACESS_LOG")
.build()
→ IncidentEventClient.sendEvent(req)
→ HTTP POST {agent.endpoint or incident.endpoint}/api/journal-events
```
`eventType` on `CreateJournalEventRequest` is a plain `String` field (`hiveops-agent-journal`'s DTO) — the agent's own `EventType` enum (`com.hiveops.events.EventType`) does **not** include the five `TCR_*` values this module emits (only `CASSETTE_EMPTY` overlaps with the enum). Nothing client-side stops an unregistered type from being sent; recognition/labeling happens server-side against hiveops-incident's DB-backed `event_type` table.
## Auth / endpoint selection
`initialize()` picks the property prefix based on whether `agent.endpoint` is set:
```java
String prefix = agentEndpoint != null ? "agent" : "incident";
HttpClientSettings incidentSettings = HttpClientHelper.loadSettingsFromProperties(props, prefix);
```
`HttpClientHelper.loadSettingsFromProperties` resolves institution key with fallback order `{prefix}.institution.key` → `agent.institution.key` → `incident.institution.key`, and always reads the bearer token from `agent.auth.token` regardless of prefix. This module has no bespoke auth handling — it's 100% the shared agent HTTP settings pattern.
## Platform abstraction
None used directly. The module reads plain files via `java.nio.file` / `RandomAccessFile` — no `PlatformService`/`PathResolver` dependency, even though it's Windows-only in practice (the LG TCR software and its log format only exist on ATEC Windows kiosks). The Windows-only constraint is enforced by convention/deployment (only installed on `device.type=ATEC_TCR` machines) rather than by a runtime OS check in this module's code.
## Cross-links
- [technical.agent] — module system, lifecycle, shared HTTP client settings, full outbound API surface
- [platform.module-map]
@@ -0,0 +1,62 @@
---
module: agent.atec-tcr-events
title: ATEC TCR Events — cassette + process monitoring for LG Teller Cash Recyclers
tab: Claude
order: 40
audience: bcos
---
> **Internal · agent reference.** Terse. Confirmed against `hiveops-module-atec-tcr-events` source 2026-07-01.
## Identity
- Maven artifact: **`hiveops-module-atec-tcr-events`** (`com.hiveops`, version `1.0.0`, packaged as a standalone `jar`, parent `hiveops-agent-parent`).
- Agent module id (`getName()`, and the string to use in **`modules.disabled`**): **`tcr-events`** — NOT `atec-tcr-events`.
- Entry class: `com.hiveops.tcrevents.TcrEventsModule`, registered via `META-INF/services/com.hiveops.core.module.AgentModule`.
- Log package: `com.hiveops.tcrevents`.
- Activation gate: `device.type` must equal `ATEC_TCR` (or legacy `TCR`); any other value → module inert, `isEnabled()` returns `false`.
- Platform: Windows-only in practice (no runtime OS check in code — enforced by deployment convention).
## Config properties (exact keys)
| Key | Default | Required | Notes |
|---|---|---|---|
| `tcr.events.enabled` | `true` | no | `false` (case-insensitive) disables the module |
| `tcr.log.dir` | *(none)* | **yes** | must exist and be a directory or module disables itself with a warning log, no crash |
| `tcr.events.poll.interval.sec` | `30` | no | **read but NOT applied** — `start()` hardcodes `DEFAULT_POLL_SEC=30`; changing this property does nothing today |
Shared (not module-specific): `agent.endpoint` / `incident.endpoint` (prefix selection: `agent` if `agent.endpoint` is set, else `incident`), `agent.auth.token`, `agent.institution.key` / `incident.institution.key`.
## Event types emitted (exact strings)
| Event type | Source file | Trigger |
|---|---|---|
| `CASSETTE_EMPTY` | `CST_SNR_MMDD.log` | slot status transitions to `EMPT` |
| `TCR_SENSOR_FAULT` | `CST_SNR_MMDD.log` | slot status transitions to `SNRF` |
| `TCR_MANUAL_REQUIRED` | `CST_SNR_MMDD.log` | slot status transitions to `TMAN` |
| `TCR_SENSOR_MISMATCH` | `CST_SNR_MMDD.log` | line matches `** CST <slot> - SNR deposit count ... mismatch ...` |
| `TCR_PROCESS_RESTART` | `ACESSLog<MMDD>.txt` | line contains `Process restarted from abnormal termination` |
| `TCR_SHUTDOWN` | `ACESSLog<MMDD>.txt` | line contains `## Shutdown ##` or `## Shutdown for reboot ##` |
- `eventType` is a free-form `String` on `CreateJournalEventRequest` (`hiveops-agent-journal` DTO) — **not validated client-side**.
- The agent's own `com.hiveops.events.EventType` enum only contains `CASSETTE_EMPTY` from this list — the five `TCR_*` strings are **not** in that enum. Recognition happens in hiveops-incident's DB-backed `event_type` table, not agent code.
- `eventSource` sent: `"TCR_CST_SNR"` (cassette log events) or `"TCR_ACESS_LOG"` (access log events).
- `cassettePosition` (slot, e.g. `AH`, `AL`) is only populated for `CstSnsLogMonitor` events; null for access-log events.
- Transport: `POST {endpoint}/api/journal-events` via `IncidentEventClient.sendEvent()` — same client/endpoint as `journal-events` module.
- Recovery to `NORM` status emits **no event** — agent-log-only (`slot {} recovered`).
## Files monitored
- `{tcr.log.dir}/CST_SNR_<MMDD>.log` — cassette sensor/status log (daily rotation; date = `MMdd`, e.g. `CST_SNR_0701.log`).
- `{tcr.log.dir}/ACESSLog<MMDD>.txt` — TCR process/access log (daily rotation; **no underscore** in code: `ACESSLog0701.txt`).
- Both use tail-by-offset (`RandomAccessFile` + stored `fileOffset`), reset on daily rollover or file truncation.
## Deployment
- Delivered fleet-wide via **`INSTALL_MODULE`** fleet task (drops JAR into agent's `modules/` dir, restarts agent — `ProcessInstallModule` in `hiveops-file-collection`).
- Disable without uninstalling: add `tcr-events` to `modules.disabled`, push `CONFIG_UPDATE`.
- Event types must be registered in hiveops-incident → Settings → Event Types (DB `event_type` table) to be recognized/labeled correctly.
## Do NOT
- Do NOT use `atec-tcr-events` as the `modules.disabled` value — the actual module id is `tcr-events`.
- Do NOT expect changing `tcr.events.poll.interval.sec` to change the poll cadence — it's a known no-op bug; cadence is always 30s.
- Do NOT assume the five `TCR_*` event type strings exist in the agent's `EventType` enum — they don't; only `CASSETTE_EMPTY` does. Don't "fix" this by adding them there — the enum isn't what gates recognition (the DB `event_type` table in hiveops-incident is).
- Do NOT expect this module to run on non-`ATEC_TCR` device types — it's a no-op by design elsewhere, not a bug to chase.
- Do NOT expect retry/queueing on send failure — a failed `sendEvent()` call is logged and dropped, not persisted or retried on the next poll.
- Do NOT look for an underscore in the access-log filename — the code is `ACESSLog<MMDD>.txt` (e.g. `ACESSLog0701.txt`), not `ACESSLog_0701.txt`; the class javadoc's `ACESSLog_MMDD.txt` is stale.
- Do NOT confuse this with `hiveops-module-nh-tcr-events` (`NhTcrEventsModule`) or `hiveops-module-nh-tcr-journal` (`NhTcrJournalModule`) — separate modules, separate vendor (NH TCR), different log formats and event pipelines.
@@ -0,0 +1,54 @@
---
module: agent.atec-tcr-events
title: ATEC TCR Events — cassette + process monitoring for LG Teller Cash Recyclers
tab: Internal
order: 20
audience: dev
---
> **Internal · ops/support.** Written 2026-07-01 from `hiveops-module-atec-tcr-events` source. Verify against code before relying on specifics.
## What this module is
`hiveops-module-atec-tcr-events` (Maven artifact `hiveops-module-atec-tcr-events`, agent module id **`tcr-events`**) is an **agent drop-in module**, not a backend service. It runs inside the HiveIQ Agent process on **ATEC/LG TCR (Teller Cash Recycler) devices only** — Windows-only, and it self-disables unless the ATM's `device.type` is `ATEC_TCR` (legacy alias `TCR`).
It tails two log files the LG TCR software already writes on disk and turns specific lines into HiveIQ journal events sent to the incident pipeline — the module does not talk to the recycler hardware directly, and it does not touch cassette counts/cash totals (that's reconciliation's job, not this module's).
## What it monitors
Two independent pollers, both driven by a single scheduler tick (default every 30s):
1. **`CST_SNR_MMDD.log`** (daily-rotated) — cassette sensor/status log. The module watches for:
- Per-slot status transitions inside `[N_E]` lines (e.g. `AH: NORM AL: EMPT`) — only emits when a slot's status *changes*, so a slot stuck in `EMPT` doesn't re-fire every poll.
- A `SNR deposit count ... mismatch` warning line (deposit-count sensor vs. transaction-data mismatch).
2. **`ACESSLog<MMDD>.txt`** (daily-rotated — note the firmware spelling `ACESS` and, in the code, **no underscore** before the date: e.g. `ACESSLog0701.txt`) — TCR process/access log. The module watches for:
- `Process restarted from abnormal termination`
- `## Shutdown ##` / `## Shutdown for reboot ##`
Both files are expected in the **same directory**, configured once via `tcr.log.dir`.
## Config properties it registers
| Property | Default | Meaning |
|---|---|---|
| `tcr.events.enabled` | `true` | Master on/off switch for this module (checked in addition to the `device.type` gate). |
| `tcr.log.dir` | *(none — required)* | Directory containing `CST_SNR_<MMDD>.log` and `ACESSLog<MMDD>.txt`. If unset or not a real directory, the module logs a warning and disables itself — it does **not** throw/crash agent startup. |
| `tcr.events.poll.interval.sec` | `30` | Poll interval in seconds. **Known bug:** `initialize()` reads this value only to log it — `start()` schedules the poller with the hardcoded `DEFAULT_POLL_SEC` (30s) regardless of what's configured. Changing this property currently has no effect. |
It also reuses the shared agent HTTP settings — `agent.endpoint` (preferred) or `incident.endpoint` (legacy fallback), `agent.auth.token`, and the institution-key chain (`agent.institution.key` / `incident.institution.key`) — the same properties every other event-reporting module uses. There are no TCR-specific auth properties.
## How it's enabled / deployed
- It only activates on ATMs configured with `device.type=ATEC_TCR` (or the legacy `TCR` value). On any other device type it logs `device.type is '<X>', not ATEC_TCR — module disabled` and does nothing — this is expected, not a bug, on non-TCR machines.
- Delivered like any other agent module: uploaded to the fleet artifact store and pushed via an **`INSTALL_MODULE`** fleet task, which drops the module JAR into the agent's `modules/` directory and restarts the agent service (see `ProcessInstallModule` in `hiveops-file-collection`). Standard rollout order applies: lab ATM → pilot device → fleet.
- Can be disabled fleet-wide without removing the JAR by adding `tcr-events` to the comma-separated `modules.disabled` property and pushing a `CONFIG_UPDATE` (module id for `modules.disabled` is **`tcr-events`**, not `atec-tcr-events` — see Claude doc for the exact string).
- Event types it emits (`CASSETTE_EMPTY`, `TCR_SENSOR_FAULT`, `TCR_MANUAL_REQUIRED`, `TCR_SENSOR_MISMATCH`, `TCR_PROCESS_RESTART`, `TCR_SHUTDOWN`) must exist as rows in hiveops-incident's `event_type` table (Incident → Settings → Event Types) for them to be recognized/labeled properly downstream. `CASSETTE_EMPTY` is a standard type shared with other modules; the five `TCR_*` types are specific to this module and may need to be added manually on a new environment.
## Common failure modes / what to check
- **No TCR events ever appear for a customer ATM** — first check `device.type` on that ATM's config; if it isn't `ATEC_TCR`/`TCR`, the module is intentionally inert. Not a bug.
- **Module loaded but silent** — check `tcr.log.dir` is set and points at a real, existing directory on that machine; a missing/wrong path disables the module at startup with a log warning, no crash.
- **Events stopped after midnight / around log rotation** — both monitors roll to a new `MMDD`-named file at local midnight; if the LG TCR software's file-naming convention ever changes (e.g. non-standard rotation, DST edge case), the module will watch a file that never appears and go silent. Check agent logs for `tcr-events: watching <path>` to confirm which file it's tailing.
- **Events sent but not visible/categorized in Incident** — check that `CASSETTE_EMPTY`, `TCR_SENSOR_FAULT`, `TCR_MANUAL_REQUIRED`, `TCR_SENSOR_MISMATCH`, `TCR_PROCESS_RESTART`, `TCR_SHUTDOWN` are registered in hiveops-incident's Event Types settings — these are DB-driven, not hardcoded in incident's code.
- **HTTP send failures** — `IncidentEventClient.sendEvent()` failures are caught and logged (`tcr-events: failed to send ...`) but not retried or queued; a transient outage silently drops that single event rather than backing it up for a later poll.
- **Slot recovered (NORM) events don't create tickets** — by design; recovery only writes an agent-side log line (`slot {} recovered`), no event is emitted for a return to `NORM`.
@@ -0,0 +1,107 @@
---
module: agent.atm-config-sync
title: ATM Config Sync — pushes ATM hardware/software/cassette config to the platform
tab: Architect
order: 30
audience: bcos
---
> **Internal · architecture.** Written 2026-07-01 from `hiveops-module-atm-config-sync` source. Verify against code before relying on specifics.
## Module shape
Package `com.hiveops.configsync`, a **single class**, no controllers/DB/Kafka (this is an agent module, not a microservice — see [technical.agent]):
- **`AtmConfigSyncModule`** — the `AgentModule` SPI entry point (registered via `META-INF/services/com.hiveops.core.module.AgentModule`). Owns the full lifecycle (`initialize()` → `isEnabled()` → `start()` → `stop()`), the `IncidentEventClient`, and a single-thread `ScheduledExecutorService`.
No separate monitors or parsers of its own — it **orchestrates existing agent parsers/readers** (from `hiveops-version`, `hiveops-agent-journal`, and the platform layer) and assembles their output into one DTO. All heavy lifting (applog parsing, MoniPlus2S parsing, cassette/version reads) is delegated to shared, `provided`-scope components supplied by the fat JAR at runtime.
## Lifecycle & flow
Unlike the log-tailing modules, this is a **poll-and-assemble-then-POST** module, not a log tail:
```
initialize(ctx)
→ resolve endpoint: agent.endpoint (preferred) else incident.endpoint; if neither → return (ready=false)
→ prefix = agent.endpoint != null ? "agent" : "incident"
→ HttpClientSettings via HttpClientHelper.loadSettingsFromProperties(props, prefix)
→ new IncidentEventClient(settings, atmName, country)
→ ready = true
→ syncAtmConfig() // one immediate sync at startup
isEnabled(ctx) → ready // true only if an endpoint was resolved
start()
→ ConfigResyncRegistry.register(this::syncAtmConfig) // on-demand RESYNC_CONFIG hook
→ ScheduledExecutorService (daemon thread "config-resync")
.scheduleAtFixedRate(syncAtmConfig, initialDelay=0, period=6h)
stop() → scheduler.shutdownNow()
```
### `syncAtmConfig()` — the one method that does everything
```
awaitReconnectJitter() // ConnectionStateTracker — post-reconnect anti-stampede
AtmConfigSyncRequest req = new(...)
req.atmName / country / agentVersion // from ModuleContext + agentContext.getVersion()
req.startupDetectionMethod // startup-info.properties → startup.detection.method (if file exists)
req.timezone = TimeZone.getDefault().getID()
req.institutionKey // incident.institution.key (only)
// platform ConfigurationReader (Optional-returning reads, each set only if present):
req.systemName / customizingVersion / hotfixVersion / countrySpecificVersion
req.cassettes = readCassetteDenominations() → List<CassetteConfig>(position, currency, value)
// applog file (only if applog.events.dir set):
req.hyosungVersion = HyosungVersionParser.parseFromFile(applogFile) // app/SDK/GSDK/Nextware
req.systemInfo = AppLogHeaderParser.parseFromFile(applogFile) // OS + installed programs/packages
// MoniPlus2S dir (moniplus2s.dir, or C:\Hyosung\MoniPlus2S on Windows):
req.moniplus2sConfig = MoniPlus2SConfigParser.parse()
req.cassettes (fallback) = parseCassettesFromHostNoteTypes(...) // only if cassettes still empty
req.moniplus2sLayeredConfig = MoniPlus2SConfigParser.parseAllLayers()
client.syncConfig(req) // POST — see below
```
Every step is best-effort: a null/absent source is skipped, and the whole body is wrapped in try/catch that logs `ATM config sync failed: <msg>` (no rethrow, no retry queue). The scheduler's next 6h tick is the retry.
### Applog file resolution
`applog.events.dir` + `applog.events.filename.format` (default `APLog{YYYY}{MM}{DD}.log`) feed an `AppLogSource(dir, format, atmName)`, whose `getCurrentLogFile()` returns the file for today. That single file is handed to both `HyosungVersionParser` and `AppLogHeaderParser`.
### MoniPlus2S path handling
Java `.properties` parsing strips single backslashes, so `moniplus2s.dir=C:\Hyosung\MoniPlus2S` arrives as `C:HyosungMoniPlus2S`. The module checks `new File(dir).isDirectory()`; if it fails it logs a warning, nulls the value, and (on Windows) falls back to the hardcoded `C:\Hyosung\MoniPlus2S`.
## How the payload reaches the platform
Transport is the shared `IncidentEventClient` (from `hiveops-agent-journal`) — the same client used by the event modules, but a **different method and endpoint**:
```
IncidentEventClient.syncConfig(AtmConfigSyncRequest req)
→ POST {agent.endpoint | incident.endpoint}/api/atms/config/sync
Content-Type: application/json (Jackson-serialized AtmConfigSyncRequest)
→ accepts HTTP 200 / 201 / 202; anything else → logErrorResponse + throw IOException
```
Note this is **not** `/api/journal-events` — config sync has its own `/api/atms/config/sync` endpoint. The backend keys off `atmName`/`country` in the body to find/update the device record. In production the base URL points at **agent-proxy** (`agent.endpoint`), which forwards to the device/incident backends; the legacy `incident.endpoint` path targets incident directly.
## On-demand resync wiring
`ConfigResyncRegistry` (in `hiveops-core`) is a static single-slot registry decoupling the `RESYNC_CONFIG` command handler from the resync implementation:
```
AtmConfigSyncModule.start() → ConfigResyncRegistry.register(this::syncAtmConfig) // sets one volatile callback
CommandCenterModule (RESYNC_CONFIG task) → ConfigResyncRegistry.trigger() → callback.run()
```
It holds exactly **one** `volatile Runnable` — last registrant wins. The registry's javadoc references `JournalEventModule` as the historical registrant, but in the current tree **`AtmConfigSyncModule` is the only caller of `register()`**, so it owns the `RESYNC_CONFIG` trigger. If another module ever also registers, whichever `start()`s last silently overwrites the other. If nothing registered (module disabled), `trigger()` logs a warning and does nothing.
## Platform abstraction
Uses the platform layer directly (unlike the file-tailing modules):
- `context.getPlatformService().getConfigurationReader()` → `readSystemName / readCustomizingVersion / readHotfixVersion / readCountrySpecificVersion` (all `Optional`) and `readCassetteDenominations()`.
- `context.getPlatformService().isWindows()` → gates the `C:\Hyosung\MoniPlus2S` fallback.
The `ConfigurationReader` abstracts Windows (registry) vs Linux (file/env) config sources, so the same module works cross-platform without OS branching in this class (aside from the Windows-only MoniPlus2S default).
## Cross-links
- [technical.agent] — module system, lifecycle, `ModuleContext`, shared `IncidentEventClient` / HTTP settings, platform abstraction
- [platform.module-map]
@@ -0,0 +1,66 @@
---
module: agent.atm-config-sync
title: ATM Config Sync — pushes ATM hardware/software/cassette config to the platform
tab: Claude
order: 40
audience: bcos
---
> **Internal · agent reference.** Terse. Confirmed against `hiveops-module-atm-config-sync` source 2026-07-01.
## Identity
- Maven artifact: **`hiveops-module-atm-config-sync`** (`com.hiveops`, version `1.0.0`, packaged as a standalone `jar`, parent `hiveops-agent-parent` `4.4.3`).
- Agent module id (`getName()`, and the string for **`modules.disabled`**): **`device-config-sync`** — NOT `atm-config-sync`.
- Entry class: `com.hiveops.configsync.AtmConfigSyncModule`, registered via `META-INF/services/com.hiveops.core.module.AgentModule`.
- Log package (`getLogPackage()`): `com.hiveops.configsync`.
- Dependencies (`getDependencies()`): none (empty list).
- Single class — no separate monitor/parser classes of its own.
## Behavior (not an event/log-tail module)
- Emits **NO** journal/incident events. Sends one consolidated config payload.
- `isEnabled()` = `ready` = true only if `agent.endpoint` OR `incident.endpoint` resolved at `initialize()`. No `device.type` gate, no dedicated enable flag.
- Sync triggers: (1) once at `initialize()`; (2) every **6h** fixed-rate (`RESYNC_INTERVAL_HOURS=6`, initialDelay 0, daemon thread `config-resync`); (3) on-demand via **`RESYNC_CONFIG`** fleet command → `ConfigResyncRegistry.trigger()`.
- Each sync calls `ConnectionStateTracker.awaitReconnectJitter()` first (post-reconnect anti-stampede).
- Failure = logged (`ATM config sync failed: ...`), NOT retried/queued; next 6h tick or `RESYNC_CONFIG` recovers.
## Config properties (exact keys)
| Key | Default | Notes |
|---|---|---|
| `agent.endpoint` | *(none)* | preferred base URL; if set → HTTP prefix `agent` |
| `incident.endpoint` | *(none)* | legacy fallback; used only if `agent.endpoint` unset → prefix `incident`. Neither set → module disabled |
| `incident.institution.key` | *(none)* | stamped on payload. Reads ONLY this key (not `agent.institution.key`) |
| `applog.events.dir` | *(none)* | dir for applog file; unset → Hyosung version + system info skipped |
| `applog.events.filename.format` | `APLog{YYYY}{MM}{DD}.log` | applog filename pattern |
| `moniplus2s.dir` | Win fallback `C:\Hyosung\MoniPlus2S` | if value isn't a real dir (backslash stripping) → warn + Windows default |
Bearer token / HTTP params: shared `HttpClientHelper.loadSettingsFromProperties(props, prefix)` (`agent.auth.token` chain). No module-specific auth.
## Transport (exact)
- Method: `IncidentEventClient.syncConfig(AtmConfigSyncRequest)`.
- Endpoint: **`POST {endpoint}/api/atms/config/sync`** (NOT `/api/journal-events`).
- Body: Jackson-serialized `AtmConfigSyncRequest` (`com.hiveops.events.dto`).
- Success: HTTP 200/201/202; else throws `IOException`.
- Backend resolves device by `atmName` + `country` in body.
## Payload fields (`AtmConfigSyncRequest`)
- Identity: `atmName`, `country`, `agentVersion`, `timezone` (`TimeZone.getDefault().getID()`), `institutionKey`, `startupDetectionMethod` (from `startup-info.properties` → `startup.detection.method`).
- Versions (platform `ConfigurationReader`): `systemName`, `customizingVersion`, `hotfixVersion`, `countrySpecificVersion`.
- `cassettes`: `List<CassetteConfig>{position, currency, denomination}` from `readCassetteDenominations()`; fallback = MoniPlus2S `HostNoteTypes` parse only if still empty.
- `hyosungVersion` (`HyosungVersionParser`): app/SDK/GSDK/Nextware/componentVersions.
- `systemInfo` (`AppLogHeaderParser`): OS + installed programs/packages.
- `moniplus2sConfig` + `moniplus2sLayeredConfig` (`MoniPlus2SConfigParser.parse()` / `.parseAllLayers()`).
## Deployment
- Delivered via **`INSTALL_MODULE`** fleet task (drops JAR into agent `modules/` dir — `ProcessInstallModule` in `hiveops-file-collection`).
- Disable without uninstall: add `device-config-sync` to `modules.disabled`, push `CONFIG_UPDATE`.
- Force immediate resync: `RESYNC_CONFIG` fleet command (no restart).
## Do NOT
- Do NOT use `atm-config-sync` as the `modules.disabled` value — the real id is **`device-config-sync`**.
- Do NOT look for emitted events — this module sends config, not `*_EVENT`s; there are no event-type strings.
- Do NOT expect it to POST to `/api/journal-events` — it uses `/api/atms/config/sync`.
- Do NOT expect `agent.institution.key` to be picked up for the payload — only `incident.institution.key` is read here.
- Do NOT put a single-backslash Windows path in `moniplus2s.dir` — properties strip it; double the backslashes or rely on the `C:\Hyosung\MoniPlus2S` default.
- Do NOT expect send failures to retry — they're logged and dropped; recovery is the next 6h tick / `RESYNC_CONFIG`.
- Do NOT assume `RESYNC_CONFIG` works if this module is disabled — it's the sole `ConfigResyncRegistry` registrant; disabled → `trigger()` no-ops with a warning.
- Do NOT expect a `device.type`/enable gate — the only gate is "an endpoint is configured."
@@ -0,0 +1,70 @@
---
module: agent.atm-config-sync
title: ATM Config Sync — pushes ATM hardware/software/cassette config to the platform
tab: Internal
order: 20
audience: dev
---
> **Internal · ops/support.** Written 2026-07-01 from `hiveops-module-atm-config-sync` source. Verify against code before relying on specifics.
## What this module is
`hiveops-module-atm-config-sync` (Maven artifact `hiveops-module-atm-config-sync`, agent module id **`device-config-sync`**) is an **agent drop-in module**, not a backend service. It runs inside the HiveIQ Agent process and periodically **snapshots the ATM's configuration and inventory** and POSTs it to the HiveIQ backend so the device record (Devices / ATM detail) stays current: software versions, timezone, cassette denominations, and Hyosung MoniPlus2S settings.
Unlike the event-reporting modules (journal-events, tcr-events), it emits **no journal/incident events**. It sends one consolidated **config-sync payload** (`AtmConfigSyncRequest`) to a dedicated endpoint. Think of it as "device metadata sync," not "alerting."
## What it collects and syncs
Each sync builds one `AtmConfigSyncRequest` from whatever sources resolve on that machine (all optional — missing sources are simply omitted):
- **Identity / basics** — `atmName`, `country`, `agentVersion` (from the agent context), `timezone` (`TimeZone.getDefault().getID()`), and `institutionKey` (only from `incident.institution.key`).
- **Startup detection method** — read from `startup-info.properties` (`startup.detection.method`) in the agent's start dir, if present.
- **Software versions** — via the platform `ConfigurationReader`: `systemName`, `customizingVersion`, `hotfixVersion`, `countrySpecificVersion`.
- **Cassette denominations** — from `ConfigurationReader.readCassetteDenominations()`; if that yields nothing, it falls back to parsing cassettes out of the MoniPlus2S `HostNoteTypes`.
- **Hyosung version info** — parsed from the current applog file (`HyosungVersionParser`): application / SDK / GSDK / Nextware / component versions.
- **System info** — parsed from the applog header (`AppLogHeaderParser`): OS version, installed programs and packages.
- **MoniPlus2S config** — parsed from the MoniPlus2S directory (`MoniPlus2SConfigParser`): serial, model, host IP/port, plus a full **layered config** view (all config folders / XML files).
Payload is POSTed to **`{endpoint}/api/atms/config/sync`** via `IncidentEventClient.syncConfig(...)`. The backend resolves the device by `atmName`/`country`.
## When it syncs
Three triggers, all running the same `syncAtmConfig()` method:
1. **On agent startup** — `initialize()` runs one sync immediately (after the endpoint is resolved).
2. **On a fixed schedule** — a daemon scheduler (`config-resync` thread) re-syncs **every 6 hours** (`RESYNC_INTERVAL_HOURS = 6`), starting immediately at `start()`.
3. **On demand** — the module registers a callback with `ConfigResyncRegistry`, so a **`RESYNC_CONFIG`** fleet command (handled by the command processor) triggers an immediate re-sync without restarting the agent.
Every sync first calls `ConnectionStateTracker.awaitReconnectJitter()` — after a reconnect it waits a randomized jitter so a fleet coming back online doesn't stampede the backend all at once.
## Config properties it reads
This module has **no dedicated `enabled` flag and no `device.type` gate** — it activates whenever an endpoint is configured. Keys read from `hiveops.properties`:
| Property | Default | Meaning |
|---|---|---|
| `agent.endpoint` | *(none)* | Preferred backend base URL (agent-proxy). If set, HTTP settings use the `agent` prefix. |
| `incident.endpoint` | *(none)* | Legacy fallback base URL, used only if `agent.endpoint` is unset (`incident` prefix). If **both** are unset, the module returns early at init and stays disabled (`isEnabled()` → false). |
| `incident.institution.key` | *(none)* | Institution key stamped onto the sync payload. Note: this method reads **only** `incident.institution.key`, not `agent.institution.key`. |
| `applog.events.dir` | *(none)* | Directory holding the applog file used for Hyosung version + system info. If unset, version/system info are skipped. |
| `applog.events.filename.format` | `APLog{YYYY}{MM}{DD}.log` | Applog filename pattern used to resolve the current file. |
| `moniplus2s.dir` | Windows fallback `C:\Hyosung\MoniPlus2S` | MoniPlus2S config directory. If the property value doesn't resolve to a real directory (Java properties strip single backslashes, e.g. `C:\Hyosung` → `C:Hyosung`), it logs a warning and falls back to the hardcoded Windows path. |
Bearer token and other HTTP params come from the shared agent HTTP settings (`HttpClientHelper.loadSettingsFromProperties(props, prefix)`) — the same `agent.auth.token` chain every module uses. No config-sync-specific auth.
## How it's enabled / deployed
- **No per-device gate** — as long as `agent.endpoint` (or legacy `incident.endpoint`) is set, the module runs on that ATM. It is safe on any device type; it only reports what it can read.
- Delivered like any other agent module: uploaded to the fleet artifact store and pushed via an **`INSTALL_MODULE`** fleet task, which drops the module JAR into the agent's `modules/` directory (handled by `ProcessInstallModule` in `hiveops-file-collection`). Standard rollout order applies: lab ATM → pilot device → fleet.
- Can be disabled fleet-wide without removing the JAR by adding **`device-config-sync`** to the comma-separated `modules.disabled` property and pushing a `CONFIG_UPDATE`. (Disable string is `device-config-sync` — the `getName()` value — **not** `atm-config-sync`.)
- Force a fresh push at any time with a **`RESYNC_CONFIG`** fleet command — no restart needed.
## Common failure modes / what to check
- **Device metadata never populates for an ATM** — confirm `agent.endpoint`/`incident.endpoint` is set; with neither, the module is inert at startup (`isEnabled()` false). Also check the agent log for `ATM config sync complete` vs `ATM config sync failed: ...`.
- **Sync runs but versions/system info are blank** — usually `applog.events.dir` is unset or points at a directory with no matching `APLog…` file; the Hyosung/system-info blocks are simply skipped. Cassette/MoniPlus2S data can still be present.
- **MoniPlus2S config missing on a Hyosung box** — check the `moniplus2s.dir` warning in the log (`… not found (backslash escaping issue?)`). A single-backslash path in `hiveops.properties` gets mangled; either double the backslashes or rely on the `C:\Hyosung\MoniPlus2S` default.
- **Cassettes wrong/empty** — the platform `ConfigurationReader` is tried first; the MoniPlus2S `HostNoteTypes` fallback only fires when the reader returns none. If both are empty, no cassettes are sent.
- **Whole sync fails** — `syncAtmConfig()` wraps everything in a try/catch and logs `ATM config sync failed: <message>`; a failed HTTP call (`syncConfig` throws on non-2xx) is logged but **not** queued/retried — the next scheduled 6h tick (or a `RESYNC_CONFIG`) is the recovery path.
- **On-demand resync does nothing** — a `RESYNC_CONFIG` only fires if this module registered its callback (i.e. it's enabled and started). If it's in `modules.disabled`, the command center logs "no callback registered."
@@ -0,0 +1,101 @@
---
module: agent.ejournal-db-monitor
title: EJournal DB Monitor — how it's built
tab: Architect
order: 30
audience: bcos
---
# EJournal DB Monitor (Architecture)
A single-class agent module, `com.hiveops.ejournal.EJournalDbMonitorModule`,
registered as an SPI service via
`META-INF/services/com.hiveops.core.module.AgentModule`. It plugs into the standard
agent module lifecycle (`initialize` → `isEnabled` → `start` → `stop`) — see
[technical.agent] for the module system as a whole.
## Module shape
- **Artifact:** `hiveops-module-ejournal-db-monitor.jar` (Maven `finalName`), module
version `1.0.0`, parent `hiveops-agent-parent` `4.4.3`.
- **Dependencies** (all `provided` — supplied by the fat JAR at runtime, not shaded
in): `hiveops-core` (module API), `hiveops-agent-journal` (`EventType`,
`IncidentEventClient`, `CreateJournalEventRequest`, `HttpClientHelper`),
`log4j-api`, `commons-lang3`.
- `getName()` → `ejournal-db-monitor`; `getDependencies()` → empty (no ordering
constraints); `getLogPackage()` → `com.hiveops.ejournal`.
## Flow: poll → detect → emit
The module is a **poller**, not a log-tail or file-watch. There is no hook into the
ATM app; it just stats a file on a schedule.
1. **`initialize(ModuleContext)`**
- Reads `ejournal.db.monitor.enabled`; returns early if disabled.
- Resolves the event endpoint: `agent.endpoint` preferred, else
`incident.endpoint`. If neither is set, returns early (module stays not-ready →
no-op). HTTP config loaded via `HttpClientHelper.loadSettingsFromProperties`
with prefix `agent` or `incident` accordingly.
- Builds an `IncidentEventClient` from those settings plus
`context.getAtmName()` and `context.getCountry()`.
- Parses thresholds/interval/cooldown into bytes/minutes/hours and sets
`ready = true`.
2. **`isEnabled(ModuleContext)`** → returns the `ready` flag set in `initialize`.
3. **`start()`** — creates a single-thread `ScheduledExecutorService` (daemon thread
named `ejournal-db-monitor`) and schedules `checkDatabaseSize()` at fixed rate,
starting immediately (`initialDelay = 0`), every `intervalMinutes`.
4. **`checkDatabaseSize()`** (each tick):
- `resolveDbFile()` — if `ejournal.db.monitor.filename` set, use it; otherwise
list the directory filtered to `.mdb/.sdf/.db/.sqlite` and pick the **largest**.
- `classify(sizeBytes)` → `NONE` / `WARNING` / `CRITICAL` against the byte
thresholds.
- State reset: if now `NONE` but previously alerted, log and clear
`lastAlertLevel`/`lastAlertTime` (DB was cleared).
- Suppression: send only if the level **escalated** (`ordinal()` increased) OR the
cooldown (`cooldownHours`) has elapsed since `lastAlertTime`.
- On send, record `lastAlertLevel` + `lastAlertTime`.
- The whole body is wrapped in try/catch so a bad tick logs and the schedule
continues.
5. **`sendAlert(level, file, sizeBytes)`** — maps level to `EventType`
(`EJOURNAL_DB_CRITICAL` or `EJOURNAL_DB_WARNING`), formats a human `eventDetails`
string (name, MB, % of 2 GB), and builds a `CreateJournalEventRequest`.
6. **`stop()`** — `scheduler.shutdownNow()`.
Alert state (`lastAlertLevel`, `lastAlertTime`, the `AlertLevel` enum
`NONE/WARNING/CRITICAL`) is **in-memory only** — it resets on agent restart, so the
first post-restart check that is over-threshold will alert again.
## How events reach the platform
The module reuses the shared journal-events pipeline rather than any bespoke
transport:
```
EJournalDbMonitorModule.sendAlert()
→ CreateJournalEventRequest.builder()
.agentAtmId(atmName)
.eventType(EJOURNAL_DB_WARNING | EJOURNAL_DB_CRITICAL)
.eventDetails(...)
.eventSource("HIVEOPS_AGENT_EJDB")
.build()
→ IncidentEventClient.sendEvent(event)
→ POST {endpoint}/api/journal-events (JSON)
```
`endpoint` is `agent.endpoint` (agent-proxy) when configured, else
`incident.endpoint` (direct to hiveops-incident). The ATM is resolved server-side by
`agentAtmId`; the module does not resolve a numeric `atmId` itself. From there the
event follows the normal HiveIQ incident path (auto-register + incident creation).
## Platform abstraction
The module itself uses **no** `PlatformService` — it only does `java.io.File`
size/listing, and the default directory is Windows-shaped (`D:\EJOURNAL`), matching
its Hyosung target. Platform-specific pieces (delivery to `modules/`, agent restart)
live in the `INSTALL_MODULE` handler (`ProcessInstallModule`), which does use
`PlatformService.getSystemCommands()` / `getPathResolver()`. For non-Windows or
non-standard installs, point `ejournal.db.monitor.dir` at the real path.
Cross-reference: [technical.agent] for the ServiceLoader module registry, lifecycle,
and the `IncidentEventClient` / `/api/journal-events` contract shared with the
`journal-events` module.
@@ -0,0 +1,82 @@
---
module: agent.ejournal-db-monitor
title: EJournal DB Monitor — act-without-guessing reference
tab: Claude
order: 40
audience: bcos
---
# EJournal DB Monitor (Claude quick-reference)
## Identity
- Module name (`getName()`): `ejournal-db-monitor`
- Entry class: `com.hiveops.ejournal.EJournalDbMonitorModule`
- Maven artifact / JAR: `hiveops-module-ejournal-db-monitor.jar` (module version `1.0.0`)
- Log package / thread name: `com.hiveops.ejournal` / `ejournal-db-monitor`
- Dependencies: none (`getDependencies()` empty)
- Purpose: poll Hyosung EJournal DB file size, alert before the **2 GB** write limit
## Config property keys (exact) — defaults from code
| Key | Default |
|-----|---------|
| `ejournal.db.monitor.enabled` | `true` |
| `ejournal.db.monitor.dir` | `D:\EJOURNAL` |
| `ejournal.db.monitor.filename` | *(blank → auto-pick largest DB file)* |
| `ejournal.db.monitor.warn.mb` | `1536` |
| `ejournal.db.monitor.critical.mb` | `2048` |
| `ejournal.db.monitor.interval.min` | `15` |
| `ejournal.db.monitor.cooldown.hours` | `24` |
Endpoint resolution (not module-specific keys, but required): uses `agent.endpoint`
if set, else `incident.endpoint`. Neither set → module is a silent no-op.
## Event types emitted (exact strings)
- `EJOURNAL_DB_WARNING` — size ≥ `warn.mb` (default 1.5 GB)
- `EJOURNAL_DB_CRITICAL` — size ≥ `critical.mb` (default 2.0 GB)
Both defined in `com.hiveops.events.EventType`. Sent via
`IncidentEventClient.sendEvent()` → `POST {endpoint}/api/journal-events`.
## Event payload fields
`CreateJournalEventRequest` with: `agentAtmId` = ATM name, `eventType` = one of the
two above (`.name()`), `eventDetails` = formatted string (filename, MB, % of 2 GB),
`eventSource` = `HIVEOPS_AGENT_EJDB` (constant — use to filter these events).
## Auto-detect file rule
If `ejournal.db.monitor.filename` is blank, picks the **largest** file in
`ejournal.db.monitor.dir` matching extensions: `.mdb`, `.sdf`, `.db`, `.sqlite`.
## Alert de-dup logic
- Fire only if level escalated (Warning→Critical) OR cooldown elapsed.
- Size dropping below warning threshold resets state (treated as DB cleared).
- State is in-memory only → resets on agent restart.
## Deploy / disable
- Deploy: fleet `INSTALL_MODULE` task (artifact `task.paths` or legacy
`configPatch.url`+`filename`, must end `.jar`) → lands in agent `modules/` →
agent restarts. Handler: `ProcessInstallModule`.
- Disable: `ejournal.db.monitor.enabled=false` OR add `ejournal-db-monitor` to
`modules.disabled` (push via `CONFIG_UPDATE`).
## Do NOT
- Do NOT assume events go to a dedicated endpoint — they use the shared
`/api/journal-events`, same as the `journal-events` module.
- Do NOT expect a numeric `atmId` from the module — it sends `agentAtmId` (name);
the server resolves the ATM.
- Do NOT expect persisted alert state across restarts — it is in-memory only.
- Do NOT expect Linux path defaults — default dir is `D:\EJOURNAL` (Hyosung/Windows);
set `ejournal.db.monitor.dir` for other layouts.
- Do NOT expect alert spam — one alert per level per cooldown (default 24 h) unless
escalating.
- Do NOT rely on it detecting DBs with extensions outside
`.mdb/.sdf/.db/.sqlite` unless you pin `ejournal.db.monitor.filename`.
- Do NOT count on any `PlatformService` use inside the module — it uses plain
`java.io.File` only.
@@ -0,0 +1,109 @@
---
module: agent.ejournal-db-monitor
title: EJournal DB Monitor — guards the Hyosung 2 GB journal write limit
tab: Internal
order: 20
audience: dev
---
# EJournal DB Monitor (Internal / Ops view)
## What it monitors and why
Hyosung MoniPlus2S ATMs store their electronic journal in a local database file
(Access `.mdb`, SQL Compact `.sdf`, or a plain `.db`/`.sqlite`). That engine has a
hard **2 GB** file-size limit — once the file reaches it, the ATM application
**stops writing journal data** and journal capture silently fails. There is no
customer-visible alarm on the machine itself.
The `ejournal-db-monitor` agent module watches that database file size on a timer
and raises HiveIQ incident events **before** the ATM hits the wall, so helpdesk can
schedule the journal-clear/maintenance while the ATM is still capturing.
Two thresholds (both grounded in `EJournalDbMonitorModule`):
| Level | Default trigger | Event emitted | Meaning |
|----------|-----------------|-------------------------|---------|
| Warning | 1.5 GB (1536 MB)| `EJOURNAL_DB_WARNING` | Early notice — plan maintenance |
| Critical | 2.0 GB (2048 MB)| `EJOURNAL_DB_CRITICAL` | At/near the limit — journal writing may stop, act immediately |
The event `eventDetails` string reports the file name, current size in MB, and the
percentage of the 2 GB limit consumed. Events are tagged with `eventSource =
HIVEOPS_AGENT_EJDB` so they can be told apart from normal journal-parsed events.
### Alert de-duplication
Once an alert level fires it is **not** repeated until one of:
- The level **escalates** (Warning → Critical), or
- The **cooldown** window elapses (default 24 h), or
- The file drops back **below** the warning threshold (i.e. the DB was cleared) —
this resets the alert state so the next growth cycle alerts fresh.
So a stuck-full ATM produces at most one Critical event per cooldown window, not a
flood.
## Config properties it registers
All read from the agent's main properties (`hiveops.properties`). Keys and defaults
come straight from `EJournalDbMonitorModule.initialize()`:
| Property | Default | Purpose |
|----------|---------|---------|
| `ejournal.db.monitor.enabled` | `true` | Master on/off. If `false`, module no-ops (never becomes ready). |
| `ejournal.db.monitor.dir` | `D:\EJOURNAL` | Directory to scan. |
| `ejournal.db.monitor.filename` | *(blank)* | Specific file to watch. If blank, auto-detects the **largest** file matching `.mdb` / `.sdf` / `.db` / `.sqlite`. |
| `ejournal.db.monitor.warn.mb` | `1536` | Warning threshold (MB). |
| `ejournal.db.monitor.critical.mb` | `2048` | Critical threshold (MB). |
| `ejournal.db.monitor.interval.min` | `15` | Check interval (minutes). First check runs immediately at start. |
| `ejournal.db.monitor.cooldown.hours` | `24` | Hours before re-alerting at the same level. |
The module also needs an event endpoint — it uses `agent.endpoint` if present
(preferred, agent-proxy), else falls back to `incident.endpoint`. HTTP settings are
loaded with the matching prefix (`agent.*` or `incident.*`). If **neither** endpoint
is set the module initializes to a no-op and never sends events.
## How it's enabled / deployed to ATMs
This is a **standalone drop-in module** (`pom.xml` `finalName =
hiveops-module-ejournal-db-monitor`, artifact `hiveops-module-ejournal-db-monitor.jar`,
module version `1.0.0`). It is not bundled in the fat JAR by default; it is discovered
at runtime via `ServiceLoader` from the agent's `modules/` directory.
Two ways to get it onto an ATM:
1. **Fleet `INSTALL_MODULE` task** (preferred) — delivers the module JAR to the
ATM's `modules/` directory, then the agent restarts to pick it up. Supports both
artifact-store delivery (chunked download, `task.paths`) and legacy URL delivery
(`configPatch.url` + `filename`, must end in `.jar`). See
`ProcessInstallModule` in `hiveops-file-collection`.
2. **Manual drop-in** — copy `hiveops-module-ejournal-db-monitor.jar` into the
agent install's `modules/` folder and restart the agent.
To turn it off without removing the JAR: either set
`ejournal.db.monitor.enabled=false`, or add `ejournal-db-monitor` to the
`modules.disabled` property in `hiveops.properties`. Both are pushable via a
`CONFIG_UPDATE` fleet task.
## Common failure modes / what to check
- **No events ever arrive** — check that `agent.endpoint` (or `incident.endpoint`)
is set; with neither, the module silently becomes a no-op. Also confirm the module
is present in `modules/` and not listed in `modules.disabled`.
- **"No EJournal database file found"** (debug log) — `ejournal.db.monitor.dir`
wrong for this box, or the DB uses an extension outside `.mdb/.sdf/.db/.sqlite`.
Set `ejournal.db.monitor.filename` explicitly.
- **Wrong file watched** — with no `filename` set, the module picks the *largest*
matching file in the directory. If backups or unrelated DBs share the dir, pin the
exact filename.
- **Expected an alert but none came** — the level may be inside its cooldown (24 h
default) and not yet escalated. Escalation Warning→Critical always fires
immediately regardless of cooldown.
- **Alerts stopped after a clear** — expected: dropping below the warning threshold
resets alert state; growth back up re-alerts.
- **Send failures** — `Failed to send EJournal DB ... event` in the log means the
size check ran but the HTTP POST to `/api/journal-events` failed; check agent-proxy
reachability. The check will retry on the next interval.
Log package for this module is `com.hiveops.ejournal` (thread name
`ejournal-db-monitor`).
@@ -0,0 +1,78 @@
---
module: agent.hw-inventory
title: Hardware Inventory — ATM hardware/OS inventory sync + CDN bandwidth probe
tab: Architect
order: 30
audience: bcos
---
> **Internal · architecture view.** Confirmed against `hiveops-module-hw-inventory` source 2026-07-01. See [technical.agent] for the module system as a whole.
## Shape
A tiny 4-type module: one `AgentModule` entry point, a collector SPI interface, and two platform collectors.
| Class | Role |
|---|---|
| `HardwareInventoryModule` | `AgentModule` entry point — lifecycle, scheduling, CDN probe, POST orchestration |
| `HardwareCollector` (interface) | `collect(atmName, country, institutionKey) → HardwareInventoryRequest` |
| `LinuxHardwareCollector` | Reads `/proc`, `/sys`, `/etc/os-release`, `lsblk`, Java `File.listRoots()` |
| `WindowsHardwareCollector` | Shells out to **PowerShell** (`Get-CimInstance`, `Get-HotFix`, registry reads) |
The transport (`IncidentEventClient.syncHardware`) and the payload DTO (`HardwareInventoryRequest`) live in the shared `hiveops-agent-journal` module (`com.hiveops.events`), reused here as a `provided` dependency.
## Lifecycle & flow
Standard SPI lifecycle (`initialize → isEnabled → start → stop`), driven by `ModuleRegistry`:
1. **`initialize(ModuleContext)`**
- Reads `hw.inventory.enabled`; if `false`, returns early (module never activates).
- Resolves the base URL: prefers `agent.endpoint`, falls back to `incident.endpoint`. If neither is set → inactive.
- Loads HTTP settings via `HttpClientHelper.loadSettingsFromProperties(props, prefix)` where `prefix` = `"agent"` or `"incident"` to match the chosen endpoint.
- Pulls identity from context: `getAtmName()`, `getCountry()`, and `incident.institution.key` from props.
- Picks the collector from `context.getPlatformService().isWindows()` → `WindowsHardwareCollector` or `LinuxHardwareCollector`.
- Sets `ready = true` and **calls `syncInventory()` once immediately** (a first sync happens during init, before `start()`).
2. **`isEnabled(context)`** → returns the `ready` flag.
3. **`start()`** — creates a single daemon `ScheduledExecutorService` (thread name `hw-inventory`) and `scheduleAtFixedRate(this::syncInventory, 0, intervalHours, HOURS)`. Because initial delay is `0`, this fires an **additional** sync right at startup (so a fresh agent syncs twice close together — once from `initialize`, once from the delay-0 schedule).
4. **`stop()`** — `scheduler.shutdownNow()`.
Each `syncInventory()`:
```
collector.collect(atmName, country, institutionKey) // build HardwareInventoryRequest
→ req.setCdnDownloadSpeedBps(probeCdnDownload()) // measure CDN bandwidth
→ client.syncHardware(req) // POST /api/atms/hardware/sync
```
The whole method is wrapped in try/catch; a failure logs `sync failed` and is swallowed so the schedule keeps running. Inside each collector, every sub-step (CPU, system, disks, drives, NICs, BIOS/boot, patches, …) is independently try/caught — a partial snapshot is still sent.
## How data reaches the platform
`IncidentEventClient.syncHardware(req)` does a plain `HttpURLConnection` **POST** of the JSON `HardwareInventoryRequest` to:
```
{endpoint}/api/atms/hardware/sync
```
with `Content-Type: application/json` and the shared HTTP settings (auth header, host header) applied by `HttpClientHelper`. `{endpoint}` is the agent-proxy base (`agent.endpoint`) on current ATMs, so the request lands on **agent-proxy** and is forwarded to the incident/devices hardware-sync handler — the same agent→agent-proxy path every other agent call uses. Accepts `200/201/202`; anything else throws `IOException`.
The ATM is identified by `atmName` + `country` in the payload (plus `institutionKey`); there is no client-side `atmId` resolution here — the platform maps it server-side.
## CDN bandwidth probe (`probeCdnDownload()`)
Runs inside the same sync, purely to populate `cdnDownloadSpeedBps`:
1. GET the manifest at `agent.update.check.url` (default `https://cdn.bcos.cloud/downloads/agent/latest.json`), sending `X-Institution-Key` if an institution key is set. Parsed leniently (`FAIL_ON_UNKNOWN_PROPERTIES=false`).
2. Resolve `patches.windows` or `patches.linux` from the manifest (chosen by `isWindows`); relative filenames are resolved against the manifest base URL.
3. HTTP **Range** request `bytes=0-524287` (first 512 KB) of that patch file; accepts `206 Partial` or `200`.
4. Read the bytes, time the transfer, compute `rawBps = bytes*1000/elapsedMs`, then `speedBps = rawBps * task.hotfix.network.cap` (cap clamped `0.01``1.0`, default `0.9`).
5. Any failure (bad HTTP, no patch URL, exception) returns `0`.
## Platform abstraction
The module uses `PlatformService` **only** for OS detection (`isWindows()`) to select the collector. The collectors themselves do **not** go through the platform abstraction — they read OS sources directly:
- **Linux:** `/proc/cpuinfo`, `/sys/devices/.../cpufreq`, `/sys/class/dmi/id/*`, `lsblk -b -o … -n -d`, `/sys/block/*/queue/rotational` (SSD/HDD), `/sys/class/net/*` (physical-NIC filter via a `device` symlink), `/proc/net/dev` (throughput), `/proc/stat` `btime` (boot time), `/etc/os-release`, `File.listRoots()` (drives).
- **Windows:** PowerShell (`-NoProfile -NonInteractive -ExecutionPolicy Bypass`) with `Get-CimInstance` (`Win32_Processor`, `Win32_ComputerSystem`, `Win32_DiskDrive`, `Win32_LogicalDisk`, `Win32_NetworkAdapter`, `Win32_BIOS`, `Win32_OperatingSystem`), `Get-NetAdapterStatistics` (throughput), `Get-HotFix` (patches), registry `Uninstall` keys (programs), and `USBSTOR`/`Control Panel\Desktop` registry reads (security posture). stderr is drained on a background thread so it can't block stdout.
> Note: the pom `<description>` says Windows uses `wmic` — that's stale; the code uses PowerShell/CIM.
@@ -0,0 +1,67 @@
---
module: agent.hw-inventory
title: Hardware Inventory — ATM hardware/OS inventory sync + CDN bandwidth probe
tab: Claude
order: 40
audience: bcos
---
> **Internal · agent reference.** Terse. Confirmed against `hiveops-module-hw-inventory` source 2026-07-01.
## Identity
- Module name (`AgentModule.getName()`): **`hw-inventory`**
- Maven artifact: **`hiveops-module-hw-inventory`** (own version `1.0.0`; parent `hiveops-agent-parent` currently `4.4.3`)
- Entry class: `com.hiveops.hwinventory.HardwareInventoryModule` (SPI file: `META-INF/services/com.hiveops.core.module.AgentModule`)
- Other classes: `HardwareCollector` (interface), `LinuxHardwareCollector`, `WindowsHardwareCollector`
- Log package (`getLogPackage()`): `com.hiveops.hwinventory` (→ per-module log, convention `logs/modules/hw-inventory.log` — path unverified)
- Dependencies (`getDependencies()`): **none** (empty list)
- Drop-in JAR: `modules/hiveops-module-hw-inventory.jar`; in **both** build-dist module sets (`atm` default **and** `srv`)
## Config properties
| Key | Default | Notes |
|---|---|---|
| `hw.inventory.enabled` | `true` | `false` → module inactive |
| `hw.inventory.sync.interval.hours` | `24` | fixed-rate resync period (hours) |
Reused (not owned):
- `agent.endpoint` (preferred) **or** `incident.endpoint` (fallback) — HTTP base URL; **neither set → inactive**
- `incident.institution.key` — institution key; sent in payload + as `X-Institution-Key` on the CDN probe
- `agent.update.check.url` — CDN manifest for probe; default `https://cdn.bcos.cloud/downloads/agent/latest.json`
- `task.hotfix.network.cap` — probe speed multiplier; default `0.9`, clamped `0.01``1.0`
- `modules.disabled` — comma list; include `hw-inventory` to disable
- auth token / host / timeouts via `HttpClientHelper.loadSettingsFromProperties(props, "agent"|"incident")`
## Events / status strings emitted
- **None.** No `EventType`, no `IAT_*`, no journal/incident events, no fleet task status. This module does **not** call the journal-event path.
- Sole output = one JSON POST of `HardwareInventoryRequest`.
## HTTP endpoint used (agent → agent-proxy → incident)
| Call | Method + Path |
|---|---|
| Inventory sync | `POST {endpoint}/api/atms/hardware/sync` (`IncidentEventClient.syncHardware`) |
| CDN probe (manifest) | `GET {agent.update.check.url}` |
| CDN probe (speed test) | `GET <patch url>` with header `Range: bytes=0-524287` |
Accepts `200/201/202` on sync; else throws `IOException`.
## Payload fields (`HardwareInventoryRequest`)
- Identity: `atmName`, `country`, `institutionKey`
- Both OS: `cpu{name,cores,maxClockMhz}`, `system{manufacturer,model,type}`, `disks[]{model,serialNumber,sizeBytes,mediaType}`, `drives[]{letter,fileSystem,totalBytes,freeBytes}`, `nics[]{name,macAddress,speedBps,rxBytesPerSec,txBytesPerSec}`, `biosSerial`, `lastBootTime`, `osVersion`, `cdnDownloadSpeedBps`
- Windows-only: `osInstallDate`, `installedPatches[]`, `installedPatchDates{}`, `installedPrograms[]{name,version,installedDate}`, `usbLockStatus`, `windowsLockStatus`
## Gotchas
- **Syncs twice at startup:** once in `initialize()`, then again from `start()`'s `scheduleAtFixedRate(..., 0, ...)` (initial delay 0).
- **~5s added per sync:** NIC RX/TX throughput is measured by sampling twice, 5s apart (`Thread.sleep(5000)`).
- **Partial payloads are normal:** every collector step is independently try/caught; failures are logged and the rest is still sent.
- **CDN probe never blocks the sync:** on any failure it returns `0` for `cdnDownloadSpeedBps`; inventory still POSTs.
- **Windows path = PowerShell/CIM**, not `wmic` (pom `<description>` is stale on this).
- **Server (`srv`) devices also run this module** — it's not ATM-only.
- Linux physical-NIC filter: skips `lo`, virtual ifaces (no `/sys/class/net/<n>/device` symlink), and MACs starting `00:00:00`.
- No client-side `atmId` resolution — platform maps `atmName`+`country` server-side.
## Do NOT
- Do **not** expect `IAT_*`/journal events from this module — it emits none.
- Do **not** look for a poll loop / fleet-task handling — it doesn't poll; it's push-only on a timer.
- Do **not** add a `hw-inventory.*`-namespaced key expecting it to work; only `hw.inventory.enabled` and `hw.inventory.sync.interval.hours` exist.
- Do **not** assume the collectors go through `PlatformService` — only `isWindows()` does; collection reads OS sources directly.
- Do **not** point this at incident directly on modern ATMs — route via `agent.endpoint` (agent-proxy).
@@ -0,0 +1,66 @@
---
module: agent.hw-inventory
title: Hardware Inventory — ATM hardware/OS inventory sync + CDN bandwidth probe
tab: Internal
order: 20
audience: dev
---
> **Internal · ops/support view.** Confirmed against `hiveops-module-hw-inventory` source 2026-07-01.
## What it does (and doesn't)
`hiveops-module-hw-inventory` (module name `hw-inventory`, entry class `HardwareInventoryModule`) is a **periodic push module**, not an event/incident monitor. Once per run it takes a full snapshot of the ATM's hardware and OS and **POSTs it to the platform** at `/api/atms/hardware/sync` (via `IncidentEventClient.syncHardware`). It does **not** tail journals, does **not** emit `IAT_*`/journal-incident events, and does **not** poll for fleet tasks. It is fire-and-forget inventory reporting.
It runs the collection **once on startup** and then **re-syncs on a fixed interval** (default every 24h).
What each sync collects:
| Area | Both platforms | Windows-only extras |
|---|---|---|
| CPU | model name, cores, max clock (MHz) | — |
| System board | manufacturer, model, type | — |
| Physical disks | model, serial, size, media type (SSD/HDD) | — |
| Logical drives | mount/letter, filesystem, total/free bytes | — |
| NICs | name, MAC, link speed, **measured RX/TX throughput** (sampled over 5s) | — |
| BIOS / boot | BIOS/product serial, last boot time | OS install date |
| OS | OS version string | installed patches (KB IDs + dates), installed programs, USB-lock status, Windows screen-lock status |
| CDN | effective CDN download speed (bytes/s) — see below | — |
**CDN bandwidth probe.** On every sync the module also measures how fast this ATM can pull from the CDN: it fetches the agent manifest (`latest.json`), resolves the platform patch file URL, issues a **512 KB HTTP Range request**, times it, multiplies the raw rate by a configurable cap, and ships the result as `cdnDownloadSpeedBps` on the same payload. The platform uses this so hotfix/software delivery can be paced per-device to the ATM's real link speed. A failed probe just sends `0` — the inventory sync still succeeds.
## Config properties it registers
| Key | Default | Meaning |
|---|---|---|
| `hw.inventory.enabled` | `true` | Master on/off. `false` → module logs "disabled" and stays inactive. |
| `hw.inventory.sync.interval.hours` | `24` | Hours between re-syncs (also the fixed-rate schedule period). |
Properties it **reuses** (does not own):
| Key | Used for |
|---|---|
| `agent.endpoint` | Preferred HTTP base URL (agent-proxy). If set, module uses the `agent` client prefix. |
| `incident.endpoint` | Fallback base URL if `agent.endpoint` is absent (older ATMs). If neither is set, the module goes inactive. |
| `incident.institution.key` | Institution key; sent as `X-Institution-Key` header on the CDN probe and included in the inventory payload. |
| `agent.update.check.url` | CDN manifest URL for the probe. Default `https://cdn.bcos.cloud/downloads/agent/latest.json`. |
| `task.hotfix.network.cap` | Multiplier applied to the measured raw CDN speed (default `0.9`, clamped to `0.01``1.0`). Same knob the hotfix delivery path uses. |
| `agent.*` / `incident.*` HTTP settings | Auth token, host header, timeouts — loaded via `HttpClientHelper.loadSettingsFromProperties(props, prefix)` under whichever prefix (`agent` or `incident`) is active. |
## Enabled / deployed to ATMs
- **Enable condition:** on by default. It self-activates whenever `hw.inventory.enabled` is not `false` **and** an `agent.endpoint` or `incident.endpoint` is configured. To disable on a specific ATM/institution, push `hw.inventory.enabled=false` (or add `hw-inventory` to `modules.disabled`) via a `CONFIG_UPDATE` fleet task.
- **Shipped how:** it is a drop-in JAR (`modules/hiveops-module-hw-inventory.jar`), discovered via ServiceLoader — **not** baked into the fat JAR. `deployment/build-dist.sh` includes it in **both** module sets: the default `atm` set and the `srv` (server-monitoring) set. So every standard install/patch ZIP carries it.
- **Retrofitting an ATM that doesn't have it yet:** deliver via a fleet `INSTALL_MODULE` task (drops the JAR into `modules/` and restarts the agent), or via a full agent update that includes the current module set.
## Common failure modes / what to check
| Symptom | Likely cause | What to check |
|---|---|---|
| No hardware ever appears for an ATM | Module inactive — no endpoint configured, or `hw.inventory.enabled=false`, or JAR missing | Confirm `agent.endpoint`/`incident.endpoint` is set; grep the module log for `hw-inventory initialized`; confirm `modules/hiveops-module-hw-inventory.jar` is present |
| Sync logs `Failed to sync hardware inventory: HTTP <code>` | agent-proxy/incident rejected the POST (auth, routing, or `/api/atms/hardware/sync` not reachable) | Verify the endpoint resolves through agent-proxy and the agent's auth token/institution key are valid |
| Windows fields (patches, programs, lock status) are empty but Linux-style fields present | Wrong collector, or PowerShell failing | These come from PowerShell (`Get-CimInstance`, `Get-HotFix`, registry reads); check the module log for `PowerShell exited <n>` warnings |
| CPU/disk/NIC fields blank | A single collector step failed but sync still ran | Each collector step is independently try/caught and logs `failed to collect <x>`; a partial payload is still sent |
| `cdnDownloadSpeedBps` is 0 | CDN probe failed or manifest had no patch URL | Non-fatal; check log for `CDN probe returned HTTP <n>` / `CDN probe failed`; verify the ATM can reach `cdn.bcos.cloud` and the manifest has `patches.windows`/`patches.linux` |
| Inventory looks ~24h stale | That's the default resync cadence | Lower `hw.inventory.sync.interval.hours` via `CONFIG_UPDATE`, or restart the agent to force an immediate sync |
| Each NIC sync adds ~5s of latency | Expected — throughput is measured by sampling `/proc/net/dev` (Linux) / `Get-NetAdapterStatistics` (Windows) twice, 5s apart | Not a bug; it's the RX/TX measurement window |
@@ -0,0 +1,84 @@
---
module: agent.log-collect
title: Log Collect Path — on-demand file/log pull from an arbitrary ATM path
tab: Architect
order: 30
audience: bcos
---
> **Internal · how it's built.** Confirmed against `hiveops-module-log-collect` source (`com.hiveops.logcollect`) 2026-07-01. See [technical.agent] for agent-wide context (module system, HTTP layer, platform abstraction).
## Maven module
`hiveops-module-log-collect` (artifact `hiveops-module-log-collect`, own version `1.0.0`, parented by `hiveops-agent-parent` — currently `4.4.3`). Packaged as a standalone jar (`<finalName>${project.artifactId}</finalName>` → `hiveops-module-log-collect.jar`).
Dependencies (all `provided` scope — supplied by the agent runtime, not shaded in):
- `hiveops-core` — module SPI + HTTP layer (`FileUploadClient`, `HttpClientHelper`, `HttpClientSettings`, `CurrentAtmPendingTask`).
- `hiveops-agent-journal` (`1.0.0`) — for `com.hiveops.logs.AgentLogUploader`, which does the actual ZIP-and-upload.
- `log4j-api`, `commons-lang3`.
Registered via ServiceLoader SPI: `src/main/resources/META-INF/services/com.hiveops.core.module.AgentModule` → `com.hiveops.logcollect.LogCollectPathModule`.
## The one class
There is a single class, `LogCollectPathModule`, implementing `AgentModule`. No sub-packages, no monitors, no emitters — the heavy lifting lives in `AgentLogUploader` (from `hiveops-agent-journal`).
### Lifecycle (`AgentModule` contract)
- `getName()` → **`log-collect-path`**
- `getVersion()` → `1.0.0`
- `getLogPackage()` → `com.hiveops.logcollect`
- `getDependencies()` → empty (no ordering constraints)
- `isEnabled(ctx)` → true when `agent.endpoint` **or** `incident.endpoint` is non-blank in the main properties.
- `initialize(ctx)` → picks the settings prefix (`agent` if `agent.endpoint` is set, else `incident`), builds an `HttpClientSettings` via `HttpClientHelper.loadSettingsFromProperties(props, prefix)`, and caches `country` + `atmName` from the `ModuleContext`.
- `start()` → creates a single-thread daemon `ScheduledExecutorService` (thread name `log-collect-path`) and schedules `poll()` at a fixed `POLL_INTERVAL_SECONDS = 30` rate.
- `stop()` → `scheduler.shutdownNow()`.
## Poll → detect → collect → emit flow
```
every 30s poll()
└─ FileUploadClient.queryCurrentAtmPendingTask(country, atmName, "LOG_COLLECT_PATH")
GET {endpoint}/api/agent/{country}/{atm}/task?kind=LOG_COLLECT_PATH
└─ null / blank taskKind → return (nothing pending; HTTP 204 → null)
└─ task present → handle(client, task)
├─ targetPath = task.configPatch.get("path")
│ └─ blank → statusReport(FAILED, "No path specified…")
├─ statusReport(tid, aid, "RUNNING", "Collecting logs from: <path>")
├─ AgentLogUploader.uploadPath(targetPath, settings, country, atmName, tid)
│ POST {endpoint}/api/agent/{country}/{atm}/log/upload?taskId={tid}
└─ statusReport(tid, aid, "COMPLETED", "…uploaded successfully")
└─ on exception → statusReport(FAILED, e.getMessage())
```
### `AgentLogUploader.uploadPath(...)` (in `hiveops-agent-journal`)
1. Normalize `\`→`/` and resolve the path. Missing path → `IOException("Path does not exist…")`.
2. If it's a regular file, collect just that file; otherwise `Files.walkFileTree` collecting **every** file. `visitFileFailed` logs and skips unreadable files (`CONTINUE`).
3. Empty result → `IOException("No files found at path…")`.
4. Build a temp ZIP (`hiveops-path-logs-*.zip`) with entries relative to the target root (single-file case roots at the parent).
5. Multipart-POST the ZIP to `…/log/upload?taskId={tid}` — boundary `----HiveOpsLogUploadBoundary`, form part name `file`, filename `hiveops-logs.zip`, `Content-Type: application/zip`. Auth headers/host applied via `HttpClientHelper.applyParameters` / `applyParameterHost`.
6. Non-2xx logs the error body; the temp ZIP is always deleted in `finally`.
The `taskId` query param is what lets the backend attach the upload to a trackable `AtmLogCollect` record for that task.
## How events reach the platform
This module reaches the platform over the **agent HTTP task API**, not the journal-event pipeline. Three calls, all against `settings.getEndpoint()` (the `agent.endpoint`/`incident.endpoint` base — served by **agent-proxy** in production, which proxies to incident):
| Purpose | Method + path |
|---------|---------------|
| Poll for work | `GET /api/agent/{country}/{atm}/task?kind=LOG_COLLECT_PATH` |
| Status updates | `POST /api/agent/tasks/{tid}/status` body `{ status, message }` |
| Deliver the bundle | `POST /api/agent/{country}/{atm}/log/upload?taskId={tid}` (multipart ZIP) |
Errors in `poll()` are classified: `IOException`/`SocketException` → warn ("transient network error"); `com.hiveops.http.Utils.MyException` (backend error) → warn ("server error"); anything else → error with stack trace. The poll loop survives all of them and retries on the next tick.
## Platform abstraction
The module itself uses **no** `PlatformService` — it operates on whatever path string the task supplies and relies on `AgentLogUploader`'s `\`→`/` normalization to accept Windows paths. (By contrast the `INSTALL_MODULE` handler that *delivers* this jar does use `PlatformService` for `restartService` / scripts path.)
## `CurrentAtmPendingTask` fields used
`tid`, `aid`, `taskKind`, and `configPatch` (a `Map<String,String>`; only key `path` is read). Other fields on the DTO (`kb`, `paths`, `artifactCdnUrl`, `cdnToken`) are ignored by this module.
@@ -0,0 +1,77 @@
---
module: agent.log-collect
title: Log Collect Path — on-demand file/log pull from an arbitrary ATM path
tab: Claude
order: 40
audience: bcos
---
> **Internal · agent reference.** Terse. Confirmed against `hiveops-module-log-collect` source 2026-07-01. Every key/string below is from code; nothing invented.
## Identity
- Module name (`AgentModule.getName()`): **`log-collect-path`**
- Maven artifact / jar: **`hiveops-module-log-collect`** → `hiveops-module-log-collect.jar` (own version `1.0.0`; parent `hiveops-agent-parent` `4.4.3`)
- Entry class: `com.hiveops.logcollect.LogCollectPathModule` (SPI: `META-INF/services/com.hiveops.core.module.AgentModule`)
- Uploader: `com.hiveops.logs.AgentLogUploader.uploadPath(...)` (from `hiveops-agent-journal`)
- Log package (`getLogPackage()`): `com.hiveops.logcollect`
- Fleet task kind it implements: **`LOG_COLLECT_PATH`**
- Dependencies: none (`getDependencies()` empty)
## Enable / disable
- Enabled when **`agent.endpoint`** OR **`incident.endpoint`** is non-blank. Both blank → `isEnabled` false, module never starts.
- Prefix selection: `agent.endpoint` set → prefix `agent`; else prefix `incident`.
- Disable explicitly: add `log-collect-path` to `modules.disabled` in `hiveops.properties`.
## Config property keys (read, not registered)
No `log-collect.*` keys exist. It reuses HTTP settings via `HttpClientHelper.loadSettingsFromProperties(props, prefix)`:
| Key | Notes |
|-----|-------|
| `agent.endpoint` / `incident.endpoint` | base URL; also the enable switch |
| `{prefix}.host` | host header |
| `{prefix}.sim` | routing hint |
| `{prefix}.to.connect` | default `10000` (ms) |
| `{prefix}.to.read` | default `10000` (ms) |
| `{prefix}.institution.key` | fallback → `agent.institution.key` → `incident.institution.key` |
| `agent.auth.token` | bearer, always this key regardless of prefix |
- Poll interval = **30s**, hardcoded constant `POLL_INTERVAL_SECONDS`. NOT configurable.
## Task-status strings it emits (`POST /api/agent/tasks/{tid}/status`)
- `RUNNING` — "Collecting logs from: <path>"
- `COMPLETED` — "Log collection uploaded successfully"
- `FAILED` — "No path specified in task configPatch" | `<exception message>`
It emits **no** journal/incident events, no `IAT_*` events. Status reports only.
## HTTP endpoints (base = `settings.getEndpoint()`)
| Call | Verb + path |
|------|-------------|
| Poll | `GET /api/agent/{country}/{atm}/task?kind=LOG_COLLECT_PATH` |
| Status | `POST /api/agent/tasks/{tid}/status` → `{status,message}` |
| Upload | `POST /api/agent/{country}/{atm}/log/upload?taskId={tid}` (multipart) |
- Multipart: boundary `----HiveOpsLogUploadBoundary`, part name `file`, filename `hiveops-logs.zip`, `Content-Type: application/zip`.
## Task input contract
- Target read from `task.configPatch.get("path")` only. Blank → `FAILED`.
- Accepts a directory (recursive, **all** files, any extension) or a single regular file.
- Windows paths OK: `\` is normalized to `/`.
- Unreadable files skipped (logged), rest still uploaded. Empty/missing path → `IOException` → `FAILED`.
## Fleet delivery (`INSTALL_MODULE`)
- Push jar `hiveops-module-log-collect.jar` via artifact (`task.paths[0]`) or url (`configPatch.url` + `filename`).
- Filename MUST end `.jar`. Agent writes to `modules/`, then **restarts the agent service** to load it (`ProcessInstallModule`).
## Gotchas
- Silent-ish poll failures: network/backend errors are `warn`-logged and retried next tick, not surfaced as task FAILED.
- Whole target tree is zipped to a temp file first — large dirs = large temp file + memory/disk pressure. Temp ZIP deleted in `finally`.
- `queryCurrentAtmPendingTask` returns `null` on HTTP 204 (nothing pending) — normal, not an error.
- `taskId` query param is required for the backend to create the trackable `AtmLogCollect` record; don't strip it.
## Do NOT
- Do NOT expect `log-collect.*` config keys — none exist; don't add one for poll interval without code.
- Do NOT assume it tails/watches logs continuously — it is poll-then-pull, task-driven only.
- Do NOT confuse with the built-in `LOG_COLLECT` command (`AgentLogUploader.uploadDirectory`, agent `logs/` only, `.log`/`.log.gz`). This module is `LOG_COLLECT_PATH` = arbitrary path, all files.
- Do NOT look for journal/incident events from this module — there are none.
- Do NOT rely on `configPatch` keys other than `path` (e.g. `url`/`filename` belong to `INSTALL_MODULE`, not this task).
@@ -0,0 +1,71 @@
---
module: agent.log-collect
title: Log Collect Path — on-demand file/log pull from an arbitrary ATM path
tab: Internal
order: 20
audience: dev
---
> **Internal · ops/support view.** Confirmed against `hiveops-module-log-collect` source (`com.hiveops.logcollect.LogCollectPathModule`) 2026-07-01. Dev-facing; not customer material.
## What it does
The Log Collect Path module lets a fleet operator pull **any file or directory** off an ATM on demand — not just the agent's own logs. It implements the `LOG_COLLECT_PATH` fleet task kind:
1. The module polls the agent endpoint every **30 seconds** for a pending `LOG_COLLECT_PATH` task for this ATM.
2. When one arrives, it reads the target path from the task's `configPatch.path`.
3. It walks that path recursively, ZIPs every readable file, and uploads the bundle to the backend tagged with the task id so operators can download it (e.g. the **Other Logs** tab in device detail).
4. It reports task status back (`RUNNING` → `COMPLETED`, or `FAILED`).
It does **not** monitor anything continuously and it emits **no journal/incident events** — it is a task-driven, request/response file collector. There is no schedule, watch, or tail; the only trigger is an operator-created fleet task.
## What it monitors / why
Nothing passively. The "monitor" is a 30-second poll for pending tasks. Use cases:
- Grab a vendor log directory (dispenser, EPP, XFS logs) that the standard `LOG_COLLECT` command doesn't bundle.
- Pull a specific config/trace file from an arbitrary path during an investigation.
- Retrieve a single file (the module handles a regular-file target as well as a directory).
Contrast with the sibling built-in `LOG_COLLECT` command (in `AgentLogUploader.uploadDirectory`), which only bundles the agent's own `logs/` tree (`.log` / `.log.gz`). `LOG_COLLECT_PATH` collects **all files** under the requested path regardless of extension.
## Config properties it registers
The module registers **no dedicated `log-collect.*` properties**. It reuses the agent's existing HTTP client settings. It is **enabled** whenever either endpoint property is set:
| Property | Purpose | Default |
|----------|---------|---------|
| `agent.endpoint` | Agent-proxy base URL (preferred). If set, the module uses the `agent.*` settings prefix. | — |
| `incident.endpoint` | Legacy incident base URL. Used only if `agent.endpoint` is blank. | — |
Derived HTTP settings (loaded via the chosen `{prefix}` = `agent` or `incident`):
| Property | Purpose | Default |
|----------|---------|---------|
| `{prefix}.host` | Host header override | — |
| `{prefix}.sim` | Sim/routing hint | — |
| `{prefix}.to.connect` | Connect timeout (ms) | `10000` |
| `{prefix}.to.read` | Read timeout (ms) | `10000` |
| `{prefix}.institution.key` | Institution key; falls back to `agent.institution.key` then `incident.institution.key` | — |
| `agent.auth.token` | Bearer token — always read as `agent.auth.token` regardless of prefix | — |
The **30-second poll interval is a hardcoded constant** (`POLL_INTERVAL_SECONDS`), not a property — it cannot be tuned via config.
## How it's enabled / deployed to ATMs
The module ships as its own jar, `hiveops-module-log-collect.jar`, discovered at agent startup via the ServiceLoader SPI (`META-INF/services/com.hiveops.core.module.AgentModule`).
- **Standard build:** it's a `<module>` in the agent parent build; if the jar is on the classpath / in the agent `modules/` directory, it auto-registers.
- **Fleet delivery (`INSTALL_MODULE`):** push an `INSTALL_MODULE` task carrying `hiveops-module-log-collect.jar` (artifact or `configPatch.url`+`filename`). The agent drops it into `modules/` and **restarts the agent service** to load it (see `ProcessInstallModule`). Filename must end in `.jar`.
- **Disable:** add `log-collect-path` to `modules.disabled` in `hiveops.properties`, or unset both `agent.endpoint` and `incident.endpoint` (then `isEnabled` returns false).
## Common failure modes / what to check
| Symptom | Likely cause / check |
|---------|----------------------|
| Task stuck `FAILED` immediately, message "No path specified in task configPatch" | The task was created without `configPatch.path`. Recreate with a path. |
| `FAILED` "Path does not exist" / "No files found at path" | The path is wrong for that OS, or empty. Windows paths are accepted with `\` (the module normalizes `\`→`/`). |
| Task never picked up | Module disabled, agent offline, or neither `agent.endpoint`/`incident.endpoint` set → module didn't start. Check `logs/modules/…` for "LogCollectPathModule started". |
| Poll warns "transient network error" / "server error" | Endpoint unreachable or backend 4xx/5xx; poll retries on the next 30s tick — not fatal. |
| Upload returns non-2xx | Check the backend `/api/agent/{country}/{atm}/log/upload` route (served by agent-proxy) and the bearer token / institution key. |
| Huge/locked files | Unreadable files are skipped with a warning (`visitFileFailed`); the ZIP still uploads with the rest. Be mindful of very large target dirs — the whole tree is zipped to a temp file first. |
@@ -0,0 +1,88 @@
---
module: agent.log
title: Agent Log — self-log upload & error-to-incident monitoring
tab: Architect
order: 30
audience: bcos
---
> **Internal · how it's built.** Confirmed against `hiveops-module-log` source (`com.hiveops.logs`) 2026-07-01. See [technical.agent] for agent-wide module/lifecycle context.
## Maven module & packaging
`hiveops-module-log` (artifact `hiveops-module-log`, own version `1.0.0`, parented by `hiveops-agent-parent` `4.4.3`). Built as a standalone jar with `<finalName>${project.artifactId}</finalName>` → `hiveops-module-log.jar`, a drop-in for the agent's `modules/` directory (not merged into the fat JAR).
Dependencies are all **`provided`** scope — supplied by the running fat JAR, not bundled:
- `hiveops-core` — the `AgentModule` SPI + `HttpClientHelper`/`HttpClientSettings`.
- `hiveops-agent-journal` — provides **`AgentLogUploader`** (the multipart/zip upload helper `LogUploadModule` delegates to). *Note: `hiveops-agent-journal` is in the `atm` build set but not the `srv` set, while `hiveops-module-log` is in both; whether the srv fat JAR still carries `AgentLogUploader` on the classpath is **unverified** — flag if a srv device throws `NoClassDefFoundError` on log upload.*
- `log4j-api`, `commons-lang3`, `commons-io`.
Two implementations are registered via the ServiceLoader SPI file `META-INF/services/com.hiveops.core.module.AgentModule`:
```
com.hiveops.logs.LogUploadModule
com.hiveops.logs.LogMonitorModule
```
## Classes and flow
| Class | Role |
|---|---|
| `LogUploadModule` | `AgentModule` `log-upload` — scheduler that ships the whole log file on an interval. |
| `LogMonitorModule` | `AgentModule` `log-monitor` (depends on `log-upload`) — scheduler that tails the log for errors and reports summaries. |
| `AgentLogUploader` | *(in `hiveops-agent-journal`)* static helper doing the actual multipart POST / ZIP bundling. Shared with the fleet log-collect commands. |
Neither module overrides `getLogPackage()`, so their own log output goes to the **root** logger (`hiveops-agent.log`) rather than a dedicated `logs/modules/*.log` — which is deliberate here, since that root file is exactly what they monitor and upload. Neither overrides `isHealthy()` either, so the `ModuleWatchdog` treats them as healthy by default even if their scheduler thread dies *(potential blind spot — unverified as an operational issue)*.
### log-upload cycle (poll/push, interval-driven)
1. `initialize()`: resolve endpoint (`agent.endpoint` else `incident.endpoint`; disable if neither), build `HttpClientSettings` via `HttpClientHelper.loadSettingsFromProperties(props, prefix)` where `prefix` is `"agent"` or `"incident"`, read `country`/`atmName` from `ModuleContext`, read `log.upload.interval.minutes` (default 15), resolve `logFilePath`.
2. `start()`: single daemon thread `log-upload`, `scheduleAtFixedRate(doUpload, 0, interval, MINUTES)` — **fires immediately** then every interval.
3. `doUpload()` → `AgentLogUploader.upload(logFilePath, settings, country, atmName)`:
- Skips silently if the file is absent.
- Multipart POST to `{endpoint}/api/agent/{country}/{atm}/log/upload` (boundary `----HiveOpsLogUploadBoundary`, part `file`, filename `hiveops-agent.log`, `Content-Type: text/plain`); logs HTTP status/elapsed/bytes.
4. `stop()`: `scheduler.shutdownNow()`.
### log-monitor cycle (tail → detect → report)
1. `initialize()`: same endpoint/settings resolution; read the four `log.monitor.*` tunables; resolve `logFilePath`.
2. `start()`: daemon thread `log-monitor`, `scheduleAtFixedRate(doMonitor, interval, interval, MINUTES)` — **first run delayed one interval** so the log settles.
3. `doMonitor()`:
- Track `lastCheckedPosition` (byte offset). If file shrank (`position > size`) → treat as rotation, reset to 0. If `position == size` → nothing new, return.
- `scanNewLines(fileSize)`: `RandomAccessFile.seek(lastCheckedPosition)`, read line-by-line, re-decode each line from ISO-8859-1 bytes → UTF-8 (works around `RandomAccessFile.readLine()`'s charset), match against `LOG_LEVEL_PATTERN` = `^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}[.,]\d+\s+(ERROR|FATAL|WARN)\s+(.+)$`. `WARN` → `warnCount`; `ERROR`/`FATAL` → `errorCount` (+ up to `maxSamples` sample lines, each truncated to 200 chars).
- Advance `lastCheckedPosition = fileSize`.
- If `errorCount < errorThreshold` → return. If `lastReportedAt > 0` and within `cooldownMinutes` → return.
- Else `reportToBackend()` and set `lastReportedAt = now`.
4. `reportToBackend()`: hand-builds JSON `{"errorCount":N,"warnCount":N,"sampleErrors":[...]}` (no Jackson — its own `jsonEscape`), `POST` with `Content-Type: application/json` to `String.format("%s/atm/log-error?country=%s&name=%s", endpoint, country, name)`. Accepts HTTP 200/201; logs (truncated) error body otherwise. Never throws out of the scheduler.
All state (`lastCheckedPosition`, `lastReportedAt`) is **in-memory only** — an agent restart resets the tail offset to 0, so the first post-restart scan re-reads the whole current file.
## How events/results reach the platform
This module has **no** journal/incident `EventType` (`IAT_*`, `CARD_READER_*`, …) integration and does **not** use `IncidentEventClient` — see [technical.agent] "API surface" for that separate path. It also does not report fleet task status. It has exactly two HTTP touchpoints, both terminating at **agent-proxy** (which owns agent log endpoints natively and forwards to incident's internal API):
```
LogUploadModule ──► AgentLogUploader.upload()
POST {endpoint}/api/agent/{country}/{atm}/log/upload (multipart)
▼ agent-proxy AgentLogController.uploadLog() (50 MB cap → 413)
→ IncidentInternalService.uploadLog() → incident internal store
LogMonitorModule ──► reportToBackend()
POST {endpoint}/atm/log-error?country=&name= (JSON summary)
│ ⚠ mismatch: agent-proxy's error endpoint is
│ POST /api/agent/{country}/{atm}/log/errors
▼ agent-proxy AgentLogController.logErrors()
→ IncidentInternalService.logErrors()
→ incident InternalAgentController POST /api/internal/agent/{country}/{atm}/log/errors
→ LogErrorReportRequest → IncidentAutoCreationService → SOFTWARE_ERROR incident
```
**Path discrepancy to be aware of:** the *upload* URL matches agent-proxy's route exactly, but the *monitor's* report URL (`/atm/log-error?country=&name=`) is a legacy shape that does **not** match agent-proxy's `/api/agent/{country}/{atm}/log/errors`. Whether an nginx/proxy rewrite bridges the two, or whether error reporting silently 404s on the current fleet, is **unverified** — verify against the live agent-proxy/nginx config before relying on error-to-incident from this module.
## Related helper capabilities (not driven by this module)
`AgentLogUploader` also exposes `uploadDirectory()` (bundles every `*.log`/`*.log.gz` under `logs/` incl. `logs/modules/` into `hiveops-logs.zip`) and `uploadPath(..., taskId)` (zips an arbitrary path, appends `?taskId=`). These back the **`LOG_COLLECT` / `LOG_COLLECT_PATH` fleet commands** and are invoked from the fleet command handlers, **not** from `log-upload`/`log-monitor`. The scheduled upload only ever ships the single current `hiveops-agent.log`.
## Platform abstraction
The module does **not** use `PlatformServiceFactory` / `PlatformService` at all — it needs no registry reads, path resolution, or shell-outs. Log path resolution is a plain JVM-system-property (`hiveops-agent.log`) lookup with a `ModuleContext.getStartedFromDir()` fallback, and all I/O is JDK (`RandomAccessFile`, `HttpURLConnection`, `Files`).
@@ -0,0 +1,72 @@
---
module: agent.log
title: Agent Log — self-log upload & error-to-incident monitoring
tab: Claude
order: 40
audience: bcos
---
> **Internal · agent reference.** Terse. Confirmed against `hiveops-module-log` source (`com.hiveops.logs`) 2026-07-01.
## Identity
- Maven artifact: **`hiveops-module-log`** (own version `1.0.0`; parent `hiveops-agent-parent` `4.4.3`)
- Drop-in JAR: **`modules/hiveops-module-log.jar`** (finalName = artifactId; NOT in fat JAR)
- SPI file `META-INF/services/com.hiveops.core.module.AgentModule` registers **two** modules:
- `com.hiveops.logs.LogUploadModule` → `AgentModule.getName()` = **`log-upload`** (no deps)
- `com.hiveops.logs.LogMonitorModule` → `AgentModule.getName()` = **`log-monitor`** (depends on `log-upload`)
- Upload helper: `com.hiveops.logs.AgentLogUploader` — lives in **`hiveops-agent-journal`**, not this jar (provided scope).
- `getLogPackage()` NOT overridden → own logging goes to root **`logs/hiveops-agent.log`** (no `logs/modules/log*.log`).
- `isHealthy()` NOT overridden → always `true` (watchdog won't catch a dead scheduler).
- Monitored/uploaded file: `logs/hiveops-agent.log` under sysprop `hiveops-agent.log` base, else `{startedFromDir}/logs/hiveops-agent.log`.
## Config property keys (exact)
| Key | Default | Module |
|---|---|---|
| `incident.log.upload.enabled` | `true` | log-upload master switch |
| `log.upload.interval.minutes` | `15` | log-upload cadence (also fires once on start) |
| `incident.log.monitor.enabled` | `true` | log-monitor master switch |
| `log.monitor.interval.minutes` | `5` | log-monitor scan cadence (first scan delayed 1 interval) |
| `log.monitor.cooldown.minutes` | `120` | min gap between error reports |
| `log.monitor.error.threshold` | `1` | min errors in a scan to report |
| `log.monitor.max.samples` | `5` | max sample error lines sent (each ≤200 chars) |
| `agent.endpoint` (pref) / `incident.endpoint` (fallback) | — | HTTP base URL; if both blank → both modules self-disable |
| `agent.*` / `incident.*` client settings | — | auth token / host header via `HttpClientHelper.loadSettingsFromProperties(props, "agent"\|"incident")` |
| `modules.disabled` | — | comma list; include `log-upload` and/or `log-monitor` to disable |
- `isEnabled()` = endpoint set **AND** the module's own `enabled` flag true **AND** settings built.
- No `getDefaultConfiguration()` override — defaults are the inline `getProperty(key, default)` values above only.
## Events / reports emitted
- **NO journal `EventType` / `IAT_*` events.** Does not call `IncidentEventClient`. Does NOT report fleet task status.
- **log-upload:** multipart `POST {endpoint}/api/agent/{country}/{atm}/log/upload` — boundary `----HiveOpsLogUploadBoundary`, part `file`, filename `hiveops-agent.log`, `text/plain`. (matches agent-proxy `AgentLogController`.)
- **log-monitor:** JSON `POST {endpoint}/atm/log-error?country={c}&name={n}` — body `{"errorCount":N,"warnCount":N,"sampleErrors":[...]}`. Backend → **`SOFTWARE_ERROR`** incident (dedup + parked/OOS suppression in `IncidentAutoCreationService`).
## Error-line detection
- Regex: `^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}[.,]\d+\s+(ERROR|FATAL|WARN)\s+(.+)$`
- `ERROR`+`FATAL` → `errorCount`; `WARN` → `warnCount` (counted, not reported as errors).
- Tails via `RandomAccessFile.seek(lastCheckedPosition)`; re-decodes each line ISO-8859-1 bytes → UTF-8.
- Rotation handling: if file size < last offset → reset offset to 0. Offsets are **in-memory only** (restart re-reads whole current file).
## Deployment
- In **BOTH** `atm` and `srv` module sets of `deployment/build-dist.sh` → ships in every install/patch ZIP.
- On by default on any device with an endpoint set.
- Disable: `modules.disabled=log-upload,log-monitor` via `CONFIG_UPDATE` fleet task.
- Retrofit: `INSTALL_MODULE` fleet task (drops jar, restarts agent) or full agent update.
- Upload cap: agent-proxy rejects >**50 MB** with `413`.
## Gotchas
- **Monitor endpoint mismatch:** module POSTs `/atm/log-error?country=&name=`, but agent-proxy's error route is `/api/agent/{country}/{atm}/log/errors`. Whether a rewrite bridges them / whether error-to-incident currently works is **unverified** — check live agent-proxy + nginx before trusting it. (Upload path is fine — it matches.)
- Two module names, one jar — `modules.disabled` must name `log-upload` and/or `log-monitor`, NOT `hiveops-module-log` / `log`.
- Only the **single current** `hiveops-agent.log` is uploaded on schedule — rotated `.gz` files are NOT (those come via the separate `LOG_COLLECT`/`LOG_COLLECT_PATH` fleet commands through the same `AgentLogUploader`).
- First error scan is delayed one full `log.monitor.interval.minutes` after start; a startup error burst won't report until then.
- Sample lines truncated to 200 chars; only up to `max.samples` errors sent — the count is full, the samples are not.
- Cooldown + backend dedup mean a chronically-failing agent yields **one** `SOFTWARE_ERROR` incident, not a stream.
- `AgentLogUploader` is in `hiveops-agent-journal` (atm set), not this jar — srv-set classpath carrying it is **unverified**.
## Do NOT
- Do NOT expect journal/hardware events (`IAT_*`, `CARD_READER_*`, `CASSETTE_*`, any `EventType`) from this module — self-log only.
- Do NOT expect fleet task status reports — this module reports none.
- Do NOT invent extra property namespaces — only the `incident.log.*` / `log.upload.*` / `log.monitor.*` keys above exist.
- Do NOT assume error-to-incident works without verifying the `/atm/log-error` vs `/api/agent/.../log/errors` routing on the live proxy.
- Do NOT disable via `modules.disabled=log` — use the exact names `log-upload` / `log-monitor`.
- Do NOT look for a `logs/modules/log*.log` — this module logs to the root `hiveops-agent.log`.
@@ -0,0 +1,74 @@
---
module: agent.log
title: Agent Log — self-log upload & error-to-incident monitoring
tab: Internal
order: 20
audience: dev
---
> **Internal · ops/support view.** Confirmed against `hiveops-module-log` source (`com.hiveops.logs`) 2026-07-01.
## What it does (and doesn't)
`hiveops-module-log` is the agent's **self-observability** module. It watches and ships the agent's **own** operational log file (`logs/hiveops-agent.log`) — it does **not** parse ATM journals, count cassettes, or emit hardware/`IAT_*` events. It exists so HiveIQ can see when the agent itself is unhealthy without SSHing to the ATM.
It packs **two independent `AgentModule` implementations** into one drop-in JAR:
| Module name | Class | Job |
|---|---|---|
| `log-upload` | `LogUploadModule` | Periodically POSTs the whole `hiveops-agent.log` to the backend so support can read an ATM's agent log from the platform. |
| `log-monitor` | `LogMonitorModule` | Tails the same log for `ERROR`/`FATAL` lines and, when enough pile up, reports a summary that the backend turns into a **`SOFTWARE_ERROR` incident**. |
`log-monitor` declares a dependency on `log-upload` (so the shared log-path resolution runs first), but the two run on separate schedulers and can be enabled/disabled independently.
Flow at a glance:
- **log-upload:** on start, and then every `log.upload.interval.minutes` (default 15), multipart-POST `logs/hiveops-agent.log` → `{endpoint}/api/agent/{country}/{atm}/log/upload`.
- **log-monitor:** every `log.monitor.interval.minutes` (default 5), read only the bytes appended since last check, count `ERROR`+`FATAL` (and `WARN` separately). If errors ≥ `log.monitor.error.threshold` **and** the cooldown has elapsed, POST a JSON summary `{errorCount, warnCount, sampleErrors[]}` → the backend creates/dedups a `SOFTWARE_ERROR` incident for that ATM.
## Config properties it registers
All read from `hiveops.properties` (main properties). There is no `getDefaultConfiguration()` override — defaults below are the inline `getProperty(key, default)` fallbacks in code.
**log-upload:**
| Key | Default | Meaning |
|---|---|---|
| `incident.log.upload.enabled` | `true` | Master switch for the upload module. |
| `log.upload.interval.minutes` | `15` | Upload cadence (also fires once immediately on start). |
**log-monitor:**
| Key | Default | Meaning |
|---|---|---|
| `incident.log.monitor.enabled` | `true` | Master switch for the monitor module. |
| `log.monitor.interval.minutes` | `5` | How often the log is scanned for new error lines. |
| `log.monitor.cooldown.minutes` | `120` | Minimum gap between two error reports (anti-spam). |
| `log.monitor.error.threshold` | `1` | Minimum error count in a scan before a report is sent. |
| `log.monitor.max.samples` | `5` | Max error lines included as `sampleErrors` (each truncated to 200 chars). |
**Shared (not owned by this module):** both modules pick the HTTP base URL from `agent.endpoint` (preferred) or `incident.endpoint` (fallback), and build their HTTP client via the matching `agent.*` / `incident.*` settings (auth token, host header, etc.). If neither endpoint is set, both modules self-disable.
The monitored/uploaded file path is `logs/hiveops-agent.log` under the base given by the JVM system property `hiveops-agent.log` (set by `AgentApplication`), falling back to `{startedFromDir}/logs/hiveops-agent.log`.
## Enabled / deployed to ATMs
- **Enable condition:** each sub-module self-enables when `agent.endpoint` **or** `incident.endpoint` is set **and** its own `incident.log.upload.enabled` / `incident.log.monitor.enabled` flag is `true` (both default true). So on any normally-configured device both are on by default.
- **Ships everywhere:** `hiveops-module-log` is in **both** the `atm` and `srv` module sets of `deployment/build-dist.sh`, so it's in every standard install/patch ZIP as the drop-in `modules/hiveops-module-log.jar` (not baked into the fat JAR).
- **Disable per ATM/institution:** push `modules.disabled=log-upload,log-monitor` (comma list; include either or both names) via a `CONFIG_UPDATE` fleet task. Turning off just the noise: set `incident.log.monitor.enabled=false` but leave upload on, or vice-versa.
- **Retrofit an ATM without it:** deliver `modules/hiveops-module-log.jar` via a fleet `INSTALL_MODULE` task (drops the JAR and restarts the agent), or via a full agent update.
## What the backend does with it
- **Log upload** → agent-proxy `AgentLogController` `POST /api/agent/{country}/{atm}/log/upload` → forwarded to incident's internal API. Agent-proxy rejects uploads over **50 MB** with `413 Payload Too Large`.
- **Error report** → the backend maps the report to a **`SOFTWARE_ERROR`** incident via `IncidentAutoCreationService`. That path **suppresses** creation if the ATM is parked or `OUT_OF_SERVICE`, and **dedups** against an already-active `SOFTWARE_ERROR` incident for the same ATM (so a flapping agent gives one incident, not hundreds).
## Common failure modes / what to check
| Symptom | Likely cause | What to check |
|---|---|---|
| No agent log ever appears in the platform for an ATM | Module disabled, or no `agent.endpoint`/`incident.endpoint` on that ATM | Check `modules.disabled`; confirm an endpoint is set; look for "Log upload module initialized" in the ATM's `hiveops-agent.log`. |
| Log upload rejected | File exceeds 50 MB | Agent-proxy returns 413; check log-rotation on the ATM — the module uploads the single current `hiveops-agent.log`, not rotated `.gz` files. |
| Expected a `SOFTWARE_ERROR` incident but none created | ATM parked/`OUT_OF_SERVICE`, or an active `SOFTWARE_ERROR` incident already open (dedup), or still inside the 120-min cooldown | Check ATM status and existing open incidents; check `log.monitor.cooldown.minutes`. |
| Error incidents never fire even though the agent logs errors | Errors don't match the expected Log4j2 line shape (`yyyy-MM-dd HH:mm:ss.SSS LEVEL ...`), so the scanner doesn't count them | Confirm the ATM's `log4j2.xml` pattern starts with timestamp + level; non-standard patterns are silently skipped. |
| Flood of errors right after startup produced nothing | First monitor scan is delayed one full interval (default 5 min) after start | Expected; give it an interval. |
| Report seems to go nowhere / backend 404 | **Path mismatch (see gotcha):** the monitor POSTs to `{endpoint}/atm/log-error?country=&name=`, but agent-proxy exposes the error endpoint at `/api/agent/{country}/{atm}/log/errors` | If error-to-incident stops working after an endpoint/proxy change, verify how `/atm/log-error` is routed for that fleet — see the Claude tab "Do NOT / gotchas". *(routing of the legacy path is unverified.)* |
@@ -0,0 +1,94 @@
---
module: agent.nh-tcr-events
title: NH TCR Events — cassette + firmware monitoring for Nautilus Hyosung / Glory BRM30 recyclers
tab: Architect
order: 30
audience: bcos
---
> **Internal · architecture.** Written 2026-07-01 from `hiveops-module-nh-tcr-events` source. Verify against code before relying on specifics.
## Module shape
Package `com.hiveops.nhtcr`, three classes, no controllers/DB/Kafka (this is an agent module, not a microservice — see [technical.agent]):
- **`NhTcrEventsModule`** — the `AgentModule` SPI entry point (registered via `META-INF/services/com.hiveops.core.module.AgentModule` → `com.hiveops.nhtcr.NhTcrEventsModule`). `getName()` = `nh-tcr-events`, `getVersion()` = `1.0.0`, `getLogPackage()` = `com.hiveops.nhtcr`. Owns lifecycle (`initialize()` → `isEnabled()` → `start()` → `stop()`) and the single shared `ScheduledExecutorService`.
- **`ApLogMonitor`** (package-private) — parses the MoniPlus2S APLog and syncs firmware inventory.
- **`FwEventLogMonitor`** (package-private) — parses the Glory BRM30 FWLogSave circular buffer.
Both monitors are plain constructed objects (`new ApLogMonitor(dir, atmName, client)`), not their own `AgentModule`s — `NhTcrEventsModule` drives both from one poll loop. Each monitor is created only if its directory property is set **and** resolves to an existing directory; `ready = apLogMonitor != null || fwEventLogMonitor != null` gates `isEnabled()`.
## Lifecycle & gating (`initialize()`)
1. Reads `device.type`; if not `NH_TCR` (uppercased/trimmed) → logs and returns (module stays disabled, `ready=false`).
2. Reads `nh.tcr.ap.log.dir` and `nh.tcr.fw.log.dir`; if **both** null → disabled.
3. Resolves the endpoint: `agent.endpoint` if present, else `incident.endpoint`; if neither → disabled. `prefix` is `"agent"` or `"incident"` accordingly.
4. Builds one `IncidentEventClient` from `HttpClientHelper.loadSettingsFromProperties(props, prefix)` + `context.getAtmName()` + `context.getCountry()`, shared by both monitors.
5. Constructs each monitor only if its dir exists (else a per-monitor warning; the other monitor can still run).
6. Reads `nh.tcr.events.poll.interval.sec` (default `DEFAULT_POLL_SEC=30`) into `pollSec`.
`start()` spins one daemon thread `"nh-tcr-events"` and `scheduleAtFixedRate(this::poll, 0, pollSec, SECONDS)` — **`pollSec` is honored here** (contrast the `atec-tcr-events` sibling, whose poll interval is a no-op bug). `poll()` calls `apLogMonitor.poll()` then `fwEventLogMonitor.poll()`, wrapping both in a try/catch so one monitor's exception can't kill the scheduler.
## Flow: poll → parse → detect → emit
```
NhTcrEventsModule.start()
→ ScheduledExecutorService (daemon "nh-tcr-events", fixed-rate, every pollSec sec)
→ poll()
→ apLogMonitor.poll() // cassette status + firmware inventory
→ fwEventLogMonitor.poll() // EP restarts
```
### `ApLogMonitor` — date-subfolder tail + header scrape
- **File selection:** `rotateDailyFileIfNeeded()` compares `LocalDate.now()` to `currentDate`; on a new day it recomputes `currentFile = baseLogDir/{YYYYMMDD}/APLog{YYYYMMDD}.log` (`DateTimeFormatter.ofPattern("yyyyMMdd")`), resets `fileOffset=0`, `headerParsed=false`, and clears the `slotStatus` map.
- **Header sync (once per daily file):** on first poll of a file, `parseAndSyncHeader()` opens a **separate** `RandomAccessFile` from offset 0 (so it doesn't disturb `fileOffset`), reads the block between the two `={20,}` delimiter lines, and extracts versions. Two line shapes: `- <label> = SP[..], EP[..]` (→ `key.sp` / `key.ep`) and simple `- <label> = <value>`. `labelToKey()` strips a trailing ` Version`, lowercases, dots spaces, and skips `Machine Number` / `Model` / `IP Address` / `Port Number` / `Time Zone`. If any versions found → `HardwareInventoryRequest{atmName, peripheralVersions}` → `client.syncHardware(req)`. `headerParsed=true` is set even on partial failure so it won't loop.
- **Tail-by-offset:** opens `currentFile` with `RandomAccessFile`, guards truncation (`raf.length() < fileOffset` → reset offset + clear `slotStatus`), seeks `fileOffset`, reads new lines to EOF, advances `fileOffset = raf.getFilePointer()`.
- **Per-line detection (`processLine`):** `STACK_BLOCK` regex captures `denom | count | status | type | slot` from `** IStackedBillsBlock = $|...`. Diffs `status` against `slotStatus.put(slot, status)` — **no emit if unchanged** (this in-agent map is the only storm guard; there's no server-side dedup). On a real change:
- Always emit **`CASSETTE_INVENTORY`** first (keeps the cassette-details tab current on any transition, including recoveries).
- Then by status: `LOW`→`CASSETTE_LOW`, `EMPTY`→`CASSETTE_EMPTY`, `MISSING`→`TCR_CASSETTE_MISSING`, `MANIP`→`TCR_CASSETTE_MANIP`, `OK`→log-only recovery notice (no event).
- `slotStatus` is per-instance/per-agent-process lifetime, cleared on truncation or day rollover — so a slot still `EMPTY` at the first block of a new day re-emits (state doesn't carry across daily rotation).
> Note: `NhTcrEventsModule`'s class-level Javadoc lists a stale subset (`CASSETTE_LOW`, `CASSETTE_EMPTY`, `TCR_CASSETTE_MISSING`, `TCR_EP_RESTART`) — the authoritative emit set is what `ApLogMonitor.processLine` / `emit` and `FwEventLogMonitor` actually send.
### `FwEventLogMonitor` — circular-buffer tail
- 10 fixed files `00FWEvent00.log`…`00FWEvent09.log` (`FILE_PREFIX="00FWEvent"`), one `long[] offsets` per index.
- **First poll:** seeks every existing file to its current size (`offsets[i]=Files.size`) and marks `seenOnInit`, so only entries written **after** agent start are processed; then returns.
- **Subsequent polls:** for each index, `scanFile()` opens the file, resets offset to 0 if `fileLen < offsets[index]` (file recycled), seeks and reads new lines. `processLine` matches `LogManager_StartUp\(\)` → emit **`TCR_EP_RESTART`** (with `FWEventNN` label in details). No de-dup beyond offset tracking.
## How events reach the platform
Both monitors emit through the same shared `IncidentEventClient` (constructed once in `initialize()`), the same client class used by `journal-events` and other event-reporting modules — see [technical.agent] for the full outbound surface.
```
ApLogMonitor/FwEventLogMonitor.emit(...)
→ CreateJournalEventRequest.builder()
.agentAtmId(atmId) // ATM name string; resolved to incident's numeric atmId server-side
.eventType(eventType) // free-form String — not validated client-side
.eventDetails(details)
// cassette fields (ApLogMonitor only):
.cassettePosition(slot) // e.g. CST_A / CST_C
.cassetteDenomination(int) .cassetteBillCount(int)
.cassetteType(type) // RECYCLING / REJECT / REPCONTAINER
.cassetteCurrency("USD") // hardcoded
.eventSource("NH_TCR_APLOG" | "NH_TCR_FWEVENT")
.build()
→ IncidentEventClient.sendEvent(req) → POST {endpoint}/api/journal-events
```
Firmware inventory takes a different path in the same client: `client.syncHardware(req)` → `POST {endpoint}/api/atms/hardware/sync`.
`eventType` is a plain `String` on `CreateJournalEventRequest` (the `hiveops-agent-journal` DTO). Nothing client-side blocks an unregistered type; recognition/labeling happens server-side against hiveops-incident's DB-backed `event_type` table. Send/sync failures throw `IOException`, which `emit()` / `parseAndSyncHeader()` catch and log — no retry or queueing.
## Auth / endpoint selection
`HttpClientHelper.loadSettingsFromProperties(props, prefix)` resolves institution key with fallback `{prefix}.institution.key` → `agent.institution.key` → `incident.institution.key`, and reads the bearer token from `agent.auth.token`. No bespoke auth in this module — 100% the shared agent HTTP settings pattern.
## Platform abstraction
None used directly. The module reads files via `java.nio.file` / `RandomAccessFile` — no `PlatformService` / `PathResolver` dependency, even though NH TCR software is Windows-only in practice. The Windows-only constraint is enforced by deployment convention (`device.type=NH_TCR`), not a runtime OS check in this module's code.
## Cross-links
- [technical.agent] — module system, lifecycle, shared HTTP client settings, full outbound API surface
- [platform.module-map]
@@ -0,0 +1,65 @@
---
module: agent.nh-tcr-events
title: NH TCR Events — cassette + firmware monitoring for Nautilus Hyosung / Glory BRM30 recyclers
tab: Claude
order: 40
audience: bcos
---
> **Internal · agent reference.** Terse. Confirmed against `hiveops-module-nh-tcr-events` source 2026-07-01.
## Identity
- Maven artifact: **`hiveops-module-nh-tcr-events`** (`com.hiveops`, version `1.0.0`, packaged as a standalone `jar`, parent `hiveops-agent-parent` `4.4.3`).
- Agent module id (`getName()`, and the string for **`modules.disabled`**): **`nh-tcr-events`**.
- `getVersion()` = `1.0.0`; `getLogPackage()` = `com.hiveops.nhtcr`.
- Entry class: `com.hiveops.nhtcr.NhTcrEventsModule`, registered via `META-INF/services/com.hiveops.core.module.AgentModule`.
- Vendor target: Nautilus Hyosung TCR + Glory BRM30 external processor (EP).
- Activation gate: `device.type` must equal **`NH_TCR`** exactly (uppercased/trimmed). No legacy alias. Any other value → module inert, `isEnabled()` = `false`.
- Platform: Windows-only in practice (no runtime OS check in code — enforced by deployment convention).
## Config properties (exact keys)
| Key | Default | Required | Notes |
|---|---|---|---|
| `device.type` | *(none)* | **yes** | must be `NH_TCR` or module disables |
| `nh.tcr.ap.log.dir` | *(none)* | one of ap/fw | MoniPlus2S APLog root; date subfolders under it. Not-a-dir → APLog monitor off (warn) |
| `nh.tcr.fw.log.dir` | *(none)* | one of ap/fw | Glory FWLogSave dir. Not-a-dir → FW monitor off (warn) |
| `nh.tcr.events.poll.interval.sec` | `30` | no | **applied** here (unlike sibling `atec-tcr-events`, where it's a no-op) |
- If **both** `nh.tcr.ap.log.dir` and `nh.tcr.fw.log.dir` are unset → module disabled. Neither dir existing → module disabled.
- Shared (not module-specific): `agent.endpoint` / `incident.endpoint` (prefix = `agent` if `agent.endpoint` set, else `incident`; if neither → disabled), `agent.auth.token`, `agent.institution.key` / `incident.institution.key`.
## Event types emitted (exact strings)
| Event type | Source | Trigger |
|---|---|---|
| `CASSETTE_INVENTORY` | APLog | **any** slot status change (emitted first, always) |
| `CASSETTE_LOW` | APLog | slot status → `LOW` |
| `CASSETTE_EMPTY` | APLog | slot status → `EMPTY` |
| `TCR_CASSETTE_MISSING` | APLog | slot status → `MISSING` |
| `TCR_CASSETTE_MANIP` | APLog | slot status → `MANIP` (suspect bills) |
| `TCR_EP_RESTART` | FWEvent | line matches `LogManager_StartUp()` |
- Status `OK` (recovery) → **no event**, agent-log-only (`slot {} recovered`).
- `eventType` is a free-form `String` on `CreateJournalEventRequest` (`hiveops-agent-journal` DTO) — **not validated client-side**. Recognition/labeling is server-side via hiveops-incident's DB `event_type` table.
- `eventSource` sent: **`NH_TCR_APLOG`** (cassette) or **`NH_TCR_FWEVENT`** (EP restart).
- Cassette events also set `cassettePosition` (slot, e.g. `CST_C`), `cassetteDenomination` (int), `cassetteBillCount` (int), `cassetteType` (`RECYCLING`/`REJECT`/`REPCONTAINER`), `cassetteCurrency="USD"` (hardcoded). FW events set none of these.
- Transport: `IncidentEventClient.sendEvent()` → `POST {endpoint}/api/journal-events`.
- Separate path — firmware inventory: `client.syncHardware()` → `POST {endpoint}/api/atms/hardware/sync`, fired **once per daily APLog file** from the header block (`peripheralVersions` map).
## Files monitored
- APLog: `{nh.tcr.ap.log.dir}/{YYYYMMDD}/APLog{YYYYMMDD}.log` — **date subfolder**, daily rotation. Tail-by-offset; offset/`slotStatus` reset on rollover or truncation.
- FWEvent: `{nh.tcr.fw.log.dir}/00FWEvent00.log` … `00FWEvent09.log` — 10-file **circular buffer**. Per-file offset; first poll seeks all to EOF (only NEW entries emit).
## Deployment
- Delivered fleet-wide via **`INSTALL_MODULE`** fleet task (drops JAR into agent's `modules/` dir, restarts agent — `ProcessInstallModule` in `hiveops-file-collection`).
- Disable without uninstalling: add `nh-tcr-events` to `modules.disabled`, push `CONFIG_UPDATE`.
- Emitted event types must be registered in hiveops-incident → Settings → Event Types (DB `event_type` table) to be recognized/labeled.
## Do NOT
- Do NOT run this on non-`NH_TCR` device types — it's a no-op by design elsewhere, not a bug to chase.
- Do NOT use anything but **`nh-tcr-events`** as the `modules.disabled` / module id string.
- Do NOT expect `TCR_EP_RESTART` for restarts that happened **before** the agent came up — the FW monitor seeks to EOF on first poll; only post-start `LogManager_StartUp()` lines emit.
- Do NOT assume firmware versions refresh continuously — the header sync runs **once per daily APLog file** and sets `headerParsed=true` even on partial failure (no same-day retry).
- Do NOT expect retry/queueing on send/sync failure — a failed `sendEvent()` / `syncHardware()` is logged and dropped.
- Do NOT assume `cassetteCurrency` reflects the real currency — it's hardcoded `"USD"`.
- Do NOT trust `NhTcrEventsModule`'s class Javadoc emit list — it's stale (omits `CASSETTE_INVENTORY` and `TCR_CASSETTE_MANIP`). Use the table above / the monitor code.
- Do NOT confuse with `hiveops-module-atec-tcr-events` (`tcr-events`, LG/ATEC vendor) or `hiveops-module-nh-tcr-journal` — separate modules, different log formats and event pipelines.
@@ -0,0 +1,60 @@
---
module: agent.nh-tcr-events
title: NH TCR Events — cassette + firmware monitoring for Nautilus Hyosung / Glory BRM30 recyclers
tab: Internal
order: 20
audience: dev
---
> **Internal · ops/support.** Written 2026-07-01 from `hiveops-module-nh-tcr-events` source (agent parent `4.4.3`). Verify against code before relying on specifics.
## What this module is
`hiveops-module-nh-tcr-events` (Maven artifact `hiveops-module-nh-tcr-events`, agent module id **`nh-tcr-events`**) is an **agent drop-in module**, not a backend service. It runs inside the HiveIQ Agent process on **Nautilus Hyosung TCR (Teller Cash Recycler) devices** that use the **Glory BRM30** external processor (EP). It self-disables unless the ATM's `device.type` is exactly `NH_TCR` (no legacy alias).
It tails log files that the Hyosung MoniPlus2S and Glory firmware already write to disk, turns specific lines into HiveIQ journal events, and additionally scrapes the daily APLog header for component firmware versions and pushes those to the incident hardware inventory. It does not talk to the recycler hardware directly, and it does not own cash totals / cassette reconciliation (that's reconciliation's job).
## What it monitors
Two independent monitors, both driven by one scheduler tick:
1. **MoniPlus2S APLog** (`ApLogMonitor`) — dir `nh.tcr.ap.log.dir`.
- File pattern: `{nh.tcr.ap.log.dir}\{YYYYMMDD}\APLog{YYYYMMDD}.log` (each day gets its own **date subfolder**, e.g. `C:\Hyosung\MoniPlus2S\MoniPlus2Slog\20230330\APLog20230330.log`).
- Parses `** IStackedBillsBlock = $|{denom}|{count}|{status}|{type}|{slot}|...` lines for per-slot cassette **level and status** (`OK` / `LOW` / `EMPTY` / `MISSING` / `MANIP`). Emits only when a slot's status **changes** (in-memory per-slot last-status guard), so a slot stuck `EMPTY` doesn't re-fire every poll.
- On the first read of each daily file it parses the **header block** (bounded by `====` delimiter lines) for component firmware versions (SIU, BRM, SDK, Application, Nextware, etc.) and syncs them to the incident backend hardware inventory.
2. **Glory BRM30 FWLogSave** (`FwEventLogMonitor`) — dir `nh.tcr.fw.log.dir`.
- Files: `00FWEvent00.log` … `00FWEvent09.log`, a **10-file circular buffer** in that directory.
- Watches for `LogManager_StartUp()` lines, which indicate the Glory EP firmware process (re)started.
The two directories are configured separately and may point anywhere; either one being missing just disables that one monitor.
## Config properties it registers
| Property | Default | Meaning |
|---|---|---|
| `device.type` | *(none)* | Activation gate. Must equal `NH_TCR` (uppercased/trimmed). Any other value → module logs `device.type='<X>', not NH_TCR — module disabled` and does nothing. |
| `nh.tcr.ap.log.dir` | *(none)* | Directory root for the MoniPlus2S APLog (date subfolders live under it). If set but not an existing directory → `APLog monitoring disabled` warning, module keeps running for FW events. |
| `nh.tcr.fw.log.dir` | *(none)* | Directory for the Glory `00FWEventNN.log` files. If set but not an existing directory → `FW event monitoring disabled` warning. |
| `nh.tcr.events.poll.interval.sec` | `30` | Poll interval in seconds. **This module actually applies the configured value** (`start()` schedules at `pollSec`) — unlike the sibling `atec-tcr-events` module, where the same-named property is a no-op. |
If **both** `nh.tcr.ap.log.dir` and `nh.tcr.fw.log.dir` are unset, the module disables itself (`neither nh.tcr.ap.log.dir nor nh.tcr.fw.log.dir configured — module disabled`) — it never throws or crashes agent startup.
It reuses the shared agent HTTP settings — `agent.endpoint` (preferred) or `incident.endpoint` (legacy fallback), `agent.auth.token`, and the institution-key chain (`agent.institution.key` / `incident.institution.key`) — the same properties every other event-reporting module uses. There are no NH-TCR-specific auth properties. If neither endpoint is set the module disables itself.
## How it's enabled / deployed
- Activates only on ATMs configured with `device.type=NH_TCR`. On any other device type it is intentionally inert — this is expected, not a bug.
- Delivered like any other agent module: uploaded to the fleet artifact store and pushed via an **`INSTALL_MODULE`** fleet task, which drops the module JAR into the agent's `modules/` directory and restarts the agent (`ProcessInstallModule` in `hiveops-file-collection`). Standard rollout order applies: lab ATM → pilot device → fleet.
- Can be disabled fleet-wide **without** removing the JAR by adding `nh-tcr-events` to the comma-separated `modules.disabled` property and pushing a `CONFIG_UPDATE`. The module id for `modules.disabled` is the `getName()` value, **`nh-tcr-events`** (see the Claude doc for the exact string).
- Event types it emits (`CASSETTE_INVENTORY`, `CASSETTE_LOW`, `CASSETTE_EMPTY`, `TCR_CASSETTE_MISSING`, `TCR_CASSETTE_MANIP`, `TCR_EP_RESTART`) must exist as rows in hiveops-incident's `event_type` table (Incident → Settings → Event Types) to be recognized/labeled downstream. `CASSETTE_LOW` / `CASSETTE_EMPTY` are standard shared types; the `TCR_*` / `CASSETTE_INVENTORY` types may need adding on a fresh environment.
## Common failure modes / what to check
- **No NH TCR events ever appear for a customer ATM** — first check `device.type` on that ATM's config; if it isn't exactly `NH_TCR`, the module is intentionally inert. Not a bug.
- **Module loaded but silent** — check that `nh.tcr.ap.log.dir` / `nh.tcr.fw.log.dir` are set and point at real existing directories on that machine; a missing/wrong path disables that monitor at startup with a log warning, no crash. Confirm via agent logs: `APLog watching <path>` and `FWEvent monitor initialized`.
- **Events stopped after midnight** — the APLog monitor rolls to a new **date subfolder** at local midnight (`rotateDailyFileIfNeeded()`). If the Hyosung software's folder/file-naming convention ever changes (non-standard rotation, DST edge case), the module will watch a path that never appears and go silent.
- **No `TCR_EP_RESTART` for restarts that happened before the agent came up** — by design: on its first poll the FW monitor **seeks each `00FWEventNN.log` to its end**, so only `LogManager_StartUp()` lines written *after* agent start are emitted. Pre-existing startup entries are ignored.
- **Events sent but not visible/categorized in Incident** — confirm the emitted event types are registered in hiveops-incident's Event Types settings; these are DB-driven, not hardcoded in incident's code.
- **Firmware versions not updating on the device detail** — version sync only fires on the **first read of each daily APLog file** (header parse). It runs once per day per file; a partial/failed header parse still marks `headerParsed=true` so it won't retry until the next day's file. Check for `synced N peripheral version(s) from APLog header` or `failed to sync peripheral versions` in agent logs.
- **HTTP send failures** — `IncidentEventClient.sendEvent()` / `syncHardware()` failures are caught and logged (`failed to send ...` / `failed to sync ...`) but **not retried or queued**; a transient outage silently drops that single event/sync rather than backing it up.
- **Slot recovered (`OK`) events don't create tickets** — by design; a return to `OK` only writes an agent-side log line (`slot {} recovered`), no event is emitted.
@@ -0,0 +1,98 @@
---
module: agent.nh-tcr-journal
title: NH TCR Journal — transaction + fault events from Nautilus Hyosung Teller Cash Recyclers
tab: Architect
order: 30
audience: bcos
---
> **Internal · architecture.** Written 2026-07-01 from `hiveops-module-nh-tcr-journal` source. Verify against code before relying on specifics.
## Module shape
Package `com.hiveops.nhtcr`, two classes, no controllers/DB/Kafka (this is an agent module, not a microservice — see [technical.agent]):
- **`NhTcrJournalModule`** — the `AgentModule` SPI entry point (registered via `META-INF/services/com.hiveops.core.module.AgentModule`). Owns lifecycle (`initialize()` → `isEnabled()` → `start()` → `stop()`), the shared `ScheduledExecutorService`, config resolution, and the `IncidentEventClient` construction.
- **`TcrJournalMonitor`** — the actual ejournal follower + parser. A plain constructed object (`new TcrJournalMonitor(journalSource, atmName, client)`), not its own `AgentModule`.
Module identity from `NhTcrJournalModule`: `getName()` = `nh-tcr-journal`, `getVersion()` = `1.0.0`, `getLogPackage()` = `com.hiveops.nhtcr`.
## Activation gating (in `initialize()`)
Three gates, all fail-soft (log + return, `ready` stays `false`, `isEnabled()` returns `false` — no exception thrown to agent startup):
1. `device.type` (uppercased/trimmed) must equal `NH_TCR`.
2. A `JournalSource` must be resolvable from the ext properties — `findJournalSource()` scans `context.getExtensionProperties()` for the first entry with `journal.dir` set and `type` in `{NH_TCR, MAINJOURNAL}`, then builds `new JournalSource(journalDir, type, fileFormat, filenameFormat, atmName)` (`file.format` defaults to `UTF8`).
3. An endpoint must exist: `agent.endpoint` if set (prefix `agent`), else `incident.endpoint` (prefix `incident`).
On success it reads `nh.tcr.journal.poll.interval.sec` (default `60`), constructs the monitor, and schedules `poll()` on a single daemon thread named `nh-tcr-journal` via `scheduleAtFixedRate(this::poll, 0, pollSec, SECONDS)`. Note: unlike the sibling ATEC module, the poll interval property here **is** applied.
## Flow: tail → parse → detect → emit
```
NhTcrJournalModule (scheduler, every nh.tcr.journal.poll.interval.sec)
→ poll() → TcrJournalMonitor.poll()
→ journalSource.getCurrentJournalFile() // resolves today's ej_..._{YYYY}{MM}{DD} file
→ RandomAccessFile tail from stored byte offset (UTF-16 LE, 2 MB/poll cap)
→ split into complete lines → processLine() per line
```
### Tail-by-offset, UTF-16 LE
`TcrJournalMonitor` uses byte-offset tailing, but with a TCR-specific twist — the file is decoded as **UTF-16 LE**:
- Tracks `currentFile` + `filePosition` (raw byte offset).
- Daily rollover: when `getCurrentJournalFile()` returns a different file, it resets `filePosition=0`, clears block state, logs `switched to ...`.
- Each poll seeks to `filePosition`, reads up to 2 MB, trims to an even byte count (UTF-16 needs pairs), decodes via `StandardCharsets.UTF_16LE.newDecoder()`, and splits on `\r?\n`.
- Only **complete** lines are processed; a trailing partial line is left unread. It advances `filePosition` by `charsProcessed * 2` (2 bytes/char) so the next poll resumes exactly where parsing stopped — the partial line is re-read once it completes.
- No explicit truncation guard: if `fileLength <= filePosition` the poll simply returns (no work), and a same-name rewrite would only be reprocessed if the file object changes at rollover.
### Line parsing (`processLine`)
`JOURNAL_LINE` regex: `^?\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}-\d+\]\[(\d+)\]\[TCR\](\w*)(.*)` — captures `level`, `eventName`, `params`. (The optional `?` absorbs a leading BOM.)
- A `level == 5` line with an **empty** event name opens a TRANSACTION RECORD block (`inTransactionBlock = true`); subsequent non-matching lines are buffered.
- Any other timestamp line first flushes an open block, then routes the event name through `handleEvent()`.
- Non-matching lines while a block is open are appended to the buffer; a blank line inside a block is buffered too.
### Detect → event mapping
`handleEvent()` (single-line device events):
| Journal `EventName` | Condition | Emitted event type |
|---|---|---|
| `CashDispenserStatusChanged` | params == `NODISPENSE` | `DISPENSER_JAM` |
| `BNAItemsRefused` | always | `DISPENSER_JAM` |
| `CashUnitThresholdChanged` | always | `CASSETTE_LOW` |
| `AcceptorStatusChanged` | params != `OK` | `SELF_TEST_FAILED` |
`flushTransactionBlock()` (multi-line): `extract()` pulls `KEY : value` fields (`TRANSACTION NO.`, `TRANSACTION TYPE`, `TRANSACTION RESULT`, `TRANSACTION AMOUNT`, `MACHINE BALANCE`, `TELLER ID`, `BRANCH NUMBER`) and `extractBracketed()` pulls `Error Code [..]`. If both transaction number and type are missing the block is dropped. Otherwise it builds a `TCR Transaction #.. | Type: .. | Result: ..` summary string and emits it as a single **`OPERATOR_ACTION`** event (error code appended only if non-zero).
## How events reach the platform
Every event goes through the shared `IncidentEventClient` (constructed once in `initialize()`), the same client class used by `journal-events` and the other event-reporting modules — see [technical.agent] for the full outbound API surface.
```
TcrJournalMonitor.sendEvent(eventType, details)
→ CreateJournalEventRequest.builder()
.agentAtmId(atmId) // ATM name string, resolved to incident's numeric atmId server-side
.eventType(eventType) // free-form String, not validated client-side
.eventDetails(details)
.eventSource("NH_TCR_JOURNAL")
.build()
→ IncidentEventClient.sendEvent(req)
→ HTTP POST {agent.endpoint or incident.endpoint}/api/journal-events
```
`eventType` is a plain `String` on `CreateJournalEventRequest` (`hiveops-agent-journal` DTO). The four types this module emits (`OPERATOR_ACTION`, `DISPENSER_JAM`, `CASSETTE_LOW`, `SELF_TEST_FAILED`) are all standard HiveIQ event types recognized server-side; there is no TCR-specific event string here. Send failures (`IOException`) are caught and logged — **no retry or queueing**.
## Auth / endpoint selection
`initialize()` picks the property prefix from whether `agent.endpoint` is set (`agent`) or not (`incident`), then `HttpClientHelper.loadSettingsFromProperties(props, prefix)` resolves timeouts, institution key, and the bearer token (`agent.auth.token`). No bespoke auth in this module — it is 100% the shared agent HTTP settings pattern.
## Platform abstraction
None used directly. The follower reads files via `RandomAccessFile` / `java.nio` and resolves the daily filename through `JournalSource` — no `PlatformService`/`PathResolver` dependency. In practice NH TCR machines are Windows, but the constraint is enforced by deployment convention (`device.type=NH_TCR` gating) rather than a runtime OS check.
## Cross-links
- [technical.agent] — module system, lifecycle, shared HTTP client settings, full outbound API surface
- [platform.module-map]
@@ -0,0 +1,63 @@
---
module: agent.nh-tcr-journal
title: NH TCR Journal — transaction + fault events from Nautilus Hyosung Teller Cash Recyclers
tab: Claude
order: 40
audience: bcos
---
> **Internal · agent reference.** Terse. Confirmed against `hiveops-module-nh-tcr-journal` source 2026-07-01.
## Identity
- Maven artifact: **`hiveops-module-nh-tcr-journal`** (`com.hiveops`, version `1.0.0`, standalone `jar`, parent `hiveops-agent-parent` 4.4.3).
- Agent module id (`getName()`, and the string for **`modules.disabled`**): **`nh-tcr-journal`**.
- Entry class: `com.hiveops.nhtcr.NhTcrJournalModule`, registered via `META-INF/services/com.hiveops.core.module.AgentModule`.
- Follower/parser class: `com.hiveops.nhtcr.TcrJournalMonitor`.
- Log package (`getLogPackage()`): `com.hiveops.nhtcr`.
- Activation gate: `device.type` must equal `NH_TCR` **and** an ext properties entry with `type=NH_TCR` (or legacy `MAINJOURNAL`) + `journal.dir` must exist, **and** `agent.endpoint` or `incident.endpoint` must be set. Any gate failing → module inert, `isEnabled()` returns `false` (no crash).
## Config properties (exact keys)
| Key | File | Default | Required | Notes |
|---|---|---|---|---|
| `device.type` | `hiveops.properties` | *(none)* | **yes** | must be `NH_TCR` (uppercased/trimmed) |
| `nh.tcr.journal.poll.interval.sec` | `hiveops.properties` | `60` | no | poll cadence; **is** applied (`scheduleAtFixedRate`) |
| `type` | ext (`ej.properties`) | *(none)* | **yes** | `NH_TCR` preferred, `MAINJOURNAL` legacy fallback |
| `journal.dir` | ext | *(none)* | **yes** | TCR ejournal directory |
| `filename.format` | ext | *(none)* | recommended | daily template, e.g. `ej_TCR1_{YYYY}{MM}{DD}.txt` |
| `file.format` | ext | `UTF8` | no | `JournalSource` descriptor; follower still decodes bytes as UTF-16 LE |
Shared (not module-specific): `agent.endpoint` (prefix `agent`) else `incident.endpoint` (prefix `incident`); `agent.auth.token`; institution-key chain — all via `HttpClientHelper.loadSettingsFromProperties(props, prefix)`.
## Event types emitted (exact strings)
| Event type | Trigger | Source |
|---|---|---|
| `DISPENSER_JAM` | `CashDispenserStatusChanged` with params `NODISPENSE` | single-line device event |
| `DISPENSER_JAM` | `BNAItemsRefused` (always) | single-line device event |
| `CASSETTE_LOW` | `CashUnitThresholdChanged` (always) | single-line device event |
| `SELF_TEST_FAILED` | `AcceptorStatusChanged` with params != `OK` | single-line device event |
| `OPERATOR_ACTION` | completed TRANSACTION RECORD block (`level 5`, empty name) | multi-line block |
- All four are standard HiveIQ event types (no TCR-specific event strings). `eventType` is a free-form `String` on `CreateJournalEventRequest` (`hiveops-agent-journal` DTO), not validated client-side.
- `eventSource` sent for all events: **`"NH_TCR_JOURNAL"`**.
- `OPERATOR_ACTION` `eventDetails` = `TCR Transaction #<no> | Type: .. | Result: .. | Amount: .. | Balance: .. | Teller: .. | Branch: ..` (`| Error: <code>` appended only if error code present and != `0`).
- Transport: `POST {endpoint}/api/journal-events` via `IncidentEventClient.sendEvent()` — same client/endpoint as `journal-events`.
## Journal format facts
- File: UTF-16 LE text, daily-rotated, resolved via `JournalSource.getCurrentJournalFile()` (filename fixed per model; ATM identity comes from the upload folder, not the filename).
- Line regex: `[YYYY-MM-DD HH:MM:SS-nnn][<level>][TCR]<EventName><params>` (optional leading BOM tolerated).
- TRANSACTION RECORD block opens on a `level 5` line with empty event name; buffered until the next timestamp line, then flushed. Block dropped if both `TRANSACTION NO.` and `TRANSACTION TYPE` are missing.
- Tail-by-byte-offset (`filePosition`), 2 MB read cap per poll, only complete lines processed; offset advanced by `chars * 2`.
## Deployment
- Delivered fleet-wide via **`INSTALL_MODULE`** fleet task (downloads JAR into agent `modules/` dir, restarts agent — `ProcessInstallModule` in `hiveops-file-collection`).
- Disable without uninstalling: add `nh-tcr-journal` to `modules.disabled`, push `CONFIG_UPDATE`.
- Rollout order: lab ATM → pilot → fleet.
## Do NOT
- Do NOT expect events on non-`NH_TCR` devices — inert by design, not a bug to chase.
- Do NOT assume plain UTF-8/ASCII parsing — the follower decodes **UTF-16 LE**; a differently-encoded ejournal parses to nothing silently.
- Do NOT expect retry/queueing on send failure — a failed `sendEvent()` is logged and dropped, not persisted or retried next poll.
- Do NOT expect in-agent de-dup for single-line events (`DISPENSER_JAM` / `CASSETTE_LOW` / `SELF_TEST_FAILED`) — every matching line emits; only the transaction-block buffer holds state.
- Do NOT rely on `MAINJOURNAL` for new installs — use `type=NH_TCR` so `journal-upload` tags uploads for the TCR parser; `MAINJOURNAL` is a compatibility fallback only.
- Do NOT confuse this with `hiveops-module-nh-tcr-events` (`NhTcrEventsModule`) or `hiveops-module-atec-tcr-events` (`tcr-events`) — separate modules, separate formats/pipelines.
- Do NOT assume the poll-interval property is a no-op — here it **is** applied (unlike the ATEC sibling).
@@ -0,0 +1,67 @@
---
module: agent.nh-tcr-journal
title: NH TCR Journal — transaction + fault events from Nautilus Hyosung Teller Cash Recyclers
tab: Internal
order: 20
audience: dev
---
> **Internal · ops/support.** Written 2026-07-01 from `hiveops-module-nh-tcr-journal` source. Verify against code before relying on specifics.
## What this module is
`hiveops-module-nh-tcr-journal` (Maven artifact `hiveops-module-nh-tcr-journal`, agent module id **`nh-tcr-journal`**) is an **agent drop-in module**, not a backend service. It runs inside the HiveIQ Agent process on **Nautilus Hyosung TCR (Teller Cash Recycler) devices only**, and it self-disables unless the ATM's `device.type` is `NH_TCR`.
It tails the NH TCR ejournal file the recycler software already writes on disk (UTF-16 LE text), turns each teller transaction record and specific device-fault lines into HiveIQ journal events, and sends them to the incident pipeline via the shared agent HTTP client. It does not talk to the recycler hardware directly and it does not reconcile cash totals — it is a read-only journal follower.
> Not to be confused with `hiveops-module-nh-tcr-events` (a separate NH TCR module) or `hiveops-module-atec-tcr-events` (ATEC/LG TCRs). Different modules, different log formats.
## What it monitors
A single poll loop (default every 60s) tails one daily-rotated ejournal file resolved through the agent's standard `JournalSource` (directory + `filename.format`, e.g. `ej_TCR1_{YYYY}{MM}{DD}.txt`). The filename is fixed per machine model — ATM identity comes from the device-specific upload folder, not the filename.
The follower recognizes two kinds of content:
1. **Single-line device events** — lines shaped `[timestamp][level][TCR]EventName,params`. It maps four of these to HiveIQ events:
- `CashDispenserStatusChanged` with params `NODISPENSE` → dispenser fault.
- `BNAItemsRefused` → dispenser fault (possible note jam / validation failure).
- `CashUnitThresholdChanged` → cash-unit low.
- `AcceptorStatusChanged` with any non-`OK` status → acceptor self-test failure.
- All other event names are ignored.
2. **Multi-line TRANSACTION RECORD blocks** — a `level 5` line with no event name opens a block; the module buffers following lines until the next timestamp line, then extracts transaction number, type, result, amount, machine balance, teller ID, branch number and error code into a one-line summary and emits it as a teller-action event.
## Config properties it registers
| Property | Where | Default | Meaning |
|---|---|---|---|
| `nh.tcr.journal.poll.interval.sec` | `hiveops.properties` | `60` | Poll interval in seconds for the ejournal tail loop. |
| `device.type` | `hiveops.properties` | *(none — required)* | Must equal `NH_TCR` for the module to activate; any other value → module inert. |
The journal location and filename come from an **ext properties file** (the standard `ext/ej.properties`), not from `hiveops.properties`:
| ext key | Default | Meaning |
|---|---|---|
| `type` | *(required)* | Must be `NH_TCR` (preferred) or `MAINJOURNAL` (legacy fallback). |
| `journal.dir` | *(required)* | Directory containing the TCR ejournal files. |
| `filename.format` | *(none)* | Daily filename template, e.g. `ej_TCR1_{YYYY}{MM}{DD}.txt`. |
| `file.format` | `UTF8` | Journal file encoding descriptor read by `JournalSource` (the follower itself decodes the raw bytes as UTF-16 LE). |
It also reuses the shared agent HTTP settings — `agent.endpoint` (preferred) or `incident.endpoint` (legacy fallback), `agent.auth.token`, and the institution-key chain — the same properties every other event-reporting module uses. There are no TCR-specific auth properties.
> Prefer `type=NH_TCR` over `MAINJOURNAL` on new installs: the `journal-upload` module uses the same `type` to tag uploads so the server routes them to the TCR parser. `MAINJOURNAL` only exists so pre-existing ATM configs keep working.
## How it's enabled / deployed
- It only activates on ATMs configured with `device.type=NH_TCR` **and** a matching ext properties entry (`type=NH_TCR`/`MAINJOURNAL` with `journal.dir` set). Missing either → the module logs a warning and disables itself; it does **not** crash agent startup.
- Delivered like any other agent module: uploaded to the fleet artifact store and pushed via an **`INSTALL_MODULE`** fleet task, which downloads the module JAR into the agent's `modules/` directory and restarts the agent (see `ProcessInstallModule` in `hiveops-file-collection`). Standard rollout order applies: lab ATM → pilot device → fleet.
- Can be disabled fleet-wide without removing the JAR by adding `nh-tcr-journal` to the comma-separated `modules.disabled` property and pushing a `CONFIG_UPDATE`.
- Event types it emits (`OPERATOR_ACTION`, `DISPENSER_JAM`, `CASSETTE_LOW`, `SELF_TEST_FAILED`) are all standard HiveIQ event types shared with other modules and normally already registered in hiveops-incident's Event Types settings.
## Common failure modes / what to check
- **No TCR events ever appear for a customer ATM** — first check `device.type` on that ATM's config; if it isn't `NH_TCR`, the module is intentionally inert. Not a bug.
- **Module loaded but silent** — confirm the ext properties file exists with `type=NH_TCR` (or `MAINJOURNAL`) and a valid `journal.dir`; a missing/wrong ext entry disables the module at startup with a log warning, no crash. Look for `nh-tcr-journal: monitoring <dir>` in agent logs to confirm what it is tailing.
- **Events stopped after midnight / around rollover** — the follower resets its read position on daily filename rollover. If the recycler's filename convention differs from the configured `filename.format`, it will look for a file that never appears and go silent. Check `nh-tcr-journal: switched to <file>` in agent logs.
- **Garbled details / no events despite an active journal** — this follower decodes the file as **UTF-16 LE**. If a given customer machine writes the ejournal in a different encoding, line parsing will fail silently. Confirm the raw file encoding before assuming a bug.
- **HTTP send failures** — `IncidentEventClient.sendEvent()` failures are caught and logged (`nh-tcr-journal: failed to send ...`) but **not** retried or queued; a transient outage drops that single event rather than backing it up for a later poll.
- **A slot/dispenser fault fires once but not repeatedly** — single-line events are emitted every time the matching line appears in the unread tail; there is no in-agent de-dup for these (unlike the block state). High-frequency `CashUnitThresholdChanged` lines can therefore produce repeated `CASSETTE_LOW` events.
@@ -0,0 +1,72 @@
---
module: agent.packet-capture
title: Packet Capture — on-demand network capture for ATM diagnostics
tab: Architect
order: 30
audience: bcos
---
> **Internal · how it's built.** Confirmed against `hiveops-module-packet-capture` source (`com.hiveops.packetcapture`) 2026-07-01. See [technical.agent] for agent-wide context.
## Maven module
`hiveops-module-packet-capture` (artifact `hiveops-module-packet-capture`, own version `1.0.0`, parented by `hiveops-agent-parent`). Packaged as a standalone jar (`<finalName>${project.artifactId}</finalName>` → `hiveops-module-packet-capture.jar`), depending only on `hiveops-core` (provided scope) — no dependency on `hiveops-journal`, `hiveops-images`, or `hiveops-file-collection`. It's listed in the parent `pom.xml` `<modules>` alongside the other module-set jars (`hiveops-module-log`, `hiveops-module-hw-inventory`, etc.).
Registered via ServiceLoader SPI: `src/main/resources/META-INF/services/com.hiveops.core.module.AgentModule` → `com.hiveops.packetcapture.PacketCaptureModule`.
## Classes and flow
Three classes, no sub-packages:
| Class | Role |
|---|---|
| `PacketCaptureModule` | `AgentModule` implementation — lifecycle, scheduler, poll loop |
| `PacketCaptureHandler` | Executes one `PACKET_CAPTURE` task: parses `configPatch`, runs the tool, uploads the result, reports status |
| `PacketCaptureTool` | Platform-specific process runner: picks and drives `tcpdump` (Linux) or `tshark`/`pktmon`/`netsh trace` (Windows, in that preference order) |
**Poll → detect → capture → upload** cycle (poll-based, not log-tail or hook-based):
1. `PacketCaptureModule.start()` schedules `poll()` on a single-thread daemon executor every **30s** (`POLL_INTERVAL_SECONDS`).
2. `poll()` calls `FileUploadClient.queryCurrentAtmPendingTask(country, atmName, "PACKET_CAPTURE")` — a **kind-filtered** poll (`GET /api/agent/{country}/{atm}/task?kind=PACKET_CAPTURE`), so this module only ever sees tasks of its own kind; it does not compete with `CommandProcessor`'s generic poll for other task kinds.
3. If a task is returned, it builds a `PacketCaptureHandler` and calls `execute(task)`. A static `AtomicBoolean captureInProgress` shared across polls ensures only one capture runs at a time per ATM — a second concurrent task is failed immediately with "Another capture is already in progress on this ATM".
4. `PacketCaptureHandler.doExecute()`:
- Reads `duration` (default 60, clamped to `MAX_DURATION=300`), `interface` (default `"auto"`), `filter` (default empty) from `task.configPatch`.
- Probes which capture tool/format will be used (`PacketCaptureTool.detectFormat()`) **before** creating the output file, so the extension (`.pcap` vs `.etl`) matches the actual tool up front.
- Reports `RUNNING` via `statusReport`, then runs `PacketCaptureTool.capture()` synchronously, with a progress callback firing roughly every 10s that updates the task's status message with remaining seconds.
- On success, uploads the file in `100_000`-byte chunks (`uploadFile()` / `FileUploadClient.fleetManagementUpload`), tracking a server-confirmed offset and retrying a stalled chunk up to 5 times before failing.
- Always deletes the local capture file in a `finally` block, whether the task succeeded or failed.
5. `PacketCaptureTool.capture()` branches on OS (`System.getProperty("os.name")`):
- **Linux:** requires `tcpdump` (`which tcpdump`); throws if missing. Runs `tcpdump -U -q [-i <iface>] -w <file> [filter tokens]` as a foreground process for the requested duration, then force-kills it.
- **Windows:** tries `tshark` first (`-a duration:<n> -w <file> [-i <iface>] [-f <filter>]`, produces `.pcap`); falls back to `pktmon start --capture --file-name <file>` / `stop` (produces `.etl`, ignores the BPF filter — captures everything); falls back further to `netsh trace start capture=yes traceFile=<file> maxSize=50` / `stop` (also `.etl`, also ignores filter, and `stop` can take up to 90s to flush).
- `abort()` (called from `PacketCaptureModule.stop()` on agent shutdown) force-kills the foreground process and, on Windows, best-effort stops `pktmon`/`netsh trace` too.
## How events/results reach the platform
This module does **not** integrate with the journal/incident event pipeline (`IncidentEventClient`, `EventType`) at all — see [technical.agent] "API surface" for that separate path. Its only platform integration is the **fleet task protocol**, shared with every other fleet-task-driven module (`command-processor`, log-collect, etc.). The agent-side calls (verified in `FileUploadClient`, `hiveops-core`) are:
```
PacketCaptureModule / PacketCaptureHandler
│ (FileUploadClient, HttpClientSettings built from agent.endpoint/incident.endpoint)
{endpoint} GET /api/agent/{country}/{atm}/task?kind=PACKET_CAPTURE (poll)
POST /api/agent/tasks/{tid}/status (RUNNING/COMPLETED/FAILED + msg)
POST /api/agent/tasks/{tid}/upload?o=<base36offset>&f=<enc> (chunked file upload)
│ (endpoint = agent.endpoint → agent-proxy in current topology)
agent-proxy → incident's internal fleet API → fleet task store / Fleet UI
(downstream controller name + internal path unverified, cross-repo)
```
- `HttpClientSettings incidentSettings` is built once in `initialize()` via `HttpClientHelper.loadSettingsFromProperties(props, prefix)`, where `prefix` is `"agent"` if `agent.endpoint` is set, else `"incident"`. This is the same pattern used by other modules that need to talk to the fleet/incident base URL.
- Endpoints hit (all via `FileUploadClient`, defined in `hiveops-core`):
- `GET {endpoint}/api/agent/{country}/{atm}/task?kind=PACKET_CAPTURE` — poll
- `POST {endpoint}/api/agent/tasks/{tid}/status` — RUNNING/COMPLETED/FAILED + message
- `POST {endpoint}/api/agent/tasks/{tid}/upload?o=...&f=...` — chunked capture-file upload
## Platform abstraction
The module does **not** use `com.hiveops.platform.PlatformServiceFactory` / `PlatformService` at all — it does its own OS detection (`PacketCaptureTool.isWindows()` checking `os.name`) and shells out directly via `ProcessBuilder`, rather than going through the shared `SystemCommands` abstraction other modules (e.g. `file-collection`'s reboot/patch handling) use. This is a deliberate/simple design: the module only ever needs to run one of four well-known external tools and doesn't need registry/config-file reads or path resolution.
## Coordination with the legacy command-processor
`CommandProcessor` (in `hiveops-file-collection`) also long-polls the server for pending tasks (unfiltered by kind). When its poll loop encounters a `PACKET_CAPTURE` task, it explicitly does **not** process it — it reports the task back to `PENDING` ("Released to packet-capture module") and sleeps, leaving it for this module's kind-filtered poll to pick up. This avoids two different modules racing to claim the same task.
@@ -0,0 +1,75 @@
---
module: agent.packet-capture
title: Packet Capture — on-demand network capture for ATM diagnostics
tab: Claude
order: 40
audience: bcos
---
> **Internal · agent reference.** Terse. Confirmed against `hiveops-module-packet-capture` source 2026-07-01.
## Identity
- Module name (`AgentModule.getName()`): **`packet-capture`**
- Maven artifact: **`hiveops-module-packet-capture`** (own version `1.0.0`; parent `hiveops-agent-parent` currently `4.4.3`)
- Entry class: `com.hiveops.packetcapture.PacketCaptureModule` (SPI file: `META-INF/services/com.hiveops.core.module.AgentModule`)
- Other classes: `PacketCaptureHandler` (task execution), `PacketCaptureTool` (OS-specific capture runner)
- Log package (`getLogPackage()`): `com.hiveops.packetcapture` → `logs/modules/packet-capture.log`
- Fleet task kind it implements: **`PACKET_CAPTURE`** (exact string, passed to `queryCurrentAtmPendingTask(country, atm, "PACKET_CAPTURE")`; the matching `FleetTaskKind` enum lives in `hiveops-fleet` — *(unverified, cross-repo)*)
## Config properties
- **No dedicated `packet-capture.*` keys exist in code.** Reuses whichever is set:
- `agent.endpoint` (preferred) or `incident.endpoint` (fallback) — HTTP base URL
- `agent.auth.token` / `agent.institution.key` — auth (shared across modules)
- `modules.disabled` — comma list; include `packet-capture` to disable
- `isEnabled()` = true whenever `agent.endpoint` OR `incident.endpoint` is non-blank (on by default).
- Hardcoded (not configurable): `POLL_INTERVAL_SECONDS = 30`, `MAX_DURATION = 300` (seconds, hard cap), `SEGMENT_SIZE = 100_000` (upload chunk bytes).
## Fleet task `configPatch` keys (set by the operator in Fleet UI)
| Key | Default if absent | Notes |
|---|---|---|
| `duration` | `60` | seconds; clamped **agent-side** to ≤300 (`Math.min(parsed, MAX_DURATION)`) |
| `interface` | `"auto"` | blank or `"auto"` → OS default interface |
| `filter` | `""` | BPF filter string, e.g. `tcp port 443`; **ignored** by `pktmon`/`netsh` Windows fallbacks |
## Event / status strings emitted
- **No `EventType` / `IAT_*` / journal-incident events.** This module never calls `IncidentEventClient`.
- Only emits **fleet task status** via `FileUploadClient.statusReport(tid, aid, status, message)`: `"RUNNING"`, `"COMPLETED"`, `"FAILED"` (plus free-text progress messages while running).
## HTTP endpoints used (agent → agent-proxy)
| Call | Path |
|---|---|
| Poll (kind-filtered) | `GET {endpoint}/api/agent/{country}/{atm}/task?kind=PACKET_CAPTURE` |
| Status report | `POST {endpoint}/api/agent/tasks/{tid}/status` |
| Upload chunk | `POST {endpoint}/api/agent/tasks/{tid}/upload?o=<base36offset>&f=<filename>` |
Base URL = `agent.endpoint` (preferred) or `incident.endpoint` (fallback), from `HttpClientHelper.loadSettingsFromProperties(props, prefix)`. In current topology `agent.endpoint` points at `agent-proxy`, which forwards to incident's internal fleet API — *(agent-proxy controller/internal path unverified, cross-repo)*. Same transport as every other fleet-task module.
## Capture tool selection (in order tried)
- **Linux:** `tcpdump` only — no fallback; module throws if `tcpdump` isn't installed. Output: `.pcap`.
- **Windows:** `tshark` → `.pcap` (supports `-f <BPF filter>`) → else `pktmon` → `.etl` (no filter support) → else `netsh trace` → `.etl` (no filter support, `stop` can take up to 90s).
- Output file extension is decided by `PacketCaptureTool.detectFormat()` **before** the real capture file is created, so it matches the tool that will actually run.
## Concurrency / lifecycle
- `AtomicBoolean captureInProgress` (static in `PacketCaptureModule`, shared with `PacketCaptureHandler`) allows only **one capture per ATM at a time**. A second concurrent `PACKET_CAPTURE` task fails immediately: "Another capture is already in progress on this ATM".
- Capture file is **always deleted** locally after upload (success or failure) — never left on ATM disk.
- `stop()` (agent shutdown) calls `PacketCaptureHandler.abort()` → `PacketCaptureTool.abort()`, force-killing the running process (and best-effort `pktmon stop`/`netsh trace stop` on Windows).
## Deployment
- Ships in the **`atm`** module set of `deployment/build-dist.sh` (default), NOT the `srv` (server-monitor) set.
- Delivered as drop-in JAR `modules/hiveops-module-packet-capture.jar` — not bundled in the fat JAR.
- Retrofit to an ATM missing it: `INSTALL_MODULE` fleet task, or a full standard-set agent update.
## Gotchas
- **Legacy `command-processor` explicitly ignores `PACKET_CAPTURE`:** when its own unfiltered poll sees a `PACKET_CAPTURE` task, it reports it back to `PENDING` ("Released to packet-capture module") and sleeps 35s — it never executes the capture itself. If tasks never leave PENDING, check that the `packet-capture` module (not just `command-processor`) is actually running, not that command-processor is "stuck."
- Requesting `duration` > 300 does **not** error — it silently clamps to 300 with no warning back to Fleet.
- Windows without `tshark` installed silently degrades to `.etl` capture with **no BPF filter applied** — a requested `filter` is quietly ignored, not rejected.
- No `PlatformService`/`SystemCommands` usage here — OS branching is done locally via `os.name` + raw `ProcessBuilder`, unlike most other modules.
- This module has zero config-property surface of its own; don't go looking for `packet-capture.*` in `hiveops.properties` — there isn't any.
## Do NOT
- Do NOT treat this as a continuous/passive network monitor — it only runs when a `PACKET_CAPTURE` fleet task is queued.
- Do NOT expect journal/incident events (`IAT_*` or any `EventType`) from this module — it reports fleet task status only.
- Do NOT assume a BPF `filter` works on Windows unless `tshark` is confirmed installed — `pktmon`/`netsh` ignore it silently.
- Do NOT invent a `packet-capture.*` properties namespace — none exists; config comes from `agent.endpoint`/`incident.endpoint`/`agent.auth.token`/`modules.disabled` only.
- Do NOT expect the captured file to persist on the ATM after task completion — it's deleted immediately after upload, success or failure.
- Do NOT add this module to the `srv` (server-monitor) build set without checking with the team — it's ATM-set only today.
@@ -0,0 +1,55 @@
---
module: agent.packet-capture
title: Packet Capture — on-demand network capture for ATM diagnostics
tab: Internal
order: 20
audience: dev
---
> **Internal · ops/support view.** Confirmed against `hiveops-module-packet-capture` source 2026-07-01.
## What it does (and doesn't)
`hiveops-module-packet-capture` (module name `packet-capture`, class `PacketCaptureModule`) is **not** a passive monitor — it is a **fleet-task executor**. It sits idle polling for work and only runs `tcpdump`/`tshark`/`pktmon`/`netsh trace` when a `PACKET_CAPTURE` fleet task is queued for that ATM from **Fleet → Tasks** (`fleet.bcos.cloud`). There is no continuous packet monitoring, no baseline, no alerting from this module — it captures for a bounded window, uploads the file, and stops.
Flow at a glance:
1. Operator creates a `PACKET_CAPTURE` fleet task for one or more devices, with `duration` (seconds), `interface` (or "auto"), and an optional BPF `filter` (e.g. `tcp port 443`).
2. The agent's `packet-capture` module polls every **30s** for a pending task of kind `PACKET_CAPTURE`.
3. It runs the platform-appropriate capture tool for the requested duration (capped at **300s / 5 minutes** even if a larger value is requested).
4. The resulting file (`.pcap` or `.etl`) is uploaded back to the fleet task in chunks, then deleted from the ATM's local disk.
5. Fleet shows the task COMPLETED with a downloadable capture file (or FAILED with a reason).
## Config properties it registers
**None.** Unlike most agent modules there is no `packet-capture.*` properties namespace. The module reuses whichever of these is already configured:
| Key | Used for |
|-----|----------|
| `agent.endpoint` | Preferred base URL for polling tasks / uploading the capture (module picks this prefix first) |
| `incident.endpoint` | Fallback base URL if `agent.endpoint` is not set (older ATMs) |
| `agent.auth.token` / `agent.institution.key` | Auth for the same HTTP calls (shared with every other module) |
There is no way to tune poll interval, max duration, or upload chunk size via properties — they are hardcoded (`POLL_INTERVAL_SECONDS = 30`, `MAX_DURATION = 300`, `SEGMENT_SIZE = 100_000` bytes) in `PacketCaptureModule` / `PacketCaptureHandler`. If Fleet requests more than 300s, the agent silently clamps it — the task will still say "300s" in its running status, not the requested value.
## Enabled / deployed to ATMs
- **Enable condition:** the module self-enables whenever `agent.endpoint` **or** `incident.endpoint` is set — i.e. it's on by default on any normally-configured ATM. To turn it off for a specific ATM/institution, push `modules.disabled=packet-capture` (comma-separated list) via a `CONFIG_UPDATE` fleet task.
- **Shipped how:** it's one of the standard ATM module set built by `deployment/build-dist.sh` (module set `atm`, the default) — it ships in every standard ATM install/patch ZIP as `modules/hiveops-module-packet-capture.jar`, a drop-in JAR (not baked into the fat JAR). It is **excluded** from the `srv` module set (server-monitoring-only installs).
- **Retrofitting an ATM that doesn't have it yet:** deliver via a fleet `INSTALL_MODULE` task (drops the JAR into `modules/` and restarts the agent), or via a full agent update that includes the current module set.
## Common failure modes / what to check
| Symptom | Likely cause | What to check |
|---|---|---|
| Task stays PENDING forever | Agent's `packet-capture` module isn't running, or was disabled via `modules.disabled` | Confirm the module started in `logs/modules/packet-capture.log`; check `modules.disabled` on that ATM |
| Task fails immediately: "Another capture is already in progress" | A second `PACKET_CAPTURE` task landed on the same ATM while one was still running | Only one capture runs per ATM at a time (`captureInProgress` guard). Wait for the first to finish or cancel it |
| Task fails: "Capture produced no output — capture tool may require elevated privileges" | `tcpdump`/capture tool isn't allowed to open the interface (not running as root/Administrator, or interface doesn't exist) | Check the agent service's OS-level privileges; on Linux confirm `tcpdump` is installed (`apt-get install tcpdump`) |
| Task fails: "tcpdump is not installed" | Linux ATM is missing `tcpdump` | Install it; there's no fallback tool on Linux (Windows has tshark → pktmon → netsh) |
| Windows capture uses `.etl` instead of `.pcap` | `tshark` isn't installed/on PATH, so the agent fell back to `pktmon` or `netsh trace` (neither supports a BPF filter — captures everything) | Install Wireshark/tshark on the ATM if a filtered `.pcap` capture is required |
| Upload never completes / times out | Network issue mid-upload, or capture file very large for a slow link | Handler retries a stalled chunk up to 5 times before failing; check ATM network health and capture duration/size |
| Task shows COMPLETED but capture file missing locally | Expected — the local file is always deleted after upload (success or failure) to avoid leaving PCAPs (which may include unmasked traffic) on the ATM disk | Download the file from the Fleet task record, not the ATM |
| A different fleet task type (e.g. `LOG_COLLECT`) seems to interfere | The legacy `command-processor` module explicitly releases any `PACKET_CAPTURE` task it sees back to `PENDING` (with message "Released to packet-capture module") so the dedicated module can claim it — this is expected coordination, not a bug | If capture tasks never move past PENDING, confirm the `packet-capture` module (not just `command-processor`) is actually running |
## Not applicable here
This module has no journal/incident event integration — it does not emit `IAT_*` or any `EventType` events. Its only "reporting" is fleet task status (`RUNNING` / `COMPLETED` / `FAILED`) via the same status-report endpoint every fleet task uses.
@@ -0,0 +1,64 @@
---
module: agent.remote
title: Remote Control Module — how it's built
tab: Architect
order: 30
audience: bcos
---
> **Architect · dev view.** Grounded in `hiveops-module-remote` source (parent v4.4.3, module `pom` version 1.0.0) on 2026-07-01. See [technical.agent] for the shared module system this plugs into.
## Shape
A standard agent module (SPI `com.hiveops.core.module.AgentModule`, registered in `META-INF/services/com.hiveops.core.module.AgentModule` → `com.hiveops.remote.module.RemoteModule`). Lifecycle is the platform default: `getDefaultConfiguration()` → `isEnabled()` → `initialize()` → `start()` → `stop()`. Unlike most modules it is a **command executor**, not a monitor: there is no log tail, no journal parse, and no incident/journal event emission. It drives a request/response loop against a separate server.
## Classes and flow
```
RemoteModule (SPI entry: config defaults, enable gate, wiring, lifecycle)
└─ RemoteCommandProcessor (register → poll loop → dispatch → status/upload)
├─ RemoteHttpClient (HttpURLConnection + Jackson; Bearer auth)
├─ ScreenCaptureService (AWT Robot → JPEG)
└─ FileSystemService (dir list / file open, guarded by PathSecurityUtils)
common.dto.* (PollResponse, CommandType, CommandStatus, CommandStatusUpdate,
AgentRegistrationRequest/Response, FileEntry, ...)
common.security.PathSecurityUtils
```
**`RemoteModule`** — `initialize()` reads `remote.server.url` (required; throws `ModuleInitializationException` if blank), derives `machineId` from `remote.machine.id` else the platform hostname (`PlatformService.getSystemInfoProvider().getHostname()`) else a random UUID, and constructs the HTTP client + services. Advertised `capabilities` are hardcoded to `["SCREENSHOT","FILE_LIST","FILE_DOWNLOAD"]`. `isEnabled()` gates on `remote.enabled`.
**`RemoteCommandProcessor`** — owns a 2-thread `ScheduledExecutorService`. On `start()`:
1. `registerWithServer()` — builds `AgentRegistrationRequest` (name = `context.getAtmName()`, hostname, `os.name`, agent version from `hiveops.version`, module version `1.0.0-SNAPSHOT`, machineId, capabilities), `POST`s it, and stores the returned Bearer token. **Registration errors are caught and logged, not retried** — a failed register leaves the token null and every subsequent poll 401s.
2. `scheduleWithFixedDelay(pollAndProcess, 0, remote.poll.interval.ms)`.
Each `pollAndProcess()` calls `GET /api/v1/agents/poll`; if `PollResponse.hasCommand`, it dispatches on `CommandType`:
- `SCREENSHOT` → `ScreenCaptureService.capture(quality, displayIndex)` → `uploadBinaryResult`.
- `FILE_LIST` → `FileSystemService.listDirectory(path)` (defaults to `user.home` if blank) → status `COMPLETED` with a JSON `{path, entries}` result (no binary).
- `FILE_DOWNLOAD` → `FileSystemService.downloadFile(path)` → read all bytes → `uploadBinaryResult`.
- default (incl. `SYSTEM_INFO`) → `FAILED: Unknown command type`.
Status transitions use `CommandStatusUpdate` factories (`inProgress()`, `completed(result)`, `failed(msg)`, `completedWithBinary(totalChunks)`) mapping to `CommandStatus` `IN_PROGRESS` / `COMPLETED` / `FAILED`. The processor sets `IN_PROGRESS` before running and reports `FAILED` on any exception (with a nested try so a failed status-report doesn't crash the loop).
**`uploadBinaryResult`** — splits `byte[]` into `remote.chunk.size.bytes` (1 MiB) chunks, computes a **SHA-256** hex checksum per chunk, and posts each as multipart `POST /api/v1/agents/commands/{id}/upload`. Empty results still send 1 chunk. No retry on chunk failure.
**`ScreenCaptureService`** — cross-platform via `java.awt.Robot`. Detects headless at construction; `capture()` throws `UnsupportedOperationException` in headless mode. JPEG quality is `quality/100f` or default `0.85`. `displayIndex` selects a `GraphicsDevice`; multi-monitor defaults to device 0. Alpha images are flattened to `TYPE_INT_RGB` before JPEG encode.
**`FileSystemService`** — enforces, in order: `PathSecurityUtils.validatePath` (null/empty, null-byte, `..` traversal), `validateWithinAllowedPaths` (only when `remote.allowed.paths` is set), and `isPathBlocked` against `remote.blocked.paths` (OS-specific defaults if unset, `*` wildcards). Then existence/type/readable checks; `downloadFile` also enforces `remote.max.file.size.bytes`. `FileEntry` carries name/size/mtime/type (`DIRECTORY`/`FILE`/`SYMLINK`/`OTHER`)/readable/writable and POSIX perm string (null on Windows). Symlinks are read `NOFOLLOW_LINKS`.
## How results reach the platform
This module does **not** use the standard agent → agent-proxy/incident journal-event path. It speaks a dedicated REST protocol to the **hiveops-remote** server (`RemoteHttpClient`, base `remote.server.url`, prod path `/api/remote/`, internal `:8090`):
| Call | Method + path | Auth |
|---|---|---|
| Register | `POST /api/v1/agents/register` | none (returns Bearer token) |
| Poll for command | `GET /api/v1/agents/poll` | Bearer |
| Report status | `POST /api/v1/agents/commands/{id}/status` | Bearer |
| Upload binary chunk | `POST /api/v1/agents/commands/{id}/upload` (multipart: `chunkIndex`, `totalChunks`, `checksum`, `data`) | Bearer |
| Heartbeat | `POST /api/v1/agents/heartbeat` | Bearer |
`RemoteHttpClient` is plain `HttpURLConnection` + Jackson (`FAIL_ON_UNKNOWN_PROPERTIES=false`, `JavaTimeModule`); any `>=400` throws `IOException` with the error body. Note: `heartbeat()` is implemented but **never invoked** by the processor — liveness is inferred from polling.
## Platform abstraction
Minimal: only `PlatformService.getSystemInfoProvider().getHostname()` for the machine-id fallback. Screen capture and file access use portable JVM APIs (AWT Robot, `java.nio.file`) rather than the agent's `SystemCommands`/`PathResolver`, so behavior differs by environment (headless, POSIX vs Windows perms) rather than by an explicit platform impl. Delivery/restart on `INSTALL_MODULE`, by contrast, does go through the platform layer (`ProcessInstallModule` → `SystemCommands.restartService`).
@@ -0,0 +1,78 @@
---
module: agent.remote
title: Remote Control Module — Claude quick reference
tab: Claude
order: 40
audience: bcos
---
> Terse, act-without-guessing reference. Everything below is confirmed in `hiveops-module-remote` source (parent v4.4.3) unless marked `(unverified)`.
## Identity
- **Module name (SPI `getName()` + JAR `Module-Name`):** `hiveops-remote`
- **SPI class:** `com.hiveops.remote.module.RemoteModule`
- **Maven artifact:** `com.hiveops:hiveops-module-remote` (pom version `1.0.0`)
- **Deployed JAR:** `modules/hiveops-module-remote.jar` (drop-in; Jackson shaded in, hiveops-core `provided`)
- **`getVersion()` returns:** `1.0.0-SNAPSHOT` (also the hardcoded `moduleVersion` sent at registration — mismatched with pom `1.0.0`)
## Config keys (all `remote.*`)
| Key | Default | Notes |
|---|---|---|
| `remote.enabled` | `false` | Must be `true` or module won't load |
| `remote.server.url` | `http://localhost:8090` | Required; init throws if blank. Prod = hiveops-remote (`/api/remote/`, `:8090`) |
| `remote.poll.interval.ms` | `5000` | Poll delay |
| `remote.poll.timeout.ms` | `15000` | Declared default but **unused in code** |
| `remote.chunk.size.bytes` | `1048576` | 1 MiB upload chunks |
| `remote.max.file.size.bytes` | `104857600` | 100 MiB cap on FILE_DOWNLOAD |
| `remote.machine.id` | *(not in defaults)* → hostname → random UUID | Registration identity |
| `remote.allowed.paths` | *(not in defaults; empty = allow all)* | Comma/semicolon list; allowlist base dirs |
| `remote.blocked.paths` | *(not in defaults; OS default block list)* | Comma/semicolon list, `*` wildcard |
## Command types (`CommandType` enum)
- Advertised capabilities / handled: `SCREENSHOT`, `FILE_LIST`, `FILE_DOWNLOAD`
- In enum but **NOT** advertised and **NOT** handled → replies `FAILED: Unknown command type`: `SYSTEM_INFO`
## Command status (`CommandStatus`)
- `IN_PROGRESS` (set before execution), `COMPLETED`, `FAILED`
- Factory helpers: `CommandStatusUpdate.inProgress() / completed(result) / completedWithBinary(totalChunks) / failed(msg)`
## Server protocol (base = `remote.server.url`)
| Purpose | Method + path | Auth |
|---|---|---|
| Register | `POST /api/v1/agents/register` | none → returns Bearer token |
| Poll | `GET /api/v1/agents/poll` | Bearer |
| Status | `POST /api/v1/agents/commands/{id}/status` | Bearer |
| Upload chunk | `POST /api/v1/agents/commands/{id}/upload` (multipart `chunkIndex`,`totalChunks`,`checksum`,`data`) | Bearer |
| Heartbeat | `POST /api/v1/agents/heartbeat` | Bearer (**method exists, never called**) |
- Upload checksum = **SHA-256** hex, per chunk.
- Screenshot = AWT Robot → JPEG (default quality `0.85`).
## Deploy / enable
- Enable: `CONFIG_UPDATE` fleet task → `remote.enabled=true` (+ `remote.server.url`, and `remote.allowed.paths` on locked-down ATMs).
- Retrofit JAR: `INSTALL_MODULE` fleet task (filename must end `.jar`; drops into `modules/`, restarts agent).
## Gotchas
- Emits **NO** incident/journal events — no `IAT_*`, no `EventType`. Reporting is only remote-command status/binary to hiveops-remote.
- Talks to **hiveops-remote** (port 8090 / `/api/remote/`), **not** agent-proxy or hiveops-incident.
- Registration failure is logged, **not retried** → token stays null → polls 401 until agent restart.
- `heartbeat()` is dead code (never invoked).
- `remote.poll.timeout.ms` has no effect (declared, unreferenced).
- SCREENSHOT throws `UnsupportedOperationException` on headless JVMs.
- Binary uploads have **no retry**; a failed chunk → command `FAILED`.
- `getVersion()`/registration report `1.0.0-SNAPSHOT` while the pom is `1.0.0`.
## Do NOT
- Do NOT expect `remote.enabled` on by default — it is `false`.
- Do NOT expect it to route through agent-proxy/incident or appear in the journal-event pipeline.
- Do NOT assume `SYSTEM_INFO` works — it is unhandled.
- Do NOT rely on `remote.allowed.paths`/`remote.machine.id`/`remote.blocked.paths` being set from defaults — they aren't in `getDefaultConfiguration()`; set them explicitly.
- Do NOT invent event strings — this module has none.
@@ -0,0 +1,71 @@
---
module: agent.remote
title: Remote Control Module — on-demand screen capture & file access on ATMs
tab: Internal
order: 20
audience: dev
---
> **Internal · ops/support view.** Confirmed against `hiveops-module-remote` source (`RemoteModule`, agent parent v4.4.3) on 2026-07-01.
## What it does (and doesn't)
`hiveops-module-remote` (module name `hiveops-remote`, class `com.hiveops.remote.module.RemoteModule`) is **not** a monitor and does **not** emit incident/journal events. It is an **on-demand remote-control executor**. It sits idle, registers with the **HiveIQ Remote server** (`hiveops-remote`, internal port 8090), long-polls it for a single queued command, executes it, and uploads the binary result back. There is no continuous monitoring, no baseline, and no `IAT_*` / `EventType` reporting from this module.
Supported commands (the module's advertised `capabilities`):
| Capability | What the agent does |
|---|---|
| `SCREENSHOT` | Captures the ATM's display via AWT `Robot`, encodes JPEG, uploads bytes in chunks |
| `FILE_LIST` | Lists a directory (name, size, mtime, type, POSIX perms) and returns JSON |
| `FILE_DOWNLOAD` | Streams a single file's bytes back in chunks |
`SYSTEM_INFO` exists in the `CommandType` enum but is **not** advertised as a capability and is **not** handled — if the server ever sends it the agent replies `FAILED: Unknown command type`.
Flow at a glance:
1. On `start()`, the module `POST`s to `/api/v1/agents/register` on the remote server and stores the returned Bearer token.
2. It polls `GET /api/v1/agents/poll` every **5s** (`remote.poll.interval.ms`).
3. When `hasCommand=true`, it sets the command `IN_PROGRESS`, runs it, then reports `COMPLETED` (with result/binary) or `FAILED`.
4. Binary results (screenshot JPEG, file bytes) are uploaded in **1 MiB** chunks with a per-chunk SHA-256 checksum to `POST /api/v1/agents/commands/{id}/upload`.
> This module talks to the **hiveops-remote** service (`/api/remote/`, port 8090) — **not** to agent-proxy or hiveops-incident. It is a separate path from the standard journal/event pipeline.
## Config properties it registers
Registered in `getDefaultConfiguration()` (namespace `remote.*`):
| Key | Default | Meaning |
|---|---|---|
| `remote.enabled` | `false` | Master on/off. Module only loads when `true`. |
| `remote.server.url` | `http://localhost:8090` | Remote-server base URL. **Required** — init throws if blank. |
| `remote.poll.interval.ms` | `5000` | Delay between polls for a queued command. |
| `remote.poll.timeout.ms` | `15000` | Registered default, but **not referenced anywhere in code** (no effect — unverified whether wired in a newer build). |
| `remote.chunk.size.bytes` | `1048576` | Upload chunk size (1 MiB) for binary results. |
| `remote.max.file.size.bytes` | `104857600` | Max file size (100 MiB) for `FILE_DOWNLOAD`; larger files fail. |
Also read at init but **not** in the defaults set (must be added explicitly to take effect):
| Key | Default if unset | Meaning |
|---|---|---|
| `remote.machine.id` | derived from hostname (then random UUID) | Stable machine identity sent at registration. |
| `remote.allowed.paths` | *(empty = all paths allowed)* | Comma/semicolon list; if set, file ops are restricted to these base dirs. |
| `remote.blocked.paths` | OS default block list | Comma/semicolon list of blocked path patterns (`*` wildcard). Defaults block `/etc/shadow`, `/etc/passwd`, `/etc/sudoers`, `/root/.ssh`, `/home/*/.ssh` (Linux) and `C:\Windows\System32\config`, `...\drivers`, credential stores (Windows). |
## Enabled / deployed to ATMs
- **Enable condition:** module self-enables only when `remote.enabled=true`. It is **off by default**. Turn it on for a device/institution with a `CONFIG_UPDATE` fleet task setting `remote.enabled=true` (and, on locked-down ATMs, `remote.allowed.paths`).
- **Shipped how:** it's a drop-in JAR (`modules/hiveops-module-remote.jar`), not baked into the fat JAR. Jackson is shaded into the JAR (hiveops-core is `provided`).
- **Retrofitting an ATM that doesn't have the JAR:** deliver via a fleet `INSTALL_MODULE` task — it drops the JAR into `modules/` and restarts the agent (`ProcessInstallModule`). The filename must end in `.jar`.
## Common failure modes / what to check
| Symptom | Likely cause | What to check |
|---|---|---|
| Module never registers / no commands run | `remote.enabled` not `true`, or module JAR absent | Confirm `remote.enabled=true` and that `modules/hiveops-module-remote.jar` is present; check agent log for `Initializing remote module for server: ...` |
| Init fails: `remote.server.url is required` | Property blank/missing | Set `remote.server.url` to the remote server (prod path is via `/api/remote/`, internal `:8090`). |
| Registration logs `Failed to register with remote server` | Server unreachable / auth rejected | Registration errors are swallowed (no retry loop) — the module keeps polling with a null token, so polls 401. Verify network path to the remote server and that it accepts the registration payload. |
| `SCREENSHOT` fails: `Screen capture not available in headless mode` | ATM/JVM is headless (no X display / service session) | Screen capture needs a real graphics environment + AWT `Robot`. Headless Linux boxes and some Windows service sessions can't capture. |
| `FILE_DOWNLOAD` fails: `File exceeds maximum size limit` | File > `remote.max.file.size.bytes` (100 MiB default) | Raise the limit via config, or collect large files with a fleet `FILECOLLECT`/`LOG_COLLECT` task instead. |
| File op fails: `Access to path is blocked` / `Path is not within allowed directories` | Hit a `remote.blocked.paths` pattern, or path is outside `remote.allowed.paths` | Expected security behavior — adjust the allow/block lists if the target should be reachable. |
| File op fails: `Path contains directory traversal sequences` / `null bytes` | Path contained `..` or `\0` | Path-traversal guard (`PathSecurityUtils`) — send a normalized absolute path. |
| Upload fails mid-transfer | Network drop, or server rejected a chunk (checksum/size) | Uploads are **not** retried by this module — the command goes `FAILED`. Re-issue the command. |
@@ -0,0 +1,90 @@
---
module: agent.srv-monitor
title: Server Monitor — CPU/memory/disk/service health watcher for SRV devices
tab: Architect
order: 30
audience: bcos
---
> **Internal · how it's built.** Confirmed against `hiveops-module-srv-monitor` source (`com.hiveops.srvmonitor`) 2026-07-01. See [technical.agent] for agent-wide module-system and event-pipeline context.
## Maven module
`hiveops-module-srv-monitor` (artifact `hiveops-module-srv-monitor`, own version `1.0.0`, parented by `hiveops-agent-parent` — currently `4.4.3`). Packaged as a standalone drop-in jar (`<finalName>${project.artifactId}</finalName>` → `hiveops-module-srv-monitor.jar`).
Dependencies (all **provided** scope — supplied by the running agent, not fat-packed):
- `hiveops-core` — the `AgentModule` SPI, `ModuleContext`, `PlatformService`, HTTP helpers.
- `hiveops-agent-journal` — the `IncidentEventClient` and `CreateJournalEventRequest` DTO (the shared journal-events pipeline).
- `log4j-api`, `commons-lang3` — logging + `StringUtils`/`NumberUtils` parsing.
Registered via ServiceLoader SPI: `src/main/resources/META-INF/services/com.hiveops.core.module.AgentModule` → `com.hiveops.srvmonitor.ServerMonitorModule`.
## Classes and flow
Five source classes, no sub-packages:
| Class | Role |
|---|---|
| `ServerMonitorModule` | `AgentModule` implementation — lifecycle, config, scheduler, transition/threshold logic, event build+send |
| `ServerMonitorCollector` | Interface: `ServerMetrics collect(List<String> monitoredServices)` |
| `LinuxServerMonitorCollector` | Linux implementation — `/proc/stat`, `/proc/meminfo`, `File.listRoots()`, `systemctl is-active` |
| `WindowsServerMonitorCollector` | Windows implementation — PowerShell (`Get-Counter`, CIM `Win32_*`, `Get-Service`) |
| `ServerMetrics` | Immutable value object: `cpuPercent`, `memoryPercent`, `List<DiskUsage>`, `Map<String,Boolean> serviceStatus` |
### Lifecycle (`AgentModule`)
`initialize()` → `isEnabled()` → `start()` → `stop()`, the standard module lifecycle from [technical.agent].
- **`initialize(ModuleContext)`** — reads `srv.monitor.enabled`; if false, logs and returns with `ready=false`. Resolves the endpoint (`agent.endpoint` preferred, else `incident.endpoint`); if neither, logs "no endpoint configured" and stays inactive. Builds `HttpClientSettings` via `HttpClientHelper.loadSettingsFromProperties(props, prefix)` where `prefix` is `"agent"` or `"incident"` to match the chosen endpoint. Parses thresholds/interval/samples, splits `srv.monitor.services` on commas. Constructs the `IncidentEventClient` (with `atmName` + `country` from the context) and selects the collector via **`context.getPlatformService().isWindows()`**. Sets `ready=true`.
- **`isEnabled()`** returns `ready` — so a module that failed the enable gate or endpoint check reports disabled and is never started by the registry.
- **`start()`** creates a single daemon-thread `ScheduledExecutorService` (thread name `srv-monitor`) and `scheduleAtFixedRate(this::poll, 0, intervalSeconds, SECONDS)` — first poll immediately, then every interval.
- **`stop()`** `shutdownNow()` on the scheduler. **`isHealthy()`** = scheduler exists and isn't terminated.
- **`getDependencies()`** is empty — no ordering constraints against other modules. **`getLogPackage()`** = `com.hiveops.srvmonitor` (routes to `logs/modules/srv-monitor.log`).
### Poll → detect → emit
`poll()` (the fixed-rate task) is a pure poll loop — no log tailing, no hooks:
1. `collector.collect(monitoredServices)` gathers one `ServerMetrics` snapshot.
2. Four checkers each append zero or one `CreateJournalEventRequest` to a batch list, based on **state transition** against the checker's stored last-state:
- `checkCpu` — increments `cpuHighCount` while at/above threshold; emits `SERVER_CPU_HIGH` only when `!cpuHighState && cpuHighCount >= sustainedSamples`. Dropping below threshold resets the counter and, if it was high, emits `SERVER_CPU_NORMAL`.
- `checkMemory` — emits `SERVER_MEMORY_HIGH`/`SERVER_MEMORY_NORMAL` on the `memHighState` boolean flip (no debounce).
- `checkDisks` — per-mount `Map<String,Boolean> diskHighState`; emits `SERVER_DISK_LOW`/`SERVER_DISK_NORMAL` per filesystem on flip.
- `checkServices` — per-service `Map<String,Boolean> serviceDownState`; emits `SERVICE_DOWN`/`SERVICE_UP` on flip.
3. If the batch is non-empty, `client.sendEvents(events)`.
All transition state (`cpuHighState`, `memHighState`, the two maps) is **in-memory only** — an agent restart clears it, so the module re-emits a HIGH on the next breaching poll after restart if the condition persists.
### Collectors (platform abstraction)
The module picks a collector at init via the agent's shared `PlatformService.isWindows()` — the same platform-detection facade the rest of the agent uses (see [technical.agent] "platform abstraction"). Beyond that choice each collector does its own low-level I/O; it does **not** route metric collection through `SystemCommands`/`PathResolver`.
- **Linux** — CPU: reads the `cpu ` line of `/proc/stat` twice, 1 s apart (`Thread.sleep(1000)` inside the poll thread), and diffs busy/total jiffies. Memory: `MemTotal`/`MemAvailable` from `/proc/meminfo` → `(total-available)/total`. Disk: `File.listRoots()` → `getTotalSpace()`/`getFreeSpace()` (root filesystems only). Services: `systemctl is-active <name>`, up iff first output line trims to `active`.
- **Windows** — all via `powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command`. CPU: `Get-Counter '\Processor(_Total)\% Processor Time'`. Memory: `Win32_OperatingSystem` `TotalVisibleMemorySize`/`FreePhysicalMemory`. Disk: `Win32_LogicalDisk` (per drive letter). Services: `Get-Service -Name @(...)`, up iff `Status = Running`; any queried service not returned is marked down. A daemon stderr-drainer thread reads PowerShell stderr and a non-zero exit is logged.
## How events reach the platform
`ServerMonitorModule` builds each event with `CreateJournalEventRequest.builder()`:
```
agentAtmId = atmName (from ModuleContext)
deviceType = "SRV" (constant DEVICE_TYPE)
eventType = <one of the 8 strings>
eventDetails = human string (e.g. "CPU at 92.3% (threshold 85.0%, sustained 3 samples)")
eventSource = "HIVEOPS_AGENT" (constant EVENT_SOURCE)
```
and sends via the shared journal-events client:
```
ServerMonitorModule
│ IncidentEventClient.sendEvents(List<CreateJournalEventRequest>)
│ (HttpClientSettings from agent.endpoint / incident.endpoint)
POST {endpoint}/api/journal-events ← one HTTP POST per event
│ (via agent-proxy, X-Institution-Key/auth on the shared client)
hiveops-incident journal-events ingest → SRV device auto-registers, events stored
```
Note: `sendEvents` is **not** a batch call — it loops and issues one `POST /api/journal-events` per event (the DTO comment states the backend "may not support batch operations"). This is the same endpoint and `IncidentEventClient` the `journal-events` module uses for ATM events; `srv-monitor` reuses it wholesale, only changing `deviceType` to `SRV`. In the current topology `agent.endpoint` points at **agent-proxy**, which forwards to incident's ingest — see [technical.agent] for the agent → agent-proxy → incident path.
@@ -0,0 +1,70 @@
---
module: agent.srv-monitor
title: Server Monitor — CPU/memory/disk/service health watcher for SRV devices
tab: Claude
order: 40
audience: bcos
---
> **Internal · agent reference.** Terse, act-without-guessing. Confirmed against `hiveops-module-srv-monitor` source 2026-07-01.
## Identity
- Module name (`AgentModule.getName()`): **`srv-monitor`**
- Maven artifact / drop-in jar: **`hiveops-module-srv-monitor.jar`** (own version `1.0.0`; parent `hiveops-agent-parent` currently `4.4.3`)
- Entry class: `com.hiveops.srvmonitor.ServerMonitorModule` (SPI file: `META-INF/services/com.hiveops.core.module.AgentModule`)
- Other classes: `ServerMonitorCollector` (iface), `LinuxServerMonitorCollector`, `WindowsServerMonitorCollector`, `ServerMetrics`
- Log package (`getLogPackage()`): `com.hiveops.srvmonitor` → `logs/modules/srv-monitor.log`
- Dependencies (`getDependencies()`): **none** (empty list)
- Constants: `deviceType = "SRV"`, `eventSource = "HIVEOPS_AGENT"`
## Config properties (exact keys, from `hiveops.properties`)
| Key | Default | Notes |
|---|---|---|
| `srv.monitor.enabled` | `false` | master gate; module inactive unless `true` |
| `srv.monitor.interval.seconds` | `60` | fixed-rate poll; initial delay 0 |
| `srv.monitor.cpu.threshold` | `85` | CPU busy % |
| `srv.monitor.cpu.sustained.samples` | `3` | consecutive breaching polls before CPU_HIGH |
| `srv.monitor.memory.threshold` | `90` | mem used % |
| `srv.monitor.disk.threshold` | `90` | per-filesystem used % |
| `srv.monitor.services` | *(empty)* | comma list of service/unit names; empty = no service checks |
- Shared (not srv-monitor-specific), required for events: `agent.endpoint` (preferred) **or** `incident.endpoint` (fallback). Neither set → module stays inactive even if enabled. HTTP client settings loaded with prefix `agent` or `incident` to match.
- Config is read **once in `initialize()`** → change needs an agent restart to apply.
- Disable via `srv.monitor.enabled=false` **or** add `srv-monitor` to `modules.disabled`.
## Event-type strings emitted (exact)
- CPU: **`SERVER_CPU_HIGH`**, **`SERVER_CPU_NORMAL`**
- Memory: **`SERVER_MEMORY_HIGH`**, **`SERVER_MEMORY_NORMAL`**
- Disk: **`SERVER_DISK_LOW`**, **`SERVER_DISK_NORMAL`**
- Service: **`SERVICE_DOWN`**, **`SERVICE_UP`**
- Edge-triggered: fired on state **transition** only, not per poll. CPU is debounced by `sustained.samples`; memory/disk/service transition immediately.
- Sent as `CreateJournalEventRequest` (`agentAtmId`, `deviceType=SRV`, `eventType`, `eventDetails`, `eventSource=HIVEOPS_AGENT`).
## Transport
- `IncidentEventClient.sendEvents(...)` → **`POST {endpoint}/api/journal-events`**, **one POST per event** (not batched — loops `sendEvent`).
- Same client/endpoint as the `journal-events` module; only `deviceType=SRV` differs. Routed via agent-proxy → incident ingest.
## Deployment (fleet)
- Deliver jar via **`INSTALL_MODULE`** fleet task (`ProcessInstallModule`): artifact chunked-download **or** `configPatch.url` + `filename` (filename must end `.jar`). Drops into `modules/`, then restarts agent.
- Then enable via `CONFIG_UPDATE` setting `srv.monitor.enabled=true`.
## Collectors
- Linux: `/proc/stat` (CPU, 1 s sample), `/proc/meminfo` (MemTotal/MemAvailable), `File.listRoots()` (disk), `systemctl is-active <svc>` (up iff `active`).
- Windows: PowerShell (`-NoProfile -NonInteractive -ExecutionPolicy Bypass`) — `Get-Counter`, `Win32_OperatingSystem`, `Win32_LogicalDisk`, `Get-Service` (up iff `Running`).
- Chosen at init by `context.getPlatformService().isWindows()`.
## Gotchas
- **Linux disk = roots only.** `File.listRoots()` is typically just `/`; separate mounts (e.g. `/var`) are not monitored per-partition. Windows lists each drive letter.
- **CPU spike shorter than `sustained.samples` polls never fires** (default 3 × 60 s ≈ 3 min sustained).
- **Transition state is in-memory only** — agent restart re-emits HIGH on next breaching poll if condition persists.
- **Flapping at the threshold** — memory/disk/service have no debounce; a value hovering at the threshold alternates HIGH/NORMAL each poll.
- **Windows collector returns 0.0/empty** if PowerShell is unavailable/locked down; non-zero exit + stderr logged.
- **Service name mismatch → false `SERVICE_DOWN`** (needs exact systemd unit / Windows service name).
- **Log-line cosmetic bug:** several info logs use `{:.1f}` placeholders (SLF4J-C style), which log4j2 does not format — the value is dropped in the log text. The `eventDetails` sent to the platform use `String.format("%.1f", …)` and are correct; only the local log strings are affected.
## Do NOT
- Do **not** expect batched event POSTs — it's one HTTP request per event.
- Do **not** assume enabling `srv.monitor.enabled` alone works — the jar must also be present in `modules/` and an endpoint configured.
- Do **not** expect config changes to apply without an agent restart.
- Do **not** point this at ATM incidents expecting ATM device type — it always emits `deviceType=SRV`.
- Do **not** invent `srv-monitor.*` (dot-dash) keys — the real prefix is `srv.monitor.*` (all dots).
- Do **not** rely on Linux per-mount disk alerts for non-root filesystems.
@@ -0,0 +1,64 @@
---
module: agent.srv-monitor
title: Server Monitor — CPU/memory/disk/service health watcher for SRV devices
tab: Internal
order: 20
audience: dev
---
> **Internal · ops/support view.** Confirmed against `hiveops-module-srv-monitor` source (`com.hiveops.srvmonitor`) 2026-07-01. HiveIQ agent drop-in module. Not customer-facing.
## What it does
The **Server Monitor** module (`srv-monitor`) turns a HiveIQ agent install into a lightweight server-health probe. On a fixed interval it samples four things and reports **only state transitions** (breach and recovery) to the platform as journal events on an `SRV`-type device:
| Signal | What it watches | Breach event | Recovery event |
|---|---|---|---|
| CPU | busy % (sustained N samples) | `SERVER_CPU_HIGH` | `SERVER_CPU_NORMAL` |
| Memory | used % of physical RAM | `SERVER_MEMORY_HIGH` | `SERVER_MEMORY_NORMAL` |
| Disk | used % per filesystem/volume | `SERVER_DISK_LOW` | `SERVER_DISK_NORMAL` |
| Services | named services up/down | `SERVICE_DOWN` | `SERVICE_UP` |
It is edge-triggered: it fires an event when a metric crosses its threshold and again when it drops back, not on every poll. This keeps the incident stream quiet — one HIGH and one NORMAL per episode, not one per minute.
CPU has a debounce: it must stay at/above threshold for `srv.monitor.cpu.sustained.samples` consecutive polls before `SERVER_CPU_HIGH` fires (default 3). Memory, disk, and service transitions fire immediately on the first breaching poll.
The module is designed for the planned **SRV device type** (branch back-office / QDS servers, not ATMs) — events carry `deviceType=SRV` and `eventSource=HIVEOPS_AGENT`, so the device auto-registers as an SRV device on first event, the same way ATMs auto-register on first journal event.
## Config properties it registers
All read from the agent's main `hiveops.properties` in `initialize()`. Defaults are the code defaults:
| Property | Default | Meaning |
|---|---|---|
| `srv.monitor.enabled` | `false` | Master gate. Module stays inactive unless this is `true`. |
| `srv.monitor.interval.seconds` | `60` | Poll interval (fixed-rate). Also the initial delay is 0 — first poll on start. |
| `srv.monitor.cpu.threshold` | `85` | CPU busy-% threshold. |
| `srv.monitor.cpu.sustained.samples` | `3` | Consecutive breaching polls required before `SERVER_CPU_HIGH`. |
| `srv.monitor.memory.threshold` | `90` | Memory used-% threshold. |
| `srv.monitor.disk.threshold` | `90` | Per-filesystem used-% threshold. |
| `srv.monitor.services` | *(empty)* | Comma-separated service names to watch (e.g. `hiveops-agent,qds,postgresql`). Empty = no service checks. |
It also **reuses** the shared agent endpoint/auth config (not srv-monitor-specific):
- `agent.endpoint` (preferred) or `incident.endpoint` (fallback) — the HTTP base URL events are POSTed to. If neither is set the module logs `no endpoint configured, module inactive` and stays down even when enabled.
- The matching HTTP client settings (`agent.*` or `incident.*` — auth token, institution key, timeouts) are loaded via the same `HttpClientHelper` path every other event-emitting module uses.
## How it's enabled / deployed to ATMs
Two independent switches must both be right: the **JAR must be present** and `srv.monitor.enabled=true`.
1. **Ship the JAR.** `srv-monitor` is a drop-in module JAR (`hiveops-module-srv-monitor.jar`), not bundled in the core agent. It lives in the agent's `modules/` directory. The agent's `ModuleLoader` discovers it there on startup.
2. **Fleet delivery — `INSTALL_MODULE` task.** Push the JAR fleet-wide with an `INSTALL_MODULE` fleet task. The agent's `ProcessInstallModule` handler downloads it (either from a fleet artifact via chunked download, or from a `configPatch.url`/`filename` pair — filename must end in `.jar`), drops it into `modules/`, then restarts the agent so the new module loads. See the [technical.agent] fleet-task docs for the task shape.
3. **Turn it on.** Set `srv.monitor.enabled=true` (plus any threshold/service overrides) in `hiveops.properties`, typically via a `CONFIG_UPDATE` fleet task. The module reads config only at `initialize()`, so a config change needs an agent restart to take effect.
4. **Disable** either by `srv.monitor.enabled=false` or by adding `srv-monitor` to the `modules.disabled` comma list.
## Failure modes / what to check
- **Enabled but no events ever.** Check `agent.endpoint`/`incident.endpoint` is set — with neither, the module logs `no endpoint configured, module inactive` and never schedules. Also confirm the JAR actually landed in `modules/` and the agent restarted after `INSTALL_MODULE`.
- **No CPU events despite obvious load.** CPU is debounced by `srv.monitor.cpu.sustained.samples` (default 3) — a spike shorter than 3 poll intervals never fires. At the 60 s default that's a ~3-minute sustained requirement.
- **No disk events for a full `/var` (Linux).** The Linux collector enumerates `File.listRoots()`, which on Linux is typically just `/`. Separate mount points that aren't reported as roots are **not** monitored per-partition. Windows reports each logical drive (`C:`, `D:` …) individually.
- **`SERVICE_DOWN` for a service that's actually up.** Linux uses `systemctl is-active <name>` (expects exactly `active`); Windows uses `Get-Service -Name <name>` (expects `Running`). A wrong unit name, a non-systemd Linux host, or a service the query can't reach is reported **down**. Verify the exact service/unit name in `srv.monitor.services`.
- **Nothing on Windows + PowerShell warnings in the log.** The Windows collector shells out to `powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass`. If PowerShell is locked down or missing, CPU/mem/disk read back as `0.0`/empty and a non-zero exit is logged with stderr.
- **Event stream noisy / flapping.** A metric hovering right at the threshold will alternate HIGH/NORMAL each poll (memory/disk/service have no debounce). Raise the threshold or the interval.
- **Where to look:** the module logs under its own package `com.hiveops.srvmonitor` → `logs/modules/srv-monitor.log`. Startup line `srv-monitor initialized (interval=…, cpu=…%, …)` confirms it armed with the config you expect.
@@ -0,0 +1,74 @@
---
module: agent.tcr-events
title: TCR Events — cassette + sensor + process monitoring for Teller Cash Recyclers
tab: Architect
order: 30
audience: bcos
---
> **Architect · dev.** Written 2026-07-01 from the compiled `hiveops-module-tcr-events` artifact (v4.3.5); `.java` sources are absent from the working tree, so class/method flow below is reconstructed from bytecode in `target/classes`. Cross-reference [technical.agent] for the module system and event pipeline.
## Shape
A ServiceLoader `AgentModule` (`com.hiveops.tcrevents.TcrEventsModule`, registered in `META-INF/services/com.hiveops.core.module.AgentModule`) that owns one scheduler and three stateless log pollers. Package `com.hiveops.tcrevents`. Classes:
- `TcrEventsModule` — lifecycle, config, scheduler, wiring.
- `TcrAccessLogMonitor` — process/shutdown events from `ACESSLog<MMdd>.txt`.
- `CstSnsLogMonitor` — sensor/mismatch events from `CST_SNR_<MMdd>.log`.
- `TcrCassetteMonitor` — per-slot inventory from `JsonLog_<MMdd>.json`.
It uses no platform-abstraction layer (`PlatformService`) — file access is portable JVM I/O (`java.nio.file.Paths`, `RandomAccessFile` opened `"r"`), even though the target log layout is the Windows LG/ATEC TCR software.
## Lifecycle & config (`TcrEventsModule`)
`getName()` → `tcr-events`; `getVersion()` → `1.0.0`.
`initialize(ModuleContext)` runs a short guard chain, then wires the pollers:
1. **Device gate** — reads `device.type` from `ctx.getMainProperties()` (default `ATM`), `trimToEmpty` + `upperCase`; unless it equals `ATEC_TCR` or `TCR`, logs `device.type is '{}', not ATEC_TCR — module disabled` and returns not-ready.
2. **Enable gate** — `tcr.events.enabled` (default `true`); if not `equalsIgnoreCase("true")`, disabled.
3. **Log dir** — `tcr.log.dir` via `trimToNull`; if null, disabled. Resolved with `Paths.get(...)`; if the path doesn't exist it only warns (`will retry each poll`) and continues — pollers re-check existence each tick.
4. **Cadence** — `tcr.events.poll.interval.sec` via `NumberUtils.toInt(..., 30)` → `pollSec` field (default 30).
5. **Transport** — if `agent.endpoint` is set, `HttpClientHelper.loadSettingsFromProperties(props, "agent")`; otherwise prefix `"incident"`. Builds one `IncidentEventClient(settings, ctx.getAtmName(), ctx.getCountry())`, shared by all three pollers.
6. **Wire** — constructs `CstSnsLogMonitor`, `TcrAccessLogMonitor`, `TcrCassetteMonitor`, each `(Path logDir, String atmName, IncidentEventClient client)`; sets `ready = true`.
`isEnabled()` returns the `ready` flag. `start()` creates a `newSingleThreadScheduledExecutor` with a daemon thread named `tcr-events` and `scheduleAtFixedRate` at `pollSec` seconds; each tick calls `cstMonitor.poll()`, `accessMonitor.poll()`, `cassetteMonitor.poll()` inside a try/catch that logs `tcr-events: poll error: {}` (a poll failure never kills the schedule). `stop()` shuts the scheduler down.
## Poller pattern (tail → detect → emit)
All three share the same structure:
1. Compute today's filename from a `MMdd` `SimpleDateFormat` and resolve it under `tcr.log.dir`.
2. Open with `RandomAccessFile(file, "r")`, seek to the retained per-file byte offset, read newly appended lines, and advance the offset. (In-memory offset state — no persistence, so an agent restart re-reads from 0 for the current file; the vendor writes append-only within a day.)
3. Match lines and, on a hit, build a `CreateJournalEventRequest` via its fluent `builder()` and call `client.sendEvent(...)`; failures log `tcr-events: failed to send {}: {}` and are swallowed per-line.
### `TcrAccessLogMonitor`
Plain substring/`contains` matching on `ACESSLog<MMdd>.txt`:
- `"Process restarted from abnormal termination"` → `TCR_PROCESS_RESTART` (details "TCR process restarted after abnormal termination").
- `"## Shutdown ##"` / `"## Shutdown for reboot ##"` → `TCR_SHUTDOWN`; details are "TCR shutdown for reboot" if the line contains `reboot`, else "TCR operator shutdown".
- Builder fields set: `agentAtmId`, `eventType`, `eventDetails`, `eventSource=TCR_ACESS_LOG`.
### `CstSnsLogMonitor`
Regex-driven parse of `CST_SNR_<MMdd>.log`. Compiled patterns cover block delimiters (`>>>>> hh:mm:ss.SSS - <TAG> START/END`), `[N_E]` lines, generic `(\w{2,3}):\s*(\w+)` key/value pairs, and the mismatch line `** CST <x> - SNR deposit count ... mismatch ... SNR count: - N - N`. State-code → event mapping:
- `EMPT` → `CASSETTE_EMPTY` ("TCR cassette slot <n> is empty")
- `SNRF` → `TCR_SENSOR_FAULT` ("... sensor failure")
- `TMAN` → `TCR_MANUAL_REQUIRED` ("... requires manual intervention")
- `NORM` → recovery only (logs `slot {} recovered (was {})`, no event)
- deposit-count mismatch → `TCR_SENSOR_MISMATCH` ("Slot <n>: SNR mismatch — data=<> sensor=<>")
- Builder fields set: `agentAtmId`, `eventType`, `eventDetails`, `eventSource=TCR_CST_SNR`, `cassettePosition` (slot).
### `TcrCassetteMonitor`
Parses `JsonLog_<MMdd>.json`, filtering to lines whose JSON has `"Command":"RU"` (recycler-unit inventory snapshot; both `"Command":"RU"` and `"Command": "RU"` spacings are matched). For each entry in `Cassette_Info` it reads `Cassette_Type`, `Cassette_Status`, `Position`/`Sub_Position`, `Nation`(US)/currency `USD`, `Denomination`, `Denom`, `Count`, and emits one `CASSETTE_INVENTORY` event per slot ("TCR slot <pos>: <status> notes (<count>)"). Mappings:
- Status `Available` / `Low` / `Empty` / `Full` → `cassetteFillLevel`.
- Type `Recycle_Cassette`→`RECYCLE`, `Operation_Cassette`→`OPERATION`, `Reject_Cassette`→`REJECT` → `cassetteType`.
- Builder fields set: `agentAtmId`, `eventType`, `eventSource=TCR_CASSETTE`, `eventDetails`, `cassettePosition`, `cassetteType`, `cassetteCurrency`, `cassetteDenomination`, `cassetteFillLevel`, `cassetteBillCount`. Logs `sent CASSETTE_INVENTORY for {} slots`.
## How events reach the platform
`IncidentEventClient` (from `hiveops-agent-journal`, `com.hiveops.events`) serializes each `CreateJournalEventRequest` with Jackson (`@JsonInclude(NON_NULL)`) and does `POST {settings.endpoint}/api/journal-events`, accepting 200/201/202. Because the endpoint prefix is chosen from `agent.endpoint` vs `incident.endpoint`, on current fleets this targets the **agent-proxy** `/agent` path, which forwards into the incident pipeline; older configs post straight to incident. The ATM is auto-registered from `agentAtmId` — no `resolveAtmId()` call (deprecated). `agent.auth.token` is the bearer for every request.
Notable: none of the three pollers call `builder.deviceType(...)`, so `deviceType` is null on the wire and the backend applies its default (ATM) — the events are not tagged as TCR-device events even though the module only runs on TCR devices.
## Platform abstraction
None used. The module is pure JVM file-tailing + HTTP; it inherits ATM identity (`getAtmName()`, `getCountry()`) and shared properties from `ModuleContext`, and the HTTP layer from `HttpClientHelper`/`HttpClientSettings`. There is no `SystemCommands`/`PathResolver`/registry interaction, so behavior is identical across OSes given the same `tcr.log.dir` contents.
@@ -0,0 +1,73 @@
---
module: agent.tcr-events
title: TCR Events — cassette + sensor + process monitoring for Teller Cash Recyclers
tab: Claude
order: 40
audience: bcos
---
> **Claude · terse act-without-guessing reference.** From compiled `hiveops-module-tcr-events` v4.3.5 (`.java` not in tree; read from bytecode + core `IncidentEventClient`/`HttpClientHelper`).
## Identity
- Maven artifact / JAR: `hiveops-module-tcr-events` (groupId `com.hiveops`, built v`4.3.5`).
- Agent module id (`getName()`): `tcr-events`. Module `getVersion()`: `1.0.0`.
- SPI: `com.hiveops.core.module.AgentModule` → `com.hiveops.tcrevents.TcrEventsModule`.
- Classes: `TcrEventsModule`, `TcrAccessLogMonitor`, `CstSnsLogMonitor`, `TcrCassetteMonitor` (pkg `com.hiveops.tcrevents`).
- Shares module id `tcr-events` with `hiveops-module-atec-tcr-events`; **this is the superset** (adds cassette-JSON monitor). Never install both on one device.
## Config keys (from `hiveops.properties`)
| Key | Default | Notes |
|---|---|---|
| `device.type` | `ATM` | Must be `ATEC_TCR` or `TCR` (upper-cased) or module disables. |
| `tcr.events.enabled` | `true` | Anything != `true` (ci) disables. |
| `tcr.log.dir` | *(required)* | Dir of the 3 log files. Missing key → disabled; missing dir → warn + retry. |
| `tcr.events.poll.interval.sec` | `30` | Scheduler period (seconds). |
| `agent.endpoint` | *(none)* | Present → HTTP prefix `agent`; absent → prefix `incident`. |
| `agent.auth.token` | — | Bearer for all requests (not module-specific). |
## Watched files (daily, `MMdd`)
- `ACESSLog<MMdd>.txt` — `TcrAccessLogMonitor` (note the misspelling "ACESS", it's the vendor's).
- `CST_SNR_<MMdd>.log` — `CstSnsLogMonitor`.
- `JsonLog_<MMdd>.json` — `TcrCassetteMonitor` (only `"Command":"RU"` lines).
## Event strings emitted
`eventType` (exact):
- `TCR_PROCESS_RESTART`
- `TCR_SHUTDOWN`
- `CASSETTE_EMPTY`
- `TCR_SENSOR_FAULT`
- `TCR_MANUAL_REQUIRED`
- `TCR_SENSOR_MISMATCH`
- `CASSETTE_INVENTORY`
`eventSource` (exact): `TCR_ACESS_LOG`, `TCR_CST_SNR`, `TCR_CASSETTE`.
Sensor state codes → events: `EMPT`→`CASSETTE_EMPTY`, `SNRF`→`TCR_SENSOR_FAULT`, `TMAN`→`TCR_MANUAL_REQUIRED`, `NORM`→**recovery, no event**.
Cassette type map: `Recycle_Cassette`→`RECYCLE`, `Operation_Cassette`→`OPERATION`, `Reject_Cassette`→`REJECT`. Status values: `Available`/`Low`/`Empty`/`Full`.
## Transport
- `IncidentEventClient.sendEvent` → `POST {endpoint}/api/journal-events`, accepts 200/201/202.
- Auto-register via `agentAtmId`; do NOT rely on `resolveAtmId()` (deprecated).
- Endpoint from `agent.endpoint` (prefix `agent`, agent-proxy `/agent`) else `incident.endpoint`.
## Gotchas
- Monitors set **no** `deviceType` on the request → backend records events as ATM default, not TCR.
- No offset persistence — agent restart re-reads current day's file from 0 (append-only vendor logs mitigate dupes; midnight rollover switches filename automatically).
- Poll errors are caught/logged (`tcr-events: poll error:`), never fatal.
- No `PlatformService` — pure `java.nio` + `RandomAccessFile("r")`; identical across OSes given same log dir.
- Deploy/retrofit: fleet `INSTALL_MODULE` task (drops JAR into `modules/`, restarts agent) or full standard-set agent update.
## Do NOT
- Do NOT expect events on a device unless `device.type=ATEC_TCR|TCR` AND `tcr.log.dir` is set.
- Do NOT install alongside `hiveops-module-atec-tcr-events` (same module id).
- Do NOT invent config keys — only the five above are read. No `tcr-events.*`-dotted keys exist; the real prefixes are `tcr.events.*` and `tcr.log.dir`.
- Do NOT assume batch upload — events post one-by-one per `sendEvent`.
- Do NOT trust `getVersion()`=`1.0.0` as the release version; the shipped artifact version is the agent build (`4.3.5` here).
@@ -0,0 +1,78 @@
---
module: agent.tcr-events
title: TCR Events — cassette + sensor + process monitoring for Teller Cash Recyclers
tab: Internal
order: 20
audience: dev
---
> **Internal · ops/support.** Written 2026-07-01 from the compiled `hiveops-module-tcr-events` artifact (v4.3.5). The `.java` sources are not in the working tree — facts below were read from the module bytecode in `target/classes` plus the core `IncidentEventClient` / `HttpClientHelper` sources. Verify against code before relying on specifics.
## What this module is
`hiveops-module-tcr-events` (Maven artifact `hiveops-module-tcr-events`, agent module id **`tcr-events`**, reported module version `1.0.0`) is an **agent drop-in module**, not a backend service. It runs inside the HiveIQ Agent process on **TCR (Teller Cash Recycler) devices only**. It self-disables unless the ATM's `device.type` is `ATEC_TCR` (or the legacy alias `TCR`).
It tails **three** log files that the LG/ATEC TCR software already writes to disk and turns specific lines into HiveIQ journal events, which it POSTs into the incident pipeline. The module does **not** talk to the recycler hardware directly and does not reconcile cash totals — it only reads logs and forwards structured events.
> Note: this module shares the agent module id `tcr-events` with `hiveops-module-atec-tcr-events`. This variant is the superset — it adds the JSON cassette-inventory monitor (`TcrCassetteMonitor`) on top of the access-log and sensor-log monitors. Do not install both on the same device.
## What it monitors
Three independent pollers, all driven by one scheduler tick (default every 30s), each reading a daily-rotated file (`MMdd` = month+day) from `tcr.log.dir`:
| Poller class | File pattern | Watches for |
|---|---|---|
| `TcrAccessLogMonitor` | `ACESSLog<MMdd>.txt` *(sic — misspelled in the vendor logs)* | abnormal process restarts, operator/reboot shutdowns |
| `CstSnsLogMonitor` | `CST_SNR_<MMdd>.log` | cassette sensor state (empty / fault / manual-required) and SNR deposit-count mismatches |
| `TcrCassetteMonitor` | `JsonLog_<MMdd>.json` | per-slot cassette inventory (`"Command":"RU"` recycler-unit status lines) |
Each poller tracks a byte offset into its current file (opened read-only) and only processes newly appended lines each tick.
## Events it emits
All events are sent as HiveIQ journal events (`POST {endpoint}/api/journal-events`). Event types by source:
| `eventSource` | `eventType` | Trigger |
|---|---|---|
| `TCR_ACESS_LOG` | `TCR_PROCESS_RESTART` | log line "Process restarted from abnormal termination" |
| `TCR_ACESS_LOG` | `TCR_SHUTDOWN` | "## Shutdown ##" / "## Shutdown for reboot ##" (reboot vs operator shutdown in details) |
| `TCR_CST_SNR` | `CASSETTE_EMPTY` | sensor state `EMPT` |
| `TCR_CST_SNR` | `TCR_SENSOR_FAULT` | sensor state `SNRF` |
| `TCR_CST_SNR` | `TCR_MANUAL_REQUIRED` | sensor state `TMAN` |
| `TCR_CST_SNR` | `TCR_SENSOR_MISMATCH` | SNR deposit-count mismatch line |
| `TCR_CASSETTE` | `CASSETTE_INVENTORY` | one per slot from each `RU` status snapshot |
Sensor state `NORM` is treated as **recovery** — it is logged (`slot {} recovered`) but emits **no** event.
## Config properties it registers
Read from the agent's main properties (`hiveops.properties`) during `initialize()`:
| Property | Default | Effect |
|---|---|---|
| `device.type` | `ATM` | Gate — module runs only when this is `ATEC_TCR` or `TCR` (upper-cased). Anything else: module disabled. |
| `tcr.events.enabled` | `true` | Master on/off. Any value other than `true` (case-insensitive): module disabled. |
| `tcr.log.dir` | *(none — required)* | Directory holding the three TCR log files. If unset: module disabled. If set but missing on disk: warns and retries each poll. |
| `tcr.events.poll.interval.sec` | `30` | Scheduler period in seconds. |
| `agent.endpoint` | *(none)* | If present, HTTP settings load from the `agent.*` prefix (agent-proxy); if absent, they fall back to the `incident.*` prefix. |
Transport credentials are **not** module-specific: the bearer token always comes from `agent.auth.token`, and the institution key resolves `<{prefix}>.institution.key` → `agent.institution.key` → `incident.institution.key` (see `HttpClientHelper.loadSettingsFromProperties`).
## How it's enabled / deployed
1. Device must be flagged `device.type=ATEC_TCR` (or `TCR`) and have `tcr.log.dir` pointed at the vendor log directory.
2. Deliver the module JAR like any other agent module: upload to the fleet artifact store, then push a fleet **`INSTALL_MODULE`** task, which drops the JAR into the agent's `modules/` directory and restarts the agent (`ProcessInstallModule` in `hiveops-file-collection`). It can also ship as part of a full standard-set agent update.
3. Standard rollout order applies: lab TCR → pilot device → fleet.
On successful init the agent logs `tcr-events initialized (log.dir={}, poll={}s)` and, once started, `tcr-events started`.
## Common failure modes / what to check
- **Module silently absent from a device.** Check `device.type` — if it is not `ATEC_TCR`/`TCR` the agent logs `tcr-events: device.type is '{}', not ATEC_TCR — module disabled` and never starts. This is the most common cause.
- **No events at all.** Confirm `tcr.events.enabled` is not set to `false` (`tcr-events: disabled via tcr.events.enabled=false`) and `tcr.log.dir` is configured (`tcr.log.dir not configured — module disabled`).
- **Log dir warning on every poll.** `log dir '{}' does not exist yet — will retry each poll` means the module initialized but the directory is missing/misnamed — the vendor software may not have created that day's folder yet.
- **Events built but not arriving in incident.** Look for `tcr-events: failed to send {}: {}` (HTTP error from `/api/journal-events`) — usually an endpoint/token/institution-key problem, or the wrong prefix (`agent.endpoint` present but `agent.*` settings wrong).
- **Read errors.** `tcr-events: failed to read {}` / `cassette monitor failed to read {}` — permissions or a locked/rotating vendor file.
- **Wrong day / nothing new.** All three files are day-stamped (`MMdd`); at midnight rollover the pollers switch to the new filename automatically.
> Gotcha: emitted events do **not** set `deviceType` on the request, so the incident backend records them under its default device type (ATM), even though the module only runs on TCR devices. See [architect.tcr-events].
@@ -0,0 +1,70 @@
---
module: agent.win-app-events
title: Windows App Event Log Monitor — forwards Windows Application-log errors as HiveIQ incidents
tab: Architect
order: 30
audience: bcos
---
> **Internal · how it's built.** Confirmed against `hiveops-module-win-app-events` source (`com.hiveops.winevents`) 2026-07-01. See [technical.agent] for agent-wide module/event context.
## Maven module
`hiveops-module-win-app-events` (artifact `hiveops-module-win-app-events`, own version `1.0.1`, parented by `hiveops-agent-parent` `4.4.3`). Packaged as a standalone jar (`<finalName>${project.artifactId}</finalName>` → `hiveops-module-win-app-events.jar`). Dependencies are all `provided` (supplied by the fat JAR at runtime):
- `hiveops-core` — the `AgentModule` SPI, `ModuleContext`, `HttpClientHelper`/`HttpClientSettings`, `PatternReloadRegistry`.
- `hiveops-agent-journal` (`1.0.0`) — `IncidentEventClient`, `EventType`, `CreateJournalEventRequest` (the same event path `journal-events` uses).
- `log4j-api`, `commons-lang3`.
Registered via ServiceLoader SPI: `src/main/resources/META-INF/services/com.hiveops.core.module.AgentModule` → `com.hiveops.winevents.WinAppEventModule`.
## Classes and flow
Four classes, no sub-packages:
| Class | Role |
|---|---|
| `WinAppEventModule` | `AgentModule` impl — OS/enable gating, config parse, scheduler, hot-reload registration |
| `WinAppEventPoller` | `Runnable` — the anchor/poll loop; runs `wevtutil`, applies filters, emits events |
| `WinAppEventParser` | Turns raw `wevtutil` XML into `WinAppEvent` records |
| `WinAppEvent` | Immutable holder: `recordId, provider, eventId, level, timeCreated, message`; builds `matchText()` and `toDetails()` |
**Poll-based** cycle (not log-tail or ETW hook — it repeatedly shells `wevtutil` and diffs by RecordID):
1. **`initialize(ModuleContext)`** — bails (leaves `ready=false`) if not Windows, if `winevt.events.enabled != true`, or if no `agent.endpoint`/`incident.endpoint`. Otherwise builds an `IncidentEventClient` from `HttpClientHelper.loadSettingsFromProperties(props, prefix)` (prefix `"agent"` if `agent.endpoint` set, else `"incident"`), reads `levels`/`batch.size`/`sources`/pattern, constructs the `WinAppEventPoller`, resolves the `hiveops.properties` path for later reloads, and sets `ready=true`.
2. **`isEnabled()`** returns `ready`.
3. **`start()`** — creates a single daemon thread (`newSingleThreadScheduledExecutor`, thread name `win-app-events`) and `scheduleAtFixedRate(poller, 0, intervalSec, SECONDS)`. Then registers `this::reloadPatterns` with `PatternReloadRegistry`.
4. **`WinAppEventPoller.run()`** — if `suspended`, skip. If `lastEventRecordId < 0`, call `anchor()` and return (the very first tick). Otherwise `poll()`.
- **`anchor()`** runs `wevtutil qe Application /rd:true /f:XML /c:1 /q:*[System[Level>=1 and Level<=<maxLevel>]]`, sets `lastEventRecordId` to that newest RecordID (or `0` if the log is empty). This is why no history is replayed.
- **`poll()`** runs `wevtutil qe Application /rd:false /f:XML /LNIFO:Message /c:<batchSize> /q:*[System[(Level>=1 and Level<=<maxLevel>) and (EventRecordID > <lastId>)]]`. `/rd:false` = oldest-first (in-order processing); `/LNIFO:Message` renders human-readable message text from the provider DLL.
5. **Filtering** (in order, per event): the **level** filter is baked into the `wevtutil` XPath; then **source** filter (`sourceFilter` non-empty → Provider name must case-insensitively equal one entry); then **pattern** filter (`eventPattern != null` → regex `.find()` on `matchText()` = `"<provider> <eventId> <message>"`).
6. **Bookmark advance** — `lastEventRecordId` is set to the max RecordID seen this poll **even if every event was filtered out**, so filtered RecordIDs are never re-queried.
7. **Emit** — survivors become `CreateJournalEventRequest`s (`agentAtmId=atmName`, `eventType=WIN_APP_EVENT_ERROR`, `eventDetails=WinAppEvent.toDetails()`, `eventSource=HIVEOPS_AGENT_WINEVT`) and are sent via `client.sendEvents(list)`.
`WinAppEventParser` wraps the `wevtutil` output in a synthetic `<Events>…</Events>` root (handles both single- and multi-event output), DOM-parses each `<Event>`, and reads `EventRecordID`, `Provider@Name`, `Level` (mapped 1→Critical, 2→Error, 3→Warning, 4→Information), `EventID`, `TimeCreated@SystemTime`, and the message. Message extraction **prefers** `RenderingInfo/Message` (present because of `/LNIFO:Message`) and **falls back** to joining `EventData/Data` values with `" | "`. Unparseable individual events are skipped at DEBUG; a totally unparseable payload yields an empty list.
## Hot reload
`WinAppEventModule.reloadPatterns()` (registered with `PatternReloadRegistry`) re-reads `hiveops.properties` **from disk** (`startedFromDir/hiveops.properties`, cwd fallback) and calls `poller.setPattern(...)` and `poller.setSuspended(...)`. Both poller fields are `volatile`. `ProcessConfigUpdate` (the `CONFIG_UPDATE` fleet-task handler in `hiveops-file-collection`) writes the new properties then calls `PatternReloadRegistry.trigger()` — that's the entire hot-reload path. Level/sources/interval/batch are **not** re-read here (captured at `initialize()` time only).
## How events reach the platform
This module rides the **same journal-event path** as the `journal-events` module — there is no separate protocol:
```
WinAppEventPoller
│ IncidentEventClient.sendEvents(List<CreateJournalEventRequest>)
│ (endpoint from agent.endpoint | incident.endpoint; auth via HttpClientHelper)
POST {endpoint}/api/journal-events ← one HTTP POST per event (sendEvents loops sendEvent)
agent-proxy (agent-facing gateway, X-Institution-Key / token auth)
hiveops-incident → journal-event ingest → incident pipeline / device_summary
```
Note: `sendEvents` is **not** a batch POST — it iterates and POSTs each event individually to `/api/journal-events`; `winevt.events.batch.size` only bounds how many events one `wevtutil` call pulls, not the HTTP shape.
## Platform abstraction
The module does **not** use `com.hiveops.platform.PlatformServiceFactory` / `PlatformService`. OS detection is done locally (`System.getProperty("os.name")…contains("windows")`) and it shells `wevtutil` directly via `ProcessBuilder`. The runner (`runWevtutil`) decodes stdout with the charset from the `file.encoding` system property (a comment notes `wevtutil` emits UTF-16LE and relies on the JVM's active code-page resolution), reads stdout fully, then `waitFor()`s; a non-zero exit is logged at DEBUG and yields an empty string (→ empty parse → no events that tick).
@@ -0,0 +1,78 @@
---
module: agent.win-app-events
title: Windows App Event Log Monitor — forwards Windows Application-log errors as HiveIQ incidents
tab: Claude
order: 40
audience: bcos
---
> **Internal · agent reference.** Terse. Confirmed against `hiveops-module-win-app-events` source (`com.hiveops.winevents`) 2026-07-01.
## Identity
- Module name (`AgentModule.getName()`): **`win-app-events`**
- Maven artifact: **`hiveops-module-win-app-events`** (own version `1.0.1`; parent `hiveops-agent-parent` `4.4.3`)
- Entry class: `com.hiveops.winevents.WinAppEventModule` (SPI: `META-INF/services/com.hiveops.core.module.AgentModule`)
- Other classes: `WinAppEventPoller`, `WinAppEventParser`, `WinAppEvent`
- Log package (`getLogPackage()`): `com.hiveops.winevents`
- Dependencies: `getDependencies()` → **empty** (no ordering deps)
- Windows-only; Linux → `initialize()` no-ops (logs `not Windows, module inactive`)
## Config property keys (all `winevt.*`, from `hiveops.properties`)
| Key | Code default | Notes |
|---|---|---|
| `winevt.events.enabled` | **`false`** | Must be `true` to run. Javadoc claims default true — **code default is `false`**. |
| `winevt.events.levels` | `3` | Max level: 1=Critical, 2=Error, 3=Warning. Query is `Level>=1 and Level<=N`. |
| `winevt.events.sources` | `""` | CSV of Provider names; exact case-insensitive match; empty = all. |
| `winevt.events.poll.interval.sec` | `60` | Scheduler period (seconds). |
| `winevt.events.batch.size` | `50` | `wevtutil /c:` cap per call (NOT an HTTP batch). |
| `winevt.pattern.WIN_APP_EVENT_ERROR` | `""` | Regex, `.find()`, CASE_INSENSITIVE, on `"<provider> <eventId> <message>"`; empty/invalid = catch-all. |
| `winevt.events.suspended` | `false` | Hot-reload-only pause; poller keeps ticking, does nothing. |
- Endpoint/auth (not owned by this module): `agent.endpoint` (preferred) else `incident.endpoint` (fallback); prefix drives `HttpClientHelper.loadSettingsFromProperties(props, prefix)`.
- Disable via `modules.disabled=win-app-events` too.
- **Hot-reloadable (via `CONFIG_UPDATE` → `PatternReloadRegistry.trigger()`):** ONLY `winevt.pattern.WIN_APP_EVENT_ERROR` + `winevt.events.suspended`. Everything else needs an **agent restart** (captured in `initialize()`).
## Event emitted
- `EventType.WIN_APP_EVENT_ERROR` → `eventType = "WIN_APP_EVENT_ERROR"` (only event type this module emits)
- `eventSource = "HIVEOPS_AGENT_WINEVT"` (constant `EVENT_SOURCE`)
- `agentAtmId = <atmName>` (from `ModuleContext`)
- `eventDetails` format: `[Source: <provider>] [EventID: <id>] [Level: <Critical|Error|Warning|...>] <message>`
- No `IAT_*` events. No fleet-task status. Nothing else.
## Wire path
- `IncidentEventClient.sendEvents(list)` → loops `sendEvent` → **one** `POST {endpoint}/api/journal-events` **per event** (not batched).
- endpoint → `agent-proxy` → `hiveops-incident` journal-event ingest.
## wevtutil commands (exact)
- Anchor (1st tick): `wevtutil qe Application /rd:true /f:XML /c:1 /q:*[System[Level>=1 and Level<=<maxLevel>]]`
- Poll: `wevtutil qe Application /rd:false /f:XML /LNIFO:Message /c:<batchSize> /q:*[System[(Level>=1 and Level<=<maxLevel>) and (EventRecordID > <lastId>)]]`
- Channel is **Application** only. `/LNIFO:Message` renders provider-DLL message text.
## Filter order (per event)
1. Level — in the `wevtutil` XPath (`Level<=maxLevel`).
2. Source — `sourceFilter` non-empty → Provider name must case-insensitively equal an entry.
3. Pattern — `eventPattern != null` → regex `.find()` on `matchText()` = `"<provider> <eventId> <message>"`.
- Bookmark `lastEventRecordId` advances to max RecordID seen **even if all filtered**.
## Deployment
- Ships in the **`atm`** module set of `deployment/build-dist.sh` (default), NOT `srv`.
- Drop-in JAR `modules/hiveops-module-win-app-events.jar` (not in fat JAR).
- Retrofit: `INSTALL_MODULE` fleet task (drops JAR → restarts agent) or full standard-set update.
## Gotchas
- `winevt.events.enabled` defaults to **`false`** in code — module is OFF unless explicitly enabled. Do not trust the class Javadoc's "default true."
- First tick **anchors** to newest RecordID → **no historical replay**; only post-start events forward.
- Changing filters does **not** replay already-skipped RecordIDs (bookmark already advanced).
- `batch.size` bounds the `wevtutil` fetch, NOT the HTTP call — each event is a separate POST.
- Uses `os.name` + raw `ProcessBuilder`; does **not** use `PlatformService`/`SystemCommands`.
- Charset for `wevtutil` stdout = `file.encoding` system property; wrong code page → garbled messages.
- Only pattern + suspended hot-reload; `levels`/`sources`/`interval`/`batch` require restart.
## Do NOT
- Do NOT assume it's running by default — `winevt.events.enabled=true` is required (code default `false`).
- Do NOT expect historical/backfilled events after enable or restart — it anchors forward-only.
- Do NOT expect `IAT_*` or fleet-task-status output — it emits **only** `WIN_APP_EVENT_ERROR`.
- Do NOT treat `winevt.events.batch.size` as an HTTP batch size — POSTs are one-per-event.
- Do NOT expect `levels`/`sources`/`interval`/`batch` changes to hot-reload — only pattern + suspended do; restart otherwise.
- Do NOT look for a `PlatformService` code path — OS detection and `wevtutil` invocation are local to this module.
- Do NOT run it on Linux and expect activity — it silently no-ops off-Windows.
@@ -0,0 +1,62 @@
---
module: agent.win-app-events
title: Windows App Event Log Monitor — forwards Windows Application-log errors as HiveIQ incidents
tab: Internal
order: 20
audience: dev
---
> **Internal · ops/support view.** Confirmed against `hiveops-module-win-app-events` source (`com.hiveops.winevents`) 2026-07-01.
## What it monitors (and why)
`hiveops-module-win-app-events` (module name `win-app-events`, class `WinAppEventModule`) is a **passive poller** of the Windows **Application** event log. Every poll it shells out to `wevtutil` for new **Critical / Error / Warning** entries, applies filters, and forwards each survivor to the HiveIQ platform as a `WIN_APP_EVENT_ERROR` journal event (which lands in the incident pipeline like any other agent event).
The point: surface application-layer failures that never appear in the ATM's own `.jrn` journal — driver faults, .NET/service crashes, XFS middleware errors, disk/hardware warnings — so they become visible in HiveIQ without logging into the box.
- **Windows-only.** On Linux it logs `not Windows, module inactive` in `initialize()` and does nothing (silent no-op).
- Reads the **Application** channel only (not System/Security).
- Only levels **1 (Critical), 2 (Error), 3 (Warning)** are ever queried; level 4 (Information) and 0 are excluded by the `wevtutil` query itself.
- On the **first tick after start** the module *anchors* to the newest existing RecordID and only forwards events that arrive **after** that — no historical flood on install/restart.
## Config properties it registers
All read from `hiveops.properties` (main properties). Namespace is `winevt.*`. Defaults are the code defaults (second arg to `getProperty`), which do **not** always match the class Javadoc — trust this table.
| Key | Default (in code) | Meaning |
|---|---|---|
| `winevt.events.enabled` | **`false`** | Master on/off. Module stays inactive unless this is explicitly `true`. (The Javadoc says default true — the **code default is `false`**.) |
| `winevt.events.levels` | `3` | Max Windows level to capture: `1`=Critical only, `2`=Critical+Error, `3`=+Warning. |
| `winevt.events.sources` | `""` (all) | Comma-separated Provider names to include; exact case-insensitive match. Empty = all providers. |
| `winevt.events.poll.interval.sec` | `60` | Poll interval in seconds. |
| `winevt.events.batch.size` | `50` | Max events pulled per `wevtutil` call (`/c:`). |
| `winevt.pattern.WIN_APP_EVENT_ERROR` | `""` (catch-all) | Regex matched (`.find()`, case-insensitive) against `"<Provider> <EventID> <message>"`. Empty/absent = accept all. Invalid regex → logs a warning and falls back to catch-all. |
| `winevt.events.suspended` | `false` | Hot-reload-only pause switch: when `true` the poller keeps ticking but skips all work. Only read on reload (see below), not at startup. |
It also reuses the shared endpoint/auth keys (not its own): `agent.endpoint` (preferred) or `incident.endpoint` (fallback), and whatever `agent.*`/`incident.*` auth settings `HttpClientHelper` loads for that prefix.
### Hot reload (no restart)
`winevt.pattern.WIN_APP_EVENT_ERROR` and `winevt.events.suspended` are **hot-reloadable**. A `CONFIG_UPDATE` fleet task rewrites `hiveops.properties` on disk and then fires `PatternReloadRegistry.trigger()`, which re-reads the file and pushes the new pattern + suspended flag into the running poller. **Everything else** (`enabled`, `levels`, `sources`, `poll.interval.sec`, `batch.size`) is captured once in `initialize()` and only changes on an **agent restart**.
## How it's enabled / deployed to ATMs
- **Enable condition:** module self-activates only when `winevt.events.enabled=true` **and** an `agent.endpoint`/`incident.endpoint` is configured **and** the OS is Windows. Missing any of these → inactive, logged at INFO.
- **Shipped how:** part of the default **`atm`** module set in `deployment/build-dist.sh` — it ships in every standard ATM install/patch ZIP as a drop-in JAR `modules/hiveops-module-win-app-events.jar` (not baked into the fat JAR). It is **not** in the `srv` (server-monitor) set.
- **Turn on for a fleet:** push `winevt.events.enabled=true` (plus any `winevt.*` tuning) via a `CONFIG_UPDATE` fleet task.
- **Turn off:** set `winevt.events.enabled=false` (needs restart to take effect since `enabled` isn't hot-reloaded) **or** set `winevt.events.suspended=true` (immediate, hot-reload) **or** add `win-app-events` to `modules.disabled`.
- **Retrofit an ATM missing the JAR:** deliver an `INSTALL_MODULE` fleet task (drops the JAR into `modules/` and restarts the agent), or a full standard-set agent update.
## Common failure modes / what to check
| Symptom | Likely cause | What to check |
|---|---|---|
| No `WIN_APP_EVENT_ERROR` events ever arrive | `winevt.events.enabled` is at its default `false`, or module not on this ATM | Confirm `winevt.events.enabled=true` in `hiveops.properties`; confirm the JAR is in `modules/`; check the module log for `win-app-events initialized`. |
| Module log says `not Windows, module inactive` | Running on a Linux ATM | Expected — this module is Windows-only. |
| Started but silent | No new qualifying events since anchor, or everything filtered out | Log shows `anchored at RecordID N`, then `no new events` / `all N event(s) filtered`. Loosen `levels`/`sources`/pattern. |
| Historical errors never backfilled after enabling | By design — first tick anchors to newest RecordID; only new events forward | Not a bug. There is no historical replay. |
| Too noisy (flooding incidents) | `levels=3` (Warnings) + no source/pattern filter | Tighten `winevt.events.levels` to `2`, set `winevt.events.sources`, or set a `winevt.pattern.WIN_APP_EVENT_ERROR` regex. |
| Changed the pattern but old skipped events don't reappear | Bookmark always advances even when events are filtered out | Expected — filters apply going forward only; already-passed RecordIDs are not re-queried. |
| Pattern/suspend change didn't take effect | Only pattern + suspended hot-reload; the change wasn't a `CONFIG_UPDATE` (which fires the reload) | Push the change via `CONFIG_UPDATE`; for `levels`/`sources`/`interval`/`batch` you must restart the agent. |
| Garbled message text | `wevtutil` charset / code page mismatch on that box | The runner decodes with the JVM `file.encoding`; check the ATM's active code page if provider messages look corrupted. |
| Events fail to POST | Endpoint/auth wrong, or agent-proxy/incident down | Module logs `failed to send events`; each event is a POST to `/api/journal-events` — verify `agent.endpoint` and token. |
@@ -0,0 +1,71 @@
---
module: analytics.adoons
title: Adoons Insights
tab: Architect
order: 30
audience: bcos
---
> **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<AiInsightResponse>` | 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].
@@ -0,0 +1,68 @@
---
module: analytics.adoons
title: Adoons Insights
tab: Claude
order: 40
audience: bcos
---
> **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 <jwt>`. 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].
@@ -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 <jwt>` 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].
@@ -0,0 +1,25 @@
---
module: analytics.adoons
title: Adoons Insights
tab: Customer
order: 10
audience: customer
---
Automatic, plain-language observations about your ATM fleet, surfaced by Adoons. Use this screen for a quick read on what's trending across your machines without digging through reports.
## 💡 What you can do here
1. **Read active insights** — each card highlights something Adoons noticed in your fleet.
2. **Refresh** — click **Refresh** to pull the latest insights.
3. **Dismiss** — click the **✕** on a card to clear an insight once you've acted on it or no longer need it.
> When there's nothing to report, you'll see "No active fleet insights" — new ones appear as patterns emerge.
## 📇 What you're looking at
Each insight card shows:
- **Severity** — how much attention it needs: **CRITICAL**, **HIGH**, **MEDIUM**, **LOW**, or **INFO**.
- **Category** — the area of the fleet the insight relates to.
- **Time** — how long ago the insight was published (e.g. "2h ago").
- **Title and detail** — a short headline plus a sentence or two explaining what Adoons observed.
> Good to know: Insights are colour-coded by severity, so red and orange cards are the ones worth looking at first.
@@ -0,0 +1,35 @@
---
module: analytics.adoons
title: Adoons Insights
tab: Testing
order: 35
audience: internal
---
Frontend test checklist for **Adoons Insights** (the **Fleet Insights** tab in Analytics, `analytics.bcos.dev`). Click through in the UI and mark each row Pass or Fail.
## Set up
Log in at **analytics.bcos.dev** and open the **Adoons / Fleet Insights** tab — test accounts (all password `Passw0rd-d3v!`):
| Login | Role | Sees |
|-------|------|------|
| `bcos_a@bcos.dev` | Admin | All institutions |
| `msp_a@msp1.bcos.dev` | MSP admin | C1 + C2 |
| `customer_a@customer1.bcos.dev` | Customer | C1 · read-only |
Insights describe the simulator fleet (**C1-ATM-001…**, **C1-TCR-001…**, **C1-SRV-001…**, plus C2-/C3-). Cards are fleet-wide, not per-device — you don't select a device here.
## What to test
| # | Do this | ✅ Pass if |
|---|---------|-----------|
| 1 | As `msp_a`, open the **Fleet Insights** tab | Header reads **FLEET INSIGHTS** with sub-text **Powered by Adoons fleet intelligence** and a **Refresh** button top-right |
| 2 | Look at any insight card on screen | Card shows a **severity** badge, a **category** badge, a **time** (e.g. "2h ago"), a **title** and a **detail** line, with a coloured **left border** matching the severity |
| 3 | Click **Refresh** | Button briefly shows a spinner + **Refreshing…**, then the list updates — **no red error bar** appears |
| 4 | Click the **✕** on one card | That card **disappears** from the list right away |
| 5 | Click **Refresh** again (or reload the tab) | The card you dismissed **does not come back** |
| 6 | Compare a **CRITICAL** or **HIGH** card against an **INFO** card | Higher-severity cards are visibly **red/orange**, INFO is **blue** — colour matches severity |
| 7 | Dismiss every card (or open a fleet with none) | Empty state shows a lightbulb icon and **"No active fleet insights"** — no error, no crash |
| 8 | Log in as `customer_a`, open the tab | You can **view** cards and use **Refresh** / **✕** — but there is **no Add / Create / Publish button** anywhere (no way to author an insight from the UI, for any role) |
**Report a fail with:** the row #, which login you used, and what you actually saw.
@@ -0,0 +1,82 @@
---
module: analytics.agents
title: Agents
tab: Architect
order: 30
audience: bcos
---
> **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 <userJWT>)
hiveops-analytics
AgentInsightController.agents()
→ extracts raw token from Authorization header
AgentInsightService.buildAgentInsight(token)
→ incidentWebClient GET /api/atms/paginated?size=500&page=0
(forwards "Bearer <userJWT>")
hiveops-devices
AtmController.getAllAtmsPaginated(...)
→ AtmService.getAllAtmsPaginated(...) → Spring Data Page<AtmDTO>
→ 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<String,Object>` 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"`.
@@ -0,0 +1,55 @@
---
module: analytics.agents
title: Agents
tab: Claude
order: 40
audience: bcos
---
> **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<version,count>`
- `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`.
@@ -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].
@@ -0,0 +1,35 @@
---
module: analytics.agents
title: Agents
tab: Customer
order: 10
audience: customer
---
A quick health check on the HiveIQ agent software running across your ATM fleet. Use this screen to see which ATMs are online, which software versions they're running, and which ones need an update.
## 👀 What you're looking at
### Version Distribution
A bar chart showing how many ATMs are on each agent software version. The version with the most ATMs has the longest bar, and the current release is marked with a green **latest** badge — so you can tell at a glance how much of your fleet is up to date.
### Connection Status
A live count of your fleet broken into three groups, each with a percentage:
- **Connected** — ATMs checking in normally.
- **Disconnected** — ATMs that were online before but have gone quiet.
- **Never Connected** — ATMs that have been added but have not yet reported in.
The **Latest** version is also shown here for reference.
### Outdated ATMs
A list of the specific ATMs that are not yet on the latest version. If everything is current, you'll see a confirmation that all ATMs are on the latest version instead.
For each outdated ATM the table shows:
- **ATM ID** — which machine it is.
- **Version** — the agent version it's currently running.
- **Last Seen** — how long ago it last checked in (for example, "2h ago" or "3d ago").
- **Status** — whether it's currently Connected, Disconnected, or Never Connected.
## 💡 Good to know
- This screen is view-only — it's a snapshot of fleet health, not a place to push updates.
- An ATM that's **Disconnected** or **Never Connected** may need attention regardless of its software version.
@@ -0,0 +1,36 @@
---
module: analytics.agents
title: Agents
tab: Testing
order: 35
audience: internal
---
Frontend test checklist for the **Agents** tab in HiveIQ Analytics (`analytics.bcos.dev`). This tab is **view-only** — there is nothing to create, edit, or push. Click through in the UI and mark each row Pass or Fail.
## Set up
Log in at **analytics.bcos.dev** — test accounts (all password `Passw0rd-d3v!`):
| Login | Role | Sees |
|-------|------|------|
| `bcos_a@bcos.dev` | Admin | All institutions |
| `msp_a@msp1.bcos.dev` | MSP admin | C1 + C2 only |
| `customer_a@customer1.bcos.dev` | Customer | C1 only · read-only |
Test devices are the simulators: **C1-ATM-001…**, **C1-TCR-001…** (and C2-/C3- for the other institutions).
## What to test
| # | Do this | ✅ Pass if |
|---|---------|-----------|
| 1 | As `msp_a`, open Analytics and click the **Agents** tab | Tab loads with three panels: **Version Distribution**, **Connection Status**, and **Outdated ATMs** — no error banner |
| 2 | Look at **Version Distribution** | Bars show a count per agent version; the version with the most ATMs has the longest bar and the current release carries a green **latest** badge |
| 3 | Look at **Connection Status** | Shows **Connected / Disconnected / Never Connected** with a count and a % on each, plus a **Latest: …** version line; the three percentages add up to ~100% |
| 4 | Look at **Outdated ATMs** | Either a table with columns **ATM ID · Version · Last Seen · Status**, or the green **"All ATMs are on the latest version"** message if the fleet is current |
| 5 | In the Outdated ATMs table, check the **Last Seen** and **Status** cells | Last Seen shows a relative time (e.g. **"2h ago"**, **"3d ago"**); Status shows a coloured badge — Connected green, Disconnected red, Never Connected grey |
| 6 | Scan the whole tab for action controls | There are **no Add / Edit / Delete / Update buttons** anywhere — it is a read-only snapshot |
| 7 | Watch the header (top right) and leave the tab open | It shows **"Updated … · Next in Ns"** counting down; when it refreshes a **"Refreshing…"** badge appears and the panels reload with no error |
| 8 | Log in as `customer_a` and open the **Agents** tab | Version counts, Connection Status, and the Outdated list reflect **only C1** devices (`C1-…`) — no C2 or C3 ATMs appear |
| 9 | Log in as `msp_a` and compare, then `bcos_a` | `msp_a` sees **C1 + C2** but not C3; `bcos_a` sees **all** institutions — totals grow accordingly |
**Report a fail with:** the row #, which login you used, and what you actually saw.
@@ -0,0 +1,85 @@
---
module: analytics.cash
title: Cash Intelligence
tab: Architect
order: 30
audience: bcos
---
> **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 <token>")
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 <token>`). 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.
@@ -0,0 +1,65 @@
---
module: analytics.cash
title: Cash Intelligence
tab: Claude
order: 40
audience: bcos
---
> 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).
@@ -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**.
@@ -0,0 +1,37 @@
---
module: analytics.cash
title: Cash Intelligence
tab: Customer
order: 10
audience: customer
---
A quick, fleet-wide view of how your ATM cash is doing — which machines are balanced, which are running low, and which have counting differences that need attention. Check it at the start of the day or before scheduling replenishments.
## 📊 What you can do here
- See at a glance how many ATMs are **balanced** versus how many have a **variance**.
- Spot machines that are **running low on cash** before they run empty.
- Review machines with **counting differences** so you can follow up.
## 🔢 The summary numbers
Four counts across the top give you the fleet snapshot:
- **Balanced** — ATMs whose cash counts match, no issues.
- **Variance** — ATMs where the counted amount doesn't match what's expected.
- **Open Cycle** — ATMs with a cash cycle still in progress.
- **Pending Conf.** — cash counts still waiting to be confirmed.
## 🪫 ATMs Near Empty
A list of machines running low, lowest balance first so the most urgent are on top.
- **ATM ID** — the machine.
- **Est. Balance** — the estimated cash still available.
- **Last Replenishment** — when it was last refilled.
> Rows highlighted in red are especially low on cash and should be prioritised for a refill.
## ⚖️ ATMs With Variance
Machines where the cash count doesn't match, largest difference first.
- **ATM ID** — the machine.
- **Variance** — the size of the difference. A negative amount (shown in red) means cash is short.
- **Cycle Status** — where that machine is in its current cash cycle.
> If either list shows "No ATMs…", there's nothing needing attention in that category right now.
@@ -0,0 +1,39 @@
---
module: analytics.cash
title: Cash Intelligence
tab: Testing
order: 35
audience: internal
---
Frontend test checklist for the **Cash Intelligence** tab (`analytics.bcos.dev`). Open the tab in the UI and mark each row Pass or Fail. This tab is **view-only** — there is nothing to create, edit, or delete.
## Set up
Log in at **analytics.bcos.dev**, then open the **Cash Intelligence** tab — test accounts (all password `Passw0rd-d3v!`):
| Login | Role | Sees |
|-------|------|------|
| `bcos_a@bcos.dev` | Admin | All institutions (C1 + C2 + C3) |
| `msp_a@msp1.bcos.dev` | MSP admin | C1 + C2 only |
| `customer_a@customer1.bcos.dev` | Customer | C1 only · read-only |
Test devices are the simulators: **C1-ATM-001…** (and C2-/C3- for the other institutions).
## What to test
| # | Do this | ✅ Pass if |
|---|---------|-----------|
| 1 | As `msp_a`, open **Cash Intelligence** | Page loads with four stat cards on top — **Balanced · Variance · Open Cycle · Pending Conf.** — each showing a number, and two tables below |
| 2 | Look at the **ATMs Near Empty** table (left) | Columns are **ATM ID · Est. Balance · Last Replenishment**, and rows are ordered **lowest balance first** (most urgent on top) |
| 3 | Find a row with a very low balance | Rows under ~$3,000 are highlighted **red** so they stand out |
| 4 | Look at the **ATMs With Variance** table (right) | Columns are **ATM ID · Variance · Cycle Status**, ordered **largest difference first**; a **negative** variance shows in **red** |
| 5 | Read a few ATM IDs in either table | The balances/variances are money-formatted (e.g. `$12,500.00`) and each ATM has a **Cycle Status** badge — nothing shows as blank or `undefined` |
| 6 | Find a category with nothing in it (or use a scope with no cash data) | The table shows a tidy **"No ATMs near empty" / "No ATMs with variance"** message — **no error or blank crash** |
| 7 | Scan the whole tab | There are **no Add / Create / Edit / Delete buttons** anywhere — it is read-only |
| 8 | As `customer_a`, open the tab | Every ATM ID listed starts with **C1-** — no C2 or C3 machines appear |
| 9 | As `msp_a`, open the tab | You see **C1 and C2** ATMs but **not C3** |
| 10 | Log in as `bcos_a`, open the tab | You see ATMs across **all institutions (C1 + C2 + C3)** |
> Note: on the admin (`bcos_a`) view, an ATM that has never had a cash cycle in Recon can appear in **Near Empty at $0.00** — that is expected, not a fail.
**Report a fail with:** the row #, which login you used, and what you actually saw.
@@ -0,0 +1,81 @@
---
module: analytics.fleet-health
title: Fleet Health
tab: Architect
order: 30
audience: bcos
---
> **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].
@@ -0,0 +1,61 @@
---
module: analytics.fleet-health
title: Fleet Health
tab: Claude
order: 40
audience: bcos
---
> 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<String,Long>` (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`.
@@ -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.
@@ -0,0 +1,32 @@
---
module: analytics.fleet-health
title: Fleet Health
tab: Customer
order: 10
audience: customer
---
A one-glance summary of how your ATM fleet is doing right now — how many machines are online, what kinds of problems are happening, and which ATMs need the most attention. Check it at the start of the day or whenever you want a quick read on fleet health.
## 🔌 Connectivity
The donut shows your whole fleet at a glance, with the big total in the center.
- **Connected** (green) — ATMs currently checking in.
- **Disconnected** (red) — ATMs that were online but have gone quiet.
- **Never connected** (grey) — ATMs registered but never yet reporting in.
The large percentage is the share of your fleet that's connected right now.
## 📊 Event Breakdown
A ranked bar chart of recent events grouped by category, so you can see where the trouble is coming from — for example **Hardware**, **Network**, **Cash**, **Power**, **Auth**, **System**, or **Operations**. The longest bar is the most common type. If nothing has been reported, it shows "No events recorded."
## 🏧 Top Problematic ATMs
A ranked list of the machines generating the most events, worst first. Each row shows the ATM's ID and its event count, so you know exactly which machines to prioritize for a service visit.
## 🚨 Alerts & Gaps
Two headline numbers to watch:
- **Open Incidents** — issues currently open across the fleet.
- **ATMs with Journal Gaps** — machines with missing stretches in their reporting, which can hint at a connection or logging problem.
> Good to know: This screen reflects your current fleet only, so the numbers update as machines reconnect and issues are resolved. Use the Top Problematic list and Open Incidents together to decide where to send help first.
@@ -0,0 +1,37 @@
---
module: analytics.fleet-health
title: Fleet Health
tab: Testing
order: 35
audience: internal
---
Frontend test checklist for the **Fleet Health** tab in **Analytics** (`analytics.bcos.dev`). Click through in the UI and mark each row Pass or Fail. This screen is **read-only** — there is nothing to create, edit, or delete.
## Set up
Open **Analytics** in the **HiveIQ Browser** (a plain web browser shows a "Browser Required" message). Log in — test accounts (all password `Passw0rd-d3v!`):
| Login | Role | Sees |
|-------|------|------|
| `bcos_a@bcos.dev` | Admin | All institutions (C1 + C2 + C3) |
| `msp_a@msp1.bcos.dev` | MSP admin | C1 + C2 only |
| `customer_a@customer1.bcos.dev` | Customer | C1 only |
Test devices are the simulators: **C1-ATM-001…**, **C1-TCR-001…** (and C2-/C3- for the other institutions).
## What to test
| # | Do this | ✅ Pass if |
|---|---------|-----------|
| 1 | As `bcos_a`, open **Analytics** | Header reads **HiveIQ Analytics**, **Fleet Health** is the highlighted (active) tab by default |
| 2 | Look at the **Fleet Health** screen | Four cards show: **Connectivity**, **Event Breakdown**, **Top Problematic ATMs**, **Alerts & Gaps** |
| 3 | Read the **Connectivity** donut | Center shows a **TOTAL** number, a big green **% connected**, and a legend with **Connected / Disconnected / Never connected** counts that add up to the total |
| 4 | Read the **Event Breakdown** bars | Categories (Hardware, Network, Cash, Power, Auth, System, Operations) are listed with the **longest bar on top** (sorted highest-first) |
| 5 | Read **Top Problematic ATMs** | A ranked list **#1, #2, #3…** shows sim ATM IDs (e.g. `C1-ATM-001`) each with an event count, worst first |
| 6 | Read **Alerts & Gaps** | Two numbers show: **Open Incidents** (red) and **ATMs with Journal Gaps** (yellow) |
| 7 | Watch the header, wait ~60s (don't touch anything) | The **"Next in Ns"** countdown ticks down and the screen **auto-refreshes** (a "Refreshing…" badge flashes / the "Updated" time changes) |
| 8 | Click the other tabs across the top, then click **Fleet Health** again | Tabs switch without error and Fleet Health loads back with its four cards |
| 9 | Log in as `customer_a`, open Fleet Health | **Top Problematic ATMs shows only C1- devices** (no C2 or C3) and the total ATM count is smaller than what `bcos_a` sees |
| 10 | Log in as `msp_a`, open Fleet Health | Top Problematic shows **C1- and C2- devices but no C3-** |
**Report a fail with:** the row #, which login you used, and what you actually saw.
@@ -0,0 +1,72 @@
---
module: analytics.incidents
title: Incident Trends
tab: Architect
order: 30
audience: bcos
---
> 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 <jwt>` 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<JournalEventDTO>`:
- **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.
@@ -0,0 +1,60 @@
---
module: analytics.incidents
title: Incident Trends
tab: Claude
order: 40
audience: bcos
---
> 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` / `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.
@@ -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` / `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.
@@ -0,0 +1,39 @@
---
module: analytics.incidents
title: Incident Trends
tab: Customer
order: 10
audience: customer
---
See how incidents across your ATM fleet are trending over time, which kinds of problems happen most, when they cluster, and which machines keep failing. Use this screen to spot patterns and get ahead of recurring issues.
## 📈 30-Day Incident Trend
A line chart showing how many incidents were raised each day over the last 30 days.
- Rising line = incidents are climbing; falling line = things are settling down.
- The date labels along the bottom mark the timeline.
> If there isn't enough history yet, you'll see "Not enough data."
## 🗂️ Event Category Breakdown
A ranked bar chart of incidents grouped by the type of problem, largest first.
- Categories include **Hardware**, **Network**, **Cash**, **Power**, **Auth**, **System**, and **Operations**.
- The number on the right is how many incidents fall into that category.
## 🔥 Peak Hours Heatmap
A grid of the week (MonSun down the side, hours 023 across the bottom) showing when incidents happen most.
- Darker cells mean quiet periods; brighter red cells mean busy ones.
- Hover a cell to see the exact day, hour, and count.
- Use this to plan maintenance and staffing around your busiest windows.
## 🔁 Recurring Failures
A table of ATMs that keep hitting the same issue.
- **ATM** — the machine's ID.
- **Event Type** — the specific fault it keeps reporting.
- **Count** — how many times it's recurred. The badge turns yellow at 3+ and red at 5+, so high-repeat problems stand out.
> Good to know: a machine near the top of Recurring Failures is usually worth a closer look or a service visit — repeated faults often point to a root cause that a single fix can clear.
@@ -0,0 +1,36 @@
---
module: analytics.incidents
title: Incident Trends
tab: Testing
order: 35
audience: internal
---
Frontend test checklist for the **Incident Trends** tab in Analytics (`analytics.bcos.dev`). This tab is **read-only** — there is nothing to create, edit, or delete, so the tests focus on the four panels rendering correctly and on role scoping. Click through in the UI and mark each row Pass or Fail.
## Set up
Log in at **analytics.bcos.dev**, then open the **Incident Trends** tab — test accounts (all password `Passw0rd-d3v!`):
| Login | Role | Sees |
|-------|------|------|
| `bcos_a@bcos.dev` | Admin | All institutions (C1 + C2 + C3) |
| `msp_a@msp1.bcos.dev` | MSP admin | C1 + C2 only |
| `customer_a@customer1.bcos.dev` | Customer | C1 only · read-only |
Fleet data comes from the simulator devices: **C1-ATM-001…**, **C1-TCR-001…**, **C1-SRV-001…** (and C2-/C3- for the other institutions).
## What to test
| # | Do this | ✅ Pass if |
|---|---------|-----------|
| 1 | As `msp_a`, open **Incident Trends** | Page loads with four panels: **30-Day Incident Trend**, **Event Category Breakdown**, **Peak Hours Heatmap**, **Recurring Failures** — no error/blank screen |
| 2 | Look at the **30-Day Incident Trend** line chart | It shows the message **"Not enough data"** — this is expected on the current build, so treat the message (not a drawn line) as a pass |
| 3 | Look at **Event Category Breakdown** | Ranked horizontal bars, **largest at the top**, each with a coloured bar and a count number on the right (e.g. Hardware, Network, Cash, Power, Auth, System) |
| 4 | Look at the **Peak Hours Heatmap** | A grid with days **MonSun** down the side and hours across the bottom; brighter red cells = busier periods |
| 5 | Hover any heatmap cell | A tooltip appears showing the **day, hour, and count** (e.g. "Wed 14:00 — 3") |
| 6 | Look at the **Recurring Failures** table | It renders without error — either rows of ATM / Event Type / Count, or the message **"No recurring failures"**. An empty table here is expected on the current build, so don't fail it for being empty |
| 7 | Scan the whole tab for buttons | There are **no Add / Edit / Delete / Export** buttons anywhere — this tab is view-only for every role |
| 8 | Log in as `bcos_a` (all institutions), note the Category/Heatmap totals; then log in as `customer_a` (C1 only) and compare | `customer_a` shows **the same or fewer** incidents than `bcos_a` — a customer never sees more data than the all-institution admin |
| 9 | Still as `customer_a`, confirm the page still loads its own C1 data | All four panels render with **no error and no institution picker** — scoping is automatic, not a dropdown |
**Report a fail with:** the row #, which login you used, and what you actually saw.
@@ -0,0 +1,76 @@
---
module: analytics.transactions
title: Transactions
tab: Architect
order: 30
audience: bcos
---
> 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 <callerToken>":
├─ 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.
@@ -0,0 +1,72 @@
---
module: analytics.transactions
title: Transactions
tab: Claude
order: 40
audience: bcos
---
> 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.
@@ -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**.
@@ -0,0 +1,31 @@
---
module: analytics.transactions
title: Transactions
tab: Customer
order: 10
audience: customer
---
A daily snapshot of transaction activity across your ATM fleet. Use it to see how much journal data is coming in, whether it is being read correctly, and which machines are busiest or missing records.
## 👀 What you can do here
1. Check **today's journal volume** at a glance to confirm your fleet is reporting activity.
2. See how reliably journals are being read with the **parse success rate**.
3. Spot machines that are missing records under **open journal gaps**.
4. Identify your busiest ATMs in the **Top 10 by transaction volume** list.
> This screen is view-only — it refreshes to show the latest fleet activity.
## 📊 What you're looking at
**Today's Journal Volume** — A bar chart of daily activity. Each bar is split into **Successful** (green) records that were read cleanly and **Failed** (red) records that could not be read. Dates run along the bottom.
**Parse Success Rate** — The share of today's journals that were read successfully, shown as a percentage. Green means healthy (95%+), amber is a warning (8095%), and red flags a problem (below 80%).
**Open Journal Gaps** — The total number of unresolved gaps, meaning stretches where expected journal records are missing.
**ATMs With Gaps (Top 10)** — A list of the machines with the most open gaps, each showing its **ATM ID** and **Gap Count** — a quick shortlist of where to look first.
**Top 10 ATMs by Transaction Volume (Today)** — Your busiest machines ranked by transaction count, with a bar showing each one relative to the top performer.
> **Good to know:** A high failure rate or a growing gap count usually points to a machine that has stopped reporting cleanly — a good prompt to check that ATM before records are lost.
@@ -0,0 +1,38 @@
---
module: analytics.transactions
title: Transactions
tab: Testing
order: 35
audience: internal
---
Frontend test checklist for the **Transactions** tab in **Analytics** (`analytics.bcos.dev`). Click through in the UI and mark each row Pass or Fail.
## Set up
Open the **HiveIQ Browser** desktop app, sign in, and open **Analytics → Transactions** tab. (Analytics only runs inside the HiveIQ Browser app — a plain web browser shows a "requires browser" message.)
Test accounts (all password `Passw0rd-d3v!`):
| Login | Role | Sees |
|-------|------|------|
| `bcos_a@bcos.dev` | Admin | All institutions |
| `msp_a@msp1.bcos.dev` | MSP admin | C1 + C2 only |
| `customer_a@customer1.bcos.dev` | Customer | C1 only · read-only |
Test devices are the simulators: **C1-ATM-001…**, **C1-TCR-001…** (and C2-/C3- for the other institutions). Everything here is **today-only**, so empty tiles early in the day are normal, not a fault.
## What to test
| # | Do this | ✅ Pass if |
|---|---------|-----------|
| 1 | As `msp_a`, click the **Transactions** tab | Tab loads showing five cards — **Today's Journal Volume**, **Parse Success Rate**, **Open Journal Gaps**, **ATMs With Gaps (Top 10)**, **Top 10 ATMs by Transaction Volume** — with no red error banner |
| 2 | Look at **Today's Journal Volume** | A bar chart with a legend **Successful (green) / Failed (red)** and dates along the bottom — or the message **"No data available"** if nothing was ingested today |
| 3 | Look at the **Parse Success Rate** ring | Shows a percentage inside a coloured ring — **green** at 95%+, **amber** at 8095%, **red** below 80% |
| 4 | Look at **Open Journal Gaps** and **ATMs With Gaps (Top 10)** | A count of unresolved gaps, plus a table of **ATM ID + Gap Count** — or **"No ATMs with open gaps"** when there are none |
| 5 | Look at **Top 10 ATMs by Transaction Volume** | A ranked list **#1…#10** of ATM IDs, each with a blue bar and a transaction count — or **"No transaction data available"** if empty |
| 6 | Watch the header for ~1 minute without touching anything | The **"Next in Ns"** countdown ticks down and the tab auto-refreshes (a brief **"Refreshing…"** badge appears) — no manual reload needed |
| 7 | Log in as `customer_a`, open the Transactions tab | Any ATM IDs listed (Top 10 / ATMs with gaps) are **only C1-** devices — no C2- or C3- (or empty if no journals today) |
| 8 | As `msp_a`, check the ATM IDs listed | You see **C1- and C2-** devices but **no C3-** |
| 9 | Scan the whole tab for any Add / Create / Edit / Delete button | There are **none** — the tab is view-only for every role |
**Report a fail with:** the row #, which login you used, and what you actually saw.
@@ -0,0 +1,99 @@
---
module: analytics.uptime
title: Uptime
tab: Architect
order: 30
audience: bcos
---
> **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].
@@ -0,0 +1,68 @@
---
module: analytics.uptime
title: Uptime
tab: Claude
order: 40
audience: bcos
---
> 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`.
@@ -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 <date>/<hour>`.
- 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;'"
```
@@ -0,0 +1,43 @@
---
module: analytics.uptime
title: Uptime
tab: Customer
order: 10
audience: customer
---
See at a glance how available your ATM fleet is right now and how it has been trending. Use this screen for a quick health check or when you need to spot machines that are currently offline.
## 📊 What you can do here
- Read the **live availability score** for your fleet at this moment.
- Compare it against the **7-day** and **30-day** averages to see if things are improving or slipping.
- Check how many ATMs are **down right now** and how many have **never connected**.
- Follow the **30-day trend line** to see good and bad days across the month.
- Review the list of **ATMs currently down**, including how long each has been offline.
## 🔎 What you're looking at
**Top cards**
- **NOW** — the percentage of your ATMs that are online this moment.
- **7-DAY AVG** — average availability over the past week.
- **30-DAY AVG** — average availability over the past month.
- **CURRENTLY DOWN** — the number of ATMs offline right now, out of your total fleet. If everything is online you'll see **All online**. A small badge also shows any machines that have **never connected**.
> The rings are colour-coded: green is healthy (95% and above), amber is a warning (8094%), and red means attention is needed (below 80%).
**30-Day Uptime Trend**
- A line chart of daily availability over the last 30 days, with guide marks at 80%, 90% and 100%. The dot on the far right is today.
**ATMs Currently Down**
- **ATM** — the machine's ID.
- **Status** — its current connection state (for example, Disconnected).
- **Down Since** — how long the ATM has been offline.
## 💡 Good to know
- This screen refreshes with the latest data each time you open it — no buttons to press.
- If the trend chart shows **Not enough data**, the fleet simply hasn't been reporting long enough yet to draw a line.
@@ -0,0 +1,37 @@
---
module: analytics.uptime
title: Uptime
tab: Testing
order: 35
audience: internal
---
Frontend test checklist for the **Uptime** tab in Analytics (`analytics.bcos.dev`). This screen is **read-only** — there are no buttons, forms, or actions, so the tests are all about what you can *see*. Click through in the UI and mark each row Pass or Fail.
## Set up
Log in at **analytics.bcos.dev** — test accounts (all password `Passw0rd-d3v!`):
| Login | Role | Sees |
|-------|------|------|
| `bcos_a@bcos.dev` | Admin | All institutions (C1 + C2 + C3) |
| `msp_a@msp1.bcos.dev` | MSP admin | C1 + C2 only |
| `customer_a@customer1.bcos.dev` | Customer | C1 only · read-only |
Test devices are the simulators: **C1-ATM-001…**, **C1-TCR-001…**, **C1-SRV-001…** (and C2-/C3- for the other institutions).
## What to test
| # | Do this | ✅ Pass if |
|---|---------|-----------|
| 1 | As `bcos_a`, open **Analytics → Uptime** | Page loads with four KPI cards across the top: **NOW**, **7-DAY AVG**, **30-DAY AVG**, **CURRENTLY DOWN** |
| 2 | Look at the **NOW** ring | It shows a percentage (e.g. `98.5 %`) inside a coloured ring — **green** ≥95%, **amber** 8094%, **red** below 80% |
| 3 | Look at **7-DAY AVG** and **30-DAY AVG** | Each shows its own percentage ring (they may read the same number — that's expected, not a fail) |
| 4 | Look at the **CURRENTLY DOWN** card | Either a green **All online** check, or a red number **X of N ATMs**; if any never connected, a small **"N never connected"** badge appears |
| 5 | Look at the **30-Day Uptime Trend** panel | You see either a trend line with 80% / 90% / 100% guide marks and a dot on the right, **or** the message **"Not enough data"** — both are valid, no crash |
| 6 | Look at the **ATMs Currently Down** table at the bottom | Either **All ATMs online** with a green check, or a table with columns **ATM · Status · Down Since** and a Down Since like `2h 15m` |
| 7 | Scan the whole Uptime screen | There are **no** action buttons — no Add, Edit, Delete, Export or Refresh anywhere (read-only screen) |
| 8 | Log in as `customer_a`, open **Uptime** | Any ATMs listed in **Currently Down** are **only C1** devices — no C2 or C3 IDs appear |
| 9 | Log in as `msp_a`, open **Uptime** | Down list / counts cover **C1 and C2** only — **no C3** devices |
| 10 | Navigate away to another tab and back to **Uptime** | The screen re-loads its numbers on open (no button needed) and shows data — **no error/blank crash** |
**Report a fail with:** the row #, which login you used, and what you actually saw.
@@ -0,0 +1,72 @@
---
module: aria.dashboard
title: Threat Dashboard
tab: Architect
order: 30
audience: bcos
---
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<String,Object> 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_<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].
@@ -0,0 +1,67 @@
---
module: aria.dashboard
title: Threat Dashboard
tab: Claude
order: 40
audience: bcos
---
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 <jwt>`. Single `role` claim → `ROLE_<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].
@@ -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_<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].
@@ -0,0 +1,40 @@
---
module: aria.dashboard
title: Threat Dashboard
tab: Customer
order: 10
audience: customer
---
Your at-a-glance view of security activity across your ATM fleet. Open this screen for a quick read on threats, alerts, and device security posture — and to jump straight to the details that need attention.
## 👀 What you're looking at
The dashboard is grouped into three sections of summary cards.
### 🛡️ Threat Activity
- **Total Events** — the total number of threat-related events recorded across your fleet.
- **Critical** — the count of critical-severity events, your highest-priority items.
- **High** — the count of high-severity events.
- **Active Sequences** — ongoing alert sequences, where a series of related events points to a possible attack in progress.
### 🔒 Compliance
- **Active IOCs** — the number of active Indicators of Compromise being watched for across the fleet.
- **Low Posture** — devices with a security posture score below 50, meaning they may need attention.
- **EOL Devices** — end-of-life devices that are no longer fully supported and carry extra risk.
- **Non-Critical Events** — all remaining events that aren't critical or high severity.
## 🖱️ What you can do here
1. Click the **Critical** or **High** card to open the matching list of threat events.
2. Click **Active Sequences** to review the current alerts.
3. Click **Active IOCs** to manage your Indicators of Compromise.
4. Click **Low Posture** or **EOL Devices** to open device posture and see which machines need attention.
> **Total Events** and **Non-Critical Events** are summary counts only — they aren't clickable.
## 🧭 Phase Coverage
Below the summary cards is a reference list showing the attack phases HiveIQ watches for — Physical Access, Malware Staging, Malware Execution, Persistence, and Cleanup — each with a short description of what it means. This is a guide to the kinds of activity being monitored across your fleet.
> **Good to know:** The figures update each time you open the dashboard, so it's a reliable starting point for your daily security check. Start with the Critical card, then work down.
@@ -0,0 +1,37 @@
---
module: aria.dashboard
title: Threat Dashboard
tab: Testing
order: 35
audience: internal
---
Frontend test checklist for the **ARIA Threat Dashboard** (`aria.bcos.dev`) — the landing screen of the ARIA SPA. Click through in the UI and mark each row Pass or Fail.
## Set up
Log in at **aria.bcos.dev** — test accounts (all password `Passw0rd-d3v!`):
| Login | Role | Sees |
|-------|------|------|
| `bcos_a@bcos.dev` | Admin | Full ARIA dashboard (BCOS_ADMIN only) |
| `msp_a@msp1.bcos.dev` | MSP admin | **No access** — dashboard fails to load |
| `customer_a@customer1.bcos.dev` | Customer | **No access** — dashboard fails to load |
The cards are fleet-wide totals built from the simulator devices (**C1-ATM-001…**, **C1-TCR-001…**, and C2-/C3- for the other institutions). The dashboard loads **once on open** — there is no refresh button, so reload the page to re-pull numbers.
## What to test
| # | Do this | ✅ Pass if |
|---|---------|-----------|
| 1 | As `bcos_a`, open the **ARIA Dashboard** | Page loads with header **ARIA Dashboard** and three sections: **Threat Activity**, **Compliance**, **Phase Coverage** — cards show numbers, no "Load Failed" toast |
| 2 | Look at the **Threat Activity** cards | Four cards visible: **Total Events**, **Critical**, **High**, **Active Sequences** |
| 3 | Click the **Critical** card | Jumps to the events list filtered to critical events |
| 4 | Click the **High** card | Jumps to the events list filtered to high events |
| 5 | Click **Active Sequences** | Opens the Precursor Alerts / sequences view |
| 6 | Click **Active IOCs** | Opens the IOC Management view |
| 7 | Click **Low Posture** (and **EOL Devices**) | Opens the Device Posture view |
| 8 | Try clicking **Total Events** and **Non-Critical Events** | Nothing happens — these two cards are **not clickable** (no hover lift, no navigation) |
| 9 | Scroll to **Phase Coverage** | Shows five reference rows: Physical Access, Malware Staging, Malware Execution, Persistence, Cleanup — each with a description |
| 10 | Log in as `msp_a`, then `customer_a`, open the dashboard | Both see a **"Could not load ARIA stats"** error toast with empty/no cards — ARIA is admin-only |
**Report a fail with:** the row #, which login you used, and what you actually saw.

Some files were not shown because too many files have changed in this diff Show More