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

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

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

View File

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