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

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

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

33
ui/src/api/client.ts Normal file
View 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);

View 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!;
},
});
}

View 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
View 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;
}