Add React UI with Execution Explorer, auth, and standalone deployment
Some checks failed
CI / build (push) Failing after 1m53s
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped

- Scaffold Vite + React + TypeScript frontend in ui/ with full design
  system (dark/light themes) matching the HTML mockups
- Implement Execution Explorer page: search filters, results table with
  expandable processor tree and exchange detail sidebar, pagination
- Add UI authentication: UiAuthController (login/refresh endpoints),
  JWT filter handles ui: subject prefix, CORS configuration
- Shared components: StatusPill, DurationBar, StatCard, AppBadge,
  FilterChip, Pagination — all using CSS Modules with design tokens
- API client layer: openapi-fetch with auth middleware, TanStack Query
  hooks for search/detail/snapshot queries, Zustand for state
- Standalone deployment: Nginx Dockerfile, K8s Deployment + ConfigMap +
  NodePort (30080), runtime config.js for API base URL
- Embedded mode: maven-resources-plugin copies ui/dist into JAR static
  resources, SPA forward controller for client-side routing
- CI/CD: UI build step, Docker build/push for server-ui image, K8s
  deploy step for UI, UI credential secrets

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-13 13:59:22 +01:00
parent 9c2391e5d4
commit 3eb83f97d3
65 changed files with 6449 additions and 22 deletions

View File

@@ -43,13 +43,18 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
if (token != null) {
try {
String agentId = jwtService.validateAndExtractAgentId(token);
if (agentRegistryService.findById(agentId) != null) {
String subject = jwtService.validateAndExtractAgentId(token);
if (subject.startsWith("ui:")) {
// UI user token — authenticate directly without agent registry lookup
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(agentId, null, List.of());
new UsernamePasswordAuthenticationToken(subject, null, List.of());
SecurityContextHolder.getContext().setAuthentication(auth);
} else if (agentRegistryService.findById(subject) != null) {
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(subject, null, List.of());
SecurityContextHolder.getContext().setAuthentication(auth);
} else {
log.debug("JWT valid but agent not found: {}", agentId);
log.debug("JWT valid but agent not found: {}", subject);
}
} catch (Exception e) {
log.debug("JWT validation failed: {}", e.getMessage());

View File

@@ -10,11 +10,16 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHt
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
/**
* Spring Security configuration for JWT-based stateless authentication.
* <p>
* Public endpoints: health, agent registration, refresh, API docs, Swagger UI.
* Public endpoints: health, agent registration, refresh, auth, API docs, Swagger UI, static resources.
* All other endpoints require a valid JWT access token.
*/
@Configuration
@@ -24,8 +29,10 @@ public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http,
JwtService jwtService,
AgentRegistryService registryService) throws Exception {
AgentRegistryService registryService,
CorsConfigurationSource corsConfigurationSource) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource))
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.formLogin(AbstractHttpConfigurer::disable)
@@ -35,12 +42,18 @@ public class SecurityConfig {
"/api/v1/health",
"/api/v1/agents/register",
"/api/v1/agents/*/refresh",
"/api/v1/auth/**",
"/api/v1/api-docs/**",
"/api/v1/swagger-ui/**",
"/swagger-ui/**",
"/v3/api-docs/**",
"/swagger-ui.html",
"/error"
"/error",
"/",
"/index.html",
"/config.js",
"/favicon.svg",
"/assets/**"
).permitAll()
.anyRequest().authenticated()
)
@@ -51,4 +64,23 @@ public class SecurityConfig {
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource(SecurityProperties properties) {
CorsConfiguration config = new CorsConfiguration();
String origin = properties.getUiOrigin();
if (origin != null && !origin.isBlank()) {
config.setAllowedOrigins(List.of(origin));
} else {
config.setAllowedOrigins(List.of("http://localhost:5173"));
}
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}

View File

@@ -13,6 +13,9 @@ public class SecurityProperties {
private long refreshTokenExpiryMs = 604_800_000;
private String bootstrapToken;
private String bootstrapTokenPrevious;
private String uiUser;
private String uiPassword;
private String uiOrigin;
public long getAccessTokenExpiryMs() {
return accessTokenExpiryMs;
@@ -45,4 +48,28 @@ public class SecurityProperties {
public void setBootstrapTokenPrevious(String bootstrapTokenPrevious) {
this.bootstrapTokenPrevious = bootstrapTokenPrevious;
}
public String getUiUser() {
return uiUser;
}
public void setUiUser(String uiUser) {
this.uiUser = uiUser;
}
public String getUiPassword() {
return uiPassword;
}
public void setUiPassword(String uiPassword) {
this.uiPassword = uiPassword;
}
public String getUiOrigin() {
return uiOrigin;
}
public void setUiOrigin(String uiOrigin) {
this.uiOrigin = uiOrigin;
}
}

View File

@@ -0,0 +1,86 @@
package com.cameleer3.server.app.security;
import com.cameleer3.server.core.security.JwtService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* Authentication endpoints for the UI.
* <p>
* Validates credentials against environment-configured username/password,
* then issues JWTs with {@code ui:} prefixed subjects to distinguish
* UI users from agent tokens in {@link JwtAuthenticationFilter}.
*/
@RestController
@RequestMapping("/api/v1/auth")
public class UiAuthController {
private static final Logger log = LoggerFactory.getLogger(UiAuthController.class);
private final JwtService jwtService;
private final SecurityProperties properties;
public UiAuthController(JwtService jwtService, SecurityProperties properties) {
this.jwtService = jwtService;
this.properties = properties;
}
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
String configuredUser = properties.getUiUser();
String configuredPassword = properties.getUiPassword();
if (configuredUser == null || configuredUser.isBlank()
|| configuredPassword == null || configuredPassword.isBlank()) {
log.warn("UI authentication attempted but CAMELEER_UI_USER / CAMELEER_UI_PASSWORD not configured");
return ResponseEntity.status(401).body(Map.of("message", "UI authentication not configured"));
}
if (!configuredUser.equals(request.username())
|| !configuredPassword.equals(request.password())) {
log.debug("UI login failed for user: {}", request.username());
return ResponseEntity.status(401).body(Map.of("message", "Invalid credentials"));
}
String subject = "ui:" + request.username();
String accessToken = jwtService.createAccessToken(subject, "ui");
String refreshToken = jwtService.createRefreshToken(subject, "ui");
log.info("UI user logged in: {}", request.username());
return ResponseEntity.ok(Map.of(
"accessToken", accessToken,
"refreshToken", refreshToken
));
}
@PostMapping("/refresh")
public ResponseEntity<?> refresh(@RequestBody RefreshRequest request) {
try {
String subject = jwtService.validateRefreshToken(request.refreshToken());
if (!subject.startsWith("ui:")) {
return ResponseEntity.status(401).body(Map.of("message", "Not a UI token"));
}
String accessToken = jwtService.createAccessToken(subject, "ui");
String refreshToken = jwtService.createRefreshToken(subject, "ui");
return ResponseEntity.ok(Map.of(
"accessToken", accessToken,
"refreshToken", refreshToken
));
} catch (Exception e) {
log.debug("UI token refresh failed: {}", e.getMessage());
return ResponseEntity.status(401).body(Map.of("message", "Invalid refresh token"));
}
}
public record LoginRequest(String username, String password) {}
public record RefreshRequest(String refreshToken) {}
}

View File

@@ -0,0 +1,24 @@
package com.cameleer3.server.app.web;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
/**
* SPA catch-all: forwards non-API, non-static requests to {@code index.html}
* so that React Router can handle client-side routing.
* <p>
* Only active when the UI is embedded (i.e., {@code static/index.html} exists
* in the classpath). When running standalone via Nginx, this is not needed.
*/
@Controller
public class SpaForwardController {
@GetMapping(value = {
"/login",
"/executions",
"/executions/{path:[^\\.]*}"
})
public String forward() {
return "forward:/index.html";
}
}