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:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user