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:
7
ui/src/components/layout/AppShell.module.css
Normal file
7
ui/src/components/layout/AppShell.module.css
Normal file
@@ -0,0 +1,7 @@
|
||||
.main {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
14
ui/src/components/layout/AppShell.tsx
Normal file
14
ui/src/components/layout/AppShell.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
114
ui/src/components/layout/TopNav.module.css
Normal file
114
ui/src/components/layout/TopNav.module.css
Normal 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);
|
||||
}
|
||||
44
ui/src/components/layout/TopNav.tsx
Normal file
44
ui/src/components/layout/TopNav.tsx
Normal 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">
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
20
ui/src/components/shared/AppBadge.tsx
Normal file
20
ui/src/components/shared/AppBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
ui/src/components/shared/DurationBar.tsx
Normal file
30
ui/src/components/shared/DurationBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
ui/src/components/shared/FilterChip.tsx
Normal file
23
ui/src/components/shared/FilterChip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
ui/src/components/shared/Pagination.tsx
Normal file
60
ui/src/components/shared/Pagination.tsx
Normal 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}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
{pages.map((p, i) =>
|
||||
p === '...' ? (
|
||||
<span key={`e${i}`} className={styles.pageEllipsis}>…</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}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
ui/src/components/shared/StatCard.tsx
Normal file
21
ui/src/components/shared/StatCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
ui/src/components/shared/StatusPill.tsx
Normal file
17
ui/src/components/shared/StatusPill.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
201
ui/src/components/shared/shared.module.css
Normal file
201
ui/src/components/shared/shared.module.css
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user