feat: initial hiveops-guide service — standalone guide content API and admin SPA
This commit is contained in:
@@ -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
@@ -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}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
+9
@@ -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
|
||||
+8
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||
Executable
+13
@@ -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 "$@"
|
||||
@@ -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>
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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 = ''; }}>×</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>
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import App from './App.svelte';
|
||||
import './app.css';
|
||||
|
||||
const app = new App({ target: document.getElementById('app')! });
|
||||
|
||||
export default app;
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["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 },
|
||||
});
|
||||
Reference in New Issue
Block a user