Add React UI with Execution Explorer, auth, and standalone deployment
- 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:
@@ -16,7 +16,7 @@ jobs:
|
||||
steps:
|
||||
- name: Install Node.js
|
||||
run: |
|
||||
apt-get update && apt-get install -y nodejs
|
||||
apt-get update && apt-get install -y nodejs npm
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -44,6 +44,12 @@ jobs:
|
||||
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
|
||||
restore-keys: ${{ runner.os }}-maven-
|
||||
|
||||
- name: Build UI
|
||||
working-directory: ui
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Build and Test
|
||||
run: mvn clean verify -DskipITs --batch-mode
|
||||
|
||||
@@ -66,7 +72,7 @@ jobs:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
- name: Set up QEMU for cross-platform builds
|
||||
run: docker run --rm --privileged tonistiigi/binfmt --install all
|
||||
- name: Build and push
|
||||
- name: Build and push server
|
||||
run: |
|
||||
docker buildx create --use --name cibuilder
|
||||
docker buildx build --platform linux/amd64 \
|
||||
@@ -79,6 +85,18 @@ jobs:
|
||||
--push .
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
- name: Build and push UI
|
||||
run: |
|
||||
docker buildx build --platform linux/amd64 \
|
||||
-f ui/Dockerfile \
|
||||
-t gitea.siegeln.net/cameleer/cameleer3-server-ui:${{ github.sha }} \
|
||||
-t gitea.siegeln.net/cameleer/cameleer3-server-ui:latest \
|
||||
--cache-from type=registry,ref=gitea.siegeln.net/cameleer/cameleer3-server-ui:buildcache \
|
||||
--cache-to type=registry,ref=gitea.siegeln.net/cameleer/cameleer3-server-ui:buildcache,mode=max \
|
||||
--provenance=false \
|
||||
--push ui/
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
- name: Cleanup local Docker
|
||||
run: docker system prune -af --filter "until=24h"
|
||||
if: always()
|
||||
@@ -88,14 +106,16 @@ jobs:
|
||||
API="https://gitea.siegeln.net/api/v1"
|
||||
AUTH="Authorization: token ${REGISTRY_TOKEN}"
|
||||
CURRENT_SHA="${{ github.sha }}"
|
||||
curl -sf -H "$AUTH" "$API/packages/cameleer/container/cameleer3-server" | \
|
||||
for PKG in cameleer3-server cameleer3-server-ui; do
|
||||
curl -sf -H "$AUTH" "$API/packages/cameleer/container/$PKG" | \
|
||||
jq -r '.[] | "\(.id) \(.version)"' | \
|
||||
while read id version; do
|
||||
if [ "$version" != "latest" ] && [ "$version" != "$CURRENT_SHA" ]; then
|
||||
echo "Deleting old image tag: $version"
|
||||
curl -sf -X DELETE -H "$AUTH" "$API/packages/cameleer/container/cameleer3-server/$version"
|
||||
echo "Deleting old image tag: $PKG:$version"
|
||||
curl -sf -X DELETE -H "$AUTH" "$API/packages/cameleer/container/$PKG/$version"
|
||||
fi
|
||||
done
|
||||
done
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
if: always()
|
||||
@@ -132,6 +152,8 @@ jobs:
|
||||
kubectl create secret generic cameleer-auth \
|
||||
--namespace=cameleer \
|
||||
--from-literal=CAMELEER_AUTH_TOKEN="$CAMELEER_AUTH_TOKEN" \
|
||||
--from-literal=CAMELEER_UI_USER="${CAMELEER_UI_USER:-admin}" \
|
||||
--from-literal=CAMELEER_UI_PASSWORD="${CAMELEER_UI_PASSWORD:-admin}" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
kubectl create secret generic clickhouse-credentials \
|
||||
@@ -147,8 +169,15 @@ jobs:
|
||||
kubectl -n cameleer set image deployment/cameleer3-server \
|
||||
server=gitea.siegeln.net/cameleer/cameleer3-server:${{ github.sha }}
|
||||
kubectl -n cameleer rollout status deployment/cameleer3-server --timeout=120s
|
||||
|
||||
kubectl apply -f deploy/ui.yaml
|
||||
kubectl -n cameleer set image deployment/cameleer3-ui \
|
||||
ui=gitea.siegeln.net/cameleer/cameleer3-server-ui:${{ github.sha }}
|
||||
kubectl -n cameleer rollout status deployment/cameleer3-ui --timeout=120s
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
CAMELEER_AUTH_TOKEN: ${{ secrets.CAMELEER_AUTH_TOKEN }}
|
||||
CAMELEER_UI_USER: ${{ secrets.CAMELEER_UI_USER }}
|
||||
CAMELEER_UI_PASSWORD: ${{ secrets.CAMELEER_UI_PASSWORD }}
|
||||
CLICKHOUSE_USER: ${{ secrets.CLICKHOUSE_USER }}
|
||||
CLICKHOUSE_PASSWORD: ${{ secrets.CLICKHOUSE_PASSWORD }}
|
||||
|
||||
55
HOWTO.md
55
HOWTO.md
@@ -4,12 +4,17 @@
|
||||
|
||||
- Java 17+
|
||||
- Maven 3.9+
|
||||
- Node.js 22+ and npm
|
||||
- Docker & Docker Compose
|
||||
- Access to the Gitea Maven registry (for `cameleer3-common` dependency)
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
# Build UI first (required for embedded mode)
|
||||
cd ui && npm ci && npm run build && cd ..
|
||||
|
||||
# Backend
|
||||
mvn clean compile # compile only
|
||||
mvn clean verify # compile + run all tests (needs Docker for integration tests)
|
||||
```
|
||||
@@ -65,7 +70,23 @@ curl -s -X POST http://localhost:8081/api/v1/agents/agent-1/refresh \
|
||||
# Response: { "accessToken": "new-jwt" }
|
||||
```
|
||||
|
||||
**Public endpoints (no JWT required):** `GET /api/v1/health`, `POST /api/v1/agents/register` (uses bootstrap token), OpenAPI/Swagger docs.
|
||||
**UI Login (for browser access):**
|
||||
```bash
|
||||
# Login with UI credentials (returns JWT tokens)
|
||||
curl -s -X POST http://localhost:8081/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"admin","password":"admin"}'
|
||||
# Response: { "accessToken": "...", "refreshToken": "..." }
|
||||
|
||||
# Refresh UI token
|
||||
curl -s -X POST http://localhost:8081/api/v1/auth/refresh \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"refreshToken":"<refreshToken>"}'
|
||||
```
|
||||
|
||||
UI credentials are configured via `CAMELEER_UI_USER` / `CAMELEER_UI_PASSWORD` env vars (default: `admin` / `admin`).
|
||||
|
||||
**Public endpoints (no JWT required):** `GET /api/v1/health`, `POST /api/v1/agents/register` (uses bootstrap token), `POST /api/v1/auth/**`, OpenAPI/Swagger docs.
|
||||
|
||||
**Protected endpoints (JWT required):** All other endpoints including ingestion, search, agent management, commands.
|
||||
|
||||
@@ -228,6 +249,30 @@ Key settings in `cameleer3-server-app/src/main/resources/application.yml`:
|
||||
| `security.refresh-token-expiry-ms` | 604800000 | Refresh token lifetime (7d) |
|
||||
| `security.bootstrap-token` | `${CAMELEER_AUTH_TOKEN}` | Bootstrap token for agent registration (required) |
|
||||
| `security.bootstrap-token-previous` | `${CAMELEER_AUTH_TOKEN_PREVIOUS}` | Previous bootstrap token for rotation (optional) |
|
||||
| `security.ui-user` | `admin` | UI login username (`CAMELEER_UI_USER` env var) |
|
||||
| `security.ui-password` | `admin` | UI login password (`CAMELEER_UI_PASSWORD` env var) |
|
||||
| `security.ui-origin` | `http://localhost:5173` | CORS allowed origin for UI (`CAMELEER_UI_ORIGIN` env var) |
|
||||
|
||||
## Web UI Development
|
||||
|
||||
```bash
|
||||
cd ui
|
||||
npm install
|
||||
npm run dev # Vite dev server on http://localhost:5173 (proxies /api to :8081)
|
||||
npm run build # Production build to ui/dist/
|
||||
```
|
||||
|
||||
Login with `admin` / `admin` (or whatever `CAMELEER_UI_USER` / `CAMELEER_UI_PASSWORD` are set to).
|
||||
|
||||
The UI uses runtime configuration via `public/config.js`. In Kubernetes, a ConfigMap overrides this file to set the correct API base URL.
|
||||
|
||||
### Regenerate API Types
|
||||
|
||||
When the backend OpenAPI spec changes:
|
||||
```bash
|
||||
cd ui
|
||||
npm run generate-api # Requires backend running on :8081
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
@@ -264,22 +309,22 @@ The full stack is deployed to k3s via CI/CD on push to `main`. K8s manifests are
|
||||
cameleer namespace:
|
||||
ClickHouse (StatefulSet, 2Gi PVC) ← clickhouse:8123 (ClusterIP)
|
||||
cameleer3-server (Deployment) ← NodePort 30081
|
||||
cameleer3-sample (Deployment) ← NodePort 30080 (from cameleer3 repo)
|
||||
cameleer3-ui (Deployment, Nginx) ← NodePort 30080
|
||||
```
|
||||
|
||||
### Access (from your network)
|
||||
|
||||
| Service | URL |
|
||||
|---------|-----|
|
||||
| Web UI | `http://192.168.50.86:30080` |
|
||||
| Server API | `http://192.168.50.86:30081/api/v1/health` |
|
||||
| Swagger UI | `http://192.168.50.86:30081/api/v1/swagger-ui.html` |
|
||||
| Sample App | `http://192.168.50.86:30080/api/orders` |
|
||||
|
||||
### CI/CD Pipeline
|
||||
|
||||
Push to `main` triggers: **build** (Maven, unit tests) → **docker** (buildx cross-compile amd64, push to Gitea registry) → **deploy** (kubectl apply + rolling update).
|
||||
Push to `main` triggers: **build** (UI npm + Maven, unit tests) → **docker** (buildx amd64 for server + UI, push to Gitea registry) → **deploy** (kubectl apply + rolling update).
|
||||
|
||||
Required Gitea org secrets: `REGISTRY_TOKEN`, `KUBECONFIG_BASE64`, `CAMELEER_AUTH_TOKEN`, `CLICKHOUSE_USER`, `CLICKHOUSE_PASSWORD`.
|
||||
Required Gitea org secrets: `REGISTRY_TOKEN`, `KUBECONFIG_BASE64`, `CAMELEER_AUTH_TOKEN`, `CLICKHOUSE_USER`, `CLICKHOUSE_PASSWORD`, `CAMELEER_UI_USER` (optional), `CAMELEER_UI_PASSWORD` (optional).
|
||||
|
||||
### Manual K8s Commands
|
||||
|
||||
|
||||
@@ -104,6 +104,28 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-resources-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>copy-ui-dist</id>
|
||||
<phase>generate-resources</phase>
|
||||
<goals>
|
||||
<goal>copy-resources</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<outputDirectory>${project.build.directory}/classes/static</outputDirectory>
|
||||
<resources>
|
||||
<resource>
|
||||
<directory>${project.basedir}/../ui/dist</directory>
|
||||
<filtering>false</filtering>
|
||||
</resource>
|
||||
</resources>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,9 @@ security:
|
||||
refresh-token-expiry-ms: 604800000
|
||||
bootstrap-token: ${CAMELEER_AUTH_TOKEN:}
|
||||
bootstrap-token-previous: ${CAMELEER_AUTH_TOKEN_PREVIOUS:}
|
||||
ui-user: ${CAMELEER_UI_USER:admin}
|
||||
ui-password: ${CAMELEER_UI_PASSWORD:admin}
|
||||
ui-origin: ${CAMELEER_UI_ORIGIN:http://localhost:5173}
|
||||
|
||||
springdoc:
|
||||
api-docs:
|
||||
|
||||
@@ -38,6 +38,20 @@ spec:
|
||||
secretKeyRef:
|
||||
name: cameleer-auth
|
||||
key: CAMELEER_AUTH_TOKEN
|
||||
- name: CAMELEER_UI_USER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: cameleer-auth
|
||||
key: CAMELEER_UI_USER
|
||||
optional: true
|
||||
- name: CAMELEER_UI_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: cameleer-auth
|
||||
key: CAMELEER_UI_PASSWORD
|
||||
optional: true
|
||||
- name: CAMELEER_UI_ORIGIN
|
||||
value: "http://192.168.50.86:30080"
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
|
||||
75
deploy/ui.yaml
Normal file
75
deploy/ui.yaml
Normal file
@@ -0,0 +1,75 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: cameleer3-ui-config
|
||||
namespace: cameleer
|
||||
data:
|
||||
config.js: |
|
||||
window.__CAMELEER_CONFIG__ = {
|
||||
apiBaseUrl: 'http://192.168.50.86:30081/api/v1',
|
||||
};
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: cameleer3-ui
|
||||
namespace: cameleer
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: cameleer3-ui
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: cameleer3-ui
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: gitea-registry
|
||||
containers:
|
||||
- name: ui
|
||||
image: gitea.siegeln.net/cameleer/cameleer3-server-ui:latest
|
||||
ports:
|
||||
- containerPort: 80
|
||||
env:
|
||||
- name: CAMELEER_API_URL
|
||||
value: "http://cameleer3-server:8081"
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /usr/share/nginx/html/config.js
|
||||
subPath: config.js
|
||||
resources:
|
||||
requests:
|
||||
memory: "32Mi"
|
||||
cpu: "10m"
|
||||
limits:
|
||||
memory: "64Mi"
|
||||
cpu: "100m"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: 80
|
||||
periodSeconds: 10
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: 80
|
||||
periodSeconds: 5
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: cameleer3-ui-config
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: cameleer3-ui
|
||||
namespace: cameleer
|
||||
spec:
|
||||
type: NodePort
|
||||
selector:
|
||||
app: cameleer3-ui
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 80
|
||||
nodePort: 30080
|
||||
3
ui/.dockerignore
Normal file
3
ui/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
2
ui/.gitignore
vendored
Normal file
2
ui/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
17
ui/Dockerfile
Normal file
17
ui/Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/templates/default.conf.template
|
||||
|
||||
# Default API URL — override via K8s env or docker run -e
|
||||
ENV CAMELEER_API_URL=http://cameleer3-server:8081
|
||||
|
||||
EXPOSE 80
|
||||
23
ui/eslint.config.js
Normal file
23
ui/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
14
ui/index.html
Normal file
14
ui/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Cameleer3</title>
|
||||
<script src="/config.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
38
ui/nginx.conf
Normal file
38
ui/nginx.conf
Normal file
@@ -0,0 +1,38 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA fallback: serve index.html for all non-file routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API proxy — target set via CAMELEER_API_URL env var (default: http://cameleer3-server:8081)
|
||||
location /api/ {
|
||||
proxy_pass ${CAMELEER_API_URL};
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# SSE support
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 86400s;
|
||||
}
|
||||
|
||||
# Caching for static assets
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Health check
|
||||
location /healthz {
|
||||
access_log off;
|
||||
return 200 "ok\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
}
|
||||
3348
ui/package-lock.json
generated
Normal file
3348
ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
ui/package.json
Normal file
36
ui/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "ui",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"generate-api": "openapi-typescript http://localhost:8081/api/v1/api-docs -o src/api/schema.d.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"openapi-fetch": "^0.17.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router": "^7.13.1",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^8.0.0"
|
||||
}
|
||||
}
|
||||
3
ui/public/config.js
Normal file
3
ui/public/config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
window.__CAMELEER_CONFIG__ = {
|
||||
apiBaseUrl: '/api/v1',
|
||||
};
|
||||
4
ui/public/favicon.svg
Normal file
4
ui/public/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#f0b429" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2"/>
|
||||
<path d="M12 6v6l4 2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 262 B |
24
ui/public/icons.svg
Normal file
24
ui/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
33
ui/src/api/client.ts
Normal file
33
ui/src/api/client.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import createClient, { type Middleware } from 'openapi-fetch';
|
||||
import type { paths } from './schema';
|
||||
import { config } from '../config';
|
||||
|
||||
let getAccessToken: () => string | null = () => null;
|
||||
let onUnauthorized: () => void = () => {};
|
||||
|
||||
export function configureAuth(opts: {
|
||||
getAccessToken: () => string | null;
|
||||
onUnauthorized: () => void;
|
||||
}) {
|
||||
getAccessToken = opts.getAccessToken;
|
||||
onUnauthorized = opts.onUnauthorized;
|
||||
}
|
||||
|
||||
const authMiddleware: Middleware = {
|
||||
async onRequest({ request }) {
|
||||
const token = getAccessToken();
|
||||
if (token) {
|
||||
request.headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
return request;
|
||||
},
|
||||
async onResponse({ response }) {
|
||||
if (response.status === 401) {
|
||||
onUnauthorized();
|
||||
}
|
||||
return response;
|
||||
},
|
||||
};
|
||||
|
||||
export const api = createClient<paths>({ baseUrl: config.apiBaseUrl });
|
||||
api.use(authMiddleware);
|
||||
15
ui/src/api/queries/agents.ts
Normal file
15
ui/src/api/queries/agents.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../client';
|
||||
|
||||
export function useAgents(status?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['agents', status],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await api.GET('/agents', {
|
||||
params: { query: status ? { status } : {} },
|
||||
});
|
||||
if (error) throw new Error('Failed to load agents');
|
||||
return data!;
|
||||
},
|
||||
});
|
||||
}
|
||||
53
ui/src/api/queries/executions.ts
Normal file
53
ui/src/api/queries/executions.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../client';
|
||||
import type { SearchRequest } from '../schema';
|
||||
|
||||
export function useSearchExecutions(filters: SearchRequest) {
|
||||
return useQuery({
|
||||
queryKey: ['executions', 'search', filters],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await api.POST('/search/executions', {
|
||||
body: filters,
|
||||
});
|
||||
if (error) throw new Error('Search failed');
|
||||
return data!;
|
||||
},
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
}
|
||||
|
||||
export function useExecutionDetail(executionId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['executions', 'detail', executionId],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await api.GET('/executions/{executionId}', {
|
||||
params: { path: { executionId: executionId! } },
|
||||
});
|
||||
if (error) throw new Error('Failed to load execution detail');
|
||||
return data!;
|
||||
},
|
||||
enabled: !!executionId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useProcessorSnapshot(
|
||||
executionId: string | null,
|
||||
index: number | null,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ['executions', 'snapshot', executionId, index],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await api.GET(
|
||||
'/executions/{executionId}/processors/{index}/snapshot',
|
||||
{
|
||||
params: {
|
||||
path: { executionId: executionId!, index: index! },
|
||||
},
|
||||
},
|
||||
);
|
||||
if (error) throw new Error('Failed to load snapshot');
|
||||
return data!;
|
||||
},
|
||||
enabled: !!executionId && index !== null,
|
||||
});
|
||||
}
|
||||
186
ui/src/api/schema.d.ts
vendored
Normal file
186
ui/src/api/schema.d.ts
vendored
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Hand-written OpenAPI types matching the cameleer3 server REST API.
|
||||
* Will be replaced by openapi-typescript codegen once backend is running.
|
||||
*/
|
||||
|
||||
export interface paths {
|
||||
'/auth/login': {
|
||||
post: {
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
401: { content: { 'application/json': { message: string } } };
|
||||
};
|
||||
};
|
||||
};
|
||||
'/auth/refresh': {
|
||||
post: {
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': {
|
||||
refreshToken: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
401: { content: { 'application/json': { message: string } } };
|
||||
};
|
||||
};
|
||||
};
|
||||
'/search/executions': {
|
||||
post: {
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': SearchRequest;
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': SearchResponse;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
'/executions/{executionId}': {
|
||||
get: {
|
||||
parameters: {
|
||||
path: { executionId: string };
|
||||
};
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': ExecutionDetail;
|
||||
};
|
||||
};
|
||||
404: { content: { 'application/json': { message: string } } };
|
||||
};
|
||||
};
|
||||
};
|
||||
'/executions/{executionId}/processors/{index}/snapshot': {
|
||||
get: {
|
||||
parameters: {
|
||||
path: { executionId: string; index: number };
|
||||
};
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': ExchangeSnapshot;
|
||||
};
|
||||
};
|
||||
404: { content: { 'application/json': { message: string } } };
|
||||
};
|
||||
};
|
||||
};
|
||||
'/agents': {
|
||||
get: {
|
||||
parameters: {
|
||||
query?: { status?: string };
|
||||
};
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': AgentInstance[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface SearchRequest {
|
||||
status?: string | null;
|
||||
timeFrom?: string | null;
|
||||
timeTo?: string | null;
|
||||
durationMin?: number | null;
|
||||
durationMax?: number | null;
|
||||
correlationId?: string | null;
|
||||
text?: string | null;
|
||||
textInBody?: string | null;
|
||||
textInHeaders?: string | null;
|
||||
textInErrors?: string | null;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface SearchResponse {
|
||||
results: ExecutionSummary[];
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface ExecutionSummary {
|
||||
executionId: string;
|
||||
routeId: string;
|
||||
agentId: string;
|
||||
status: 'COMPLETED' | 'FAILED' | 'RUNNING';
|
||||
startTime: string;
|
||||
duration: number;
|
||||
processorCount: number;
|
||||
correlationId: string | null;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
export interface ExecutionDetail {
|
||||
executionId: string;
|
||||
routeId: string;
|
||||
agentId: string;
|
||||
status: 'COMPLETED' | 'FAILED' | 'RUNNING';
|
||||
startTime: string;
|
||||
duration: number;
|
||||
correlationId: string | null;
|
||||
errorMessage: string | null;
|
||||
processors: ProcessorNode[];
|
||||
}
|
||||
|
||||
export interface ProcessorNode {
|
||||
index: number;
|
||||
processorId: string;
|
||||
processorType: string;
|
||||
uri: string | null;
|
||||
status: 'COMPLETED' | 'FAILED' | 'RUNNING';
|
||||
duration: number;
|
||||
errorMessage: string | null;
|
||||
children: ProcessorNode[];
|
||||
}
|
||||
|
||||
export interface ExchangeSnapshot {
|
||||
exchangeId: string;
|
||||
correlationId: string | null;
|
||||
bodyType: string | null;
|
||||
body: string | null;
|
||||
headers: Record<string, string> | null;
|
||||
properties: Record<string, string> | null;
|
||||
}
|
||||
|
||||
export interface AgentInstance {
|
||||
agentId: string;
|
||||
group: string;
|
||||
state: 'LIVE' | 'STALE' | 'DEAD';
|
||||
lastHeartbeat: string;
|
||||
registeredAt: string;
|
||||
}
|
||||
103
ui/src/auth/LoginPage.module.css
Normal file
103
ui/src/auth/LoginPage.module.css
Normal file
@@ -0,0 +1,103 @@
|
||||
.page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
animation: fadeIn 0.3s ease-out both;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
font-size: 20px;
|
||||
color: var(--amber);
|
||||
letter-spacing: -0.5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 14px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--amber-dim);
|
||||
box-shadow: 0 0 0 3px var(--amber-glow);
|
||||
}
|
||||
|
||||
.submit {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
padding: 10px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--amber);
|
||||
background: var(--amber);
|
||||
color: #0a0e17;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.submit:hover {
|
||||
background: var(--amber-hover);
|
||||
border-color: var(--amber-hover);
|
||||
}
|
||||
|
||||
.submit:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
background: var(--rose-glow);
|
||||
border: 1px solid rgba(244, 63, 94, 0.2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
color: var(--rose);
|
||||
}
|
||||
61
ui/src/auth/LoginPage.tsx
Normal file
61
ui/src/auth/LoginPage.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { type FormEvent, useState } from 'react';
|
||||
import { Navigate } from 'react-router';
|
||||
import { useAuthStore } from './auth-store';
|
||||
import styles from './LoginPage.module.css';
|
||||
|
||||
export function LoginPage() {
|
||||
const { isAuthenticated, login, loading, error } = useAuthStore();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
if (isAuthenticated) return <Navigate to="/" replace />;
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
login(username, password);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<form className={styles.card} onSubmit={handleSubmit}>
|
||||
<div className={styles.logo}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2" />
|
||||
<path d="M12 6v6l4 2" />
|
||||
</svg>
|
||||
cameleer3
|
||||
</div>
|
||||
<div className={styles.subtitle}>Sign in to access the observability dashboard</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Username</label>
|
||||
<input
|
||||
className={styles.input}
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Password</label>
|
||||
<input
|
||||
className={styles.input}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button className={styles.submit} type="submit" disabled={loading || !username || !password}>
|
||||
{loading ? 'Signing in...' : 'Sign In'}
|
||||
</button>
|
||||
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
ui/src/auth/ProtectedRoute.tsx
Normal file
12
ui/src/auth/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Navigate, Outlet } from 'react-router';
|
||||
import { useAuthStore } from './auth-store';
|
||||
import { useAuth } from './use-auth';
|
||||
|
||||
export function ProtectedRoute() {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
// Initialize auth hooks (auto-refresh, API client wiring)
|
||||
useAuth();
|
||||
|
||||
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
||||
return <Outlet />;
|
||||
}
|
||||
109
ui/src/auth/auth-store.ts
Normal file
109
ui/src/auth/auth-store.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { create } from 'zustand';
|
||||
import { config } from '../config';
|
||||
|
||||
interface AuthState {
|
||||
accessToken: string | null;
|
||||
refreshToken: string | null;
|
||||
username: string | null;
|
||||
isAuthenticated: boolean;
|
||||
error: string | null;
|
||||
loading: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
refresh: () => Promise<boolean>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
function loadTokens() {
|
||||
return {
|
||||
accessToken: localStorage.getItem('cameleer-access-token'),
|
||||
refreshToken: localStorage.getItem('cameleer-refresh-token'),
|
||||
username: localStorage.getItem('cameleer-username'),
|
||||
};
|
||||
}
|
||||
|
||||
function persistTokens(access: string, refresh: string, username: string) {
|
||||
localStorage.setItem('cameleer-access-token', access);
|
||||
localStorage.setItem('cameleer-refresh-token', refresh);
|
||||
localStorage.setItem('cameleer-username', username);
|
||||
}
|
||||
|
||||
function clearTokens() {
|
||||
localStorage.removeItem('cameleer-access-token');
|
||||
localStorage.removeItem('cameleer-refresh-token');
|
||||
localStorage.removeItem('cameleer-username');
|
||||
}
|
||||
|
||||
const initial = loadTokens();
|
||||
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
accessToken: initial.accessToken,
|
||||
refreshToken: initial.refreshToken,
|
||||
username: initial.username,
|
||||
isAuthenticated: !!initial.accessToken,
|
||||
error: null,
|
||||
loading: false,
|
||||
|
||||
login: async (username, password) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const res = await fetch(`${config.apiBaseUrl}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.message || 'Invalid credentials');
|
||||
}
|
||||
const { accessToken, refreshToken } = await res.json();
|
||||
persistTokens(accessToken, refreshToken, username);
|
||||
set({
|
||||
accessToken,
|
||||
refreshToken,
|
||||
username,
|
||||
isAuthenticated: true,
|
||||
loading: false,
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
set({
|
||||
error: e instanceof Error ? e.message : 'Login failed',
|
||||
loading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
refresh: async () => {
|
||||
const { refreshToken } = get();
|
||||
if (!refreshToken) return false;
|
||||
try {
|
||||
const res = await fetch(`${config.apiBaseUrl}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const data = await res.json();
|
||||
const username = get().username ?? '';
|
||||
persistTokens(data.accessToken, data.refreshToken, username);
|
||||
set({
|
||||
accessToken: data.accessToken,
|
||||
refreshToken: data.refreshToken,
|
||||
isAuthenticated: true,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
clearTokens();
|
||||
set({
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
username: null,
|
||||
isAuthenticated: false,
|
||||
error: null,
|
||||
});
|
||||
},
|
||||
}));
|
||||
45
ui/src/auth/use-auth.ts
Normal file
45
ui/src/auth/use-auth.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAuthStore } from './auth-store';
|
||||
import { configureAuth } from '../api/client';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
export function useAuth() {
|
||||
const { accessToken, isAuthenticated, refresh, logout } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Wire API client to auth store
|
||||
useEffect(() => {
|
||||
configureAuth({
|
||||
getAccessToken: () => useAuthStore.getState().accessToken,
|
||||
onUnauthorized: async () => {
|
||||
const ok = await useAuthStore.getState().refresh();
|
||||
if (!ok) {
|
||||
useAuthStore.getState().logout();
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [navigate]);
|
||||
|
||||
// Auto-refresh: check token expiry every 30s
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
const interval = setInterval(async () => {
|
||||
const token = useAuthStore.getState().accessToken;
|
||||
if (!token) return;
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
const expiresIn = payload.exp * 1000 - Date.now();
|
||||
// Refresh when less than 5 minutes remaining
|
||||
if (expiresIn < 5 * 60 * 1000) {
|
||||
await refresh();
|
||||
}
|
||||
} catch {
|
||||
// Token parse failure — ignore, will fail on next API call
|
||||
}
|
||||
}, 30_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isAuthenticated, refresh]);
|
||||
|
||||
return { accessToken, isAuthenticated, logout };
|
||||
}
|
||||
7
ui/src/components/layout/AppShell.module.css
Normal file
7
ui/src/components/layout/AppShell.module.css
Normal file
@@ -0,0 +1,7 @@
|
||||
.main {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
14
ui/src/components/layout/AppShell.tsx
Normal file
14
ui/src/components/layout/AppShell.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Outlet } from 'react-router';
|
||||
import { TopNav } from './TopNav';
|
||||
import styles from './AppShell.module.css';
|
||||
|
||||
export function AppShell() {
|
||||
return (
|
||||
<>
|
||||
<TopNav />
|
||||
<main className={styles.main}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
114
ui/src/components/layout/TopNav.module.css
Normal file
114
ui/src/components/layout/TopNav.module.css
Normal file
@@ -0,0 +1,114 @@
|
||||
.topnav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: var(--topnav-bg);
|
||||
backdrop-filter: blur(20px) saturate(1.2);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 56px;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
color: var(--amber);
|
||||
letter-spacing: -0.5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.logo:hover { color: var(--amber); }
|
||||
|
||||
.navLinks {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.navLink {
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.15s;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.navLink:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
|
||||
.navLinkActive {
|
||||
composes: navLink;
|
||||
color: var(--amber);
|
||||
background: var(--amber-glow);
|
||||
}
|
||||
|
||||
.navRight {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.envBadge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 99px;
|
||||
background: var(--green-glow);
|
||||
color: var(--green);
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.themeToggle {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 8px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.themeToggle:hover {
|
||||
border-color: var(--text-muted);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.userInfo {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.logoutBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
padding: 4px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.logoutBtn:hover {
|
||||
color: var(--rose);
|
||||
}
|
||||
44
ui/src/components/layout/TopNav.tsx
Normal file
44
ui/src/components/layout/TopNav.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { NavLink } from 'react-router';
|
||||
import { useThemeStore } from '../../theme/theme-store';
|
||||
import { useAuthStore } from '../../auth/auth-store';
|
||||
import styles from './TopNav.module.css';
|
||||
|
||||
export function TopNav() {
|
||||
const { theme, toggle } = useThemeStore();
|
||||
const { username, logout } = useAuthStore();
|
||||
|
||||
return (
|
||||
<nav className={styles.topnav}>
|
||||
<NavLink to="/" className={styles.logo}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2" />
|
||||
<path d="M12 6v6l4 2" />
|
||||
</svg>
|
||||
cameleer3
|
||||
</NavLink>
|
||||
|
||||
<ul className={styles.navLinks}>
|
||||
<li>
|
||||
<NavLink to="/executions" className={({ isActive }) => isActive ? styles.navLinkActive : styles.navLink}>
|
||||
Transactions
|
||||
</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className={styles.navRight}>
|
||||
<span className={styles.envBadge}>PRODUCTION</span>
|
||||
<button className={styles.themeToggle} onClick={toggle} title="Toggle theme">
|
||||
{theme === 'dark' ? '\u2600\uFE0F' : '\uD83C\uDF19'}
|
||||
</button>
|
||||
{username && (
|
||||
<span className={styles.userInfo}>
|
||||
{username}
|
||||
<button className={styles.logoutBtn} onClick={logout} title="Sign out">
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
20
ui/src/components/shared/AppBadge.tsx
Normal file
20
ui/src/components/shared/AppBadge.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import styles from './shared.module.css';
|
||||
|
||||
const COLORS = ['#3b82f6', '#f0b429', '#10b981', '#a855f7', '#f43f5e', '#22d3ee', '#ec4899'];
|
||||
|
||||
function hashColor(name: string) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return COLORS[Math.abs(hash) % COLORS.length];
|
||||
}
|
||||
|
||||
export function AppBadge({ name }: { name: string }) {
|
||||
return (
|
||||
<span className={styles.appBadge}>
|
||||
<span className={styles.appDot} style={{ background: hashColor(name) }} />
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
30
ui/src/components/shared/DurationBar.tsx
Normal file
30
ui/src/components/shared/DurationBar.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import styles from './shared.module.css';
|
||||
|
||||
function durationClass(ms: number) {
|
||||
if (ms < 100) return styles.barFast;
|
||||
if (ms < 1000) return styles.barMedium;
|
||||
return styles.barSlow;
|
||||
}
|
||||
|
||||
function durationColor(ms: number) {
|
||||
if (ms < 100) return 'var(--green)';
|
||||
if (ms < 1000) return 'var(--amber)';
|
||||
return 'var(--rose)';
|
||||
}
|
||||
|
||||
export function DurationBar({ duration }: { duration: number }) {
|
||||
const widthPct = Math.min(100, (duration / 5000) * 100);
|
||||
return (
|
||||
<div className={styles.durationBar}>
|
||||
<span className="mono" style={{ color: durationColor(duration) }}>
|
||||
{duration.toLocaleString()}ms
|
||||
</span>
|
||||
<div className={styles.bar}>
|
||||
<div
|
||||
className={`${styles.barFill} ${durationClass(duration)}`}
|
||||
style={{ width: `${widthPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
ui/src/components/shared/FilterChip.tsx
Normal file
23
ui/src/components/shared/FilterChip.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import styles from './shared.module.css';
|
||||
|
||||
interface FilterChipProps {
|
||||
label: string;
|
||||
active: boolean;
|
||||
accent?: 'green' | 'rose' | 'blue';
|
||||
count?: number;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function FilterChip({ label, active, accent, count, onClick }: FilterChipProps) {
|
||||
const accentClass = accent ? styles[`chip${accent.charAt(0).toUpperCase()}${accent.slice(1)}`] : '';
|
||||
return (
|
||||
<span
|
||||
className={`${styles.chip} ${active ? styles.chipActive : ''} ${accentClass}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{accent && <span className={styles.chipDot} />}
|
||||
{label}
|
||||
{count !== undefined && <span className={styles.chipCount}>{count.toLocaleString()}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
60
ui/src/components/shared/Pagination.tsx
Normal file
60
ui/src/components/shared/Pagination.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import styles from './shared.module.css';
|
||||
|
||||
interface PaginationProps {
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
onChange: (offset: number) => void;
|
||||
}
|
||||
|
||||
export function Pagination({ total, offset, limit, onChange }: PaginationProps) {
|
||||
const currentPage = Math.floor(offset / limit) + 1;
|
||||
const totalPages = Math.max(1, Math.ceil(total / limit));
|
||||
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
const pages: (number | '...')[] = [];
|
||||
if (totalPages <= 7) {
|
||||
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
||||
} else {
|
||||
pages.push(1);
|
||||
if (currentPage > 3) pages.push('...');
|
||||
for (let i = Math.max(2, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
if (currentPage < totalPages - 2) pages.push('...');
|
||||
pages.push(totalPages);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.pagination}>
|
||||
<button
|
||||
className={`${styles.pageBtn} ${currentPage === 1 ? styles.pageBtnDisabled : ''}`}
|
||||
onClick={() => currentPage > 1 && onChange((currentPage - 2) * limit)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
{pages.map((p, i) =>
|
||||
p === '...' ? (
|
||||
<span key={`e${i}`} className={styles.pageEllipsis}>…</span>
|
||||
) : (
|
||||
<button
|
||||
key={p}
|
||||
className={`${styles.pageBtn} ${p === currentPage ? styles.pageBtnActive : ''}`}
|
||||
onClick={() => onChange((p - 1) * limit)}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
<button
|
||||
className={`${styles.pageBtn} ${currentPage === totalPages ? styles.pageBtnDisabled : ''}`}
|
||||
onClick={() => currentPage < totalPages && onChange(currentPage * limit)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
ui/src/components/shared/StatCard.tsx
Normal file
21
ui/src/components/shared/StatCard.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import styles from './shared.module.css';
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: string;
|
||||
accent: 'amber' | 'cyan' | 'rose' | 'green' | 'blue';
|
||||
change?: string;
|
||||
changeDirection?: 'up' | 'down' | 'neutral';
|
||||
}
|
||||
|
||||
export function StatCard({ label, value, accent, change, changeDirection = 'neutral' }: StatCardProps) {
|
||||
return (
|
||||
<div className={`${styles.statCard} ${styles[accent]}`}>
|
||||
<div className={styles.statLabel}>{label}</div>
|
||||
<div className={styles.statValue}>{value}</div>
|
||||
{change && (
|
||||
<div className={`${styles.statChange} ${styles[changeDirection]}`}>{change}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
ui/src/components/shared/StatusPill.tsx
Normal file
17
ui/src/components/shared/StatusPill.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import styles from './shared.module.css';
|
||||
|
||||
const STATUS_MAP = {
|
||||
COMPLETED: { className: styles.pillCompleted, label: 'Completed' },
|
||||
FAILED: { className: styles.pillFailed, label: 'Failed' },
|
||||
RUNNING: { className: styles.pillRunning, label: 'Running' },
|
||||
} as const;
|
||||
|
||||
export function StatusPill({ status }: { status: string }) {
|
||||
const info = STATUS_MAP[status as keyof typeof STATUS_MAP] ?? STATUS_MAP.COMPLETED;
|
||||
return (
|
||||
<span className={`${styles.statusPill} ${info.className}`}>
|
||||
<span className={styles.statusDot} />
|
||||
{info.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
201
ui/src/components/shared/shared.module.css
Normal file
201
ui/src/components/shared/shared.module.css
Normal file
@@ -0,0 +1,201 @@
|
||||
/* ─── Status Pill ─── */
|
||||
.statusPill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 99px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.statusDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.pillCompleted { background: var(--green-glow); color: var(--green); }
|
||||
.pillFailed { background: var(--rose-glow); color: var(--rose); }
|
||||
.pillRunning { background: rgba(59, 130, 246, 0.12); color: var(--blue); }
|
||||
.pillRunning .statusDot { animation: livePulse 1.5s ease-in-out infinite; }
|
||||
|
||||
/* ─── Duration Bar ─── */
|
||||
.durationBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bar {
|
||||
width: 60px;
|
||||
height: 4px;
|
||||
background: var(--bg-base);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.barFill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.barFast { background: var(--green); }
|
||||
.barMedium { background: var(--amber); }
|
||||
.barSlow { background: var(--rose); }
|
||||
|
||||
/* ─── Stat Card ─── */
|
||||
.statCard {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px 20px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.statCard:hover { border-color: var(--border); }
|
||||
|
||||
.statCard::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
.amber::before { background: linear-gradient(90deg, var(--amber), transparent); }
|
||||
.cyan::before { background: linear-gradient(90deg, var(--cyan), transparent); }
|
||||
.rose::before { background: linear-gradient(90deg, var(--rose), transparent); }
|
||||
.green::before { background: linear-gradient(90deg, var(--green), transparent); }
|
||||
.blue::before { background: linear-gradient(90deg, var(--blue), transparent); }
|
||||
|
||||
.statLabel {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.amber .statValue { color: var(--amber); }
|
||||
.cyan .statValue { color: var(--cyan); }
|
||||
.rose .statValue { color: var(--rose); }
|
||||
.green .statValue { color: var(--green); }
|
||||
.blue .statValue { color: var(--blue); }
|
||||
|
||||
.statChange {
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.up { color: var(--rose); }
|
||||
.down { color: var(--green); }
|
||||
.neutral { color: var(--text-muted); }
|
||||
|
||||
/* ─── App Badge ─── */
|
||||
.appBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 2px 8px;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.appDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* ─── Filter Chip ─── */
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 12px;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 99px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.chip:hover { border-color: var(--text-muted); color: var(--text-primary); }
|
||||
|
||||
.chipActive { background: var(--amber-glow); border-color: var(--amber-dim); color: var(--amber); }
|
||||
.chipActive.chipGreen { background: var(--green-glow); border-color: rgba(16, 185, 129, 0.3); color: var(--green); }
|
||||
.chipActive.chipRose { background: var(--rose-glow); border-color: rgba(244, 63, 94, 0.3); color: var(--rose); }
|
||||
.chipActive.chipBlue { background: rgba(59, 130, 246, 0.12); border-color: rgba(59, 130, 246, 0.3); color: var(--blue); }
|
||||
|
||||
.chipDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.chipCount {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ─── Pagination ─── */
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.pageBtn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.pageBtn:hover:not(:disabled) { border-color: var(--border); background: var(--bg-raised); }
|
||||
.pageBtnActive { background: var(--amber-glow); border-color: var(--amber-dim); color: var(--amber); }
|
||||
.pageBtnDisabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
.pageEllipsis {
|
||||
color: var(--text-muted);
|
||||
padding: 0 4px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
13
ui/src/config.ts
Normal file
13
ui/src/config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
__CAMELEER_CONFIG__?: {
|
||||
apiBaseUrl?: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const config = {
|
||||
get apiBaseUrl(): string {
|
||||
return window.__CAMELEER_CONFIG__?.apiBaseUrl ?? '/api/v1';
|
||||
},
|
||||
};
|
||||
27
ui/src/main.tsx
Normal file
27
ui/src/main.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { RouterProvider } from 'react-router';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ThemeProvider } from './theme/ThemeProvider';
|
||||
import { router } from './router';
|
||||
import './theme/fonts.css';
|
||||
import './theme/tokens.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
75
ui/src/pages/executions/ExchangeDetail.module.css
Normal file
75
ui/src/pages/executions/ExchangeDetail.module.css
Normal file
@@ -0,0 +1,75 @@
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 4px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.kvKey {
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.kvValue {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.bodyPreview {
|
||||
margin-top: 16px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
max-height: 120px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.bodyLabel {
|
||||
font-family: var(--font-body);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.errorPreview {
|
||||
margin-top: 12px;
|
||||
background: var(--rose-glow);
|
||||
border: 1px solid rgba(244, 63, 94, 0.2);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--rose);
|
||||
max-height: 80px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.sidebar { width: 100%; }
|
||||
}
|
||||
45
ui/src/pages/executions/ExchangeDetail.tsx
Normal file
45
ui/src/pages/executions/ExchangeDetail.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useProcessorSnapshot } from '../../api/queries/executions';
|
||||
import type { ExecutionSummary } from '../../api/schema';
|
||||
import styles from './ExchangeDetail.module.css';
|
||||
|
||||
interface ExchangeDetailProps {
|
||||
execution: ExecutionSummary;
|
||||
}
|
||||
|
||||
export function ExchangeDetail({ execution }: ExchangeDetailProps) {
|
||||
// Fetch the first processor's snapshot (index 0) for body preview
|
||||
const { data: snapshot } = useProcessorSnapshot(execution.executionId, 0);
|
||||
|
||||
return (
|
||||
<div className={styles.sidebar}>
|
||||
<h4 className={styles.title}>Exchange Details</h4>
|
||||
<dl className={styles.kv}>
|
||||
<dt className={styles.kvKey}>Exchange ID</dt>
|
||||
<dd className={styles.kvValue}>{execution.executionId}</dd>
|
||||
<dt className={styles.kvKey}>Correlation</dt>
|
||||
<dd className={styles.kvValue}>{execution.correlationId ?? '-'}</dd>
|
||||
<dt className={styles.kvKey}>Application</dt>
|
||||
<dd className={styles.kvValue}>{execution.agentId}</dd>
|
||||
<dt className={styles.kvKey}>Route</dt>
|
||||
<dd className={styles.kvValue}>{execution.routeId}</dd>
|
||||
<dt className={styles.kvKey}>Timestamp</dt>
|
||||
<dd className={styles.kvValue}>{new Date(execution.startTime).toISOString()}</dd>
|
||||
<dt className={styles.kvKey}>Duration</dt>
|
||||
<dd className={styles.kvValue}>{execution.duration}ms</dd>
|
||||
<dt className={styles.kvKey}>Processors</dt>
|
||||
<dd className={styles.kvValue}>{execution.processorCount}</dd>
|
||||
</dl>
|
||||
|
||||
{snapshot?.body && (
|
||||
<div className={styles.bodyPreview}>
|
||||
<span className={styles.bodyLabel}>Input Body</span>
|
||||
{snapshot.body}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{execution.errorMessage && (
|
||||
<div className={styles.errorPreview}>{execution.errorMessage}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
ui/src/pages/executions/ExecutionExplorer.module.css
Normal file
72
ui/src/pages/executions/ExecutionExplorer.module.css
Normal file
@@ -0,0 +1,72 @@
|
||||
.pageHeader {
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.pageHeader h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.liveIndicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--green);
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.liveDot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--green);
|
||||
animation: livePulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.statsBar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.resultsHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.resultsCount {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.resultsCount strong {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ─── Responsive ─── */
|
||||
@media (max-width: 1200px) {
|
||||
.statsBar { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.statsBar { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
67
ui/src/pages/executions/ExecutionExplorer.tsx
Normal file
67
ui/src/pages/executions/ExecutionExplorer.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useSearchExecutions } from '../../api/queries/executions';
|
||||
import { useExecutionSearch } from './use-execution-search';
|
||||
import { StatCard } from '../../components/shared/StatCard';
|
||||
import { Pagination } from '../../components/shared/Pagination';
|
||||
import { SearchFilters } from './SearchFilters';
|
||||
import { ResultsTable } from './ResultsTable';
|
||||
import styles from './ExecutionExplorer.module.css';
|
||||
|
||||
export function ExecutionExplorer() {
|
||||
const { toSearchRequest, offset, limit, setOffset } = useExecutionSearch();
|
||||
const searchRequest = toSearchRequest();
|
||||
const { data, isLoading, isFetching } = useSearchExecutions(searchRequest);
|
||||
|
||||
const total = data?.total ?? 0;
|
||||
const results = data?.results ?? [];
|
||||
|
||||
// Derive stats from current search results
|
||||
const failedCount = results.filter((r) => r.status === 'FAILED').length;
|
||||
const avgDuration = results.length > 0
|
||||
? Math.round(results.reduce((sum, r) => sum + r.duration, 0) / results.length)
|
||||
: 0;
|
||||
|
||||
const showFrom = total > 0 ? offset + 1 : 0;
|
||||
const showTo = Math.min(offset + limit, total);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Page Header */}
|
||||
<div className={`${styles.pageHeader} animate-in`}>
|
||||
<div>
|
||||
<h1>Transaction Explorer</h1>
|
||||
<div className={styles.subtitle}>Search and analyze route executions</div>
|
||||
</div>
|
||||
<div className={styles.liveIndicator}>
|
||||
<span className={styles.liveDot} />
|
||||
LIVE
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Bar */}
|
||||
<div className={styles.statsBar}>
|
||||
<StatCard label="Total Matches" value={total.toLocaleString()} accent="amber" change={`from current search`} />
|
||||
<StatCard label="Avg Duration" value={`${avgDuration}ms`} accent="cyan" />
|
||||
<StatCard label="Failed (page)" value={failedCount.toString()} accent="rose" />
|
||||
<StatCard label="P99 Latency" value="--" accent="green" change="stats endpoint coming soon" />
|
||||
<StatCard label="Active Now" value="--" accent="blue" change="stats endpoint coming soon" />
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<SearchFilters />
|
||||
|
||||
{/* Results Header */}
|
||||
<div className={`${styles.resultsHeader} animate-in delay-4`}>
|
||||
<span className={styles.resultsCount}>
|
||||
Showing <strong>{showFrom}–{showTo}</strong> of <strong>{total.toLocaleString()}</strong> results
|
||||
{isFetching && !isLoading && ' · updating...'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Results Table */}
|
||||
<ResultsTable results={results} loading={isLoading} />
|
||||
|
||||
{/* Pagination */}
|
||||
<Pagination total={total} offset={offset} limit={limit} onChange={setOffset} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
97
ui/src/pages/executions/ProcessorTree.module.css
Normal file
97
ui/src/pages/executions/ProcessorTree.module.css
Normal file
@@ -0,0 +1,97 @@
|
||||
.tree {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.procNode {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 2px;
|
||||
transition: background 0.1s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.procNode:hover { background: var(--bg-surface); }
|
||||
|
||||
.procConnector {
|
||||
position: absolute;
|
||||
left: 22px;
|
||||
top: 28px;
|
||||
bottom: -4px;
|
||||
width: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.procNode:last-child .procConnector { display: none; }
|
||||
|
||||
.procIcon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
z-index: 1;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.iconEndpoint { background: rgba(59, 130, 246, 0.15); color: var(--blue); border: 1px solid rgba(59, 130, 246, 0.3); }
|
||||
.iconProcessor { background: var(--green-glow); color: var(--green); border: 1px solid rgba(16, 185, 129, 0.3); }
|
||||
.iconEip { background: rgba(168, 85, 247, 0.12); color: #a855f7; border: 1px solid rgba(168, 85, 247, 0.3); }
|
||||
.iconError { background: var(--rose-glow); color: var(--rose); border: 1px solid rgba(244, 63, 94, 0.3); }
|
||||
|
||||
.procInfo { flex: 1; min-width: 0; }
|
||||
|
||||
.procType {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.procUri {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.procTiming {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.procDuration {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.nested {
|
||||
margin-left: 24px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
padding: 12px;
|
||||
}
|
||||
70
ui/src/pages/executions/ProcessorTree.tsx
Normal file
70
ui/src/pages/executions/ProcessorTree.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useExecutionDetail } from '../../api/queries/executions';
|
||||
import type { ProcessorNode as ProcessorNodeType } from '../../api/schema';
|
||||
import styles from './ProcessorTree.module.css';
|
||||
|
||||
const ICON_MAP: Record<string, { label: string; className: string }> = {
|
||||
from: { label: 'EP', className: styles.iconEndpoint },
|
||||
to: { label: 'EP', className: styles.iconEndpoint },
|
||||
toD: { label: 'EP', className: styles.iconEndpoint },
|
||||
choice: { label: 'CB', className: styles.iconEip },
|
||||
when: { label: 'CB', className: styles.iconEip },
|
||||
otherwise: { label: 'CB', className: styles.iconEip },
|
||||
split: { label: 'CB', className: styles.iconEip },
|
||||
aggregate: { label: 'CB', className: styles.iconEip },
|
||||
filter: { label: 'CB', className: styles.iconEip },
|
||||
multicast: { label: 'CB', className: styles.iconEip },
|
||||
recipientList: { label: 'CB', className: styles.iconEip },
|
||||
routingSlip: { label: 'CB', className: styles.iconEip },
|
||||
dynamicRouter: { label: 'CB', className: styles.iconEip },
|
||||
exception: { label: '!!', className: styles.iconError },
|
||||
onException: { label: '!!', className: styles.iconError },
|
||||
};
|
||||
|
||||
function getIcon(type: string, status: string) {
|
||||
if (status === 'FAILED') return { label: '!!', className: styles.iconError };
|
||||
const key = type.toLowerCase();
|
||||
return ICON_MAP[key] ?? { label: 'PR', className: styles.iconProcessor };
|
||||
}
|
||||
|
||||
export function ProcessorTree({ executionId }: { executionId: string }) {
|
||||
const { data, isLoading } = useExecutionDetail(executionId);
|
||||
|
||||
if (isLoading) return <div className={styles.tree}><div className={styles.loading}>Loading processor tree...</div></div>;
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.tree}>
|
||||
<h4 className={styles.title}>Processor Execution Tree</h4>
|
||||
{data.processors.map((proc) => (
|
||||
<ProcessorNodeView key={proc.index} node={proc} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProcessorNodeView({ node }: { node: ProcessorNodeType }) {
|
||||
const icon = getIcon(node.processorType, node.status);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.procNode}>
|
||||
<div className={styles.procConnector} />
|
||||
<div className={`${styles.procIcon} ${icon.className}`}>{icon.label}</div>
|
||||
<div className={styles.procInfo}>
|
||||
<div className={styles.procType}>{node.processorType}</div>
|
||||
{node.uri && <div className={styles.procUri}>{node.uri}</div>}
|
||||
</div>
|
||||
<div className={styles.procTiming}>
|
||||
<span className={styles.procDuration}>{node.duration}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
{node.children.length > 0 && (
|
||||
<div className={styles.nested}>
|
||||
{node.children.map((child) => (
|
||||
<ProcessorNodeView key={child.index} node={child} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
ui/src/pages/executions/ResultsTable.module.css
Normal file
104
ui/src/pages/executions/ResultsTable.module.css
Normal file
@@ -0,0 +1,104 @@
|
||||
.tableWrap {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.thead {
|
||||
background: var(--bg-raised);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.th {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
transition: background 0.1s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.row:last-child { border-bottom: none; }
|
||||
.row:hover { background: var(--bg-raised); }
|
||||
|
||||
.td {
|
||||
padding: 12px 16px;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tdExpand {
|
||||
width: 32px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 16px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.expanded .tdExpand {
|
||||
transform: rotate(90deg);
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.correlationId {
|
||||
max-width: 140px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ─── Detail Row ─── */
|
||||
.detailRow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.detailRowVisible {
|
||||
display: table-row;
|
||||
}
|
||||
|
||||
.detailCell {
|
||||
padding: 0 !important;
|
||||
background: var(--bg-base);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.detailContent {
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* ─── Loading / Empty ─── */
|
||||
.emptyState {
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.loadingOverlay {
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.detailContent { flex-direction: column; }
|
||||
}
|
||||
120
ui/src/pages/executions/ResultsTable.tsx
Normal file
120
ui/src/pages/executions/ResultsTable.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useState } from 'react';
|
||||
import type { ExecutionSummary } from '../../api/schema';
|
||||
import { StatusPill } from '../../components/shared/StatusPill';
|
||||
import { DurationBar } from '../../components/shared/DurationBar';
|
||||
import { AppBadge } from '../../components/shared/AppBadge';
|
||||
import { ProcessorTree } from './ProcessorTree';
|
||||
import { ExchangeDetail } from './ExchangeDetail';
|
||||
import styles from './ResultsTable.module.css';
|
||||
|
||||
interface ResultsTableProps {
|
||||
results: ExecutionSummary[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
function formatTime(iso: string) {
|
||||
return new Date(iso).toLocaleTimeString('en-GB', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
fractionalSecondDigits: 3,
|
||||
});
|
||||
}
|
||||
|
||||
export function ResultsTable({ results, loading }: ResultsTableProps) {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
if (loading && results.length === 0) {
|
||||
return (
|
||||
<div className={styles.tableWrap}>
|
||||
<div className={styles.loadingOverlay}>Loading executions...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<div className={styles.tableWrap}>
|
||||
<div className={styles.emptyState}>No executions found matching your filters.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.tableWrap}>
|
||||
<table className={styles.table}>
|
||||
<thead className={styles.thead}>
|
||||
<tr>
|
||||
<th className={styles.th} style={{ width: 32 }} />
|
||||
<th className={styles.th}>Timestamp</th>
|
||||
<th className={styles.th}>Status</th>
|
||||
<th className={styles.th}>Application</th>
|
||||
<th className={styles.th}>Route</th>
|
||||
<th className={styles.th}>Correlation ID</th>
|
||||
<th className={styles.th}>Duration</th>
|
||||
<th className={styles.th}>Processors</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.map((exec) => {
|
||||
const isExpanded = expandedId === exec.executionId;
|
||||
return (
|
||||
<ResultRow
|
||||
key={exec.executionId}
|
||||
exec={exec}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={() => setExpandedId(isExpanded ? null : exec.executionId)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultRow({
|
||||
exec,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: {
|
||||
exec: ExecutionSummary;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className={`${styles.row} ${isExpanded ? styles.expanded : ''}`}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<td className={`${styles.td} ${styles.tdExpand}`}>›</td>
|
||||
<td className={`${styles.td} mono`}>{formatTime(exec.startTime)}</td>
|
||||
<td className={styles.td}>
|
||||
<StatusPill status={exec.status} />
|
||||
</td>
|
||||
<td className={styles.td}>
|
||||
<AppBadge name={exec.agentId} />
|
||||
</td>
|
||||
<td className={`${styles.td} mono text-secondary`}>{exec.routeId}</td>
|
||||
<td className={`${styles.td} mono text-muted ${styles.correlationId}`} title={exec.correlationId ?? ''}>
|
||||
{exec.correlationId ?? '-'}
|
||||
</td>
|
||||
<td className={styles.td}>
|
||||
<DurationBar duration={exec.duration} />
|
||||
</td>
|
||||
<td className={`${styles.td} mono text-muted`}>{exec.processorCount}</td>
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<tr className={styles.detailRowVisible}>
|
||||
<td className={styles.detailCell} colSpan={8}>
|
||||
<div className={styles.detailContent}>
|
||||
<ProcessorTree executionId={exec.executionId} />
|
||||
<ExchangeDetail execution={exec} />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
214
ui/src/pages/executions/SearchFilters.module.css
Normal file
214
ui/src/pages/executions/SearchFilters.module.css
Normal file
@@ -0,0 +1,214 @@
|
||||
.filterBar {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filterRow {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.searchInputWrap {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
width: 100%;
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 14px 10px 40px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.searchInput::placeholder {
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.searchInput:focus {
|
||||
border-color: var(--amber-dim);
|
||||
box-shadow: 0 0 0 3px var(--amber-glow);
|
||||
}
|
||||
|
||||
.searchHint {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
padding: 3px 8px;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.filterGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.filterLabel {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filterChips {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.separator {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: var(--border);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.dateInput {
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 12px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
width: 180px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.dateInput:focus { border-color: var(--amber-dim); }
|
||||
|
||||
.dateArrow {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.durationRange {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rangeInput {
|
||||
width: 100px;
|
||||
accent-color: var(--amber);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rangeLabel {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:hover { background: var(--bg-hover); color: var(--text-primary); border-color: var(--text-muted); }
|
||||
|
||||
.btnPrimary {
|
||||
composes: btn;
|
||||
background: var(--amber);
|
||||
color: #0a0e17;
|
||||
border-color: var(--amber);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btnPrimary:hover { background: var(--amber-hover); border-color: var(--amber-hover); color: #0a0e17; }
|
||||
|
||||
.filterTags {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filterTag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
background: var(--amber-glow);
|
||||
border: 1px solid rgba(240, 180, 41, 0.2);
|
||||
border-radius: 99px;
|
||||
font-size: 12px;
|
||||
color: var(--amber);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.filterTagRemove {
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.filterTagRemove:hover { opacity: 1; }
|
||||
|
||||
.clearAll {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.clearAll:hover { color: var(--rose); }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filterRow { flex-direction: column; align-items: stretch; }
|
||||
.searchInputWrap { min-width: unset; }
|
||||
}
|
||||
124
ui/src/pages/executions/SearchFilters.tsx
Normal file
124
ui/src/pages/executions/SearchFilters.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useRef, useCallback } from 'react';
|
||||
import { useExecutionSearch } from './use-execution-search';
|
||||
import { FilterChip } from '../../components/shared/FilterChip';
|
||||
import styles from './SearchFilters.module.css';
|
||||
|
||||
export function SearchFilters() {
|
||||
const {
|
||||
status, toggleStatus,
|
||||
timeFrom, setTimeFrom,
|
||||
timeTo, setTimeTo,
|
||||
durationMax, setDurationMax,
|
||||
text, setText,
|
||||
clearAll,
|
||||
} = useExecutionSearch();
|
||||
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
const handleTextChange = useCallback(
|
||||
(value: string) => {
|
||||
clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => setText(value), 300);
|
||||
},
|
||||
[setText],
|
||||
);
|
||||
|
||||
const activeTags: { label: string; onRemove: () => void }[] = [];
|
||||
if (text) activeTags.push({ label: `text:"${text}"`, onRemove: () => setText('') });
|
||||
if (timeFrom) activeTags.push({ label: `from:${timeFrom}`, onRemove: () => setTimeFrom('') });
|
||||
if (timeTo) activeTags.push({ label: `to:${timeTo}`, onRemove: () => setTimeTo('') });
|
||||
if (durationMax && durationMax < 5000) {
|
||||
activeTags.push({ label: `duration:≤${durationMax}ms`, onRemove: () => setDurationMax(null) });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${styles.filterBar} animate-in delay-3`}>
|
||||
{/* Row 1: Search */}
|
||||
<div className={styles.filterRow}>
|
||||
<div className={styles.searchInputWrap}>
|
||||
<svg className={styles.searchIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="M21 21l-4.35-4.35" />
|
||||
</svg>
|
||||
<input
|
||||
className={styles.searchInput}
|
||||
type="text"
|
||||
placeholder="Search by correlation ID, error message, route ID..."
|
||||
defaultValue={text}
|
||||
onChange={(e) => handleTextChange(e.target.value)}
|
||||
/>
|
||||
<span className={styles.searchHint}>⌘K</span>
|
||||
</div>
|
||||
<button className={styles.btnPrimary}>Search</button>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Status chips + date + duration */}
|
||||
<div className={styles.filterRow}>
|
||||
<div className={styles.filterGroup}>
|
||||
<label className={styles.filterLabel}>Status</label>
|
||||
<div className={styles.filterChips}>
|
||||
<FilterChip label="Completed" accent="green" active={status.includes('COMPLETED')} onClick={() => toggleStatus('COMPLETED')} />
|
||||
<FilterChip label="Failed" accent="rose" active={status.includes('FAILED')} onClick={() => toggleStatus('FAILED')} />
|
||||
<FilterChip label="Running" accent="blue" active={status.includes('RUNNING')} onClick={() => toggleStatus('RUNNING')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.separator} />
|
||||
|
||||
<div className={styles.filterGroup}>
|
||||
<label className={styles.filterLabel}>Date</label>
|
||||
<input
|
||||
className={styles.dateInput}
|
||||
type="datetime-local"
|
||||
value={timeFrom}
|
||||
onChange={(e) => setTimeFrom(e.target.value)}
|
||||
/>
|
||||
<span className={styles.dateArrow}>→</span>
|
||||
<input
|
||||
className={styles.dateInput}
|
||||
type="datetime-local"
|
||||
value={timeTo}
|
||||
onChange={(e) => setTimeTo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.separator} />
|
||||
|
||||
<div className={styles.filterGroup}>
|
||||
<label className={styles.filterLabel}>Duration</label>
|
||||
<div className={styles.durationRange}>
|
||||
<span className={styles.rangeLabel}>0ms</span>
|
||||
<input
|
||||
className={styles.rangeInput}
|
||||
type="range"
|
||||
min="0"
|
||||
max="5000"
|
||||
step="100"
|
||||
value={durationMax ?? 5000}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
setDurationMax(v >= 5000 ? null : v);
|
||||
}}
|
||||
/>
|
||||
<span className={styles.rangeLabel}>≤ {durationMax ?? 5000}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: Active filter tags */}
|
||||
{activeTags.length > 0 && (
|
||||
<div className={styles.filterRow}>
|
||||
<div className={styles.filterTags}>
|
||||
{activeTags.map((tag) => (
|
||||
<span key={tag.label} className={styles.filterTag}>
|
||||
{tag.label}
|
||||
<button className={styles.filterTagRemove} onClick={tag.onRemove}>×</button>
|
||||
</span>
|
||||
))}
|
||||
<button className={styles.clearAll} onClick={clearAll}>Clear all</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
ui/src/pages/executions/use-execution-search.ts
Normal file
77
ui/src/pages/executions/use-execution-search.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { create } from 'zustand';
|
||||
import type { SearchRequest } from '../../api/schema';
|
||||
|
||||
interface ExecutionSearchState {
|
||||
status: string[];
|
||||
timeFrom: string;
|
||||
timeTo: string;
|
||||
durationMin: number | null;
|
||||
durationMax: number | null;
|
||||
text: string;
|
||||
offset: number;
|
||||
limit: number;
|
||||
|
||||
setStatus: (statuses: string[]) => void;
|
||||
toggleStatus: (s: string) => void;
|
||||
setTimeFrom: (v: string) => void;
|
||||
setTimeTo: (v: string) => void;
|
||||
setDurationMin: (v: number | null) => void;
|
||||
setDurationMax: (v: number | null) => void;
|
||||
setText: (v: string) => void;
|
||||
setOffset: (v: number) => void;
|
||||
clearAll: () => void;
|
||||
toSearchRequest: () => SearchRequest;
|
||||
}
|
||||
|
||||
export const useExecutionSearch = create<ExecutionSearchState>((set, get) => ({
|
||||
status: ['COMPLETED', 'FAILED'],
|
||||
timeFrom: '',
|
||||
timeTo: '',
|
||||
durationMin: null,
|
||||
durationMax: null,
|
||||
text: '',
|
||||
offset: 0,
|
||||
limit: 25,
|
||||
|
||||
setStatus: (statuses) => set({ status: statuses, offset: 0 }),
|
||||
toggleStatus: (s) =>
|
||||
set((state) => ({
|
||||
status: state.status.includes(s)
|
||||
? state.status.filter((x) => x !== s)
|
||||
: [...state.status, s],
|
||||
offset: 0,
|
||||
})),
|
||||
setTimeFrom: (v) => set({ timeFrom: v, offset: 0 }),
|
||||
setTimeTo: (v) => set({ timeTo: v, offset: 0 }),
|
||||
setDurationMin: (v) => set({ durationMin: v, offset: 0 }),
|
||||
setDurationMax: (v) => set({ durationMax: v, offset: 0 }),
|
||||
setText: (v) => set({ text: v, offset: 0 }),
|
||||
setOffset: (v) => set({ offset: v }),
|
||||
clearAll: () =>
|
||||
set({
|
||||
status: ['COMPLETED', 'FAILED', 'RUNNING'],
|
||||
timeFrom: '',
|
||||
timeTo: '',
|
||||
durationMin: null,
|
||||
durationMax: null,
|
||||
text: '',
|
||||
offset: 0,
|
||||
}),
|
||||
|
||||
toSearchRequest: (): SearchRequest => {
|
||||
const s = get();
|
||||
const statusStr = s.status.length > 0 && s.status.length < 3
|
||||
? s.status.join(',')
|
||||
: undefined;
|
||||
return {
|
||||
status: statusStr ?? undefined,
|
||||
timeFrom: s.timeFrom ? new Date(s.timeFrom).toISOString() : undefined,
|
||||
timeTo: s.timeTo ? new Date(s.timeTo).toISOString() : undefined,
|
||||
durationMin: s.durationMin,
|
||||
durationMax: s.durationMax,
|
||||
text: s.text || undefined,
|
||||
offset: s.offset,
|
||||
limit: s.limit,
|
||||
};
|
||||
},
|
||||
}));
|
||||
24
ui/src/router.tsx
Normal file
24
ui/src/router.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { createBrowserRouter, Navigate } from 'react-router';
|
||||
import { AppShell } from './components/layout/AppShell';
|
||||
import { ProtectedRoute } from './auth/ProtectedRoute';
|
||||
import { LoginPage } from './auth/LoginPage';
|
||||
import { ExecutionExplorer } from './pages/executions/ExecutionExplorer';
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: '/login',
|
||||
element: <LoginPage />,
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute />,
|
||||
children: [
|
||||
{
|
||||
element: <AppShell />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/executions" replace /> },
|
||||
{ path: 'executions', element: <ExecutionExplorer /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
12
ui/src/theme/ThemeProvider.tsx
Normal file
12
ui/src/theme/ThemeProvider.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useThemeStore } from './theme-store';
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const theme = useThemeStore((s) => s.theme);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
1
ui/src/theme/fonts.css
Normal file
1
ui/src/theme/fonts.css
Normal file
@@ -0,0 +1 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@300;400;500;600&display=swap');
|
||||
21
ui/src/theme/theme-store.ts
Normal file
21
ui/src/theme/theme-store.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
type Theme = 'dark' | 'light';
|
||||
|
||||
interface ThemeState {
|
||||
theme: Theme;
|
||||
toggle: () => void;
|
||||
}
|
||||
|
||||
const stored = localStorage.getItem('cameleer-theme') as Theme | null;
|
||||
const initial: Theme = stored === 'light' ? 'light' : 'dark';
|
||||
|
||||
export const useThemeStore = create<ThemeState>((set) => ({
|
||||
theme: initial,
|
||||
toggle: () =>
|
||||
set((state) => {
|
||||
const next = state.theme === 'dark' ? 'light' : 'dark';
|
||||
localStorage.setItem('cameleer-theme', next);
|
||||
return { theme: next };
|
||||
}),
|
||||
}));
|
||||
143
ui/src/theme/tokens.css
Normal file
143
ui/src/theme/tokens.css
Normal file
@@ -0,0 +1,143 @@
|
||||
/* ─── Dark Theme (default) ─── */
|
||||
:root {
|
||||
--bg-deep: #060a13;
|
||||
--bg-base: #0a0e17;
|
||||
--bg-surface: #111827;
|
||||
--bg-raised: #1a2332;
|
||||
--bg-hover: #1e2d3d;
|
||||
--border: #1e2d3d;
|
||||
--border-subtle: #152030;
|
||||
--text-primary: #e2e8f0;
|
||||
--text-secondary: #8b9cb6;
|
||||
--text-muted: #4a5e7a;
|
||||
--amber: #f0b429;
|
||||
--amber-dim: #b8860b;
|
||||
--amber-glow: rgba(240, 180, 41, 0.15);
|
||||
--cyan: #22d3ee;
|
||||
--cyan-dim: #0e7490;
|
||||
--cyan-glow: rgba(34, 211, 238, 0.12);
|
||||
--rose: #f43f5e;
|
||||
--rose-dim: #9f1239;
|
||||
--rose-glow: rgba(244, 63, 94, 0.12);
|
||||
--green: #10b981;
|
||||
--green-glow: rgba(16, 185, 129, 0.12);
|
||||
--blue: #3b82f6;
|
||||
--purple: #a855f7;
|
||||
--font-body: 'DM Sans', system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
|
||||
/* TopNav glass */
|
||||
--topnav-bg: rgba(6, 10, 19, 0.85);
|
||||
/* Button primary hover */
|
||||
--amber-hover: #d4a017;
|
||||
}
|
||||
|
||||
/* ─── Light Theme ─── */
|
||||
[data-theme="light"] {
|
||||
--bg-deep: #f7f5f2;
|
||||
--bg-base: #efecea;
|
||||
--bg-surface: #ffffff;
|
||||
--bg-raised: #f3f1ee;
|
||||
--bg-hover: #eae7e3;
|
||||
--border: #d4cfc8;
|
||||
--border-subtle: #e4e0db;
|
||||
--text-primary: #1c1917;
|
||||
--text-secondary: #57534e;
|
||||
--text-muted: #a8a29e;
|
||||
--amber: #b45309;
|
||||
--amber-dim: #92400e;
|
||||
--amber-glow: rgba(180, 83, 9, 0.07);
|
||||
--cyan: #0e7490;
|
||||
--cyan-dim: #155e75;
|
||||
--cyan-glow: rgba(14, 116, 144, 0.06);
|
||||
--rose: #be123c;
|
||||
--rose-dim: #9f1239;
|
||||
--rose-glow: rgba(190, 18, 60, 0.05);
|
||||
--green: #047857;
|
||||
--green-glow: rgba(4, 120, 87, 0.06);
|
||||
--blue: #1d4ed8;
|
||||
--purple: #7c3aed;
|
||||
|
||||
--topnav-bg: rgba(247, 245, 242, 0.85);
|
||||
--amber-hover: #92400e;
|
||||
}
|
||||
|
||||
/* ─── Global Reset & Body ─── */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg-deep);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
a { color: var(--amber); text-decoration: none; }
|
||||
a:hover { color: var(--text-primary); }
|
||||
|
||||
/* ─── Background Treatment ─── */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(ellipse 800px 400px at 20% 20%, rgba(240, 180, 41, 0.03), transparent),
|
||||
radial-gradient(ellipse 600px 600px at 80% 80%, rgba(34, 211, 238, 0.02), transparent);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
opacity: 0.025;
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='400' height='400' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 200 Q100 150 200 200 T400 200' fill='none' stroke='%23f0b429' stroke-width='1'/%3E%3Cpath d='M0 220 Q120 170 200 220 T400 220' fill='none' stroke='%23f0b429' stroke-width='0.5'/%3E%3Cpath d='M0 180 Q80 130 200 180 T400 180' fill='none' stroke='%23f0b429' stroke-width='0.5'/%3E%3Cpath d='M0 100 Q150 60 200 100 T400 100' fill='none' stroke='%2322d3ee' stroke-width='0.5'/%3E%3Cpath d='M0 300 Q100 260 200 300 T400 300' fill='none' stroke='%2322d3ee' stroke-width='0.5'/%3E%3C/svg%3E");
|
||||
background-size: 400px 400px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
[data-theme="light"] body::before,
|
||||
[data-theme="light"] body::after {
|
||||
opacity: 0.015;
|
||||
}
|
||||
|
||||
/* ─── Animations ─── */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes livePulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); }
|
||||
50% { box-shadow: 0 0 0 6px rgba(16, 185, 129, 0); }
|
||||
}
|
||||
|
||||
.animate-in { animation: fadeIn 0.3s ease-out both; }
|
||||
.delay-1 { animation-delay: 0.05s; }
|
||||
.delay-2 { animation-delay: 0.1s; }
|
||||
.delay-3 { animation-delay: 0.15s; }
|
||||
.delay-4 { animation-delay: 0.2s; }
|
||||
.delay-5 { animation-delay: 0.25s; }
|
||||
|
||||
/* ─── Scrollbar ─── */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
||||
|
||||
/* ─── Utility ─── */
|
||||
.mono { font-family: var(--font-mono); font-size: 12px; }
|
||||
.text-muted { color: var(--text-muted); }
|
||||
.text-secondary { color: var(--text-secondary); }
|
||||
1
ui/src/vite-env.d.ts
vendored
Normal file
1
ui/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
28
ui/tsconfig.app.json
Normal file
28
ui/tsconfig.app.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
ui/tsconfig.json
Normal file
7
ui/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
ui/tsconfig.node.json
Normal file
26
ui/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
17
ui/vite.config.ts
Normal file
17
ui/vite.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8081',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user