Merge pull request 'feat(guide): markdown-driven backend API, retire DB/CRUD (Phase 1)' (#2) from feat/1-markdown-guide-backend into develop
Merge PR #2: markdown-driven guide backend (Refs #1)
This commit was merged in pull request #2.
This commit is contained in:
@@ -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.
|
||||
@@ -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." }
|
||||
]
|
||||
}
|
||||
]
|
||||
}$$);
|
||||
Generated
+2169
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user