feat(guide): markdown-driven backend API, retire DB/CRUD (Refs #1)

Phase 1 of the guide rebuild (DESIGN.md):
- MarkdownGuideService scans content/**/*.md (frontmatter: module/title/tab/order/audience)
- GET /api/v1/guide/nav + /api/v1/guide/modules/{module}; audience gated by role
  (internal tabs only for MSP_ADMIN/BCOS_ADMIN)
- Removed JPA/Flyway/Postgres, Guide entity/repo/service, admin CRUD, seed migrations
- Seed content: devices.sw-deploy (Overview/Data/Technical-internal), fleet.tasks
- Verified: boots with no DB, loads 4 docs, nav + audience filtering + 401 all correct

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 23:49:58 -04:00
parent 9c91c56672
commit 4b7f50ec6a
18 changed files with 2431 additions and 577 deletions
-19
View File
@@ -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/**")
// 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", "QDS_ADMIN", "BCOS_ADMIN")
// Admin CRUD — handled by @PreAuthorize on the controller
.requestMatchers("/api/v1/admin/guides/**").authenticated()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
@@ -1,86 +1,42 @@
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));
}
// ── 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))
.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);
}
@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;
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"));
}
}
@@ -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);
}
@@ -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,120 @@
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();
@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);
}
private boolean visible(GuideDoc d, boolean includeInternal) {
return includeInternal || !"internal".equals(d.audience());
}
/** apps → modules → tabs, filtered by audience. */
public List<NavApp> nav(boolean includeInternal) {
Map<String, Map<String, List<GuideDoc>>> byApp = new TreeMap<>();
for (GuideDoc d : docs) {
if (!visible(d, includeInternal)) 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) {
List<GuideDoc> mdocs = docs.stream()
.filter(d -> d.module().equals(moduleKey) && visible(d, includeInternal))
.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
@@ -0,0 +1,24 @@
---
module: devices.sw-deploy
title: Software Deployment
tab: The Data
order: 20
audience: customer
---
## What you're looking at
Each row is a **software task** for this device:
| Field | Meaning |
|-------|---------|
| **Type** | `Download` (package delivered) or `Install` (package applied) |
| **Artifact** | the software package and version |
| **Status** | `Completed`, `Running`, `Failed`, `Rejected`, or `Cancelled` |
| **Duration** | how long the step took |
| **Message** | result detail, if any |
The two step cards at the top show the **latest** download and install, so you can see the
current state at a glance; the table below is the full history.
If the tab is **empty**, no software task has run for this device yet.
@@ -0,0 +1,19 @@
---
module: devices.sw-deploy
title: Software Deployment
tab: Overview
order: 10
audience: customer
---
## What this tab is for
The **SW Deploy** tab shows the software-update history for this device — every software
**download** and **install** that has run, with its status, package, and timing.
Use it to confirm a software update reached a device and completed, and to review past updates.
- **Download** — the software package is delivered to the device.
- **Install** — the delivered package is applied on the device.
Each update appears as a completed (or failed) record here once it finishes.
@@ -0,0 +1,25 @@
---
module: devices.sw-deploy
title: Software Deployment
tab: Technical
order: 90
audience: internal
---
## Where the data lives (internal)
Software tasks follow HiveIQ's executor → Kafka → record-store pattern:
1. **Fleet is the executor.** It runs the job (download / install) and **keeps no long-term
history** — it stays clean, execution-only. The standalone fleet task endpoints are empty by
design once a job finishes.
2. On **complete or fail**, fleet **produces a Kafka event** with the result.
3. The **devices service consumes** the event and **stores the record**. That record store is the
source of truth for a device's software history, served at
`GET /api/atms/{id}/fleet-tasks` (`DeviceFleetTask`).
**So the SW Deploy tab reads the devices record store, not the fleet executor.** The install
action is the exception — it's a *command*, sent directly to the fleet executor.
> Rule of thumb: **history/records → the record store (Kafka-fed); live state or commands →
> a direct API to the executor.** Use the mechanism that fits the need, not one dogmatically.
@@ -0,0 +1,16 @@
---
module: fleet.tasks
title: Fleet Tasks
tab: Overview
order: 10
audience: customer
---
## What this is for
**Fleet Tasks** is where you deploy software and hotfixes to ATMs and track their execution.
A task moves through: **created → pending approval → running → completed** (or failed). Tabs
across the top separate **Current**, **Completed**, and **Failed** tasks, plus **Approvals**.
See the module-specific tabs for how to create and approve tasks.
@@ -1,11 +0,0 @@
CREATE TABLE guides (
id BIGSERIAL PRIMARY KEY,
page_key VARCHAR(100) NOT NULL UNIQUE,
content_json TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
updated_by VARCHAR(255),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ
);
CREATE INDEX idx_guides_page_key ON guides (page_key);
@@ -1,321 +0,0 @@
INSERT INTO guides (page_key, content_json) VALUES
('fleet.tasks', $${
"title": "Fleet Task Workflow",
"subtitle": "How to deploy hotfixes and software to ATMs",
"sections": [
{
"icon": "📤",
"heading": "Upload Hotfix to CDN",
"steps": [
{ "text": "Go to <strong>cdn.bcos.cloud/uploads.html</strong> and log in with your credentials." },
{ "text": "Obtain your upload code from the portal." },
{ "text": "Upload your hotfix <code>.zip</code> file using the upload code.", "note": "This CDN upload step applies to HOTFIX artifacts only." }
]
},
{
"icon": "📦",
"heading": "Import into Fleet Artifacts",
"steps": [
{ "text": "Go to the <strong>Artifacts</strong> tab in Fleet Tasks." },
{ "text": "Click <strong>Import Hotfix from CDN</strong> — available hotfixes on the CDN will be listed." },
{ "text": "Click <strong>Import</strong> next to the hotfix you uploaded." },
{ "text": "Once imported, click <strong>Enable</strong> on the artifact.", "note": "An artifact must be enabled before it can be used in a task." }
]
},
{
"icon": "🚀",
"heading": "Create a Deployment Task",
"steps": [
{ "text": "Click <strong>Create Task</strong> in the toolbar." },
{ "text": "Select task kind: <strong>HOTFIX</strong> to download and apply immediately, or <strong>HOTFIX_INSTALL</strong> to schedule within a patch window." },
{ "text": "Choose the imported artifact and select one or more target ATMs." },
{ "text": "Submit — the task is created with status <strong>Pending Approval</strong> and will not execute until approved." }
]
},
{
"icon": "✅",
"heading": "Approval",
"steps": [
{ "text": "A user with fleet approver permission opens the <strong>Approvals</strong> tab." },
{ "text": "They can add an optional note, then click <strong>Approve</strong> or <strong>Reject</strong>." },
{ "text": "Approved tasks move to <strong>Queued</strong> and ATMs pick them up on the next check-in.", "note": "Rejected tasks are cancelled. The requester should be notified separately." }
]
},
{
"icon": "🕐",
"heading": "Patch Windows (HOTFIX_INSTALL)",
"steps": [
{ "text": "Assign a <strong>Patch Window</strong> to ATMs via device settings to control when installs run." },
{ "text": "When a HOTFIX_INSTALL task is created, it auto-schedules within that window — the <strong>Scheduled</strong> tab shows the queued time." },
{ "text": "The task still requires approval before it will execute." },
{ "text": "If an ATM has no patch window assigned, the install runs immediately after approval.", "note": "Patch windows are defined in the Patch Windows tab." }
]
}
]
}$$),
('fleet.artifacts', $${
"title": "Artifact Import Guide",
"subtitle": "Fleet Management",
"sections": [
{
"icon": "🤖",
"heading": "Import Agent",
"steps": [
{ "text": "Fetches the latest HiveOps Agent release from the CDN manifest." },
{ "text": "Pulls all available packages — patch JARs and standard full installers — for each platform (Windows, Linux)." },
{ "text": "Import the patch package to create an <strong>UPDATE_AGENT</strong> fleet task that pushes agent upgrades to devices.", "note": "Use patch packages (small, agent core only) for regular upgrades." }
]
},
{
"icon": "🔧",
"heading": "Import Hotfix",
"steps": [
{ "text": "Fetches hotfix packages from the CDN manifest at <code>cdn.bcos.cloud</code>." },
{ "text": "Hotfix artifacts are deployed to devices via a <strong>UPDATE_HOTFIX</strong> fleet task without requiring an agent update.", "note": "Use for urgent patches, customer-specific fixes, or OS-level updates." }
]
},
{
"icon": "📦",
"heading": "Import Software",
"steps": [
{ "text": "Fetches third-party software packages from the CDN software manifest." },
{ "text": "Deploy via <strong>UPDATE_SOFTWARE</strong> fleet tasks to install or update third-party applications.", "note": "New packages are uploaded via the Browser CDN tool." }
]
}
]
}$$),
('fleet.patch-windows', $${
"title": "Patch Window Guide",
"subtitle": "How to schedule and manage ATM maintenance windows",
"sections": [
{
"icon": "🕐",
"heading": "What is a Patch Window?",
"steps": [
{ "text": "A Patch Window defines a recurring time slot when ATMs are allowed to install hotfixes." },
{ "text": "ATMs assigned to a window will only run <strong>HOTFIX_INSTALL</strong> tasks during that window — never outside of it.", "note": "HOTFIX tasks (immediate download) are not affected by patch windows — only HOTFIX_INSTALL." },
{ "text": "Windows can recur <strong>every week</strong> on selected days, or on a <strong>specific week of the month</strong> (e.g. 2nd Tuesday)." }
]
},
{
"icon": "",
"heading": "Create a Patch Window",
"steps": [
{ "text": "Click <strong>+ New Patch Window</strong> in the toolbar." },
{ "text": "Enter a name and select the <strong>institution</strong> this window belongs to." },
{ "text": "Select one or more <strong>days of the week</strong> when the window is active." },
{ "text": "Optionally set a <strong>Week of Month</strong> to make it monthly (e.g. 1st week only). Leave blank for weekly recurrence." },
{ "text": "Set the <strong>start and end time</strong> and select the correct <strong>timezone</strong>.", "note": "The window runs from start time to end time — keep it outside business hours." }
]
},
{
"icon": "🏧",
"heading": "Assign ATMs to a Window",
"steps": [
{ "text": "Click any window row in the table to open the ATM assignment panel." },
{ "text": "ATMs currently assigned to that window are listed with their location." },
{ "text": "To assign an ATM, edit the ATM record in Device Management and set its Patch Window." },
{ "text": "To remove an ATM, click <strong>Unassign</strong> next to it in the panel.", "note": "An ATM can only belong to one patch window at a time." }
]
},
{
"icon": "🚀",
"heading": "Using with HOTFIX_INSTALL Tasks",
"steps": [
{ "text": "When a <strong>HOTFIX_INSTALL</strong> task is created for an ATM with a patch window, it is automatically scheduled for the next available window slot." },
{ "text": "The scheduled time appears in the <strong>Scheduled</strong> tab in Fleet Tasks." },
{ "text": "The task still requires <strong>approval</strong> before it will execute — approval can happen before the window opens." },
{ "text": "If the ATM has no patch window assigned, the install runs immediately after approval.", "note": "Always assign a patch window to ATMs in live production environments." }
]
}
]
}$$),
('devices.list', $${
"title": "Device List",
"subtitle": "How to find, filter, and manage your devices",
"sections": [
{
"icon": "🔍",
"heading": "Finding Devices",
"steps": [
{ "text": "Use the Search box to find by ATM ID or location name." },
{ "text": "Filter by Status, Model, Group, or Agent Version in the left sidebar." },
{ "text": "Active filters appear as tags above the table — click × to remove any." }
]
},
{
"icon": "🟢",
"heading": "Understanding Status Indicators",
"steps": [
{ "text": "<strong>IN SERVICE</strong> (green) — device is operational." },
{ "text": "<strong>DOWN</strong> (red) — device has an active fault." },
{ "text": "<strong>MAINTENANCE</strong> (orange) — device is under scheduled service." },
{ "text": "<strong>AGENT_OFFLINE</strong> (grey dot) — agent has not checked in for more than 5 minutes." },
{ "text": "The agent version and CDN speed bars show the software state of each device." }
]
},
{
"icon": "👆",
"heading": "Viewing a Device",
"steps": [
{ "text": "Click any row to open the quick-view sidebar on the right." },
{ "text": "The sidebar has three tabs: Properties, Modules, and Command Center." },
{ "text": "Click the device ID link to open the full Device Detail page." }
]
},
{
"icon": "",
"heading": "Adding a Device",
"steps": [
{ "text": "Click <strong>+ Add Device</strong> in the toolbar." },
{ "text": "Enter the ATM ID, Location, and Model (required)." },
{ "text": "Use <strong>Lookup Coords</strong> to geocode the address automatically." },
{ "text": "Assign to an Institution if the device belongs to a specific customer." }
]
}
]
}$$),
('devices.detail', $${
"title": "Device Detail",
"subtitle": "How to view and manage an individual device",
"sections": [
{
"icon": "📋",
"heading": "Navigating Tabs",
"steps": [
{ "text": "Use the tab bar to switch between all device views." },
{ "text": "<strong>Details</strong> — core properties and location." },
{ "text": "<strong>Software</strong> — full software inventory by category (ATM, OS, Security, Patches)." },
{ "text": "<strong>Hardware</strong> — component inventory; a badge appears when unacknowledged changes exist." },
{ "text": "<strong>Cassettes</strong> — current cassette config; apply a template from here." },
{ "text": "<strong>Activity</strong> — 24/48/72-hour timeline of power, state, and network events." }
]
},
{
"icon": "✏️",
"heading": "Editing Properties",
"steps": [
{ "text": "In the <strong>Details</strong> tab, click <strong>Edit</strong>." },
{ "text": "Update location, address, coordinates, or custom fields." },
{ "text": "Changing the address triggers auto-geocoding — review coordinates before saving." },
{ "text": "Click <strong>Save</strong> to confirm, or <strong>Cancel</strong> to discard." },
{ "text": "Use <strong>Rename</strong> to change the ATM ID itself (updates the agent registration)." }
]
},
{
"icon": "🔀",
"heading": "Moving & Reassigning",
"steps": [
{ "text": "Click <strong>Move Institution</strong> to reassign the device to a different customer." },
{ "text": "Select the destination institution from the dropdown and confirm." },
{ "text": "The device will immediately appear under the new institution for all users." }
]
},
{
"icon": "⚠️",
"heading": "Resetting Device State",
"steps": [
{ "text": "If a device is stuck in <strong>IN SUPERVISOR</strong> or an incorrect state, click <strong>Reset to In Service</strong>." },
{ "text": "This clears the supervisor flag and returns the device to normal operation." },
{ "text": "Only use this after confirming the physical ATM is no longer in supervisor mode." }
]
}
]
}$$),
('incidents.list', $${
"title": "Incident Management",
"subtitle": "How to track, assign, and resolve ATM incidents",
"sections": [
{
"icon": "🔍",
"heading": "Finding Incidents",
"steps": [
{ "text": "Use the search bar to find by Incident #, ATM name/ID, or description text." },
{ "text": "Filter by Status, Severity, Type, Technician, or ATM Group in the left sidebar." },
{ "text": "Save a filter combination as a <strong>Preset</strong> for quick reuse." },
{ "text": "Switch between the <strong>Active</strong> and <strong>Parked</strong> tabs to see parked incidents." }
]
},
{
"icon": "🔄",
"heading": "Incident Lifecycle",
"steps": [
{ "text": "New incidents start as <strong>OPEN</strong> — assign to a technician to begin." },
{ "text": "<strong>ASSIGNED</strong> → click <strong>Start</strong> when work begins → <strong>IN PROGRESS</strong>." },
{ "text": "Once resolved, click <strong>Resolve</strong> → <strong>RESOLVED</strong>." },
{ "text": "Final review, then <strong>Close</strong> → <strong>CLOSED</strong>." },
{ "text": "Park an incident at any stage to temporarily hold it without closing." }
]
},
{
"icon": "👤",
"heading": "Assigning & Reassigning",
"steps": [
{ "text": "Click an incident row to open the detail sidebar." },
{ "text": "Set a <strong>Helpdesk Person</strong> (remote support) and a <strong>Field Technician</strong> (on-site)." },
{ "text": "Either assignment is optional — you can advance the workflow with just one or neither." },
{ "text": "Reassign at any stage by clicking the assignment field in the sidebar." }
]
},
{
"icon": "⚠️",
"heading": "Special Cases",
"steps": [
{ "text": "<strong>Possible Fraud</strong> type — requires investigation notes before the incident can be closed." },
{ "text": "<strong>Linked incidents</strong> — group related tickets from the same ATM using the Link action." },
{ "text": "<strong>Recurring incidents</strong> — if the same issue reopens within 30 minutes, the recurrence counter increments automatically." },
{ "text": "<strong>Bulk close</strong> — select multiple resolved incidents and close them in one action." }
]
}
]
}$$),
('incidents.create', $${
"title": "Reporting an Incident",
"subtitle": "How to manually log a new ATM incident",
"sections": [
{
"icon": "🏧",
"heading": "Selecting the ATM",
"steps": [
{ "text": "Use the ATM dropdown to search by ATM ID or location name." },
{ "text": "Only ATMs your institution owns will appear." }
]
},
{
"icon": "📋",
"heading": "Choosing the Right Type",
"steps": [
{ "text": "Select the incident type that best describes the fault." },
{ "text": "If unsure, use <strong>General Fault</strong> — type can be reclassified later from the Incident List." },
{ "text": "<strong>Possible Fraud</strong> triggers an investigation workflow — only select if fraud is suspected." }
]
},
{
"icon": "🎯",
"heading": "Setting Severity",
"steps": [
{ "text": "<strong>Low</strong> — minor impact, ATM still functional." },
{ "text": "<strong>Medium</strong> — degraded operation (e.g. one cassette low)." },
{ "text": "<strong>High</strong> — ATM partially out of service." },
{ "text": "<strong>Critical</strong> — ATM fully down or security incident." }
]
},
{
"icon": "📝",
"heading": "Writing a Good Description",
"steps": [
{ "text": "Include what the customer or technician observed." },
{ "text": "Note any error codes shown on the ATM screen." },
{ "text": "Include the time the fault was first noticed." },
{ "text": "A clear description speeds up technician dispatch and resolution." }
]
}
]
}$$);