commit 638760e0b399d947ab8a626eaaa675008c3fee4c Author: Johannes Date: Mon Jun 29 15:39:17 2026 -0400 feat: initial hiveops-guide service — standalone guide content API and admin SPA diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..c6dcb61 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,16 @@ +FROM eclipse-temurin:21-jre-alpine + +WORKDIR /app + +RUN addgroup -g 1000 hiveiq && \ + adduser -D -u 1000 -G hiveiq hiveiq + +COPY target/*.jar app.jar + +RUN chown -R hiveiq:hiveiq /app + +USER hiveiq + +EXPOSE 8099 + +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/backend/pom.xml b/backend/pom.xml new file mode 100644 index 0000000..d3bd5f2 --- /dev/null +++ b/backend/pom.xml @@ -0,0 +1,124 @@ + + + 4.0.0 + + com.hiveops + hiveops-guide + 1.0.0 + jar + + HiveOps Guide Service — in-app contextual help content API + + + com.hiveops + hiveops-bom + 1.0.2 + + + + + 21 + 21 + 21 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-actuator + + + + org.postgresql + postgresql + runtime + + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-database-postgresql + + + + com.hiveops + hiveops-security-common + 1.0.1 + + + + org.projectlombok + lombok + 1.18.40 + provided + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.0 + + ${java.version} + ${java.version} + + + org.projectlombok + lombok + 1.18.40 + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + + + gitea + https://hiveiq-gitea.directlx.dev/api/packages/hiveops/maven + + + + diff --git a/backend/src/main/java/com/hiveops/guide/GuideApplication.java b/backend/src/main/java/com/hiveops/guide/GuideApplication.java new file mode 100644 index 0000000..bb3b388 --- /dev/null +++ b/backend/src/main/java/com/hiveops/guide/GuideApplication.java @@ -0,0 +1,11 @@ +package com.hiveops.guide; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class GuideApplication { + public static void main(String[] args) { + SpringApplication.run(GuideApplication.class, args); + } +} diff --git a/backend/src/main/java/com/hiveops/guide/config/SecurityConfig.java b/backend/src/main/java/com/hiveops/guide/config/SecurityConfig.java new file mode 100644 index 0000000..0828f67 --- /dev/null +++ b/backend/src/main/java/com/hiveops/guide/config/SecurityConfig.java @@ -0,0 +1,49 @@ +package com.hiveops.guide.config; + +import com.hiveops.guide.security.JwtAuthenticationFilter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) { + this.jwtAuthenticationFilter = jwtAuthenticationFilter; + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .cors(AbstractHttpConfigurer::disable) + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(ex -> ex + .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() + .anyRequest().authenticated() + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } +} diff --git a/backend/src/main/java/com/hiveops/guide/controller/GuideController.java b/backend/src/main/java/com/hiveops/guide/controller/GuideController.java new file mode 100644 index 0000000..747e27b --- /dev/null +++ b/backend/src/main/java/com/hiveops/guide/controller/GuideController.java @@ -0,0 +1,86 @@ +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 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; + +@RestController +@RequiredArgsConstructor +@Slf4j +public class GuideController { + + private final GuideService guideService; + private final ObjectMapper objectMapper; + + // ── Consumer: any authenticated user (called by SPAs via GuidePanel) ───── + + @GetMapping("/api/v1/guides/{pageKey}") + public ResponseEntity getGuide(@PathVariable String pageKey) { + Optional 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(); + } + } + + // ── Admin: list + CRUD (MSP_ADMIN or BCOS_ADMIN) ───────────────────────── + + @GetMapping("/api/v1/admin/guides") + @PreAuthorize("hasAnyRole('MSP_ADMIN','BCOS_ADMIN')") + public ResponseEntity> listGuides() { + return ResponseEntity.ok(guideService.findAll()); + } + + @PostMapping("/api/v1/admin/guides") + @PreAuthorize("hasAnyRole('MSP_ADMIN','BCOS_ADMIN')") + public ResponseEntity createGuide(@RequestBody Map 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 updateGuide(@PathVariable String pageKey, + @RequestBody Map 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 deleteGuide(@PathVariable String pageKey) { + guideService.delete(pageKey); + return ResponseEntity.noContent().build(); + } + + private String requireString(Map 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; + } +} diff --git a/backend/src/main/java/com/hiveops/guide/entity/Guide.java b/backend/src/main/java/com/hiveops/guide/entity/Guide.java new file mode 100644 index 0000000..7cb3f7c --- /dev/null +++ b/backend/src/main/java/com/hiveops/guide/entity/Guide.java @@ -0,0 +1,43 @@ +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; +} diff --git a/backend/src/main/java/com/hiveops/guide/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/hiveops/guide/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..85b546a --- /dev/null +++ b/backend/src/main/java/com/hiveops/guide/exception/GlobalExceptionHandler.java @@ -0,0 +1,45 @@ +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; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Map; + +@RestControllerAdvice +public class GlobalExceptionHandler { + private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler(ResponseStatusException.class) + public ResponseEntity> handleResponseStatus(ResponseStatusException ex) { + String reason = ex.getReason(); + return ResponseEntity.status(ex.getStatusCode()) + .body(Map.of("error", reason != null ? reason : "Request failed")); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgument(IllegalArgumentException ex) { + logger.warn("Invalid request: {}", ex.getMessage()); + return ResponseEntity.badRequest() + .body(Map.of("error", ex.getMessage() != null ? ex.getMessage() : "Invalid request")); + } + + @ExceptionHandler(DataIntegrityViolationException.class) + public ResponseEntity> 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> handleAll(Exception ex) { + logger.error("Unhandled error: {}", ex.getMessage(), ex); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "Something went wrong. Please try again.")); + } +} diff --git a/backend/src/main/java/com/hiveops/guide/repository/GuideRepository.java b/backend/src/main/java/com/hiveops/guide/repository/GuideRepository.java new file mode 100644 index 0000000..74d911c --- /dev/null +++ b/backend/src/main/java/com/hiveops/guide/repository/GuideRepository.java @@ -0,0 +1,16 @@ +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 { + Optional findByPageKeyAndEnabledTrue(String pageKey); + Optional findByPageKey(String pageKey); + List findAllByOrderByPageKeyAsc(); + boolean existsByPageKey(String pageKey); +} diff --git a/backend/src/main/java/com/hiveops/guide/security/JwtAuthenticationFilter.java b/backend/src/main/java/com/hiveops/guide/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..f3cba33 --- /dev/null +++ b/backend/src/main/java/com/hiveops/guide/security/JwtAuthenticationFilter.java @@ -0,0 +1,57 @@ +package com.hiveops.guide.security; + +import com.hiveops.security.JwtTokenValidator; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +@Component +@RequiredArgsConstructor +@Slf4j +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenValidator tokenValidator; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + try { + String jwt = getJwtFromRequest(request); + if (StringUtils.hasText(jwt) && tokenValidator.validateToken(jwt)) { + String email = tokenValidator.getEmail(jwt); + String role = tokenValidator.getRole(jwt); + + UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken( + email, null, List.of(new SimpleGrantedAuthority("ROLE_" + role))); + auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(auth); + } + } catch (Exception ex) { + log.error("Could not set user authentication in security context", ex); + } + filterChain.doFilter(request, response); + } + + private String getJwtFromRequest(HttpServletRequest request) { + String bearer = request.getHeader("Authorization"); + if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) { + String token = bearer.substring(7); + return token.contains(".") ? token : null; + } + return null; + } +} diff --git a/backend/src/main/java/com/hiveops/guide/service/GuideService.java b/backend/src/main/java/com/hiveops/guide/service/GuideService.java new file mode 100644 index 0000000..540a4f9 --- /dev/null +++ b/backend/src/main/java/com/hiveops/guide/service/GuideService.java @@ -0,0 +1,75 @@ +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 findByPageKey(String pageKey) { + return guideRepository.findByPageKeyAndEnabledTrue(pageKey); + } + + @Transactional(readOnly = true) + public List 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()); + } + } +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties new file mode 100644 index 0000000..4aa97fa --- /dev/null +++ b/backend/src/main/resources/application.properties @@ -0,0 +1,37 @@ +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 + +logging.level.root=WARN +logging.level.com.hiveops.guide=INFO +logging.level.org.springframework.security=DEBUG + +server.error.include-message=never + +management.endpoints.web.exposure.include=health,info +management.endpoint.health.show-details=never + +spring.jackson.default-property-inclusion=non_null +spring.jackson.serialization.write-dates-as-timestamps=false +spring.jackson.time-zone=UTC + +jwt.secret=${JWT_SECRET} + +internal.secret=${INTERNAL_SECRET:dev-internal-secret} + +cors.allowed-origins=${CORS_ALLOWED_ORIGINS:http://localhost:5188} diff --git a/backend/src/main/resources/db/migration/V1__create_guides_table.sql b/backend/src/main/resources/db/migration/V1__create_guides_table.sql new file mode 100644 index 0000000..291633b --- /dev/null +++ b/backend/src/main/resources/db/migration/V1__create_guides_table.sql @@ -0,0 +1,11 @@ +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); diff --git a/backend/src/main/resources/db/migration/V2__seed_guides.sql b/backend/src/main/resources/db/migration/V2__seed_guides.sql new file mode 100644 index 0000000..3df8b65 --- /dev/null +++ b/backend/src/main/resources/db/migration/V2__seed_guides.sql @@ -0,0 +1,321 @@ +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 cdn.bcos.cloud/uploads.html and log in with your credentials." }, + { "text": "Obtain your upload code from the portal." }, + { "text": "Upload your hotfix .zip 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 Artifacts tab in Fleet Tasks." }, + { "text": "Click Import Hotfix from CDN — available hotfixes on the CDN will be listed." }, + { "text": "Click Import next to the hotfix you uploaded." }, + { "text": "Once imported, click Enable 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 Create Task in the toolbar." }, + { "text": "Select task kind: HOTFIX to download and apply immediately, or HOTFIX_INSTALL 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 Pending Approval and will not execute until approved." } + ] + }, + { + "icon": "✅", + "heading": "Approval", + "steps": [ + { "text": "A user with fleet approver permission opens the Approvals tab." }, + { "text": "They can add an optional note, then click Approve or Reject." }, + { "text": "Approved tasks move to Queued 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 Patch Window 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 Scheduled 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 UPDATE_AGENT 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 cdn.bcos.cloud." }, + { "text": "Hotfix artifacts are deployed to devices via a UPDATE_HOTFIX 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 UPDATE_SOFTWARE 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 HOTFIX_INSTALL 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 every week on selected days, or on a specific week of the month (e.g. 2nd Tuesday)." } + ] + }, + { + "icon": "➕", + "heading": "Create a Patch Window", + "steps": [ + { "text": "Click + New Patch Window in the toolbar." }, + { "text": "Enter a name and select the institution this window belongs to." }, + { "text": "Select one or more days of the week when the window is active." }, + { "text": "Optionally set a Week of Month to make it monthly (e.g. 1st week only). Leave blank for weekly recurrence." }, + { "text": "Set the start and end time and select the correct timezone.", "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 Unassign 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 HOTFIX_INSTALL 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 Scheduled tab in Fleet Tasks." }, + { "text": "The task still requires approval 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": "IN SERVICE (green) — device is operational." }, + { "text": "DOWN (red) — device has an active fault." }, + { "text": "MAINTENANCE (orange) — device is under scheduled service." }, + { "text": "AGENT_OFFLINE (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 + Add Device in the toolbar." }, + { "text": "Enter the ATM ID, Location, and Model (required)." }, + { "text": "Use Lookup Coords 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": "Details — core properties and location." }, + { "text": "Software — full software inventory by category (ATM, OS, Security, Patches)." }, + { "text": "Hardware — component inventory; a badge appears when unacknowledged changes exist." }, + { "text": "Cassettes — current cassette config; apply a template from here." }, + { "text": "Activity — 24/48/72-hour timeline of power, state, and network events." } + ] + }, + { + "icon": "✏️", + "heading": "Editing Properties", + "steps": [ + { "text": "In the Details tab, click Edit." }, + { "text": "Update location, address, coordinates, or custom fields." }, + { "text": "Changing the address triggers auto-geocoding — review coordinates before saving." }, + { "text": "Click Save to confirm, or Cancel to discard." }, + { "text": "Use Rename to change the ATM ID itself (updates the agent registration)." } + ] + }, + { + "icon": "🔀", + "heading": "Moving & Reassigning", + "steps": [ + { "text": "Click Move Institution 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 IN SUPERVISOR or an incorrect state, click Reset to In Service." }, + { "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 Preset for quick reuse." }, + { "text": "Switch between the Active and Parked tabs to see parked incidents." } + ] + }, + { + "icon": "🔄", + "heading": "Incident Lifecycle", + "steps": [ + { "text": "New incidents start as OPEN — assign to a technician to begin." }, + { "text": "ASSIGNED → click Start when work begins → IN PROGRESS." }, + { "text": "Once resolved, click ResolveRESOLVED." }, + { "text": "Final review, then CloseCLOSED." }, + { "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 Helpdesk Person (remote support) and a Field Technician (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": "Possible Fraud type — requires investigation notes before the incident can be closed." }, + { "text": "Linked incidents — group related tickets from the same ATM using the Link action." }, + { "text": "Recurring incidents — if the same issue reopens within 30 minutes, the recurrence counter increments automatically." }, + { "text": "Bulk close — 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 General Fault — type can be reclassified later from the Incident List." }, + { "text": "Possible Fraud triggers an investigation workflow — only select if fraud is suspected." } + ] + }, + { + "icon": "🎯", + "heading": "Setting Severity", + "steps": [ + { "text": "Low — minor impact, ATM still functional." }, + { "text": "Medium — degraded operation (e.g. one cassette low)." }, + { "text": "High — ATM partially out of service." }, + { "text": "Critical — 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." } + ] + } + ] +}$$); diff --git a/backend/target/classes/application.properties b/backend/target/classes/application.properties new file mode 100644 index 0000000..4aa97fa --- /dev/null +++ b/backend/target/classes/application.properties @@ -0,0 +1,37 @@ +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 + +logging.level.root=WARN +logging.level.com.hiveops.guide=INFO +logging.level.org.springframework.security=DEBUG + +server.error.include-message=never + +management.endpoints.web.exposure.include=health,info +management.endpoint.health.show-details=never + +spring.jackson.default-property-inclusion=non_null +spring.jackson.serialization.write-dates-as-timestamps=false +spring.jackson.time-zone=UTC + +jwt.secret=${JWT_SECRET} + +internal.secret=${INTERNAL_SECRET:dev-internal-secret} + +cors.allowed-origins=${CORS_ALLOWED_ORIGINS:http://localhost:5188} diff --git a/backend/target/classes/com/hiveops/guide/GuideApplication.class b/backend/target/classes/com/hiveops/guide/GuideApplication.class new file mode 100644 index 0000000..94a6888 Binary files /dev/null and b/backend/target/classes/com/hiveops/guide/GuideApplication.class differ diff --git a/backend/target/classes/com/hiveops/guide/config/SecurityConfig.class b/backend/target/classes/com/hiveops/guide/config/SecurityConfig.class new file mode 100644 index 0000000..2162126 Binary files /dev/null and b/backend/target/classes/com/hiveops/guide/config/SecurityConfig.class differ diff --git a/backend/target/classes/com/hiveops/guide/controller/GuideController.class b/backend/target/classes/com/hiveops/guide/controller/GuideController.class new file mode 100644 index 0000000..deabe37 Binary files /dev/null and b/backend/target/classes/com/hiveops/guide/controller/GuideController.class differ diff --git a/backend/target/classes/com/hiveops/guide/entity/Guide$GuideBuilder.class b/backend/target/classes/com/hiveops/guide/entity/Guide$GuideBuilder.class new file mode 100644 index 0000000..cd48381 Binary files /dev/null and b/backend/target/classes/com/hiveops/guide/entity/Guide$GuideBuilder.class differ diff --git a/backend/target/classes/com/hiveops/guide/entity/Guide.class b/backend/target/classes/com/hiveops/guide/entity/Guide.class new file mode 100644 index 0000000..b7432b4 Binary files /dev/null and b/backend/target/classes/com/hiveops/guide/entity/Guide.class differ diff --git a/backend/target/classes/com/hiveops/guide/exception/GlobalExceptionHandler.class b/backend/target/classes/com/hiveops/guide/exception/GlobalExceptionHandler.class new file mode 100644 index 0000000..ea35504 Binary files /dev/null and b/backend/target/classes/com/hiveops/guide/exception/GlobalExceptionHandler.class differ diff --git a/backend/target/classes/com/hiveops/guide/repository/GuideRepository.class b/backend/target/classes/com/hiveops/guide/repository/GuideRepository.class new file mode 100644 index 0000000..99c376d Binary files /dev/null and b/backend/target/classes/com/hiveops/guide/repository/GuideRepository.class differ diff --git a/backend/target/classes/com/hiveops/guide/security/JwtAuthenticationFilter.class b/backend/target/classes/com/hiveops/guide/security/JwtAuthenticationFilter.class new file mode 100644 index 0000000..8c4fc47 Binary files /dev/null and b/backend/target/classes/com/hiveops/guide/security/JwtAuthenticationFilter.class differ diff --git a/backend/target/classes/com/hiveops/guide/service/GuideService.class b/backend/target/classes/com/hiveops/guide/service/GuideService.class new file mode 100644 index 0000000..3ec2405 Binary files /dev/null and b/backend/target/classes/com/hiveops/guide/service/GuideService.class differ diff --git a/backend/target/classes/db/migration/V1__create_guides_table.sql b/backend/target/classes/db/migration/V1__create_guides_table.sql new file mode 100644 index 0000000..291633b --- /dev/null +++ b/backend/target/classes/db/migration/V1__create_guides_table.sql @@ -0,0 +1,11 @@ +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); diff --git a/backend/target/classes/db/migration/V2__seed_guides.sql b/backend/target/classes/db/migration/V2__seed_guides.sql new file mode 100644 index 0000000..3df8b65 --- /dev/null +++ b/backend/target/classes/db/migration/V2__seed_guides.sql @@ -0,0 +1,321 @@ +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 cdn.bcos.cloud/uploads.html and log in with your credentials." }, + { "text": "Obtain your upload code from the portal." }, + { "text": "Upload your hotfix .zip 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 Artifacts tab in Fleet Tasks." }, + { "text": "Click Import Hotfix from CDN — available hotfixes on the CDN will be listed." }, + { "text": "Click Import next to the hotfix you uploaded." }, + { "text": "Once imported, click Enable 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 Create Task in the toolbar." }, + { "text": "Select task kind: HOTFIX to download and apply immediately, or HOTFIX_INSTALL 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 Pending Approval and will not execute until approved." } + ] + }, + { + "icon": "✅", + "heading": "Approval", + "steps": [ + { "text": "A user with fleet approver permission opens the Approvals tab." }, + { "text": "They can add an optional note, then click Approve or Reject." }, + { "text": "Approved tasks move to Queued 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 Patch Window 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 Scheduled 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 UPDATE_AGENT 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 cdn.bcos.cloud." }, + { "text": "Hotfix artifacts are deployed to devices via a UPDATE_HOTFIX 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 UPDATE_SOFTWARE 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 HOTFIX_INSTALL 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 every week on selected days, or on a specific week of the month (e.g. 2nd Tuesday)." } + ] + }, + { + "icon": "➕", + "heading": "Create a Patch Window", + "steps": [ + { "text": "Click + New Patch Window in the toolbar." }, + { "text": "Enter a name and select the institution this window belongs to." }, + { "text": "Select one or more days of the week when the window is active." }, + { "text": "Optionally set a Week of Month to make it monthly (e.g. 1st week only). Leave blank for weekly recurrence." }, + { "text": "Set the start and end time and select the correct timezone.", "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 Unassign 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 HOTFIX_INSTALL 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 Scheduled tab in Fleet Tasks." }, + { "text": "The task still requires approval 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": "IN SERVICE (green) — device is operational." }, + { "text": "DOWN (red) — device has an active fault." }, + { "text": "MAINTENANCE (orange) — device is under scheduled service." }, + { "text": "AGENT_OFFLINE (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 + Add Device in the toolbar." }, + { "text": "Enter the ATM ID, Location, and Model (required)." }, + { "text": "Use Lookup Coords 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": "Details — core properties and location." }, + { "text": "Software — full software inventory by category (ATM, OS, Security, Patches)." }, + { "text": "Hardware — component inventory; a badge appears when unacknowledged changes exist." }, + { "text": "Cassettes — current cassette config; apply a template from here." }, + { "text": "Activity — 24/48/72-hour timeline of power, state, and network events." } + ] + }, + { + "icon": "✏️", + "heading": "Editing Properties", + "steps": [ + { "text": "In the Details tab, click Edit." }, + { "text": "Update location, address, coordinates, or custom fields." }, + { "text": "Changing the address triggers auto-geocoding — review coordinates before saving." }, + { "text": "Click Save to confirm, or Cancel to discard." }, + { "text": "Use Rename to change the ATM ID itself (updates the agent registration)." } + ] + }, + { + "icon": "🔀", + "heading": "Moving & Reassigning", + "steps": [ + { "text": "Click Move Institution 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 IN SUPERVISOR or an incorrect state, click Reset to In Service." }, + { "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 Preset for quick reuse." }, + { "text": "Switch between the Active and Parked tabs to see parked incidents." } + ] + }, + { + "icon": "🔄", + "heading": "Incident Lifecycle", + "steps": [ + { "text": "New incidents start as OPEN — assign to a technician to begin." }, + { "text": "ASSIGNED → click Start when work begins → IN PROGRESS." }, + { "text": "Once resolved, click ResolveRESOLVED." }, + { "text": "Final review, then CloseCLOSED." }, + { "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 Helpdesk Person (remote support) and a Field Technician (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": "Possible Fraud type — requires investigation notes before the incident can be closed." }, + { "text": "Linked incidents — group related tickets from the same ATM using the Link action." }, + { "text": "Recurring incidents — if the same issue reopens within 30 minutes, the recurrence counter increments automatically." }, + { "text": "Bulk close — 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 General Fault — type can be reclassified later from the Incident List." }, + { "text": "Possible Fraud triggers an investigation workflow — only select if fraud is suspected." } + ] + }, + { + "icon": "🎯", + "heading": "Setting Severity", + "steps": [ + { "text": "Low — minor impact, ATM still functional." }, + { "text": "Medium — degraded operation (e.g. one cassette low)." }, + { "text": "High — ATM partially out of service." }, + { "text": "Critical — 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." } + ] + } + ] +}$$); diff --git a/backend/target/hiveops-guide-1.0.0.jar b/backend/target/hiveops-guide-1.0.0.jar new file mode 100644 index 0000000..8828dd5 Binary files /dev/null and b/backend/target/hiveops-guide-1.0.0.jar differ diff --git a/backend/target/hiveops-guide-1.0.0.jar.original b/backend/target/hiveops-guide-1.0.0.jar.original new file mode 100644 index 0000000..ab88258 Binary files /dev/null and b/backend/target/hiveops-guide-1.0.0.jar.original differ diff --git a/backend/target/maven-archiver/pom.properties b/backend/target/maven-archiver/pom.properties new file mode 100644 index 0000000..fb526a5 --- /dev/null +++ b/backend/target/maven-archiver/pom.properties @@ -0,0 +1,3 @@ +artifactId=hiveops-guide +groupId=com.hiveops +version=1.0.0 diff --git a/backend/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/backend/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst new file mode 100644 index 0000000..7c9e1c0 --- /dev/null +++ b/backend/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst @@ -0,0 +1,9 @@ +com/hiveops/guide/entity/Guide.class +com/hiveops/guide/config/SecurityConfig.class +com/hiveops/guide/entity/Guide$GuideBuilder.class +com/hiveops/guide/repository/GuideRepository.class +com/hiveops/guide/security/JwtAuthenticationFilter.class +com/hiveops/guide/exception/GlobalExceptionHandler.class +com/hiveops/guide/GuideApplication.class +com/hiveops/guide/service/GuideService.class +com/hiveops/guide/controller/GuideController.class diff --git a/backend/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/backend/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst new file mode 100644 index 0000000..2605a80 --- /dev/null +++ b/backend/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst @@ -0,0 +1,8 @@ +/source/hiveops-src/hiveops-guide/backend/src/main/java/com/hiveops/guide/GuideApplication.java +/source/hiveops-src/hiveops-guide/backend/src/main/java/com/hiveops/guide/config/SecurityConfig.java +/source/hiveops-src/hiveops-guide/backend/src/main/java/com/hiveops/guide/controller/GuideController.java +/source/hiveops-src/hiveops-guide/backend/src/main/java/com/hiveops/guide/entity/Guide.java +/source/hiveops-src/hiveops-guide/backend/src/main/java/com/hiveops/guide/exception/GlobalExceptionHandler.java +/source/hiveops-src/hiveops-guide/backend/src/main/java/com/hiveops/guide/repository/GuideRepository.java +/source/hiveops-src/hiveops-guide/backend/src/main/java/com/hiveops/guide/security/JwtAuthenticationFilter.java +/source/hiveops-src/hiveops-guide/backend/src/main/java/com/hiveops/guide/service/GuideService.java diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..a1878b8 --- /dev/null +++ b/build.sh @@ -0,0 +1,40 @@ +#!/bin/bash +set -e + +REGISTRY="registry.bcos.cloud" +SHOULD_PUSH=false + +while [[ "$#" -gt 0 ]]; do + case $1 in + --registry) REGISTRY="$2"; shift ;; + --push) SHOULD_PUSH=true ;; + esac + shift +done + +if [[ "$REGISTRY" == *"bcos.dev"* ]]; then + TAG="dev" +else + TAG="latest" +fi + +BACKEND_IMAGE="$REGISTRY/hiveiq-guide-backend:$TAG" +FRONTEND_IMAGE="$REGISTRY/hiveiq-guide-frontend:$TAG" + +echo "Building backend..." +cd backend +mvn clean package -DskipTests -q +docker build -t "$BACKEND_IMAGE" . +cd .. + +echo "Building frontend..." +docker build -t "$FRONTEND_IMAGE" ./frontend + +if [ "$SHOULD_PUSH" = true ]; then + echo "Pushing $BACKEND_IMAGE..." + docker push "$BACKEND_IMAGE" + echo "Pushing $FRONTEND_IMAGE..." + docker push "$FRONTEND_IMAGE" +fi + +echo "Done: $BACKEND_IMAGE, $FRONTEND_IMAGE" diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..9e8caa0 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,16 @@ +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY . . +RUN npm run build + +FROM node:20-alpine +RUN npm install -g serve +WORKDIR /app +COPY --from=builder /app/dist ./dist +COPY entrypoint.sh ./entrypoint.sh +RUN chmod +x ./entrypoint.sh +EXPOSE 5188 +ENTRYPOINT ["./entrypoint.sh"] +CMD ["serve", "-s", "dist", "-l", "5188"] diff --git a/frontend/entrypoint.sh b/frontend/entrypoint.sh new file mode 100755 index 0000000..a7701c0 --- /dev/null +++ b/frontend/entrypoint.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -e + +cat > /app/dist/config.js < + + + + + + HiveOps Guide Admin + + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..40e96ab --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,22 @@ +{ + "name": "hiveops-guide-frontend", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-check --tsconfig ./tsconfig.json" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^3.0.0", + "@tsconfig/svelte": "^5.0.0", + "svelte": "^4.0.0", + "svelte-check": "^3.0.0", + "typescript": "^5.0.0", + "vite": "^5.0.0" + }, + "dependencies": { + "axios": "^1.6.0" + } +} diff --git a/frontend/public/hiveiq_logo.png b/frontend/public/hiveiq_logo.png new file mode 100644 index 0000000..a9d9821 Binary files /dev/null and b/frontend/public/hiveiq_logo.png differ diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte new file mode 100644 index 0000000..5200e59 --- /dev/null +++ b/frontend/src/App.svelte @@ -0,0 +1,113 @@ + + +{#if checking} +
+{:else if !authenticated} + +{:else} + +{/if} + + +
+ {#each $toasts as t (t.id)} +
+ {t.title}{#if t.message}{t.message}{/if} +
+ {/each} +
+ + diff --git a/frontend/src/app.css b/frontend/src/app.css new file mode 100644 index 0000000..76521bc --- /dev/null +++ b/frontend/src/app.css @@ -0,0 +1,30 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --font-size-tiny: 0.72rem; + --font-size-label: 0.8rem; + --font-size-body-sm: 0.875rem; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 14px; + line-height: 1.5; + color: #1f2937; + background: #f5f6fa; +} + +body { margin: 0; min-height: 100vh; background: #f5f6fa; } + +button { font-family: inherit; cursor: pointer; } + +.btn { + display: inline-flex; align-items: center; justify-content: center; + border: none; border-radius: 6px; font-weight: 500; cursor: pointer; + transition: background 0.15s, opacity 0.15s; +} +.btn-primary { background: #2563eb; color: white; } +.btn-primary:hover { background: #1d4ed8; } +.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; } +.btn-secondary { background: #f3f4f6; color: #374151; border: 1px solid #d1d5db; } +.btn-secondary:hover { background: #e5e7eb; } +.btn-secondary:disabled { opacity: 0.5; cursor: not-allowed; } +.btn-sm { padding: 6px 14px; font-size: 0.82rem; } +.btn-md { padding: 8px 18px; font-size: 0.875rem; } diff --git a/frontend/src/components/Guides/GuidesView.svelte b/frontend/src/components/Guides/GuidesView.svelte new file mode 100644 index 0000000..74db5b8 --- /dev/null +++ b/frontend/src/components/Guides/GuidesView.svelte @@ -0,0 +1,347 @@ + + +
+
+
+

Guide Admin

+

Manage in-app help guides by page key — no code deploy needed to update content.

+
+
+ +
+
+ +
+
+
+ {#if !filterSidebarCollapsed} + Filters +
+ {filtered.length} + +
+ {:else} + + {/if} +
+ {#if !filterSidebarCollapsed} +
+ {#if hasActiveFilters} + + {/if} +
+ + +
+
+ {/if} +
+ +
+ {#if hasActiveFilters} +
+ Filters: + {#if search} + + + "{search}" + + + {/if} +
+ {/if} + +
+
+
+ {filtered.length} guide{filtered.length !== 1 ? 's' : ''} +
+ +
+
+ + {#if loading} +
Loading guides…
+ {:else if filtered.length === 0} +
{guides.length === 0 ? 'No guides yet. Click "+ New Guide" to create one.' : 'No guides match the current filters.'}
+ {:else} +
+
+ + + + + + + + + + + + {#each filtered as g (g.id)} + + + + + + + + {/each} + +
Page KeyStatusLast UpdatedUpdated By
{g.pageKey} + + {formatDate(g.updatedAt ?? g.createdAt)}{g.updatedBy ?? '—'} + +
+
+
+ {/if} +
+
+
+
+
+ +{#if panelOpen} + +
panelOpen = false}>
+ +{/if} + + diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..5497d79 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,33 @@ +import axios from 'axios'; + +declare global { + interface Window { + __APP_CONFIG__?: { apiUrl?: string; authUrl?: string; }; + } +} + +const BASE = window.__APP_CONFIG__?.apiUrl || 'http://localhost:8099'; +const AUTH_BASE = window.__APP_CONFIG__?.authUrl || 'http://localhost:8082'; + +const TOKEN_KEY = 'guide_token'; + +export function getToken(): string | null { return localStorage.getItem(TOKEN_KEY); } +export function setToken(t: string) { localStorage.setItem(TOKEN_KEY, t); } +export function clearToken() { localStorage.removeItem(TOKEN_KEY); } + +const api = axios.create({ + baseURL: BASE, + headers: { 'Content-Type': 'application/json' }, +}); +api.interceptors.request.use(cfg => { + const t = getToken(); + if (t) cfg.headers['Authorization'] = `Bearer ${t}`; + return cfg; +}); + +export const authApi = axios.create({ + baseURL: AUTH_BASE, + headers: { 'Content-Type': 'application/json' }, +}); + +export default api; diff --git a/frontend/src/lib/stores.ts b/frontend/src/lib/stores.ts new file mode 100644 index 0000000..fe2fde4 --- /dev/null +++ b/frontend/src/lib/stores.ts @@ -0,0 +1,12 @@ +import { writable } from 'svelte/store'; + +export interface Toast { id: number; type: 'success' | 'error' | 'info'; title: string; message?: string; } + +let _id = 0; +export const toasts = writable([]); + +export function addToast(type: Toast['type'], title: string, message = '') { + const id = ++_id; + toasts.update(t => [...t, { id, type, title, message }]); + setTimeout(() => toasts.update(t => t.filter(x => x.id !== id)), 4000); +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..312661e --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,6 @@ +import App from './App.svelte'; +import './app.css'; + +const app = new App({ target: document.getElementById('app')! }); + +export default app; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..c21df49 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "@tsconfig/svelte/tsconfig.json", + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "resolveJsonModule": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "strict": true + }, + "include": ["src/**/*"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..f72ac01 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; + +export default defineConfig({ + plugins: [svelte()], + server: { port: 5188 }, + preview: { port: 5188 }, +});