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

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

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

View File

@@ -0,0 +1,7 @@
.main {
position: relative;
z-index: 1;
max-width: 1440px;
margin: 0 auto;
padding: 24px;
}

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

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

View 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">
&#x2715;
</button>
</span>
)}
</div>
</nav>
);
}

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

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

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

View 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}
>
&#8249;
</button>
{pages.map((p, i) =>
p === '...' ? (
<span key={`e${i}`} className={styles.pageEllipsis}>&hellip;</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}
>
&#8250;
</button>
</div>
);
}

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

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

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

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

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

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

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

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

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

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

View 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}`}>&rsaquo;</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>
)}
</>
);
}

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

View 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}>&#8984;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}>&rarr;</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}>&le; {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}>&times;</button>
</span>
))}
<button className={styles.clearAll} onClick={clearAll}>Clear all</button>
</div>
</div>
)}
</div>
);
}

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

View 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
View 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');

View 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
View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />