feat: initial hiveops-guide service — standalone guide content API and admin SPA

This commit is contained in:
2026-06-29 15:39:17 -04:00
commit 638760e0b3
45 changed files with 1958 additions and 0 deletions
+16
View File
@@ -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"]
+124
View File
@@ -0,0 +1,124 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.hiveops</groupId>
<artifactId>hiveops-guide</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<description>HiveOps Guide Service — in-app contextual help content API</description>
<parent>
<groupId>com.hiveops</groupId>
<artifactId>hiveops-bom</artifactId>
<version>1.0.2</version>
<relativePath/>
</parent>
<properties>
<java.version>21</java.version>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<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>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<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>
<version>1.0.1</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.40</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.0</version>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.40</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
<repositories>
<repository>
<id>gitea</id>
<url>https://hiveiq-gitea.directlx.dev/api/packages/hiveops/maven</url>
</repository>
</repositories>
</project>
@@ -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);
}
}
@@ -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();
}
}
@@ -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<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();
}
}
// ── 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());
}
@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;
}
}
@@ -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;
}
@@ -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<Map<String, String>> 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<Map<String, String>> 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<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);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Something went wrong. Please try again."));
}
}
@@ -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<Guide, Long> {
Optional<Guide> findByPageKeyAndEnabledTrue(String pageKey);
Optional<Guide> findByPageKey(String pageKey);
List<Guide> findAllByOrderByPageKeyAsc();
boolean existsByPageKey(String pageKey);
}
@@ -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;
}
}
@@ -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<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,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}
@@ -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);
@@ -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 <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." }
]
}
]
}$$);
@@ -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}
@@ -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);
@@ -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 <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." }
]
}
]
}$$);
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,3 @@
artifactId=hiveops-guide
groupId=com.hiveops
version=1.0.0
@@ -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
@@ -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
Executable
+40
View File
@@ -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"
+16
View File
@@ -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"]
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
set -e
cat > /app/dist/config.js <<EOF
window.__APP_CONFIG__ = {
apiUrl: '${VITE_API_URL:-http://localhost:8099}',
authUrl: '${VITE_AUTH_URL:-http://localhost:8082}'
};
EOF
echo "Generated config.js — apiUrl: ${VITE_API_URL}"
exec "$@"
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/hiveiq_logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HiveOps Guide Admin</title>
<script src="/config.js"></script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+22
View File
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

+113
View File
@@ -0,0 +1,113 @@
<script lang="ts">
import { onMount } from 'svelte';
import { authApi, setToken, getToken, clearToken } from './lib/api';
import { toasts } from './lib/stores';
import GuidesView from './components/Guides/GuidesView.svelte';
let authenticated = false;
let checking = true;
let email = '';
let password = '';
let loginError = '';
let loggingIn = false;
async function checkAuth() {
const t = getToken();
if (!t) { checking = false; return; }
try {
await authApi.get('/auth/api/users/me', { headers: { Authorization: `Bearer ${t}` } });
authenticated = true;
} catch {
clearToken();
} finally {
checking = false;
}
}
async function login() {
loginError = '';
loggingIn = true;
try {
const res = await authApi.post('/auth/api/login', { email, password, rememberMe: false });
const token = res.data.token;
if (!token) throw new Error('No token in response');
setToken(token);
authenticated = true;
} catch (e: any) {
loginError = e?.response?.data?.message ?? 'Invalid email or password.';
} finally {
loggingIn = false;
}
}
function logout() { clearToken(); authenticated = false; }
onMount(checkAuth);
</script>
{#if checking}
<div class="splash"><div class="spinner"></div></div>
{:else if !authenticated}
<div class="login-wrap">
<div class="login-card">
<div class="login-logo">
<img src="/hiveiq_logo.png" alt="HiveIQ" />
</div>
<h1>Guide Admin</h1>
<p class="login-sub">Sign in with your HiveOps account</p>
{#if loginError}
<div class="login-error">{loginError}</div>
{/if}
<div class="form-group">
<label for="email">Email</label>
<input id="email" type="email" bind:value={email} on:keydown={e => e.key === 'Enter' && login()} placeholder="you@example.com" />
</div>
<div class="form-group">
<label for="password">Password</label>
<input id="password" type="password" bind:value={password} on:keydown={e => e.key === 'Enter' && login()} placeholder="••••••••" />
</div>
<button class="btn btn-primary btn-md login-btn" on:click={login} disabled={loggingIn}>
{loggingIn ? 'Signing in…' : 'Sign In'}
</button>
</div>
</div>
{:else}
<GuidesView on:logout={logout} />
{/if}
<!-- Toast notifications -->
<div class="toast-stack">
{#each $toasts as t (t.id)}
<div class="toast toast-{t.type}">
<strong>{t.title}</strong>{#if t.message}<span>{t.message}</span>{/if}
</div>
{/each}
</div>
<style>
.splash { display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.spinner { width: 36px; height: 36px; border: 3px solid #e5e7eb; border-top-color: #2563eb; border-radius: 50%; animation: spin 0.7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.login-wrap { display: flex; align-items: center; justify-content: center; min-height: 100vh; background: #f5f6fa; }
.login-card { background: white; border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,0.1); padding: 40px; width: 380px; }
.login-logo { text-align: center; margin-bottom: 16px; }
.login-logo img { height: 40px; }
h1 { text-align: center; font-size: 1.4rem; color: #111827; margin-bottom: 4px; }
.login-sub { text-align: center; color: #6b7280; font-size: 0.875rem; margin-bottom: 24px; }
.login-error { background: #fef2f2; border: 1px solid #fca5a5; border-radius: 6px; color: #b91c1c; font-size: 0.85rem; padding: 10px 12px; margin-bottom: 16px; }
.form-group { display: flex; flex-direction: column; gap: 5px; margin-bottom: 16px; }
.form-group label { font-size: 0.82rem; font-weight: 600; color: #374151; }
.form-group input { padding: 9px 12px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.875rem; color: #1f2937; }
.form-group input:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.15); }
.login-btn { width: 100%; margin-top: 8px; }
.toast-stack { position: fixed; bottom: 24px; right: 24px; display: flex; flex-direction: column; gap: 8px; z-index: 9999; }
.toast { padding: 10px 16px; border-radius: 8px; font-size: 0.85rem; display: flex; gap: 8px; align-items: baseline; box-shadow: 0 4px 12px rgba(0,0,0,0.12); animation: fadeIn 0.2s ease; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
.toast-success { background: #dcfce7; color: #166534; border: 1px solid #86efac; }
.toast-error { background: #fef2f2; color: #b91c1c; border: 1px solid #fca5a5; }
.toast-info { background: #eff6ff; color: #1e40af; border: 1px solid #93c5fd; }
</style>
+30
View File
@@ -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; }
@@ -0,0 +1,347 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import api from '../../lib/api';
import { addToast } from '../../lib/stores';
const dispatch = createEventDispatcher();
interface Guide {
id: number;
pageKey: string;
contentJson: string;
enabled: boolean;
updatedBy: string | null;
createdAt: string;
updatedAt: string | null;
}
let guides: Guide[] = [];
let loading = true;
let filterSidebarCollapsed = true;
let searchInput = '';
let search = '';
$: hasActiveFilters = !!search;
$: filtered = guides.filter(g =>
!search || g.pageKey.toLowerCase().includes(search.toLowerCase())
);
function clearAllFilters() { search = ''; searchInput = ''; }
let searchTimer: ReturnType<typeof setTimeout>;
function handleSearchInput() { clearTimeout(searchTimer); searchTimer = setTimeout(() => { search = searchInput; }, 350); }
let panelOpen = false;
let editingGuide: Guide | null = null;
let submitting = false;
let formError = '';
let jsonError = '';
let fPageKey = '';
let fContentJson = '';
let fEnabled = true;
async function load() {
loading = true;
try {
const res = await api.get<Guide[]>('/api/v1/admin/guides');
guides = res.data;
} catch (e: any) {
addToast('error', 'Failed to load guides', e?.response?.data?.error ?? '');
} finally {
loading = false;
}
}
function openCreate() {
editingGuide = null; fPageKey = ''; fContentJson = ''; fEnabled = true;
formError = ''; jsonError = ''; panelOpen = true;
}
function openEdit(g: Guide) {
editingGuide = g; fPageKey = g.pageKey;
try { fContentJson = JSON.stringify(JSON.parse(g.contentJson), null, 2); } catch { fContentJson = g.contentJson; }
fEnabled = g.enabled; formError = ''; jsonError = ''; panelOpen = true;
}
function validateJson(): boolean {
try { JSON.parse(fContentJson); jsonError = ''; return true; }
catch (e: any) { jsonError = 'Invalid JSON: ' + (e?.message ?? 'parse error'); return false; }
}
async function submit() {
if (!fPageKey.trim()) { formError = 'Page key is required.'; return; }
if (!fContentJson.trim()) { formError = 'Content JSON is required.'; return; }
if (!validateJson()) return;
submitting = true; formError = '';
try {
if (editingGuide) {
await api.put(`/api/v1/admin/guides/${editingGuide.pageKey}`, { contentJson: fContentJson, enabled: fEnabled });
addToast('success', 'Guide updated', fPageKey);
} else {
await api.post('/api/v1/admin/guides', { pageKey: fPageKey.trim(), contentJson: fContentJson, enabled: fEnabled });
addToast('success', 'Guide created', fPageKey);
}
panelOpen = false; load();
} catch (e: any) {
formError = e?.response?.data?.error ?? 'An error occurred.';
} finally { submitting = false; }
}
async function toggleEnabled(g: Guide) {
try {
await api.put(`/api/v1/admin/guides/${g.pageKey}`, { contentJson: g.contentJson, enabled: !g.enabled });
g.enabled = !g.enabled; guides = [...guides];
} catch (e: any) {
addToast('error', 'Failed to update guide', e?.response?.data?.error ?? '');
}
}
function formatDate(iso: string | null): string {
if (!iso) return '—';
return new Date(iso).toLocaleDateString('en-ZA', { month: 'short', day: 'numeric', year: 'numeric' });
}
onMount(load);
</script>
<div class="page-wrapper">
<div class="header">
<div class="header-text">
<h1>Guide Admin</h1>
<p>Manage in-app help guides by page key — no code deploy needed to update content.</p>
</div>
<div class="header-controls">
<button class="btn-logout" on:click={() => dispatch('logout')}>Sign out</button>
</div>
</div>
<div class="page-body">
<div class="filter-sidebar" class:sidebar-collapsed={filterSidebarCollapsed}>
<div class="filter-sidebar-toggle-bar" class:bar-filters-active={hasActiveFilters}>
{#if !filterSidebarCollapsed}
<span class="filter-sidebar-title">Filters</span>
<div class="toggle-bar-right">
<span class="sidebar-stat-pill">{filtered.length}</span>
<button class="sidebar-toggle-btn" on:click={() => filterSidebarCollapsed = true}>◀</button>
</div>
{:else}
<button class="sidebar-toggle-btn" on:click={() => filterSidebarCollapsed = false}>▶</button>
{/if}
</div>
{#if !filterSidebarCollapsed}
<div class="filter-sidebar-content">
{#if hasActiveFilters}
<button class="sidebar-clear-all-btn" on:click={clearAllFilters}>✕ Clear all</button>
{/if}
<div class="filter-section">
<label class="filter-section-label">Search</label>
<input type="text" placeholder="Page key…" bind:value={searchInput} on:input={handleSearchInput} class="search-input" />
</div>
</div>
{/if}
</div>
<div class="page-right">
{#if hasActiveFilters}
<div class="active-filters">
<span class="active-filters-label">Filters:</span>
{#if search}
<span class="filter-tag">
<span class="filter-tag-dot"></span>
"{search}"
<button class="filter-tag-remove" on:click={() => { search = ''; searchInput = ''; }}>&times;</button>
</span>
{/if}
</div>
{/if}
<div class="content-frame">
<div class="page-main">
<div class="toolbar">
<span class="toolbar-count">{filtered.length} guide{filtered.length !== 1 ? 's' : ''}</span>
<div class="toolbar-right">
<button class="btn btn-primary btn-sm" on:click={openCreate}>+ New Guide</button>
</div>
</div>
{#if loading}
<div class="loading-msg">Loading guides…</div>
{:else if filtered.length === 0}
<div class="empty-msg">{guides.length === 0 ? 'No guides yet. Click "+ New Guide" to create one.' : 'No guides match the current filters.'}</div>
{:else}
<div class="table-card">
<div class="table-container">
<table>
<thead>
<tr>
<th>Page Key</th>
<th>Status</th>
<th>Last Updated</th>
<th>Updated By</th>
<th></th>
</tr>
</thead>
<tbody>
{#each filtered as g (g.id)}
<tr>
<td class="key-cell"><code>{g.pageKey}</code></td>
<td>
<button class="status-toggle" class:enabled={g.enabled} on:click={() => toggleEnabled(g)}>
{g.enabled ? 'Enabled' : 'Disabled'}
</button>
</td>
<td class="muted-cell">{formatDate(g.updatedAt ?? g.createdAt)}</td>
<td class="muted-cell">{g.updatedBy ?? '—'}</td>
<td class="actions">
<button class="btn-sm btn-edit" on:click={() => openEdit(g)}>Edit</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
</div>
</div>
</div>
</div>
</div>
{#if panelOpen}
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
<div class="panel-overlay" on:click={() => panelOpen = false}></div>
<div class="panel" role="dialog" aria-modal="true">
<div class="panel-header">
<div class="panel-header-text">
<div class="panel-title">{editingGuide ? 'Edit Guide' : 'New Guide'}</div>
<div class="panel-subtitle">{editingGuide ? editingGuide.pageKey : 'Create a new in-app guide'}</div>
</div>
<button class="panel-close" on:click={() => panelOpen = false}>✕</button>
</div>
<div class="panel-body">
{#if formError}<div class="form-error">{formError}</div>{/if}
<div class="form-group">
<label>Page Key</label>
<input class="form-input" type="text" bind:value={fPageKey} placeholder="e.g. devices.list" disabled={!!editingGuide} />
<span class="field-hint">Unique identifier used by the SPA to fetch this guide.</span>
</div>
<div class="form-group">
<label>Enabled</label>
<label class="toggle-label">
<input type="checkbox" bind:checked={fEnabled} />
<span>{fEnabled ? 'Guide is visible to users' : 'Guide is hidden'}</span>
</label>
</div>
<div class="form-group">
<label>Content JSON</label>
<textarea
class="form-textarea" class:json-error-border={!!jsonError}
bind:value={fContentJson} rows={22}
placeholder={`{\n "title": "Page Name",\n "subtitle": "How this page works",\n "sections": [\n {\n "icon": "🔍",\n "heading": "Section Title",\n "steps": [\n { "text": "Step description." }\n ]\n }\n ]\n}`}
on:blur={validateJson}
></textarea>
{#if jsonError}<span class="json-error-msg">{jsonError}</span>{/if}
<span class="field-hint">Must be valid JSON: title, subtitle, sections[].</span>
</div>
</div>
<div class="panel-footer">
<button class="btn btn-secondary btn-sm" on:click={() => panelOpen = false} disabled={submitting}>Cancel</button>
<button class="btn btn-primary btn-sm" on:click={submit} disabled={submitting}>
{submitting ? 'Saving…' : editingGuide ? 'Save Changes' : 'Create Guide'}
</button>
</div>
</div>
{/if}
<style>
.page-wrapper { display: flex; flex-direction: column; height: 100vh; overflow: hidden; padding: 24px 24px 0; }
.page-body { display: flex; flex: 1; overflow: hidden; min-height: 0; margin: 0 0 16px; }
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; flex-shrink: 0; }
.header-text h1 { font-size: 1.25rem; font-weight: 700; color: #111827; }
.header-text p { font-size: 0.82rem; color: #6b7280; margin-top: 2px; }
.header-controls { display: flex; align-items: center; gap: 0.5rem; }
.btn-logout { background: none; border: 1px solid #d1d5db; border-radius: 6px; padding: 5px 12px; font-size: 0.82rem; color: #6b7280; cursor: pointer; }
.btn-logout:hover { background: #f3f4f6; }
.page-right { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-height: 0; padding: 0.65rem; gap: 0.5rem; }
.content-frame { flex: 1; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; display: flex; flex-direction: column; min-height: 0; background: white; }
.page-main { flex: 1; overflow-y: auto; padding: 16px 24px 24px; min-width: 0; display: flex; flex-direction: column; }
.filter-sidebar { width: 220px; min-width: 220px; background: #f8f9fa; border-right: 1px solid #dee2e6; display: flex; flex-direction: column; overflow: hidden; transition: width 0.2s ease, min-width 0.2s ease; flex-shrink: 0; }
.filter-sidebar.sidebar-collapsed { width: 40px; min-width: 40px; }
.filter-sidebar-toggle-bar { display: flex; align-items: center; justify-content: space-between; padding: 0.55rem 0.65rem; border-bottom: 1px solid #dee2e6; background: #eef2f7; flex-shrink: 0; min-height: 36px; }
.bar-filters-active { background: #dbeafe !important; border-bottom-color: #93c5fd !important; }
.bar-filters-active .filter-sidebar-title { color: #1e40af !important; }
.filter-sidebar-title { font-size: var(--font-size-label); font-weight: 700; color: #374151; text-transform: uppercase; letter-spacing: 0.5px; }
.toggle-bar-right { display: flex; align-items: center; gap: 0.35rem; margin-left: auto; }
.sidebar-stat-pill { font-size: var(--font-size-tiny); background: #e0e7ff; border: 1px solid #c7d2fe; color: #3730a3; border-radius: 10px; padding: 1px 8px; white-space: nowrap; }
.sidebar-toggle-btn { background: none; border: 1px solid #d1d5db; border-radius: 4px; padding: 1px 6px; cursor: pointer; font-size: 0.82rem; color: #6b7280; line-height: 1.4; flex-shrink: 0; margin-left: auto; }
.sidebar-toggle-btn:hover { background: #e5e7eb; border-color: #9ca3af; }
.filter-sidebar-content { display: flex; flex-direction: column; gap: 0.75rem; padding: 0.75rem 0.65rem; overflow-y: auto; flex: 1; }
.filter-section { display: flex; flex-direction: column; gap: 0.3rem; }
.filter-section-label { font-size: var(--font-size-tiny); font-weight: 700; color: #6b7280; text-transform: uppercase; letter-spacing: 0.5px; }
.search-input { padding: 0.4rem 0.6rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: var(--font-size-body-sm); width: 100%; background: white; }
.search-input:focus { outline: none; border-color: #3b82f6; }
.sidebar-clear-all-btn { border: none; background: #fee2e2; color: #dc2626; border-radius: 4px; padding: 0.3rem 0.6rem; cursor: pointer; font-size: var(--font-size-tiny); font-weight: 600; text-align: left; }
.sidebar-clear-all-btn:hover { background: #fecaca; }
.active-filters { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; flex-shrink: 0; }
.active-filters-label { font-size: var(--font-size-tiny); font-weight: 600; color: #555; margin-right: 0.25rem; }
.filter-tag { display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.3rem 0.6rem; background: white; border: 1px solid #ddd; border-left: 3px solid #6b7280; border-radius: 4px; font-size: var(--font-size-tiny); font-weight: 500; color: #333; }
.filter-tag-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #6b7280; flex-shrink: 0; }
.filter-tag-remove { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; border-radius: 50%; border: none; background: #e5e7eb; color: #374151; font-size: 0.7rem; cursor: pointer; }
.filter-tag-remove:hover { background: #f44336; color: white; }
.loading-msg, .empty-msg { padding: 2rem; text-align: center; color: #6b7280; }
.toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; flex-shrink: 0; }
.toolbar-count { font-size: var(--font-size-body-sm); color: #6b7280; }
.toolbar-right { display: flex; gap: 0.5rem; }
.table-card { background: white; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; flex: 1; display: flex; flex-direction: column; min-height: 0; }
.table-container { overflow-x: auto; overflow-y: auto; flex: 1; }
table { width: 100%; border-collapse: collapse; }
thead { background: #f9fafb; }
th { color: #374151; font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.3px; font-weight: 600; border-bottom: 2px solid #e5e7eb; padding: 0.6rem 0.75rem; text-align: left; }
td { border-bottom: 1px solid #f3f4f6; color: #1f2937; padding: 0.55rem 0.75rem; font-size: var(--font-size-label); }
tbody tr:hover { background: #eff6ff; }
.key-cell code { font-size: 0.82rem; background: #f1f5f9; border: 1px solid #e2e8f0; border-radius: 4px; padding: 2px 6px; color: #2563eb; }
.muted-cell { color: #9ca3af; white-space: nowrap; }
.actions { white-space: nowrap; display: flex; gap: 0.4rem; justify-content: flex-end; }
.btn-sm { border: none; border-radius: 5px; padding: 0.3rem 0.65rem; cursor: pointer; font-size: 0.8rem; font-weight: 500; }
.btn-edit { background: #f3f4f6; color: #374151; border: 1px solid #d1d5db; }
.btn-edit:hover { background: #e5e7eb; }
.status-toggle { padding: 2px 10px; border-radius: 12px; font-size: 0.75rem; font-weight: 600; cursor: pointer; border: 1px solid transparent; transition: all 0.15s; background: #fee2e2; color: #b91c1c; border-color: #fca5a5; }
.status-toggle.enabled { background: #dcfce7; color: #166534; border-color: #86efac; }
.status-toggle:hover { opacity: 0.8; }
.panel-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.35); z-index: 999; cursor: pointer; }
.panel { position: fixed; top: 0; right: 0; width: 560px; height: 100vh; background: white; z-index: 1000; display: flex; flex-direction: column; box-shadow: -4px 0 20px rgba(0,0,0,0.15); animation: panelIn 0.25s ease; overflow: hidden; }
@keyframes panelIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
.panel-header { background: linear-gradient(180deg, #081651 0%, #1c49b8 100%); padding: 1rem 1.25rem; display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; }
.panel-header-text { flex: 1; min-width: 0; }
.panel-title { color: white; font-size: 1rem; font-weight: 700; }
.panel-subtitle { color: rgba(255,255,255,0.65); font-size: 0.78rem; margin-top: 2px; }
.panel-close { background: rgba(255,255,255,0.15); border: none; color: white; width: 28px; height: 28px; border-radius: 50%; cursor: pointer; font-size: 0.85rem; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.panel-close:hover { background: rgba(255,255,255,0.3); }
.panel-body { flex: 1; overflow-y: auto; padding: 1.25rem; display: flex; flex-direction: column; gap: 1rem; }
.panel-footer { border-top: 1px solid #e5e7eb; padding: 1rem 1.5rem; display: flex; justify-content: flex-end; gap: 0.75rem; flex-shrink: 0; background: #f9fafb; }
.form-error { background: #fef2f2; border: 1px solid #fca5a5; border-radius: 6px; color: #b91c1c; font-size: var(--font-size-body-sm); padding: 0.6rem 0.75rem; }
.form-group { display: flex; flex-direction: column; gap: 0.35rem; }
.form-group label { font-size: var(--font-size-label); font-weight: 600; color: #374151; }
.form-input { padding: 0.55rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: var(--font-size-body-sm); color: #1f2937; background: white; width: 100%; }
.form-input:disabled { background: #f9fafb; color: #6b7280; cursor: not-allowed; }
.form-input:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.15); }
.form-textarea { padding: 0.55rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-family: 'Courier New', monospace; font-size: 0.78rem; color: #1f2937; resize: vertical; line-height: 1.5; width: 100%; background: white; }
.form-textarea:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.15); }
.form-textarea.json-error-border { border-color: #ef4444; }
.json-error-msg { font-size: 0.75rem; color: #dc2626; }
.field-hint { font-size: var(--font-size-tiny); color: #9ca3af; margin-top: 2px; }
.toggle-label { display: flex; align-items: center; gap: 0.5rem; font-size: var(--font-size-body-sm); color: #374151; cursor: pointer; }
.toggle-label input { width: 15px; height: 15px; cursor: pointer; }
</style>
+33
View File
@@ -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;
+12
View File
@@ -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<Toast[]>([]);
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);
}
+6
View File
@@ -0,0 +1,6 @@
import App from './App.svelte';
import './app.css';
const app = new App({ target: document.getElementById('app')! });
export default app;
+14
View File
@@ -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" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+8
View File
@@ -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 },
});