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