refactor: UI consistency — shared CSS, design system colors, no inline styles
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m22s
CI / docker (push) Successful in 1m9s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 36s

Phase 1: Extract 6 shared CSS modules (table-section, log-panel,
rate-colors, refresh-indicator, chart-card, section-card) eliminating
~135 duplicate class definitions across 11 files.

Phase 2: Replace all hardcoded hex colors in CSS modules with design
system variables. Strip ~55 hex fallbacks from var() patterns. Fix 4
undefined variable names (--accent, --bg-base, --surface, --bg-surface-raised).

Phase 3: Replace ~45 hardcoded hex values in ProcessDiagram SVG
components with var() CSS custom properties. Fix Dashboard.tsx color prop.

Phase 4: Create CSS modules for AdminLayout, DatabaseAdminPage,
OidcCallback (previously 100% inline). Extract shared PageLoader
component (replaces 3 copy-pasted spinner patterns). Move AppsTab
static inline styles to CSS classes. Extract LayoutShell StarredList styles.

58 files changed, net -219 lines.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-09 14:55:54 +02:00
parent bfed8174ca
commit ff62a34d89
58 changed files with 770 additions and 989 deletions

View File

@@ -3,7 +3,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: 100vh; min-height: 100vh;
background: var(--bg-base); background: var(--bg-body);
} }
.card { .card {

View File

@@ -0,0 +1,21 @@
.page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: var(--bg-body);
}
.card {
padding: 2rem;
text-align: center;
min-width: 320px;
}
.heading {
margin-bottom: 1rem;
}
.backButton {
margin-top: 16px;
}

View File

@@ -4,6 +4,7 @@ import { useAuthStore } from './auth-store';
import { api } from '../api/client'; import { api } from '../api/client';
import { Card, Spinner, Alert, Button } from '@cameleer/design-system'; import { Card, Spinner, Alert, Button } from '@cameleer/design-system';
import { config } from '../config'; import { config } from '../config';
import styles from './OidcCallback.module.css';
export function OidcCallback() { export function OidcCallback() {
const { isAuthenticated, loading, error, loginWithOidcCode } = useAuthStore(); const { isAuthenticated, loading, error, loginWithOidcCode } = useAuthStore();
@@ -67,15 +68,15 @@ export function OidcCallback() {
if (isAuthenticated) return <Navigate to="/" replace />; if (isAuthenticated) return <Navigate to="/" replace />;
return ( return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', background: 'var(--surface-ground)' }}> <div className={styles.page}>
<Card> <Card>
<div style={{ padding: '2rem', textAlign: 'center', minWidth: 320 }}> <div className={styles.card}>
<h2 style={{ marginBottom: '1rem' }}>cameleer3</h2> <h2 className={styles.heading}>cameleer3</h2>
{loading && <Spinner />} {loading && <Spinner />}
{error && ( {error && (
<> <>
<Alert variant="error">{error}</Alert> <Alert variant="error">{error}</Alert>
<Button variant="secondary" onClick={() => navigate('/login?local')} style={{ marginTop: 16 }}> <Button variant="secondary" onClick={() => navigate('/login?local')} className={styles.backButton}>
Back to Login Back to Login
</Button> </Button>
</> </>

View File

@@ -5,6 +5,6 @@
padding: 0 1.5rem; padding: 0 1.5rem;
padding-top: 0.375rem; padding-top: 0.375rem;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
background: var(--surface); background: var(--bg-surface);
gap: 1rem; gap: 1rem;
} }

View File

@@ -45,7 +45,7 @@
height: 10px; height: 10px;
border-radius: 50%; border-radius: 50%;
border: 2px solid var(--border-subtle); border: 2px solid var(--border-subtle);
background: var(--surface, #1a1a1a); background: var(--bg-surface);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -56,7 +56,7 @@
.dot.completed { .dot.completed {
background: var(--amber); background: var(--amber);
border-color: var(--amber); border-color: var(--amber);
color: var(--surface, #1a1a1a); color: var(--bg-surface);
} }
.dot.active { .dot.active {

View File

@@ -18,7 +18,7 @@
} }
.select:focus-visible { .select:focus-visible {
outline: 1px solid var(--accent); outline: 1px solid var(--amber);
outline-offset: 2px; outline-offset: 2px;
border-radius: 2px; border-radius: 2px;
} }

View File

@@ -15,37 +15,37 @@
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 8px 14px; padding: 8px 14px;
background: var(--bg-surface, #FFFFFF); background: var(--bg-surface);
border-bottom: 1px solid var(--border, #E4DFD8); border-bottom: 1px solid var(--border);
font-size: 12px; font-size: 12px;
color: var(--text-secondary, #5C5347); color: var(--text-secondary);
flex-shrink: 0; flex-shrink: 0;
} }
.exchangeLabel { .exchangeLabel {
font-weight: 600; font-weight: 600;
color: var(--text-primary, #1A1612); color: var(--text-primary);
} }
.exchangeId { .exchangeId {
font-size: 12px; font-size: 12px;
background: var(--bg-hover, #F5F0EA); background: var(--bg-hover);
padding: 2px 6px; padding: 2px 6px;
border-radius: 3px; border-radius: 3px;
color: var(--text-primary, #1A1612); color: var(--text-primary);
} }
.exchangeMeta { .exchangeMeta {
color: var(--text-muted, #9C9184); color: var(--text-muted);
} }
.jumpToError { .jumpToError {
margin-left: auto; margin-left: auto;
font-size: 12px; font-size: 12px;
padding: 3px 10px; padding: 3px 10px;
border: 1px solid var(--error, #C0392B); border: 1px solid var(--error);
background: #FDF2F0; background: var(--error-bg);
color: var(--error, #C0392B); color: var(--error);
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-weight: 500; font-weight: 500;
@@ -53,7 +53,7 @@
} }
.jumpToError:hover { .jumpToError:hover {
background: #F9E0DC; background: color-mix(in srgb, var(--error) 15%, var(--bg-surface));
} }
.diagramArea { .diagramArea {
@@ -67,12 +67,12 @@
right: 8px; right: 8px;
z-index: 10; z-index: 10;
font-size: 12px; font-size: 12px;
font-family: var(--font-mono, monospace); font-family: var(--font-mono);
padding: 3px 8px; padding: 3px 8px;
border: 1px solid var(--border, #E4DFD8); border: 1px solid var(--border);
border-radius: 4px; border-radius: 4px;
background: var(--bg-surface, #FFFFFF); background: var(--bg-surface);
color: var(--text-secondary, #5C5347); color: var(--text-secondary);
cursor: pointer; cursor: pointer;
opacity: 0.7; opacity: 0.7;
transition: opacity 0.15s, background 0.15s; transition: opacity 0.15s, background 0.15s;
@@ -80,18 +80,18 @@
.downloadBtn:hover { .downloadBtn:hover {
opacity: 1; opacity: 1;
background: var(--bg-hover, #F5F0EA); background: var(--bg-hover);
} }
.splitter { .splitter {
height: 4px; height: 4px;
background: var(--border, #E4DFD8); background: var(--border);
cursor: row-resize; cursor: row-resize;
flex-shrink: 0; flex-shrink: 0;
} }
.splitter:hover { .splitter:hover {
background: var(--amber, #C6820E); background: var(--amber);
} }
.detailArea { .detailArea {
@@ -106,7 +106,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex: 1; flex: 1;
color: var(--text-muted, #9C9184); color: var(--text-muted);
font-size: 13px; font-size: 13px;
} }
@@ -115,13 +115,13 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex: 1; flex: 1;
color: var(--error, #C0392B); color: var(--error);
font-size: 13px; font-size: 13px;
} }
.statusRunning { .statusRunning {
color: var(--amber, #C6820E); color: var(--amber);
background: #FFF8F0; background: var(--amber-bg);
} }
/* ========================================================================== /* ==========================================================================
@@ -131,8 +131,8 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
background: var(--bg-surface, #FFFFFF); background: var(--bg-surface);
border-top: 1px solid var(--border, #E4DFD8); border-top: 1px solid var(--border);
flex: 1; flex: 1;
min-height: 0; min-height: 0;
} }
@@ -143,27 +143,27 @@
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 6px 14px; padding: 6px 14px;
border-bottom: 1px solid var(--border, #E4DFD8); border-bottom: 1px solid var(--border);
background: #FAFAF8; background: var(--bg-raised);
min-height: 32px; min-height: 32px;
} }
.processorName { .processorName {
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
color: var(--text-primary, #1A1612); color: var(--text-primary);
} }
.processorId { .processorId {
font-size: 12px; font-size: 12px;
font-family: var(--font-mono, monospace); font-family: var(--font-mono);
color: var(--text-muted, #9C9184); color: var(--text-muted);
} }
.processorDuration { .processorDuration {
font-size: 12px; font-size: 12px;
font-family: var(--font-mono, monospace); font-family: var(--font-mono);
color: var(--text-secondary, #5C5347); color: var(--text-secondary);
margin-left: auto; margin-left: auto;
} }
@@ -180,13 +180,13 @@
} }
.statusCompleted { .statusCompleted {
color: var(--success, #3D7C47); color: var(--success);
background: #F0F9F1; background: var(--success-bg);
} }
.statusFailed { .statusFailed {
color: var(--error, #C0392B); color: var(--error);
background: #FDF2F0; background: var(--error-bg);
} }
/* ========================================================================== /* ==========================================================================
@@ -195,18 +195,18 @@
.tabBar { .tabBar {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
border-bottom: 1px solid var(--border, #E4DFD8); border-bottom: 1px solid var(--border);
padding: 0 14px; padding: 0 14px;
background: #FAFAF8; background: var(--bg-raised);
gap: 0; gap: 0;
} }
.tab { .tab {
padding: 6px 12px; padding: 6px 12px;
font-size: 12px; font-size: 12px;
font-family: var(--font-body, inherit); font-family: var(--font-body);
cursor: pointer; cursor: pointer;
color: var(--text-muted, #9C9184); color: var(--text-muted);
border: none; border: none;
background: none; background: none;
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
@@ -215,12 +215,12 @@
} }
.tab:hover { .tab:hover {
color: var(--text-secondary, #5C5347); color: var(--text-secondary);
} }
.tabActive { .tabActive {
color: var(--amber, #C6820E); color: var(--amber);
border-bottom: 2px solid var(--amber, #C6820E); border-bottom: 2px solid var(--amber);
font-weight: 600; font-weight: 600;
} }
@@ -230,15 +230,15 @@
} }
.tabDisabled:hover { .tabDisabled:hover {
color: var(--text-muted, #9C9184); color: var(--text-muted);
} }
.tabError { .tabError {
color: var(--error, #C0392B); color: var(--error);
} }
.tabError:hover { .tabError:hover {
color: var(--error, #C0392B); color: var(--error);
} }
/* ========================================================================== /* ==========================================================================
@@ -262,7 +262,7 @@
.fieldLabel { .fieldLabel {
font-size: 12px; font-size: 12px;
color: var(--text-muted, #9C9184); color: var(--text-muted);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
margin-bottom: 2px; margin-bottom: 2px;
@@ -270,14 +270,14 @@
.fieldValue { .fieldValue {
font-size: 12px; font-size: 12px;
color: var(--text-primary, #1A1612); color: var(--text-primary);
word-break: break-all; word-break: break-all;
} }
.fieldValueMono { .fieldValueMono {
font-size: 12px; font-size: 12px;
color: var(--text-primary, #1A1612); color: var(--text-primary);
font-family: var(--font-mono, monospace); font-family: var(--font-mono);
word-break: break-all; word-break: break-all;
} }
@@ -287,12 +287,12 @@
.attributesSection { .attributesSection {
margin-top: 14px; margin-top: 14px;
padding-top: 10px; padding-top: 10px;
border-top: 1px solid var(--border, #E4DFD8); border-top: 1px solid var(--border);
} }
.attributesLabel { .attributesLabel {
font-size: 12px; font-size: 12px;
color: var(--text-muted, #9C9184); color: var(--text-muted);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
margin-bottom: 6px; margin-bottom: 6px;
@@ -307,10 +307,10 @@
.attributePill { .attributePill {
font-size: 12px; font-size: 12px;
padding: 2px 8px; padding: 2px 8px;
background: var(--bg-hover, #F5F0EA); background: var(--bg-hover);
border-radius: 10px; border-radius: 10px;
color: var(--text-secondary, #5C5347); color: var(--text-secondary);
font-family: var(--font-mono, monospace); font-family: var(--font-mono);
} }
/* ========================================================================== /* ==========================================================================
@@ -330,7 +330,7 @@
} }
.headersColumn + .headersColumn { .headersColumn + .headersColumn {
border-left: 1px solid var(--border, #E4DFD8); border-left: 1px solid var(--border);
padding-left: 14px; padding-left: 14px;
margin-left: 14px; margin-left: 14px;
} }
@@ -338,7 +338,7 @@
.headersColumnLabel { .headersColumnLabel {
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
color: var(--text-muted, #9C9184); color: var(--text-muted);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
margin-bottom: 6px; margin-bottom: 6px;
@@ -352,7 +352,7 @@
.headersTable td { .headersTable td {
padding: 3px 0; padding: 3px 0;
border-bottom: 1px solid var(--border, #E4DFD8); border-bottom: 1px solid var(--border);
vertical-align: top; vertical-align: top;
} }
@@ -361,9 +361,9 @@
} }
.headerKey { .headerKey {
font-family: var(--font-mono, monospace); font-family: var(--font-mono);
font-weight: 600; font-weight: 600;
color: var(--text-muted, #9C9184); color: var(--text-muted);
white-space: nowrap; white-space: nowrap;
padding-right: 12px; padding-right: 12px;
width: 140px; width: 140px;
@@ -373,8 +373,8 @@
} }
.headerVal { .headerVal {
font-family: var(--font-mono, monospace); font-family: var(--font-mono);
color: var(--text-primary, #1A1612); color: var(--text-primary);
word-break: break-all; word-break: break-all;
} }
@@ -391,39 +391,41 @@
.codeFormat { .codeFormat {
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
color: var(--text-muted, #9C9184); color: var(--text-muted);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
.codeSize { .codeSize {
font-size: 12px; font-size: 12px;
color: var(--text-muted, #9C9184); color: var(--text-muted);
font-family: var(--font-mono, monospace); font-family: var(--font-mono);
} }
.codeCopyBtn { .codeCopyBtn {
margin-left: auto; margin-left: auto;
font-size: 12px; font-size: 12px;
font-family: var(--font-body, inherit); font-family: var(--font-body);
padding: 2px 8px; padding: 2px 8px;
border: 1px solid var(--border, #E4DFD8); border: 1px solid var(--border);
border-radius: 4px; border-radius: 4px;
background: var(--bg-surface, #FFFFFF); background: var(--bg-surface);
color: var(--text-secondary, #5C5347); color: var(--text-secondary);
cursor: pointer; cursor: pointer;
} }
.codeCopyBtn:hover { .codeCopyBtn:hover {
background: var(--bg-hover, #F5F0EA); background: var(--bg-hover);
} }
.codeBlock { .codeBlock {
background: #1A1612; --code-bg: var(--bg-inset);
color: #E4DFD8; --code-fg: var(--text-primary);
background: var(--code-bg);
color: var(--code-fg);
padding: 12px; padding: 12px;
border-radius: 6px; border-radius: 6px;
font-family: var(--font-mono, monospace); font-family: var(--font-mono);
font-size: 12px; font-size: 12px;
line-height: 1.5; line-height: 1.5;
overflow-x: auto; overflow-x: auto;
@@ -439,15 +441,15 @@
.errorType { .errorType {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: var(--error, #C0392B); color: var(--error);
margin-bottom: 8px; margin-bottom: 8px;
} }
.errorMessage { .errorMessage {
font-size: 12px; font-size: 12px;
color: var(--text-primary, #1A1612); color: var(--text-primary);
background: #FDF2F0; background: var(--error-bg);
border: 1px solid #F5D5D0; border: 1px solid var(--error-border);
border-radius: 6px; border-radius: 6px;
padding: 10px 12px; padding: 10px 12px;
margin-bottom: 12px; margin-bottom: 12px;
@@ -456,11 +458,13 @@
} }
.errorStackTrace { .errorStackTrace {
background: #1A1612; --code-bg: var(--bg-inset);
color: #E4DFD8; --code-fg: var(--text-primary);
background: var(--code-bg);
color: var(--code-fg);
padding: 12px; padding: 12px;
border-radius: 6px; border-radius: 6px;
font-family: var(--font-mono, monospace); font-family: var(--font-mono);
font-size: 12px; font-size: 12px;
line-height: 1.5; line-height: 1.5;
overflow-x: auto; overflow-x: auto;
@@ -477,7 +481,7 @@
.errorStackLabel { .errorStackLabel {
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
color: var(--text-muted, #9C9184); color: var(--text-muted);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
margin-bottom: 6px; margin-bottom: 6px;
@@ -505,22 +509,22 @@
} }
.ganttRow:hover { .ganttRow:hover {
background: var(--bg-hover, #F5F0EA); background: var(--bg-hover);
} }
.ganttSelected { .ganttSelected {
background: #FFF8F0; background: var(--amber-bg);
} }
.ganttSelected:hover { .ganttSelected:hover {
background: #FFF8F0; background: var(--amber-bg);
} }
.ganttLabel { .ganttLabel {
width: 100px; width: 100px;
min-width: 100px; min-width: 100px;
font-size: 12px; font-size: 12px;
color: var(--text-secondary, #5C5347); color: var(--text-secondary);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -529,7 +533,7 @@
.ganttBar { .ganttBar {
flex: 1; flex: 1;
height: 16px; height: 16px;
background: var(--bg-hover, #F5F0EA); background: var(--bg-hover);
border-radius: 2px; border-radius: 2px;
position: relative; position: relative;
min-width: 0; min-width: 0;
@@ -543,19 +547,19 @@
} }
.ganttFillCompleted { .ganttFillCompleted {
background: var(--success, #3D7C47); background: var(--success);
} }
.ganttFillFailed { .ganttFillFailed {
background: var(--error, #C0392B); background: var(--error);
} }
.ganttDuration { .ganttDuration {
width: 50px; width: 50px;
min-width: 50px; min-width: 50px;
font-size: 12px; font-size: 12px;
font-family: var(--font-mono, monospace); font-family: var(--font-mono);
color: var(--text-muted, #9C9184); color: var(--text-muted);
text-align: right; text-align: right;
} }
@@ -564,7 +568,7 @@
========================================================================== */ ========================================================================== */
.emptyState { .emptyState {
text-align: center; text-align: center;
color: var(--text-muted, #9C9184); color: var(--text-muted);
font-size: 12px; font-size: 12px;
padding: 20px; padding: 20px;
} }

View File

@@ -14,3 +14,39 @@
margin-left: 4px; margin-left: 4px;
font-size: 12px; font-size: 12px;
} }
.starredList {
padding: 4px 0;
}
.starredIconWrap {
display: flex;
align-items: center;
color: var(--sidebar-muted);
}
.starredLabel {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.starredRemoveBtn {
background: none;
border: none;
padding: 2px;
cursor: pointer;
color: var(--sidebar-muted);
display: flex;
align-items: center;
opacity: 0.6;
}
.mainContent {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}

View File

@@ -232,7 +232,7 @@ function collectStarredItems(apps: SidebarApp[], starredIds: Set<string>): Starr
function StarredList({ items, onNavigate, onRemove }: { items: StarredItem[]; onNavigate: (path: string) => void; onRemove: (key: string) => void }) { function StarredList({ items, onNavigate, onRemove }: { items: StarredItem[]; onNavigate: (path: string) => void; onRemove: (key: string) => void }) {
if (items.length === 0) return null; if (items.length === 0) return null;
return ( return (
<div style={{ padding: '4px 0' }}> <div className={css.starredList}>
{items.map((item) => ( {items.map((item) => (
<div <div
key={item.starKey} key={item.starKey}
@@ -242,13 +242,13 @@ function StarredList({ items, onNavigate, onRemove }: { items: StarredItem[]; on
tabIndex={0} tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onNavigate(item.path); }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onNavigate(item.path); }}
> >
{item.icon && <span style={{ display: 'flex', alignItems: 'center', color: 'var(--sidebar-muted)' }}>{item.icon}</span>} {item.icon && <span className={css.starredIconWrap}>{item.icon}</span>}
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <span className={css.starredLabel}>
{item.label} {item.label}
{item.parentApp && <span className={css.starredParentApp}>{item.parentApp}</span>} {item.parentApp && <span className={css.starredParentApp}>{item.parentApp}</span>}
</span> </span>
<button <button
style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--sidebar-muted)', display: 'flex', alignItems: 'center', opacity: 0.6 }} className={css.starredRemoveBtn}
onClick={(e) => { e.stopPropagation(); onRemove(item.starKey); }} onClick={(e) => { e.stopPropagation(); onRemove(item.starKey); }}
aria-label={`Remove ${item.label} from starred`} aria-label={`Remove ${item.label} from starred`}
> >
@@ -771,7 +771,7 @@ function LayoutContent() {
<ContentTabs active={scope.tab} onChange={setTab} scope={scope} /> <ContentTabs active={scope.tab} onChange={setTab} scope={scope} />
)} )}
<main style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}> <main className={css.mainContent}>
<Outlet key={selectedEnv ?? '__all__'} /> <Outlet key={selectedEnv ?? '__all__'} />
</main> </main>
</AppShell> </AppShell>

View File

@@ -0,0 +1,5 @@
.container {
display: flex;
justify-content: center;
padding: 4rem;
}

View File

@@ -0,0 +1,10 @@
import { Spinner } from '@cameleer/design-system';
import styles from './PageLoader.module.css';
export function PageLoader() {
return (
<div className={styles.container}>
<Spinner size="lg" />
</div>
);
}

View File

@@ -79,8 +79,8 @@ export function CompoundNode({
// Color: own status first, then infer from descendants (for path containers like when/otherwise) // Color: own status first, then infer from descendants (for path containers like when/otherwise)
const effectiveColor = isGated ? 'var(--amber)' const effectiveColor = isGated ? 'var(--amber)'
: isFailed || descendantFailed ? '#C0392B' : isFailed || descendantFailed ? 'var(--error)'
: isCompleted || executedDescendant ? '#3D7C47' : isCompleted || executedDescendant ? 'var(--success)'
: color; : color;
// Dim compound when overlay is active but neither the compound nor any // Dim compound when overlay is active but neither the compound nor any
@@ -99,7 +99,7 @@ export function CompoundNode({
// _CB_FALLBACK: section styling with EIP purple // _CB_FALLBACK: section styling with EIP purple
if (node.type === '_CB_FALLBACK') { if (node.type === '_CB_FALLBACK') {
const fallbackColor = '#7C3AED'; // EIP purple const fallbackColor = 'var(--purple)';
return ( return (
<g data-node-id={node.id} transform={`translate(${x}, ${y})`} opacity={isSkipped ? 0.35 : undefined}> <g data-node-id={node.id} transform={`translate(${x}, ${y})`} opacity={isSkipped ? 0.35 : undefined}>
<rect x={0} y={0} width={w} height={h} rx={CORNER_RADIUS} <rect x={0} y={0} width={w} height={h} rx={CORNER_RADIUS}
@@ -140,7 +140,7 @@ export function CompoundNode({
} }
// Default compound rendering (DO_TRY, EIP_CHOICE, EIP_FILTER, EIP_IDEMPOTENT_CONSUMER, etc.) // Default compound rendering (DO_TRY, EIP_CHOICE, EIP_FILTER, EIP_IDEMPOTENT_CONSUMER, etc.)
const containerFill = isGated ? 'var(--amber-bg)' : 'white'; const containerFill = isGated ? 'var(--amber-bg)' : 'var(--bg-surface)';
return ( return (
<g data-node-id={node.id} transform={`translate(${x}, ${y})`} opacity={isSkipped ? 0.35 : undefined}> <g data-node-id={node.id} transform={`translate(${x}, ${y})`} opacity={isSkipped ? 0.35 : undefined}>
{/* Container body */} {/* Container body */}
@@ -163,15 +163,15 @@ export function CompoundNode({
<g transform={`translate(6, ${HEADER_HEIGHT / 2 - 5}) scale(0.417)`}> <g transform={`translate(6, ${HEADER_HEIGHT / 2 - 5}) scale(0.417)`}>
{iconForType(node.type).map((el: IconElement, i: number) => {iconForType(node.type).map((el: IconElement, i: number) =>
'd' in el 'd' in el
? <path key={i} d={el.d} fill="none" stroke="white" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" /> ? <path key={i} d={el.d} fill="none" stroke="var(--text-inverse)" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
: <circle key={i} cx={el.cx} cy={el.cy} r={el.r} fill="none" stroke="white" strokeWidth={2} /> : <circle key={i} cx={el.cx} cy={el.cy} r={el.r} fill="none" stroke="var(--text-inverse)" strokeWidth={2} />
)} )}
</g> </g>
{/* Header label (centered) */} {/* Header label (centered) */}
<text <text
x={w / 2} x={w / 2}
y={HEADER_HEIGHT / 2 + 4} y={HEADER_HEIGHT / 2 + 4}
fill="white" fill="var(--text-inverse)"
fontSize={10} fontSize={10}
fontWeight={600} fontWeight={600}
textAnchor="middle" textAnchor="middle"

View File

@@ -3,8 +3,8 @@ import type { NodeConfig } from './types';
const BADGE_SIZE = 18; const BADGE_SIZE = 18;
const BADGE_GAP = 4; const BADGE_GAP = 4;
const TRACE_COLOR = '#1A7F8E'; // teal const TRACE_COLOR = 'var(--running)';
const TAP_COLOR = '#7C3AED'; // purple const TAP_COLOR = 'var(--purple)';
interface ConfigBadgeProps { interface ConfigBadgeProps {
nodeWidth: number; nodeWidth: number;
@@ -42,7 +42,7 @@ export function ConfigBadge({ nodeWidth, config, hasTraceData }: ConfigBadgeProp
badges.push( badges.push(
<g key="tap" transform={`translate(${xOffset}, ${-BADGE_SIZE - 4})`}> <g key="tap" transform={`translate(${xOffset}, ${-BADGE_SIZE - 4})`}>
<circle cx={cx} cy={cy} r={BADGE_SIZE / 2} fill={TAP_COLOR} /> <circle cx={cx} cy={cy} r={BADGE_SIZE / 2} fill={TAP_COLOR} />
<g transform="translate(4, 4)" stroke="white" strokeWidth={1.4} fill="none" strokeLinecap="round" strokeLinejoin="round"> <g transform="translate(4, 4)" stroke="var(--text-inverse)" strokeWidth={1.4} fill="none" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 1 C5 1 2 4.5 2 6.5a3 3 0 006 0C8 4.5 5 1 5 1z" /> <path d="M5 1 C5 1 2 4.5 2 6.5a3 3 0 006 0C8 4.5 5 1 5 1z" />
</g> </g>
</g>, </g>,
@@ -70,7 +70,7 @@ export function ConfigBadge({ nodeWidth, config, hasTraceData }: ConfigBadgeProp
</circle> </circle>
<circle cx={cx} cy={cy} r={r} fill={TRACE_COLOR} /> <circle cx={cx} cy={cy} r={r} fill={TRACE_COLOR} />
<g transform={`translate(${(BADGE_SIZE - 14) / 2}, ${(BADGE_SIZE - 14) / 2})`}> <g transform={`translate(${(BADGE_SIZE - 14) / 2}, ${(BADGE_SIZE - 14) / 2})`}>
<FootprintsIcon color="white" size={14} /> <FootprintsIcon color="var(--text-inverse)" size={14} />
</g> </g>
</g>, </g>,
); );

View File

@@ -23,7 +23,7 @@ export function DiagramEdge({ edge, offsetY = 0, traversed }: DiagramEdgeProps)
<path <path
d={d} d={d}
fill="none" fill="none"
stroke={traversed === true ? '#3D7C47' : '#9CA3AF'} stroke={traversed === true ? 'var(--success)' : 'var(--text-muted)'}
strokeWidth={traversed === true ? 1.5 : traversed === false ? 1 : 1.5} strokeWidth={traversed === true ? 1.5 : traversed === false ? 1 : 1.5}
strokeDasharray={traversed === false ? '4,3' : undefined} strokeDasharray={traversed === false ? '4,3' : undefined}
markerEnd={traversed === true ? 'url(#arrowhead-green)' : traversed === false ? undefined : 'url(#arrowhead)'} markerEnd={traversed === true ? 'url(#arrowhead-green)' : traversed === false ? undefined : 'url(#arrowhead)'}
@@ -32,7 +32,7 @@ export function DiagramEdge({ edge, offsetY = 0, traversed }: DiagramEdgeProps)
<text <text
x={(pts[0][0] + pts[pts.length - 1][0]) / 2} x={(pts[0][0] + pts[pts.length - 1][0]) / 2}
y={(pts[0][1] + pts[pts.length - 1][1]) / 2 + offsetY - 6} y={(pts[0][1] + pts[pts.length - 1][1]) / 2 + offsetY - 6}
fill="#9C9184" fill="var(--text-muted)"
fontSize={9} fontSize={9}
textAnchor="middle" textAnchor="middle"
> >

View File

@@ -61,23 +61,27 @@ export function DiagramNode({
const isSkipped = overlayActive && !executionState; const isSkipped = overlayActive && !executionState;
// Colors based on execution state (heatmap takes priority when no execution overlay) // Colors based on execution state (heatmap takes priority when no execution overlay)
let cardFill = isHovered ? '#F5F0EA' : 'white'; let cardFill = isHovered ? 'var(--bg-hover)' : 'var(--bg-surface)';
let borderStroke = isHovered || isSelected ? color : '#E4DFD8'; let borderStroke = isHovered || isSelected ? color : 'var(--border-subtle)';
let borderWidth = isHovered || isSelected ? 1.5 : 1; let borderWidth = isHovered || isSelected ? 1.5 : 1;
let topBarColor = color; let topBarColor = color;
let labelColor = '#1A1612'; let labelColor = 'var(--text-primary)';
if (isCompleted) { if (isCompleted) {
cardFill = isHovered ? '#E4F5E6' : '#F0F9F1'; cardFill = isHovered
borderStroke = '#3D7C47'; ? 'color-mix(in srgb, var(--success) 15%, var(--bg-surface))'
: 'color-mix(in srgb, var(--success) 10%, var(--bg-surface))';
borderStroke = 'var(--success)';
borderWidth = 1.5; borderWidth = 1.5;
topBarColor = '#3D7C47'; topBarColor = 'var(--success)';
} else if (isFailed) { } else if (isFailed) {
cardFill = isHovered ? '#F9E4E1' : '#FDF2F0'; cardFill = isHovered
borderStroke = '#C0392B'; ? 'color-mix(in srgb, var(--error) 15%, var(--bg-surface))'
: 'color-mix(in srgb, var(--error) 10%, var(--bg-surface))';
borderStroke = 'var(--error)';
borderWidth = 2; borderWidth = 2;
topBarColor = '#C0392B'; topBarColor = 'var(--error)';
labelColor = '#C0392B'; labelColor = 'var(--error)';
} else if (heatmapEntry && !overlayActive) { } else if (heatmapEntry && !overlayActive) {
cardFill = heatmapColor(heatmapEntry.pctOfRoute); cardFill = heatmapColor(heatmapEntry.pctOfRoute);
borderStroke = heatmapBorderColor(heatmapEntry.pctOfRoute); borderStroke = heatmapBorderColor(heatmapEntry.pctOfRoute);
@@ -85,7 +89,7 @@ export function DiagramNode({
topBarColor = heatmapBorderColor(heatmapEntry.pctOfRoute); topBarColor = heatmapBorderColor(heatmapEntry.pctOfRoute);
} }
const statusColor = isCompleted ? '#3D7C47' : isFailed ? '#C0392B' : undefined; const statusColor = isCompleted ? 'var(--success)' : isFailed ? 'var(--error)' : undefined;
return ( return (
<g <g
@@ -107,7 +111,7 @@ export function DiagramNode({
height={h + 4} height={h + 4}
rx={CORNER_RADIUS + 2} rx={CORNER_RADIUS + 2}
fill="none" fill="none"
stroke="#C6820E" stroke="var(--amber)"
strokeWidth={2.5} strokeWidth={2.5}
/> />
)} )}
@@ -150,11 +154,11 @@ export function DiagramNode({
{typeName} {typeName}
</text> </text>
{detail && detail !== typeName && ( {detail && detail !== typeName && (
<text x={TEXT_LEFT} y={TOP_BAR_HEIGHT + 24} fill={isFailed ? '#C0392B' : '#5C5347'} fontSize={10}> <text x={TEXT_LEFT} y={TOP_BAR_HEIGHT + 24} fill={isFailed ? 'var(--error)' : 'var(--text-secondary)'} fontSize={10}>
{detail} {detail}
</text> </text>
)} )}
<text x={TEXT_LEFT} y={TOP_BAR_HEIGHT + (detail && detail !== typeName ? 35 : 24)} fill="#1A7F8E" fontSize={9} fontStyle="italic"> <text x={TEXT_LEFT} y={TOP_BAR_HEIGHT + (detail && detail !== typeName ? 35 : 24)} fill="var(--running)" fontSize={9} fontStyle="italic">
{resolvedUri.split('?')[0]} {resolvedUri.split('?')[0]}
</text> </text>
</> </>
@@ -164,7 +168,7 @@ export function DiagramNode({
{typeName} {typeName}
</text> </text>
{detail && detail !== typeName && ( {detail && detail !== typeName && (
<text x={TEXT_LEFT} y={h / 2 + 12} fill={isFailed ? '#C0392B' : '#5C5347'} fontSize={10}> <text x={TEXT_LEFT} y={h / 2 + 12} fill={isFailed ? 'var(--error)' : 'var(--text-secondary)'} fontSize={10}>
{detail} {detail}
</text> </text>
)} )}
@@ -189,24 +193,24 @@ export function DiagramNode({
if (isCompleted) { if (isCompleted) {
badges.push( badges.push(
<g key="status"> <g key="status">
<circle cx={statusCx} cy={cy} r={BADGE_R} fill="#3D7C47" /> <circle cx={statusCx} cy={cy} r={BADGE_R} fill="var(--success)" />
<path d={`M${statusCx - 3} ${cy} l2 2 4-4`} fill="none" stroke="white" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" /> <path d={`M${statusCx - 3} ${cy} l2 2 4-4`} fill="none" stroke="var(--text-inverse)" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
</g> </g>
); );
slot++; slot++;
} else if (isFailed) { } else if (isFailed) {
badges.push( badges.push(
<g key="status"> <g key="status">
<circle cx={statusCx} cy={cy} r={BADGE_R} fill="none" stroke="#C0392B" strokeWidth={2} opacity={0.5}> <circle cx={statusCx} cy={cy} r={BADGE_R} fill="none" stroke="var(--error)" strokeWidth={2} opacity={0.5}>
<animate attributeName="r" values="6;14" dur="1.5s" repeatCount="indefinite" /> <animate attributeName="r" values="6;14" dur="1.5s" repeatCount="indefinite" />
<animate attributeName="opacity" values="0.5;0" dur="1.5s" repeatCount="indefinite" /> <animate attributeName="opacity" values="0.5;0" dur="1.5s" repeatCount="indefinite" />
</circle> </circle>
<circle cx={statusCx} cy={cy} r={BADGE_R} fill="none" stroke="#C0392B" strokeWidth={2} opacity={0.5}> <circle cx={statusCx} cy={cy} r={BADGE_R} fill="none" stroke="var(--error)" strokeWidth={2} opacity={0.5}>
<animate attributeName="r" values="6;14" dur="1.5s" begin="0.75s" repeatCount="indefinite" /> <animate attributeName="r" values="6;14" dur="1.5s" begin="0.75s" repeatCount="indefinite" />
<animate attributeName="opacity" values="0.5;0" dur="1.5s" begin="0.75s" repeatCount="indefinite" /> <animate attributeName="opacity" values="0.5;0" dur="1.5s" begin="0.75s" repeatCount="indefinite" />
</circle> </circle>
<circle cx={statusCx} cy={cy} r={BADGE_R} fill="#C0392B" /> <circle cx={statusCx} cy={cy} r={BADGE_R} fill="var(--error)" />
<path d={`M${statusCx} ${cy - 3} v4 M${statusCx} ${cy + 2.5} v0.5`} fill="none" stroke="white" strokeWidth={1.5} strokeLinecap="round" /> <path d={`M${statusCx} ${cy - 3} v4 M${statusCx} ${cy + 2.5} v0.5`} fill="none" stroke="var(--text-inverse)" strokeWidth={1.5} strokeLinecap="round" />
</g> </g>
); );
slot++; slot++;
@@ -217,8 +221,8 @@ export function DiagramNode({
const tapCx = statusCx - slot * (BADGE_D + BADGE_GAP); const tapCx = statusCx - slot * (BADGE_D + BADGE_GAP);
badges.push( badges.push(
<g key="tap"> <g key="tap">
<circle cx={tapCx} cy={cy} r={BADGE_R} fill="#7C3AED" /> <circle cx={tapCx} cy={cy} r={BADGE_R} fill="var(--purple)" />
<g transform={`translate(${tapCx - 5}, ${cy - 5})`} stroke="white" strokeWidth={1.4} fill="none" strokeLinecap="round" strokeLinejoin="round"> <g transform={`translate(${tapCx - 5}, ${cy - 5})`} stroke="var(--text-inverse)" strokeWidth={1.4} fill="none" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 1 C5 1 2 4.5 2 6.5a3 3 0 006 0C8 4.5 5 1 5 1z" /> <path d="M5 1 C5 1 2 4.5 2 6.5a3 3 0 006 0C8 4.5 5 1 5 1z" />
</g> </g>
</g> </g>
@@ -235,18 +239,18 @@ export function DiagramNode({
<g key="trace"> <g key="trace">
{tracePulse && ( {tracePulse && (
<> <>
<circle cx={traceCx} cy={cy} r={BADGE_R} fill="none" stroke="#1A7F8E" strokeWidth={2} opacity={0.5}> <circle cx={traceCx} cy={cy} r={BADGE_R} fill="none" stroke="var(--running)" strokeWidth={2} opacity={0.5}>
<animate attributeName="r" values={`${BADGE_R};${BADGE_R + 8}`} dur="1.5s" repeatCount="indefinite" /> <animate attributeName="r" values={`${BADGE_R};${BADGE_R + 8}`} dur="1.5s" repeatCount="indefinite" />
<animate attributeName="opacity" values="0.5;0" dur="1.5s" repeatCount="indefinite" /> <animate attributeName="opacity" values="0.5;0" dur="1.5s" repeatCount="indefinite" />
</circle> </circle>
<circle cx={traceCx} cy={cy} r={BADGE_R} fill="none" stroke="#1A7F8E" strokeWidth={2} opacity={0.5}> <circle cx={traceCx} cy={cy} r={BADGE_R} fill="none" stroke="var(--running)" strokeWidth={2} opacity={0.5}>
<animate attributeName="r" values={`${BADGE_R};${BADGE_R + 8}`} dur="1.5s" begin="0.75s" repeatCount="indefinite" /> <animate attributeName="r" values={`${BADGE_R};${BADGE_R + 8}`} dur="1.5s" begin="0.75s" repeatCount="indefinite" />
<animate attributeName="opacity" values="0.5;0" dur="1.5s" begin="0.75s" repeatCount="indefinite" /> <animate attributeName="opacity" values="0.5;0" dur="1.5s" begin="0.75s" repeatCount="indefinite" />
</circle> </circle>
</> </>
)} )}
<circle cx={traceCx} cy={cy} r={BADGE_R} fill={traceHasData ? '#1A7F8E' : '#1A7F8E'} opacity={traceHasData ? 1 : 0.2} /> <circle cx={traceCx} cy={cy} r={BADGE_R} fill="var(--running)" opacity={traceHasData ? 1 : 0.2} />
<g transform={`translate(${traceCx - 5}, ${cy - 5}) scale(${10/24})`} stroke={traceHasData ? 'white' : '#1A7F8E'} strokeWidth={2.4} fill="none" strokeLinecap="round" strokeLinejoin="round"> <g transform={`translate(${traceCx - 5}, ${cy - 5}) scale(${10/24})`} stroke={traceHasData ? 'var(--text-inverse)' : 'var(--running)'} strokeWidth={2.4} fill="none" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 16v-2.38C4 11.5 2.97 10.5 3 8c.03-2.72 1.49-6 4.5-6C9.37 2 10 3.8 10 5.5c0 3.11-2 5.66-2 8.68V16a2 2 0 1 1-4 0Z" /> <path d="M4 16v-2.38C4 11.5 2.97 10.5 3 8c.03-2.72 1.49-6 4.5-6C9.37 2 10 3.8 10 5.5c0 3.11-2 5.66-2 8.68V16a2 2 0 1 1-4 0Z" />
<path d="M20 20v-2.38c0-2.12 1.03-3.12 1-5.62-.03-2.72-1.49-6-4.5-6C14.63 6 14 7.8 14 9.5c0 3.11 2 5.66 2 8.68V20a2 2 0 1 0 4 0Z" /> <path d="M20 20v-2.38c0-2.12 1.03-3.12 1-5.62-.03-2.72-1.49-6-4.5-6C14.63 6 14 7.8 14 9.5c0 3.11 2 5.66 2 8.68V20a2 2 0 1 0 4 0Z" />
<path d="M16 17h4" /> <path d="M16 17h4" />
@@ -292,9 +296,9 @@ export function DiagramNode({
<g transform={`translate(4, ${h - 14})`}> <g transform={`translate(4, ${h - 14})`}>
<path <path
d="M2 2 v5 a3 3 0 003 3 h5" d="M2 2 v5 a3 3 0 003 3 h5"
fill="none" stroke="#C0392B" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" fill="none" stroke="var(--error)" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round"
/> />
<path d="M8 8 l2 2 -2 2" fill="none" stroke="#C0392B" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" /> <path d="M8 8 l2 2 -2 2" fill="none" stroke="var(--error)" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
</g> </g>
)} )}
</g> </g>

View File

@@ -32,8 +32,8 @@ interface ErrorSectionProps {
} }
const VARIANT_COLORS: Record<string, string> = { const VARIANT_COLORS: Record<string, string> = {
error: '#C0392B', error: 'var(--error)',
completion: '#1A7F8E', completion: 'var(--running)',
}; };
export function ErrorSection({ export function ErrorSection({

View File

@@ -147,12 +147,12 @@ function nodeColor(
): { fill: string; opacity: number } { ): { fill: string; opacity: number } {
if (!overlay) return { fill: colorForType(node.type), opacity: 0.7 }; if (!overlay) return { fill: colorForType(node.type), opacity: 0.7 };
const state = node.id ? overlay.get(node.id) : undefined; const state = node.id ? overlay.get(node.id) : undefined;
if (state?.status === 'COMPLETED') return { fill: '#3D7C47', opacity: 0.85 }; if (state?.status === 'COMPLETED') return { fill: 'var(--success)', opacity: 0.85 };
if (state?.status === 'FAILED') return { fill: '#C0392B', opacity: 0.85 }; if (state?.status === 'FAILED') return { fill: 'var(--error)', opacity: 0.85 };
// ENDPOINT is always traversed when overlay is active (route entry point) // ENDPOINT is always traversed when overlay is active (route entry point)
if (node.type === 'ENDPOINT' && overlay.size > 0) return { fill: '#3D7C47', opacity: 0.85 }; if (node.type === 'ENDPOINT' && overlay.size > 0) return { fill: 'var(--success)', opacity: 0.85 };
// Skipped (overlay active but node not executed) // Skipped (overlay active but node not executed)
return { fill: '#9CA3AF', opacity: 0.35 }; return { fill: 'var(--text-muted)', opacity: 0.35 };
} }
function renderMinimapNodes( function renderMinimapNodes(

View File

@@ -4,7 +4,7 @@ import type { NodeAction, NodeConfig } from './types';
import styles from './ProcessDiagram.module.css'; import styles from './ProcessDiagram.module.css';
const HIDE_DELAY = 150; const HIDE_DELAY = 150;
const TRACE_ACTIVE_COLOR = '#1A7F8E'; const TRACE_ACTIVE_COLOR = 'var(--running)';
interface NodeToolbarProps { interface NodeToolbarProps {
nodeId: string; nodeId: string;
@@ -50,7 +50,7 @@ export function NodeToolbar({
className={`${styles.nodeToolbarBtn} ${tapActive ? styles.nodeToolbarBtnActive : ''}`} className={`${styles.nodeToolbarBtn} ${tapActive ? styles.nodeToolbarBtnActive : ''}`}
title={tapActive ? 'Edit tap' : 'Configure tap'} title={tapActive ? 'Edit tap' : 'Configure tap'}
onClick={(e) => { e.stopPropagation(); onAction(nodeId, 'configure-tap'); }} onClick={(e) => { e.stopPropagation(); onAction(nodeId, 'configure-tap'); }}
style={tapActive ? { color: '#7C3AED' } : undefined} style={tapActive ? { color: 'var(--purple)' } : undefined}
> >
<Droplets size={14} /> <Droplets size={14} />
</button> </button>

View File

@@ -4,9 +4,9 @@
height: 100%; height: 100%;
min-height: 300px; min-height: 300px;
overflow: hidden; overflow: hidden;
background: var(--bg-surface, #FFFFFF); background: var(--bg-surface);
border: 1px solid var(--border, #E4DFD8); border: 1px solid var(--border);
border-radius: var(--radius-md, 8px); border-radius: var(--radius-md);
} }
.svg { .svg {
@@ -25,7 +25,7 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
min-height: 300px; min-height: 300px;
color: var(--text-muted, #9C9184); color: var(--text-muted);
font-size: 14px; font-size: 14px;
} }
@@ -36,7 +36,7 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
min-height: 300px; min-height: 300px;
color: var(--error, #C0392B); color: var(--error);
font-size: 14px; font-size: 14px;
} }
@@ -47,11 +47,11 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
background: var(--bg-surface, #FFFFFF); background: var(--bg-surface);
border: 1px solid var(--border, #E4DFD8); border: 1px solid var(--border);
border-radius: var(--radius-sm, 5px); border-radius: var(--radius-sm);
padding: 4px; padding: 4px;
box-shadow: var(--shadow-md, 0 2px 8px rgba(44, 37, 32, 0.08)); box-shadow: var(--shadow-md);
} }
.zoomBtn { .zoomBtn {
@@ -62,19 +62,19 @@
height: 28px; height: 28px;
border: none; border: none;
background: transparent; background: transparent;
color: var(--text-primary, #1A1612); color: var(--text-primary);
font-size: 16px; font-size: 16px;
cursor: pointer; cursor: pointer;
border-radius: var(--radius-sm, 5px); border-radius: var(--radius-sm);
} }
.zoomBtn:hover { .zoomBtn:hover {
background: var(--bg-hover, #F5F0EA); background: var(--bg-hover);
} }
.zoomLevel { .zoomLevel {
font-size: 12px; font-size: 12px;
color: var(--text-muted, #9C9184); color: var(--text-muted);
min-width: 36px; min-width: 36px;
text-align: center; text-align: center;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
@@ -87,10 +87,10 @@
display: flex; display: flex;
align-items: center; align-items: center;
padding: 4px 10px; padding: 4px 10px;
background: var(--bg-surface, #FFFFFF); background: var(--bg-surface);
border: 1px solid var(--border, #E4DFD8); border: 1px solid var(--border);
border-radius: var(--radius-sm, 5px); border-radius: var(--radius-sm);
box-shadow: var(--shadow-sm, 0 1px 2px rgba(44, 37, 32, 0.06)); box-shadow: var(--shadow-sm);
z-index: 10; z-index: 10;
font-size: 12px; font-size: 12px;
} }
@@ -102,13 +102,13 @@
.breadcrumbSep { .breadcrumbSep {
margin: 0 6px; margin: 0 6px;
color: var(--text-muted, #9C9184); color: var(--text-muted);
} }
.breadcrumbLink { .breadcrumbLink {
background: none; background: none;
border: none; border: none;
color: var(--running, #1A7F8E); color: var(--running);
cursor: pointer; cursor: pointer;
padding: 0; padding: 0;
font-size: 12px; font-size: 12px;
@@ -120,7 +120,7 @@
} }
.breadcrumbCurrent { .breadcrumbCurrent {
color: var(--text-primary, #1A1612); color: var(--text-primary);
font-weight: 600; font-weight: 600;
} }
@@ -128,9 +128,9 @@
position: absolute; position: absolute;
bottom: 52px; bottom: 52px;
right: 12px; right: 12px;
border: 1px solid var(--border, #E4DFD8); border: 1px solid var(--border);
border-radius: var(--radius-sm, 5px); border-radius: var(--radius-sm);
box-shadow: var(--shadow-md, 0 2px 8px rgba(44, 37, 32, 0.08)); box-shadow: var(--shadow-md);
overflow: hidden; overflow: hidden;
z-index: 5; z-index: 5;
} }
@@ -141,10 +141,10 @@
align-items: center; align-items: center;
gap: 2px; gap: 2px;
padding: 3px 4px; padding: 3px 4px;
background: var(--bg-surface, #FFFFFF); background: var(--bg-surface);
border: 1px solid var(--border, #E4DFD8); border: 1px solid var(--border);
border-radius: var(--radius-sm, 5px); border-radius: var(--radius-sm);
box-shadow: var(--shadow-lg, 0 4px 16px rgba(44, 37, 32, 0.10)); box-shadow: var(--shadow-lg);
transform: translate(-50%, -100%); transform: translate(-50%, -100%);
margin-top: -6px; margin-top: -6px;
z-index: 10; z-index: 10;
@@ -159,31 +159,31 @@
height: 26px; height: 26px;
border: none; border: none;
background: transparent; background: transparent;
color: var(--text-secondary, #5C5347); color: var(--text-secondary);
font-size: 12px; font-size: 12px;
cursor: pointer; cursor: pointer;
border-radius: var(--radius-sm, 5px); border-radius: var(--radius-sm);
padding: 0; padding: 0;
} }
.nodeToolbarBtn:hover { .nodeToolbarBtn:hover {
background: var(--bg-hover, #F5F0EA); background: var(--bg-hover);
color: var(--text-primary, #1A1612); color: var(--text-primary);
} }
.nodeToolbarBtnActive { .nodeToolbarBtnActive {
background: var(--bg-hover, #F5F0EA); background: var(--bg-hover);
} }
.iterationStepper { .iterationStepper {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 2px; gap: 2px;
background: rgba(255, 255, 255, 0.15); background: color-mix(in srgb, var(--bg-surface) 15%, transparent);
border-radius: 3px; border-radius: 3px;
padding: 1px 3px; padding: 1px 3px;
font-size: 12px; font-size: 12px;
color: white; color: var(--text-primary);
font-family: inherit; font-family: inherit;
} }
@@ -191,8 +191,8 @@
width: 16px; width: 16px;
height: 16px; height: 16px;
border: none; border: none;
background: rgba(255, 255, 255, 0.2); background: color-mix(in srgb, var(--bg-surface) 20%, transparent);
color: white; color: var(--text-primary);
border-radius: 2px; border-radius: 2px;
cursor: pointer; cursor: pointer;
font-size: 12px; font-size: 12px;

View File

@@ -294,7 +294,7 @@ export function ProcessDiagram({
refY="3" refY="3"
orient="auto" orient="auto"
> >
<polygon points="0 0, 8 3, 0 6" fill="#9CA3AF" /> <polygon points="0 0, 8 3, 0 6" fill="var(--text-muted)" />
</marker> </marker>
<marker <marker
id="arrowhead-green" id="arrowhead-green"
@@ -304,7 +304,7 @@ export function ProcessDiagram({
refY="3" refY="3"
orient="auto" orient="auto"
> >
<polygon points="0 0, 8 3, 0 6" fill="#3D7C47" /> <polygon points="0 0, 8 3, 0 6" fill="var(--success)" />
</marker> </marker>
</defs> </defs>

View File

@@ -0,0 +1,6 @@
.content {
flex: 1;
overflow: auto;
min-height: 0;
padding: 20px 24px 40px;
}

View File

@@ -1,8 +1,9 @@
import { Outlet } from 'react-router'; import { Outlet } from 'react-router';
import styles from './AdminLayout.module.css';
export default function AdminLayout() { export default function AdminLayout() {
return ( return (
<div style={{ flex: 1, overflow: 'auto', minHeight: 0, padding: '20px 24px 40px' }}> <div className={styles.content}>
<Outlet /> <Outlet />
</div> </div>
); );

View File

@@ -103,18 +103,6 @@
color: var(--text-muted); color: var(--text-muted);
} }
.section {
margin-bottom: 16px;
display: flex;
flex-direction: column;
gap: 12px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 16px 20px;
}
.sectionSummary { .sectionSummary {
font-size: 12px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);

View File

@@ -11,6 +11,7 @@ import { useCatalog } from '../../api/queries/catalog';
import type { CatalogApp, CatalogRoute } from '../../api/queries/catalog'; import type { CatalogApp, CatalogRoute } from '../../api/queries/catalog';
import { applyTracedProcessorUpdate, applyRouteRecordingUpdate } from '../../utils/config-draft-utils'; import { applyTracedProcessorUpdate, applyRouteRecordingUpdate } from '../../utils/config-draft-utils';
import styles from './AppConfigDetailPage.module.css'; import styles from './AppConfigDetailPage.module.css';
import sectionStyles from '../../styles/section-card.module.css';
type BadgeColor = 'primary' | 'success' | 'warning' | 'error' | 'running' | 'auto'; type BadgeColor = 'primary' | 'success' | 'warning' | 'error' | 'running' | 'auto';
@@ -325,7 +326,7 @@ export default function AppConfigDetailPage() {
</div> </div>
{/* ── Settings ──────────────────────────────────────────────────── */} {/* ── Settings ──────────────────────────────────────────────────── */}
<div className={styles.section}> <div className={sectionStyles.section}>
<SectionHeader>Settings</SectionHeader> <SectionHeader>Settings</SectionHeader>
<div className={styles.settingsGrid}> <div className={styles.settingsGrid}>
<div className={styles.field}> <div className={styles.field}>
@@ -424,7 +425,7 @@ export default function AppConfigDetailPage() {
</div> </div>
{/* ── Traces & Taps ─────────────────────────────────────────────── */} {/* ── Traces & Taps ─────────────────────────────────────────────── */}
<div className={styles.section}> <div className={sectionStyles.section}>
<SectionHeader>Traces &amp; Taps</SectionHeader> <SectionHeader>Traces &amp; Taps</SectionHeader>
<span className={styles.sectionSummary}> <span className={styles.sectionSummary}>
{tracedCount} traced &middot; {tapCount} taps &middot; manage taps on route pages {tracedCount} traced &middot; {tapCount} taps &middot; manage taps on route pages
@@ -440,7 +441,7 @@ export default function AppConfigDetailPage() {
</div> </div>
{/* ── Route Recording ───────────────────────────────────────────── */} {/* ── Route Recording ───────────────────────────────────────────── */}
<div className={styles.section}> <div className={sectionStyles.section}>
<SectionHeader>Route Recording</SectionHeader> <SectionHeader>Route Recording</SectionHeader>
<span className={styles.sectionSummary}> <span className={styles.sectionSummary}>
{recordingCount} of {routeRecordingRows.length} routes recording {recordingCount} of {routeRecordingRows.length} routes recording

View File

@@ -13,39 +13,6 @@
width: 160px; width: 160px;
} }
.tableSection {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
}
.tableHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
}
.tableTitle {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.tableRight {
display: flex;
align-items: center;
gap: 10px;
}
.tableMeta {
font-size: 12px;
color: var(--text-muted);
}
.target { .target {
display: inline-block; display: inline-block;
max-width: 220px; max-width: 220px;

View File

@@ -5,6 +5,7 @@ import {
import type { Column } from '@cameleer/design-system'; import type { Column } from '@cameleer/design-system';
import { useAuditLog, type AuditEvent } from '../../api/queries/admin/audit'; import { useAuditLog, type AuditEvent } from '../../api/queries/admin/audit';
import styles from './AuditLogPage.module.css'; import styles from './AuditLogPage.module.css';
import tableStyles from '../../styles/table-section.module.css';
const CATEGORIES = [ const CATEGORIES = [
{ value: '', label: 'All categories' }, { value: '', label: 'All categories' },
@@ -117,11 +118,11 @@ export default function AuditLogPage() {
/> />
</div> </div>
<div className={styles.tableSection}> <div className={tableStyles.tableSection}>
<div className={styles.tableHeader}> <div className={tableStyles.tableHeader}>
<span className={styles.tableTitle}>Audit Log</span> <span className={tableStyles.tableTitle}>Audit Log</span>
<div className={styles.tableRight}> <div className={tableStyles.tableRight}>
<span className={styles.tableMeta}> <span className={tableStyles.tableMeta}>
{totalCount} events {totalCount} events
</span> </span>
<Badge label="AUTO" color="success" /> <Badge label="AUTO" color="success" />

View File

@@ -34,32 +34,7 @@
} }
.tableSection { .tableSection {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
margin-bottom: 16px; margin-bottom: 16px;
overflow: hidden;
}
.tableHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
}
.tableTitle {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.tableMeta {
font-size: 12px;
color: var(--text-muted);
font-family: var(--font-mono);
} }
.queryText { .queryText {

View File

@@ -2,6 +2,7 @@ import { StatCard, DataTable, ProgressBar } from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system'; import type { Column } from '@cameleer/design-system';
import { useClickHouseStatus, useClickHouseTables, useClickHousePerformance, useClickHouseQueries, useIndexerPipeline } from '../../api/queries/admin/clickhouse'; import { useClickHouseStatus, useClickHouseTables, useClickHousePerformance, useClickHouseQueries, useIndexerPipeline } from '../../api/queries/admin/clickhouse';
import styles from './ClickHouseAdminPage.module.css'; import styles from './ClickHouseAdminPage.module.css';
import tableStyles from '../../styles/table-section.module.css';
export default function ClickHouseAdminPage() { export default function ClickHouseAdminPage() {
const { data: status, isError: statusError } = useClickHouseStatus(); const { data: status, isError: statusError } = useClickHouseStatus();
@@ -65,10 +66,10 @@ export default function ClickHouseAdminPage() {
)} )}
{/* Tables */} {/* Tables */}
<div className={styles.tableSection}> <div className={`${tableStyles.tableSection} ${styles.tableSection}`}>
<div className={styles.tableHeader}> <div className={tableStyles.tableHeader}>
<span className={styles.tableTitle}>Tables ({(tables || []).length})</span> <span className={tableStyles.tableTitle}>Tables ({(tables || []).length})</span>
{totalSizeLabel && <span className={styles.tableMeta}>{totalSizeLabel} total</span>} {totalSizeLabel && <span className={tableStyles.tableMeta}>{totalSizeLabel} total</span>}
</div> </div>
<DataTable <DataTable
columns={tableColumns} columns={tableColumns}
@@ -80,9 +81,9 @@ export default function ClickHouseAdminPage() {
</div> </div>
{/* Active Queries */} {/* Active Queries */}
<div className={styles.tableSection}> <div className={`${tableStyles.tableSection} ${styles.tableSection}`}>
<div className={styles.tableHeader}> <div className={tableStyles.tableHeader}>
<span className={styles.tableTitle}>Active Queries ({(queries || []).length})</span> <span className={tableStyles.tableTitle}>Active Queries ({(queries || []).length})</span>
</div> </div>
<DataTable <DataTable
columns={queryColumns} columns={queryColumns}

View File

@@ -0,0 +1,44 @@
.pageTitle {
font-size: 13px;
font-weight: 600;
margin-bottom: 1rem;
}
.statStrip {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.cardBody {
padding: 1rem;
}
.sectionTitle {
font-size: 13px;
font-weight: 600;
margin-bottom: 0.5rem;
}
.poolStats {
display: flex;
gap: 2rem;
margin-top: 0.5rem;
font-size: 0.875rem;
}
.section {
margin-top: 1.5rem;
}
.sectionHeading {
font-size: 13px;
font-weight: 600;
margin-bottom: 0.75rem;
}
.querySnippet {
font-size: 0.75rem;
font-family: var(--font-mono);
}

View File

@@ -1,6 +1,7 @@
import { StatCard, Card, DataTable, Badge, Button, ProgressBar, Spinner } from '@cameleer/design-system'; import { StatCard, Card, DataTable, Badge, Button, ProgressBar, Spinner } from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system'; import type { Column } from '@cameleer/design-system';
import { useDatabaseStatus, useConnectionPool, useDatabaseTables, useActiveQueries, useKillQuery } from '../../api/queries/admin/database'; import { useDatabaseStatus, useConnectionPool, useDatabaseTables, useActiveQueries, useKillQuery } from '../../api/queries/admin/database';
import styles from './DatabaseAdminPage.module.css';
export default function DatabaseAdminPage() { export default function DatabaseAdminPage() {
const { data: status, isError: statusError } = useDatabaseStatus(); const { data: status, isError: statusError } = useDatabaseStatus();
@@ -23,7 +24,7 @@ export default function DatabaseAdminPage() {
{ key: 'pid', header: 'PID' }, { key: 'pid', header: 'PID' },
{ key: 'durationSeconds', header: 'Duration', render: (v) => `${v}s` }, { key: 'durationSeconds', header: 'Duration', render: (v) => `${v}s` },
{ key: 'state', header: 'State', render: (v) => <Badge label={String(v)} /> }, { key: 'state', header: 'State', render: (v) => <Badge label={String(v)} /> },
{ key: 'query', header: 'Query', render: (v) => <span style={{ fontSize: '0.75rem', fontFamily: 'var(--font-mono)' }}>{String(v).slice(0, 80)}</span> }, { key: 'query', header: 'Query', render: (v) => <span className={styles.querySnippet}>{String(v).slice(0, 80)}</span> },
{ {
key: 'pid', header: '', width: '80px', key: 'pid', header: '', width: '80px',
render: (v) => <Button variant="danger" size="sm" onClick={() => killQuery.mutate(v as number)}>Kill</Button>, render: (v) => <Button variant="danger" size="sm" onClick={() => killQuery.mutate(v as number)}>Kill</Button>,
@@ -32,19 +33,19 @@ export default function DatabaseAdminPage() {
return ( return (
<div> <div>
<h2 style={{ marginBottom: '1rem' }}>Database Administration</h2> <div className={styles.pageTitle}>Database Administration</div>
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}> <div className={styles.statStrip}>
<StatCard label="Status" value={unreachable ? 'Disconnected' : status ? 'Connected' : '\u2014'} accent={unreachable ? 'error' : status ? 'success' : undefined} /> <StatCard label="Status" value={unreachable ? 'Disconnected' : status ? 'Connected' : '\u2014'} accent={unreachable ? 'error' : status ? 'success' : undefined} />
<StatCard label="Version" value={status?.version ?? '—'} /> <StatCard label="Version" value={status?.version ?? '—'} />
</div> </div>
{pool && ( {pool && (
<Card> <Card>
<div style={{ padding: '1rem' }}> <div className={styles.cardBody}>
<h3 style={{ marginBottom: '0.5rem' }}>Connection Pool</h3> <div className={styles.sectionTitle}>Connection Pool</div>
<ProgressBar value={poolPct} /> <ProgressBar value={poolPct} />
<div style={{ display: 'flex', gap: '2rem', marginTop: '0.5rem', fontSize: '0.875rem' }}> <div className={styles.poolStats}>
<span>Active: {pool.activeConnections}</span> <span>Active: {pool.activeConnections}</span>
<span>Idle: {pool.idleConnections}</span> <span>Idle: {pool.idleConnections}</span>
<span>Max: {pool.maximumPoolSize}</span> <span>Max: {pool.maximumPoolSize}</span>
@@ -53,13 +54,13 @@ export default function DatabaseAdminPage() {
</Card> </Card>
)} )}
<div style={{ marginTop: '1.5rem' }}> <div className={styles.section}>
<h3 style={{ marginBottom: '0.75rem' }}>Tables</h3> <div className={styles.sectionHeading}>Tables</div>
<DataTable columns={tableColumns} data={(tables || []).map((t: any) => ({ ...t, id: t.tableName }))} sortable pageSize={20} /> <DataTable columns={tableColumns} data={(tables || []).map((t: any) => ({ ...t, id: t.tableName }))} sortable pageSize={20} />
</div> </div>
<div style={{ marginTop: '1.5rem' }}> <div className={styles.section}>
<h3 style={{ marginBottom: '0.75rem' }}>Active Queries</h3> <div className={styles.sectionHeading}>Active Queries</div>
<DataTable columns={queryColumns} data={(queries || []).map((q: any) => ({ ...q, id: String(q.pid) }))} /> <DataTable columns={queryColumns} data={(queries || []).map((q: any) => ({ ...q, id: String(q.pid) }))} />
</div> </div>
</div> </div>

View File

@@ -10,18 +10,6 @@
margin-bottom: 20px; margin-bottom: 20px;
} }
.section {
margin-bottom: 16px;
display: flex;
flex-direction: column;
gap: 12px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 16px 20px;
}
.toggleRow { .toggleRow {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -5,6 +5,7 @@ import {
import { useToast } from '@cameleer/design-system'; import { useToast } from '@cameleer/design-system';
import { adminFetch } from '../../api/queries/admin/admin-api'; import { adminFetch } from '../../api/queries/admin/admin-api';
import styles from './OidcConfigPage.module.css'; import styles from './OidcConfigPage.module.css';
import sectionStyles from '../../styles/section-card.module.css';
interface OidcFormData { interface OidcFormData {
enabled: boolean; enabled: boolean;
@@ -137,7 +138,7 @@ export default function OidcConfigPage() {
{error && <div style={{ marginBottom: 16 }}><Alert variant="error">{error}</Alert></div>} {error && <div style={{ marginBottom: 16 }}><Alert variant="error">{error}</Alert></div>}
<section className={styles.section}> <section className={sectionStyles.section}>
<SectionHeader>Behavior</SectionHeader> <SectionHeader>Behavior</SectionHeader>
<div className={styles.toggleRow}> <div className={styles.toggleRow}>
<Toggle <Toggle
@@ -156,7 +157,7 @@ export default function OidcConfigPage() {
</div> </div>
</section> </section>
<section className={styles.section}> <section className={sectionStyles.section}>
<SectionHeader>Provider Settings</SectionHeader> <SectionHeader>Provider Settings</SectionHeader>
<FormField label="Issuer URI" htmlFor="issuer"> <FormField label="Issuer URI" htmlFor="issuer">
<Input <Input
@@ -200,7 +201,7 @@ export default function OidcConfigPage() {
</FormField> </FormField>
</section> </section>
<section className={styles.section}> <section className={sectionStyles.section}>
<SectionHeader>Claim Mapping</SectionHeader> <SectionHeader>Claim Mapping</SectionHeader>
<FormField label="Roles Claim" htmlFor="roles-claim" hint="JSON path to roles in the access token or ID token"> <FormField label="Roles Claim" htmlFor="roles-claim" hint="JSON path to roles in the access token or ID token">
<Input <Input
@@ -225,7 +226,7 @@ export default function OidcConfigPage() {
</FormField> </FormField>
</section> </section>
<section className={styles.section}> <section className={sectionStyles.section}>
<SectionHeader>Default Roles</SectionHeader> <SectionHeader>Default Roles</SectionHeader>
<div className={styles.tagList}> <div className={styles.tagList}>
{(form.defaultRoles || []).map((role) => ( {(form.defaultRoles || []).map((role) => (
@@ -249,7 +250,7 @@ export default function OidcConfigPage() {
</div> </div>
</section> </section>
<section className={styles.section}> <section className={sectionStyles.section}>
<SectionHeader>Danger Zone</SectionHeader> <SectionHeader>Danger Zone</SectionHeader>
<Button size="sm" variant="danger" onClick={() => setDeleteOpen(true)}> <Button size="sm" variant="danger" onClick={() => setDeleteOpen(true)}>
Delete OIDC Configuration Delete OIDC Configuration

View File

@@ -124,7 +124,7 @@
border: none; border: none;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--amber); background: var(--amber);
color: #fff; color: var(--bg-surface);
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
@@ -308,7 +308,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 80px; height: 80px;
background: var(--bg-surface-raised); background: var(--bg-raised);
border: 1px dashed var(--border-subtle); border: 1px dashed var(--border-subtle);
border-radius: var(--radius-md); border-radius: var(--radius-md);
font-size: 12px; font-size: 12px;
@@ -323,96 +323,6 @@
margin-top: 20px; margin-top: 20px;
} }
/* Log viewer */
.logCard {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
display: flex;
flex-direction: column;
max-height: 420px;
}
.logHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
}
.logToolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--border-subtle);
background: var(--bg-surface);
}
.logSearchWrap {
position: relative;
flex: 1;
min-width: 0;
}
.logSearchInput {
width: 100%;
padding: 5px 28px 5px 10px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: var(--bg-body);
color: var(--text-primary);
font-size: 12px;
font-family: var(--font-body);
outline: none;
}
.logSearchInput:focus {
border-color: var(--amber);
}
.logSearchInput::placeholder {
color: var(--text-faint);
}
.logSearchClear {
position: absolute;
right: 4px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 14px;
padding: 0 4px;
line-height: 1;
}
.logClearFilters {
background: none;
border: none;
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
padding: 2px 6px;
white-space: nowrap;
}
.logClearFilters:hover {
color: var(--text-primary);
}
.logEmpty {
padding: 24px;
text-align: center;
color: var(--text-faint);
font-size: 12px;
}
/* Event card (timeline panel) */ /* Event card (timeline panel) */
.eventCard { .eventCard {
background: var(--bg-surface); background: var(--bg-surface);
@@ -433,26 +343,3 @@
border-bottom: 1px solid var(--border-subtle); border-bottom: 1px solid var(--border-subtle);
} }
.headerActions {
display: flex;
align-items: center;
gap: 6px;
}
.sortBtn,
.refreshBtn {
background: none;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
color: var(--text-muted);
cursor: pointer;
font-size: 13px;
padding: 2px 6px;
line-height: 1;
}
.sortBtn:hover,
.refreshBtn:hover {
color: var(--text-primary);
border-color: var(--amber);
}

View File

@@ -8,6 +8,7 @@ import {
} from '@cameleer/design-system'; } from '@cameleer/design-system';
import type { Column, FeedEvent, LogEntry, ButtonGroupItem } from '@cameleer/design-system'; import type { Column, FeedEvent, LogEntry, ButtonGroupItem } from '@cameleer/design-system';
import styles from './AgentHealth.module.css'; import styles from './AgentHealth.module.css';
import logStyles from '../../styles/log-panel.module.css';
import { useAgents, useAgentEvents } from '../../api/queries/agents'; import { useAgents, useAgentEvents } from '../../api/queries/agents';
import { useApplicationLogs } from '../../api/queries/logs'; import { useApplicationLogs } from '../../api/queries/logs';
import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands'; import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands';
@@ -508,24 +509,24 @@ export default function AgentHealth() {
{/* Log + Timeline side by side */} {/* Log + Timeline side by side */}
<div className={styles.bottomRow}> <div className={styles.bottomRow}>
<div className={styles.logCard}> <div className={logStyles.logCard}>
<div className={styles.logHeader}> <div className={logStyles.logHeader}>
<SectionHeader>Application Log</SectionHeader> <SectionHeader>Application Log</SectionHeader>
<div className={styles.headerActions}> <div className={logStyles.headerActions}>
<span className={styles.sectionMeta}>{logEntries.length} entries</span> <span className={styles.sectionMeta}>{logEntries.length} entries</span>
<button className={styles.sortBtn} onClick={() => setLogSortAsc((v) => !v)} title={logSortAsc ? 'Oldest first' : 'Newest first'}> <button className={logStyles.sortBtn} onClick={() => setLogSortAsc((v) => !v)} title={logSortAsc ? 'Oldest first' : 'Newest first'}>
{logSortAsc ? '\u2191' : '\u2193'} {logSortAsc ? '\u2191' : '\u2193'}
</button> </button>
<button className={styles.refreshBtn} onClick={() => setLogRefreshTo(new Date().toISOString())} title="Refresh"> <button className={logStyles.refreshBtn} onClick={() => setLogRefreshTo(new Date().toISOString())} title="Refresh">
<RefreshCw size={14} /> <RefreshCw size={14} />
</button> </button>
</div> </div>
</div> </div>
<div className={styles.logToolbar}> <div className={logStyles.logToolbar}>
<div className={styles.logSearchWrap}> <div className={logStyles.logSearchWrap}>
<input <input
type="text" type="text"
className={styles.logSearchInput} className={logStyles.logSearchInput}
placeholder="Search logs\u2026" placeholder="Search logs\u2026"
value={logSearch} value={logSearch}
onChange={(e) => setLogSearch(e.target.value)} onChange={(e) => setLogSearch(e.target.value)}
@@ -534,7 +535,7 @@ export default function AgentHealth() {
{logSearch && ( {logSearch && (
<button <button
type="button" type="button"
className={styles.logSearchClear} className={logStyles.logSearchClear}
onClick={() => setLogSearch('')} onClick={() => setLogSearch('')}
aria-label="Clear search" aria-label="Clear search"
> >
@@ -544,7 +545,7 @@ export default function AgentHealth() {
</div> </div>
<ButtonGroup items={LOG_LEVEL_ITEMS} value={logLevels} onChange={setLogLevels} /> <ButtonGroup items={LOG_LEVEL_ITEMS} value={logLevels} onChange={setLogLevels} />
{logLevels.size > 0 && ( {logLevels.size > 0 && (
<button className={styles.logClearFilters} onClick={() => setLogLevels(new Set())}> <button className={logStyles.logClearFilters} onClick={() => setLogLevels(new Set())}>
Clear Clear
</button> </button>
)} )}
@@ -552,7 +553,7 @@ export default function AgentHealth() {
{filteredLogs.length > 0 ? ( {filteredLogs.length > 0 ? (
<LogViewer entries={filteredLogs} maxHeight={360} /> <LogViewer entries={filteredLogs} maxHeight={360} />
) : ( ) : (
<div className={styles.logEmpty}> <div className={logStyles.logEmpty}>
{logSearch || logLevels.size > 0 ? 'No matching log entries' : 'No log entries available'} {logSearch || logLevels.size > 0 ? 'No matching log entries' : 'No log entries available'}
</div> </div>
)} )}
@@ -561,12 +562,12 @@ export default function AgentHealth() {
<div className={styles.eventCard}> <div className={styles.eventCard}>
<div className={styles.eventCardHeader}> <div className={styles.eventCardHeader}>
<span className={styles.sectionTitle}>Timeline</span> <span className={styles.sectionTitle}>Timeline</span>
<div className={styles.headerActions}> <div className={logStyles.headerActions}>
<span className={styles.sectionMeta}>{feedEvents.length} events</span> <span className={styles.sectionMeta}>{feedEvents.length} events</span>
<button className={styles.sortBtn} onClick={() => setEventSortAsc((v) => !v)} title={eventSortAsc ? 'Oldest first' : 'Newest first'}> <button className={logStyles.sortBtn} onClick={() => setEventSortAsc((v) => !v)} title={eventSortAsc ? 'Oldest first' : 'Newest first'}>
{eventSortAsc ? '\u2191' : '\u2193'} {eventSortAsc ? '\u2191' : '\u2193'}
</button> </button>
<button className={styles.refreshBtn} onClick={() => setEventRefreshTo(new Date().toISOString())} title="Refresh"> <button className={logStyles.refreshBtn} onClick={() => setEventRefreshTo(new Date().toISOString())} title="Refresh">
<RefreshCw size={14} /> <RefreshCw size={14} />
</button> </button>
</div> </div>
@@ -574,7 +575,7 @@ export default function AgentHealth() {
{feedEvents.length > 0 ? ( {feedEvents.length > 0 ? (
<EventFeed events={feedEvents} maxItems={100} /> <EventFeed events={feedEvents} maxItems={100} />
) : ( ) : (
<div className={styles.logEmpty}>No events in the selected time range.</div> <div className={logStyles.logEmpty}>No events in the selected time range.</div>
)} )}
</div> </div>
</div> </div>

View File

@@ -90,15 +90,6 @@
margin-bottom: 20px; margin-bottom: 20px;
} }
.chartCard {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 16px;
overflow: hidden;
}
.chartHeader { .chartHeader {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -125,121 +116,6 @@
gap: 14px; gap: 14px;
} }
/* Log viewer */
.logCard {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
display: flex;
flex-direction: column;
max-height: 420px;
}
.logHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
}
.headerActions {
display: flex;
align-items: center;
gap: 6px;
}
.sortBtn,
.refreshBtn {
background: none;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
color: var(--text-muted);
cursor: pointer;
font-size: 13px;
padding: 2px 6px;
line-height: 1;
}
.sortBtn:hover,
.refreshBtn:hover {
color: var(--text-primary);
border-color: var(--amber);
}
.logToolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--border-subtle);
background: var(--bg-surface);
}
.logSearchWrap {
position: relative;
flex: 1;
min-width: 0;
}
.logSearchInput {
width: 100%;
padding: 5px 28px 5px 10px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: var(--bg-body);
color: var(--text-primary);
font-size: 12px;
font-family: var(--font-body);
outline: none;
}
.logSearchInput:focus {
border-color: var(--amber);
}
.logSearchInput::placeholder {
color: var(--text-faint);
}
.logSearchClear {
position: absolute;
right: 4px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 14px;
padding: 0 4px;
line-height: 1;
}
.logClearFilters {
background: none;
border: none;
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
padding: 2px 6px;
white-space: nowrap;
}
.logClearFilters:hover {
color: var(--text-primary);
}
/* Empty state (shared) */
.logEmpty {
padding: 24px;
text-align: center;
color: var(--text-faint);
font-size: 12px;
}
/* Timeline card */ /* Timeline card */
.timelineCard { .timelineCard {
background: var(--bg-surface); background: var(--bg-surface);

View File

@@ -8,6 +8,8 @@ import {
} from '@cameleer/design-system'; } from '@cameleer/design-system';
import type { FeedEvent, LogEntry, ButtonGroupItem } from '@cameleer/design-system'; import type { FeedEvent, LogEntry, ButtonGroupItem } from '@cameleer/design-system';
import styles from './AgentInstance.module.css'; import styles from './AgentInstance.module.css';
import logStyles from '../../styles/log-panel.module.css';
import chartCardStyles from '../../styles/chart-card.module.css';
import { useAgents, useAgentEvents } from '../../api/queries/agents'; import { useAgents, useAgentEvents } from '../../api/queries/agents';
import { useApplicationLogs } from '../../api/queries/logs'; import { useApplicationLogs } from '../../api/queries/logs';
import { useStatsTimeseries } from '../../api/queries/executions'; import { useStatsTimeseries } from '../../api/queries/executions';
@@ -290,7 +292,7 @@ export default function AgentInstance() {
{/* Charts grid — 3x2 */} {/* Charts grid — 3x2 */}
<div className={styles.chartsGrid}> <div className={styles.chartsGrid}>
<div className={styles.chartCard}> <div className={chartCardStyles.chartCard}>
<div className={styles.chartHeader}> <div className={styles.chartHeader}>
<span className={styles.chartTitle}>CPU Usage</span> <span className={styles.chartTitle}>CPU Usage</span>
<span className={styles.chartMeta}> <span className={styles.chartMeta}>
@@ -309,7 +311,7 @@ export default function AgentInstance() {
)} )}
</div> </div>
<div className={styles.chartCard}> <div className={chartCardStyles.chartCard}>
<div className={styles.chartHeader}> <div className={styles.chartHeader}>
<span className={styles.chartTitle}>Memory (Heap)</span> <span className={styles.chartTitle}>Memory (Heap)</span>
<span className={styles.chartMeta}> <span className={styles.chartMeta}>
@@ -325,7 +327,7 @@ export default function AgentInstance() {
)} )}
</div> </div>
<div className={styles.chartCard}> <div className={chartCardStyles.chartCard}>
<div className={styles.chartHeader}> <div className={styles.chartHeader}>
<span className={styles.chartTitle}>Throughput</span> <span className={styles.chartTitle}>Throughput</span>
<span className={styles.chartMeta}> <span className={styles.chartMeta}>
@@ -339,7 +341,7 @@ export default function AgentInstance() {
)} )}
</div> </div>
<div className={styles.chartCard}> <div className={chartCardStyles.chartCard}>
<div className={styles.chartHeader}> <div className={styles.chartHeader}>
<span className={styles.chartTitle}>Error Rate</span> <span className={styles.chartTitle}>Error Rate</span>
<span className={styles.chartMeta}> <span className={styles.chartMeta}>
@@ -353,7 +355,7 @@ export default function AgentInstance() {
)} )}
</div> </div>
<div className={styles.chartCard}> <div className={chartCardStyles.chartCard}>
<div className={styles.chartHeader}> <div className={styles.chartHeader}>
<span className={styles.chartTitle}>Thread Count</span> <span className={styles.chartTitle}>Thread Count</span>
<span className={styles.chartMeta}> <span className={styles.chartMeta}>
@@ -369,7 +371,7 @@ export default function AgentInstance() {
)} )}
</div> </div>
<div className={styles.chartCard}> <div className={chartCardStyles.chartCard}>
<div className={styles.chartHeader}> <div className={styles.chartHeader}>
<span className={styles.chartTitle}>GC Pauses</span> <span className={styles.chartTitle}>GC Pauses</span>
<span className={styles.chartMeta} /> <span className={styles.chartMeta} />
@@ -384,24 +386,24 @@ export default function AgentInstance() {
{/* Log + Timeline side by side */} {/* Log + Timeline side by side */}
<div className={styles.bottomRow}> <div className={styles.bottomRow}>
<div className={styles.logCard}> <div className={logStyles.logCard}>
<div className={styles.logHeader}> <div className={logStyles.logHeader}>
<SectionHeader>Application Log</SectionHeader> <SectionHeader>Application Log</SectionHeader>
<div className={styles.headerActions}> <div className={logStyles.headerActions}>
<span className={styles.chartMeta}>{logEntries.length} entries</span> <span className={styles.chartMeta}>{logEntries.length} entries</span>
<button className={styles.sortBtn} onClick={() => setLogSortAsc((v) => !v)} title={logSortAsc ? 'Oldest first' : 'Newest first'}> <button className={logStyles.sortBtn} onClick={() => setLogSortAsc((v) => !v)} title={logSortAsc ? 'Oldest first' : 'Newest first'}>
{logSortAsc ? '\u2191' : '\u2193'} {logSortAsc ? '\u2191' : '\u2193'}
</button> </button>
<button className={styles.refreshBtn} onClick={() => setLogRefreshTo(new Date().toISOString())} title="Refresh"> <button className={logStyles.refreshBtn} onClick={() => setLogRefreshTo(new Date().toISOString())} title="Refresh">
<RefreshCw size={14} /> <RefreshCw size={14} />
</button> </button>
</div> </div>
</div> </div>
<div className={styles.logToolbar}> <div className={logStyles.logToolbar}>
<div className={styles.logSearchWrap}> <div className={logStyles.logSearchWrap}>
<input <input
type="text" type="text"
className={styles.logSearchInput} className={logStyles.logSearchInput}
placeholder="Search logs\u2026" placeholder="Search logs\u2026"
value={logSearch} value={logSearch}
onChange={(e) => setLogSearch(e.target.value)} onChange={(e) => setLogSearch(e.target.value)}
@@ -410,7 +412,7 @@ export default function AgentInstance() {
{logSearch && ( {logSearch && (
<button <button
type="button" type="button"
className={styles.logSearchClear} className={logStyles.logSearchClear}
onClick={() => setLogSearch('')} onClick={() => setLogSearch('')}
aria-label="Clear search" aria-label="Clear search"
> >
@@ -420,7 +422,7 @@ export default function AgentInstance() {
</div> </div>
<ButtonGroup items={LOG_LEVEL_ITEMS} value={logLevels} onChange={setLogLevels} /> <ButtonGroup items={LOG_LEVEL_ITEMS} value={logLevels} onChange={setLogLevels} />
{logLevels.size > 0 && ( {logLevels.size > 0 && (
<button className={styles.logClearFilters} onClick={() => setLogLevels(new Set())}> <button className={logStyles.logClearFilters} onClick={() => setLogLevels(new Set())}>
Clear Clear
</button> </button>
)} )}
@@ -428,7 +430,7 @@ export default function AgentInstance() {
{filteredLogs.length > 0 ? ( {filteredLogs.length > 0 ? (
<LogViewer entries={filteredLogs} maxHeight={360} /> <LogViewer entries={filteredLogs} maxHeight={360} />
) : ( ) : (
<div className={styles.logEmpty}> <div className={logStyles.logEmpty}>
{logSearch || logLevels.size > 0 ? 'No matching log entries' : 'No log entries available'} {logSearch || logLevels.size > 0 ? 'No matching log entries' : 'No log entries available'}
</div> </div>
)} )}
@@ -437,12 +439,12 @@ export default function AgentInstance() {
<div className={styles.timelineCard}> <div className={styles.timelineCard}>
<div className={styles.timelineHeader}> <div className={styles.timelineHeader}>
<span className={styles.chartTitle}>Timeline</span> <span className={styles.chartTitle}>Timeline</span>
<div className={styles.headerActions}> <div className={logStyles.headerActions}>
<span className={styles.chartMeta}>{feedEvents.length} events</span> <span className={styles.chartMeta}>{feedEvents.length} events</span>
<button className={styles.sortBtn} onClick={() => setEventSortAsc((v) => !v)} title={eventSortAsc ? 'Oldest first' : 'Newest first'}> <button className={logStyles.sortBtn} onClick={() => setEventSortAsc((v) => !v)} title={eventSortAsc ? 'Oldest first' : 'Newest first'}>
{eventSortAsc ? '\u2191' : '\u2193'} {eventSortAsc ? '\u2191' : '\u2193'}
</button> </button>
<button className={styles.refreshBtn} onClick={() => setEventRefreshTo(new Date().toISOString())} title="Refresh"> <button className={logStyles.refreshBtn} onClick={() => setEventRefreshTo(new Date().toISOString())} title="Refresh">
<RefreshCw size={14} /> <RefreshCw size={14} />
</button> </button>
</div> </div>
@@ -450,7 +452,7 @@ export default function AgentInstance() {
{feedEvents.length > 0 ? ( {feedEvents.length > 0 ? (
<EventFeed events={feedEvents} maxItems={50} /> <EventFeed events={feedEvents} maxItems={50} />
) : ( ) : (
<div className={styles.logEmpty}>No events in the selected time range.</div> <div className={logStyles.logEmpty}>No events in the selected time range.</div>
)} )}
</div> </div>
</div> </div>

View File

@@ -57,7 +57,7 @@
.stepIndicator { .stepIndicator {
font-size: 12px; font-size: 12px;
color: var(--accent, #6c7aff); color: var(--amber);
font-style: italic; font-style: italic;
} }
@@ -143,8 +143,8 @@
} }
.subTabActive { .subTabActive {
color: var(--accent, #6c7aff); color: var(--amber);
border-bottom-color: var(--accent, #6c7aff); border-bottom-color: var(--amber);
} }
/* Table */ /* Table */
@@ -230,7 +230,7 @@
.editBannerActive { .editBannerActive {
border-color: var(--warning); border-color: var(--warning);
background: rgba(251, 191, 36, 0.06); background: color-mix(in srgb, var(--amber) 6%, transparent);
} }
.editBannerText { .editBannerText {
@@ -432,3 +432,44 @@
.removeBtn:hover { .removeBtn:hover {
color: var(--error); color: var(--error);
} }
/* Visually hidden file inputs */
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Fixed-width inputs */
.inputXs {
width: 50px;
}
.inputSm {
width: 60px;
}
.inputMd {
width: 70px;
}
.inputLg {
width: 80px;
}
.inputXl {
width: 90px;
}
/* Table cell flex layout */
.cellFlex {
display: flex;
align-items: center;
gap: 4px;
}

View File

@@ -301,7 +301,7 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
<span className={styles.configLabel}>Application JAR</span> <span className={styles.configLabel}>Application JAR</span>
<div className={styles.fileRow}> <div className={styles.fileRow}>
<input ref={fileInputRef} type="file" accept=".jar" <input ref={fileInputRef} type="file" accept=".jar"
style={{ position: 'absolute', width: 1, height: 1, margin: -1, padding: 0, overflow: 'hidden', clip: 'rect(0,0,0,0)', border: 0 }} className={styles.visuallyHidden}
onChange={(e) => setFile(e.target.files?.[0] ?? null)} /> onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
<Button size="sm" variant="secondary" type="button" onClick={() => fileInputRef.current?.click()} disabled={busy}> <Button size="sm" variant="secondary" type="button" onClick={() => fileInputRef.current?.click()} disabled={busy}>
{file ? 'Change file' : 'Select JAR'} {file ? 'Change file' : 'Select JAR'}
@@ -355,8 +355,8 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
<span className={styles.configLabel}>Max Payload Size</span> <span className={styles.configLabel}>Max Payload Size</span>
<div className={styles.configInline}> <div className={styles.configInline}>
<Input disabled={busy} value={payloadSize} onChange={(e) => setPayloadSize(e.target.value)} style={{ width: 70 }} /> <Input disabled={busy} value={payloadSize} onChange={(e) => setPayloadSize(e.target.value)} className={styles.inputMd} />
<Select disabled={busy} value={payloadUnit} onChange={(e) => setPayloadUnit(e.target.value)} style={{ width: 90 }} <Select disabled={busy} value={payloadUnit} onChange={(e) => setPayloadUnit(e.target.value)} className={styles.inputXl}
options={[{ value: 'bytes', label: 'bytes' }, { value: 'KB', label: 'KB' }, { value: 'MB', label: 'MB' }]} /> options={[{ value: 'bytes', label: 'bytes' }, { value: 'KB', label: 'KB' }, { value: 'MB', label: 'MB' }]} />
</div> </div>
@@ -373,12 +373,12 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
<Toggle checked={metricsEnabled} onChange={() => !busy && setMetricsEnabled(!metricsEnabled)} disabled={busy} /> <Toggle checked={metricsEnabled} onChange={() => !busy && setMetricsEnabled(!metricsEnabled)} disabled={busy} />
<span className={metricsEnabled ? styles.toggleEnabled : styles.toggleDisabled}>{metricsEnabled ? 'Enabled' : 'Disabled'}</span> <span className={metricsEnabled ? styles.toggleEnabled : styles.toggleDisabled}>{metricsEnabled ? 'Enabled' : 'Disabled'}</span>
<span className={styles.cellMeta} style={{ marginLeft: 8 }}>Interval</span> <span className={styles.cellMeta} style={{ marginLeft: 8 }}>Interval</span>
<Input disabled={busy} value={metricsInterval} onChange={(e) => setMetricsInterval(e.target.value)} style={{ width: 50 }} /> <Input disabled={busy} value={metricsInterval} onChange={(e) => setMetricsInterval(e.target.value)} className={styles.inputXs} />
<span className={styles.cellMeta}>s</span> <span className={styles.cellMeta}>s</span>
</div> </div>
<span className={styles.configLabel}>Sampling Rate</span> <span className={styles.configLabel}>Sampling Rate</span>
<Input disabled={busy} value={samplingRate} onChange={(e) => setSamplingRate(e.target.value)} style={{ width: 80 }} /> <Input disabled={busy} value={samplingRate} onChange={(e) => setSamplingRate(e.target.value)} className={styles.inputLg} />
<span className={styles.configLabel}>Compress Success</span> <span className={styles.configLabel}>Compress Success</span>
<div className={styles.configInline}> <div className={styles.configInline}>
@@ -406,25 +406,25 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
<div className={styles.configGrid}> <div className={styles.configGrid}>
<span className={styles.configLabel}>Memory Limit</span> <span className={styles.configLabel}>Memory Limit</span>
<div className={styles.configInline}> <div className={styles.configInline}>
<Input disabled={busy} value={memoryLimit} onChange={(e) => setMemoryLimit(e.target.value)} style={{ width: 80 }} /> <Input disabled={busy} value={memoryLimit} onChange={(e) => setMemoryLimit(e.target.value)} className={styles.inputLg} />
<span className={styles.cellMeta}>MB</span> <span className={styles.cellMeta}>MB</span>
</div> </div>
<span className={styles.configLabel}>Memory Reserve</span> <span className={styles.configLabel}>Memory Reserve</span>
<div> <div>
<div className={styles.configInline}> <div className={styles.configInline}>
<Input disabled={!isProd || busy} value={memoryReserve} onChange={(e) => setMemoryReserve(e.target.value)} placeholder="---" style={{ width: 80 }} /> <Input disabled={!isProd || busy} value={memoryReserve} onChange={(e) => setMemoryReserve(e.target.value)} placeholder="---" className={styles.inputLg} />
<span className={styles.cellMeta}>MB</span> <span className={styles.cellMeta}>MB</span>
</div> </div>
{!isProd && <span className={styles.configHint}>Available in production environments only</span>} {!isProd && <span className={styles.configHint}>Available in production environments only</span>}
</div> </div>
<span className={styles.configLabel}>CPU Request</span> <span className={styles.configLabel}>CPU Request</span>
<Input disabled={busy} value={cpuRequest} onChange={(e) => setCpuRequest(e.target.value)} style={{ width: 80 }} /> <Input disabled={busy} value={cpuRequest} onChange={(e) => setCpuRequest(e.target.value)} className={styles.inputLg} />
<span className={styles.configLabel}>CPU Limit</span> <span className={styles.configLabel}>CPU Limit</span>
<div className={styles.configInline}> <div className={styles.configInline}>
<Input disabled={busy} value={cpuLimit} onChange={(e) => setCpuLimit(e.target.value)} placeholder="e.g. 1000" style={{ width: 80 }} /> <Input disabled={busy} value={cpuLimit} onChange={(e) => setCpuLimit(e.target.value)} placeholder="e.g. 1000" className={styles.inputLg} />
<span className={styles.cellMeta}>millicores</span> <span className={styles.cellMeta}>millicores</span>
</div> </div>
@@ -443,10 +443,10 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
</div> </div>
<span className={styles.configLabel}>App Port</span> <span className={styles.configLabel}>App Port</span>
<Input disabled={busy} value={appPort} onChange={(e) => setAppPort(e.target.value)} style={{ width: 80 }} /> <Input disabled={busy} value={appPort} onChange={(e) => setAppPort(e.target.value)} className={styles.inputLg} />
<span className={styles.configLabel}>Replicas</span> <span className={styles.configLabel}>Replicas</span>
<Input disabled={busy} value={replicas} onChange={(e) => setReplicas(e.target.value)} style={{ width: 60 }} type="number" /> <Input disabled={busy} value={replicas} onChange={(e) => setReplicas(e.target.value)} className={styles.inputSm} type="number" />
<span className={styles.configLabel}>Deploy Strategy</span> <span className={styles.configLabel}>Deploy Strategy</span>
<Select disabled={busy} value={deployStrategy} onChange={(e) => setDeployStrategy(e.target.value)} <Select disabled={busy} value={deployStrategy} onChange={(e) => setDeployStrategy(e.target.value)}
@@ -551,7 +551,7 @@ function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: s
</div> </div>
<div className={styles.detailActions}> <div className={styles.detailActions}>
<input ref={fileInputRef} type="file" accept=".jar" <input ref={fileInputRef} type="file" accept=".jar"
style={{ position: 'absolute', width: 1, height: 1, margin: -1, padding: 0, overflow: 'hidden', clip: 'rect(0,0,0,0)', border: 0 }} className={styles.visuallyHidden}
onChange={handleUpload} /> onChange={handleUpload} />
<Button size="sm" variant="primary" type="button" onClick={() => fileInputRef.current?.click()} loading={uploadJar.isPending}>Upload JAR</Button> <Button size="sm" variant="primary" type="button" onClick={() => fileInputRef.current?.click()} loading={uploadJar.isPending}>Upload JAR</Button>
<Button size="sm" variant="danger" onClick={() => setDeleteConfirm(true)}>Delete App</Button> <Button size="sm" variant="danger" onClick={() => setDeleteConfirm(true)}>Delete App</Button>
@@ -636,7 +636,7 @@ function OverviewSubTab({ app, deployments, versions, environments, envMap, sele
<Badge label={dEnv?.displayName ?? '?'} color={dEnv?.production ? 'error' : 'auto'} /> <Badge label={dEnv?.displayName ?? '?'} color={dEnv?.production ? 'error' : 'auto'} />
</td> </td>
<td><Badge label={version ? `v${version.version}` : '?'} color="auto" /></td> <td><Badge label={version ? `v${version.version}` : '?'} color="auto" /></td>
<td style={{ display: 'flex', alignItems: 'center', gap: 4 }}> <td className={styles.cellFlex}>
<StatusDot variant={DEPLOY_STATUS_DOT[d.status] ?? 'dead'} /> <StatusDot variant={DEPLOY_STATUS_DOT[d.status] ?? 'dead'} />
<Badge label={d.status} color={STATUS_COLORS[d.status] ?? 'auto'} /> <Badge label={d.status} color={STATUS_COLORS[d.status] ?? 'auto'} />
</td> </td>
@@ -962,8 +962,8 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
<span className={styles.configLabel}>Max Payload Size</span> <span className={styles.configLabel}>Max Payload Size</span>
<div className={styles.configInline}> <div className={styles.configInline}>
<Input disabled={!editing} value={payloadSize} onChange={(e) => setPayloadSize(e.target.value)} style={{ width: 70 }} /> <Input disabled={!editing} value={payloadSize} onChange={(e) => setPayloadSize(e.target.value)} className={styles.inputMd} />
<Select disabled={!editing} value={payloadUnit} onChange={(e) => setPayloadUnit(e.target.value)} style={{ width: 90 }} <Select disabled={!editing} value={payloadUnit} onChange={(e) => setPayloadUnit(e.target.value)} className={styles.inputXl}
options={[{ value: 'bytes', label: 'bytes' }, { value: 'KB', label: 'KB' }, { value: 'MB', label: 'MB' }]} /> options={[{ value: 'bytes', label: 'bytes' }, { value: 'KB', label: 'KB' }, { value: 'MB', label: 'MB' }]} />
</div> </div>
@@ -980,12 +980,12 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
<Toggle checked={metricsEnabled} onChange={() => editing && setMetricsEnabled(!metricsEnabled)} disabled={!editing} /> <Toggle checked={metricsEnabled} onChange={() => editing && setMetricsEnabled(!metricsEnabled)} disabled={!editing} />
<span className={metricsEnabled ? styles.toggleEnabled : styles.toggleDisabled}>{metricsEnabled ? 'Enabled' : 'Disabled'}</span> <span className={metricsEnabled ? styles.toggleEnabled : styles.toggleDisabled}>{metricsEnabled ? 'Enabled' : 'Disabled'}</span>
<span className={styles.cellMeta} style={{ marginLeft: 8 }}>Interval</span> <span className={styles.cellMeta} style={{ marginLeft: 8 }}>Interval</span>
<Input disabled={!editing} value={metricsInterval} onChange={(e) => setMetricsInterval(e.target.value)} style={{ width: 50 }} /> <Input disabled={!editing} value={metricsInterval} onChange={(e) => setMetricsInterval(e.target.value)} className={styles.inputXs} />
<span className={styles.cellMeta}>s</span> <span className={styles.cellMeta}>s</span>
</div> </div>
<span className={styles.configLabel}>Sampling Rate</span> <span className={styles.configLabel}>Sampling Rate</span>
<Input disabled={!editing} value={samplingRate} onChange={(e) => setSamplingRate(e.target.value)} style={{ width: 80 }} /> <Input disabled={!editing} value={samplingRate} onChange={(e) => setSamplingRate(e.target.value)} className={styles.inputLg} />
<span className={styles.configLabel}>Compress Success</span> <span className={styles.configLabel}>Compress Success</span>
<div className={styles.configInline}> <div className={styles.configInline}>
@@ -1032,25 +1032,25 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
<div className={styles.configGrid}> <div className={styles.configGrid}>
<span className={styles.configLabel}>Memory Limit</span> <span className={styles.configLabel}>Memory Limit</span>
<div className={styles.configInline}> <div className={styles.configInline}>
<Input disabled={!editing} value={memoryLimit} onChange={(e) => setMemoryLimit(e.target.value)} style={{ width: 80 }} /> <Input disabled={!editing} value={memoryLimit} onChange={(e) => setMemoryLimit(e.target.value)} className={styles.inputLg} />
<span className={styles.cellMeta}>MB</span> <span className={styles.cellMeta}>MB</span>
</div> </div>
<span className={styles.configLabel}>Memory Reserve</span> <span className={styles.configLabel}>Memory Reserve</span>
<div> <div>
<div className={styles.configInline}> <div className={styles.configInline}>
<Input disabled value={memoryReserve} onChange={(e) => setMemoryReserve(e.target.value)} placeholder="---" style={{ width: 80 }} /> <Input disabled value={memoryReserve} onChange={(e) => setMemoryReserve(e.target.value)} placeholder="---" className={styles.inputLg} />
<span className={styles.cellMeta}>MB</span> <span className={styles.cellMeta}>MB</span>
</div> </div>
{!isProd && <span className={styles.configHint}>Available in production environments only</span>} {!isProd && <span className={styles.configHint}>Available in production environments only</span>}
</div> </div>
<span className={styles.configLabel}>CPU Request</span> <span className={styles.configLabel}>CPU Request</span>
<Input disabled={!editing} value={cpuRequest} onChange={(e) => setCpuRequest(e.target.value)} style={{ width: 80 }} /> <Input disabled={!editing} value={cpuRequest} onChange={(e) => setCpuRequest(e.target.value)} className={styles.inputLg} />
<span className={styles.configLabel}>CPU Limit</span> <span className={styles.configLabel}>CPU Limit</span>
<div className={styles.configInline}> <div className={styles.configInline}>
<Input disabled={!editing} value={cpuLimit} onChange={(e) => setCpuLimit(e.target.value)} placeholder="e.g. 1000" style={{ width: 80 }} /> <Input disabled={!editing} value={cpuLimit} onChange={(e) => setCpuLimit(e.target.value)} placeholder="e.g. 1000" className={styles.inputLg} />
<span className={styles.cellMeta}>millicores</span> <span className={styles.cellMeta}>millicores</span>
</div> </div>
@@ -1069,10 +1069,10 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
</div> </div>
<span className={styles.configLabel}>App Port</span> <span className={styles.configLabel}>App Port</span>
<Input disabled={!editing} value={appPort} onChange={(e) => setAppPort(e.target.value)} style={{ width: 80 }} /> <Input disabled={!editing} value={appPort} onChange={(e) => setAppPort(e.target.value)} className={styles.inputLg} />
<span className={styles.configLabel}>Replicas</span> <span className={styles.configLabel}>Replicas</span>
<Input disabled={!editing} value={replicas} onChange={(e) => setReplicas(e.target.value)} style={{ width: 60 }} type="number" /> <Input disabled={!editing} value={replicas} onChange={(e) => setReplicas(e.target.value)} className={styles.inputSm} type="number" />
<span className={styles.configLabel}>Deploy Strategy</span> <span className={styles.configLabel}>Deploy Strategy</span>
<Select disabled={!editing} value={deployStrategy} onChange={(e) => setDeployStrategy(e.target.value)} <Select disabled={!editing} value={deployStrategy} onChange={(e) => setDeployStrategy(e.target.value)}

View File

@@ -34,16 +34,16 @@
height: 18px; height: 18px;
margin-left: 4px; margin-left: 4px;
border: none; border: none;
background: var(--bg-hover, #F5F0EA); background: var(--bg-hover);
color: var(--text-secondary, #5C5347); color: var(--text-secondary);
border-radius: 50%; border-radius: 50%;
cursor: pointer; cursor: pointer;
padding: 0; padding: 0;
} }
.clearSearch:hover { .clearSearch:hover {
background: var(--border, #E4DFD8); background: var(--border);
color: var(--text-primary, #1A1612); color: var(--text-primary);
} }
.tableRight { .tableRight {
@@ -109,8 +109,8 @@
width: 6px; width: 6px;
height: 6px; height: 6px;
border-radius: 50%; border-radius: 50%;
background: #5db866; background: var(--success);
box-shadow: 0 0 4px rgba(93, 184, 102, 0.4); box-shadow: 0 0 4px color-mix(in srgb, var(--success) 40%, transparent);
flex-shrink: 0; flex-shrink: 0;
} }

View File

@@ -64,7 +64,7 @@ function buildBaseColumns(): Column<Row>[] {
<span className={styles.statusCell}> <span className={styles.statusCell}>
<StatusDot variant={statusToVariant(row.status)} /> <StatusDot variant={statusToVariant(row.status)} />
<MonoText size="xs">{statusLabel(row.status)}</MonoText> <MonoText size="xs">{statusLabel(row.status)}</MonoText>
{row.hasTraceData && <Footprints size={11} color="#3D7C47" style={{ marginLeft: 2, flexShrink: 0 }} />} {row.hasTraceData && <Footprints size={11} color="var(--success)" style={{ marginLeft: 2, flexShrink: 0 }} />}
{row.isReplay && <RotateCcw size={11} color="var(--amber)" style={{ marginLeft: 2, flexShrink: 0 }} />} {row.isReplay && <RotateCcw size={11} color="var(--amber)" style={{ marginLeft: 2, flexShrink: 0 }} />}
</span> </span>
), ),

View File

@@ -29,6 +29,9 @@ import {
type HealthStatus, type HealthStatus,
} from './dashboard-utils'; } from './dashboard-utils';
import styles from './DashboardTab.module.css'; import styles from './DashboardTab.module.css';
import tableStyles from '../../styles/table-section.module.css';
import refreshStyles from '../../styles/refresh-indicator.module.css';
import rateStyles from '../../styles/rate-colors.module.css';
// ── Row type for application health table ─────────────────────────────────── // ── Row type for application health table ───────────────────────────────────
@@ -75,7 +78,7 @@ const APP_COLUMNS: Column<AppRow>[] = [
sortable: true, sortable: true,
render: (_, row) => { render: (_, row) => {
const pct = row.successRate; const pct = row.successRate;
const cls = pct >= 99 ? styles.rateGood : pct >= 97 ? styles.rateWarn : styles.rateBad; const cls = pct >= 99 ? rateStyles.rateGood : pct >= 97 ? rateStyles.rateWarn : rateStyles.rateBad;
return <MonoText size="sm" className={cls}>{pct.toFixed(1)}%</MonoText>; return <MonoText size="sm" className={cls}>{pct.toFixed(1)}%</MonoText>;
}, },
}, },
@@ -84,7 +87,7 @@ const APP_COLUMNS: Column<AppRow>[] = [
header: 'P99', header: 'P99',
sortable: true, sortable: true,
render: (_, row) => { render: (_, row) => {
const cls = row.p99DurationMs > 300 ? styles.rateBad : row.p99DurationMs > 200 ? styles.rateWarn : styles.rateGood; const cls = row.p99DurationMs > 300 ? rateStyles.rateBad : row.p99DurationMs > 200 ? rateStyles.rateWarn : rateStyles.rateGood;
return <MonoText size="sm" className={cls}>{Math.round(row.p99DurationMs)}ms</MonoText>; return <MonoText size="sm" className={cls}>{Math.round(row.p99DurationMs)}ms</MonoText>;
}, },
}, },
@@ -93,7 +96,7 @@ const APP_COLUMNS: Column<AppRow>[] = [
header: 'SLA %', header: 'SLA %',
sortable: true, sortable: true,
render: (_, row) => { render: (_, row) => {
const cls = row.slaCompliance >= 99 ? styles.rateGood : row.slaCompliance >= 95 ? styles.rateWarn : styles.rateBad; const cls = row.slaCompliance >= 99 ? rateStyles.rateGood : row.slaCompliance >= 95 ? rateStyles.rateWarn : rateStyles.rateBad;
return <MonoText size="sm" className={cls}>{formatSlaCompliance(row.slaCompliance)}</MonoText>; return <MonoText size="sm" className={cls}>{formatSlaCompliance(row.slaCompliance)}</MonoText>;
}, },
}, },
@@ -102,7 +105,7 @@ const APP_COLUMNS: Column<AppRow>[] = [
header: 'Errors', header: 'Errors',
sortable: true, sortable: true,
render: (_, row) => { render: (_, row) => {
const cls = row.errorCount > 10 ? styles.rateBad : row.errorCount > 0 ? styles.rateWarn : styles.rateGood; const cls = row.errorCount > 10 ? rateStyles.rateBad : row.errorCount > 0 ? rateStyles.rateWarn : rateStyles.rateGood;
return <MonoText size="sm" className={cls}>{row.errorCount.toLocaleString()}</MonoText>; return <MonoText size="sm" className={cls}>{row.errorCount.toLocaleString()}</MonoText>;
}, },
}, },
@@ -399,20 +402,20 @@ export default function DashboardL1() {
return ( return (
<div className={styles.content}> <div className={styles.content}>
<div className={styles.refreshIndicator}> <div className={refreshStyles.refreshIndicator}>
<span className={styles.refreshDot} /> <span className={refreshStyles.refreshDot} />
<span className={styles.refreshText}>Auto-refresh: 30s</span> <span className={refreshStyles.refreshText}>Auto-refresh: 30s</span>
</div> </div>
{/* KPI header cards */} {/* KPI header cards */}
<KpiStrip items={kpiItems} /> <KpiStrip items={kpiItems} />
{/* Application Health table */} {/* Application Health table */}
<div className={styles.tableSection}> <div className={tableStyles.tableSection}>
<div className={styles.tableHeader}> <div className={tableStyles.tableHeader}>
<span className={styles.tableTitle}>Application Health</span> <span className={tableStyles.tableTitle}>Application Health</span>
<div className={styles.tableRight}> <div className={tableStyles.tableRight}>
<span className={styles.tableMeta}>{appRows.length} applications</span> <span className={tableStyles.tableMeta}>{appRows.length} applications</span>
<Badge label="ALL" color="auto" /> <Badge label="ALL" color="auto" />
</div> </div>
</div> </div>

View File

@@ -33,6 +33,9 @@ import {
formatRelativeTime, formatRelativeTime,
} from './dashboard-utils'; } from './dashboard-utils';
import styles from './DashboardTab.module.css'; import styles from './DashboardTab.module.css';
import tableStyles from '../../styles/table-section.module.css';
import refreshStyles from '../../styles/refresh-indicator.module.css';
import rateStyles from '../../styles/rate-colors.module.css';
// ── Route table row type ──────────────────────────────────────────────────── // ── Route table row type ────────────────────────────────────────────────────
@@ -72,7 +75,7 @@ const ROUTE_COLUMNS: Column<RouteRow>[] = [
sortable: true, sortable: true,
render: (_, row) => { render: (_, row) => {
const pct = row.successRate * 100; const pct = row.successRate * 100;
const cls = pct >= 99 ? styles.rateGood : pct >= 97 ? styles.rateWarn : styles.rateBad; const cls = pct >= 99 ? rateStyles.rateGood : pct >= 97 ? rateStyles.rateWarn : rateStyles.rateBad;
return <MonoText size="sm" className={cls}>{pct.toFixed(1)}%</MonoText>; return <MonoText size="sm" className={cls}>{pct.toFixed(1)}%</MonoText>;
}, },
}, },
@@ -89,7 +92,7 @@ const ROUTE_COLUMNS: Column<RouteRow>[] = [
header: 'P99(ms)', header: 'P99(ms)',
sortable: true, sortable: true,
render: (_, row) => { render: (_, row) => {
const cls = row.p99DurationMs > 300 ? styles.rateBad : row.p99DurationMs > 200 ? styles.rateWarn : styles.rateGood; const cls = row.p99DurationMs > 300 ? rateStyles.rateBad : row.p99DurationMs > 200 ? rateStyles.rateWarn : rateStyles.rateGood;
return <MonoText size="sm" className={cls}>{Math.round(row.p99DurationMs)}</MonoText>; return <MonoText size="sm" className={cls}>{Math.round(row.p99DurationMs)}</MonoText>;
}, },
}, },
@@ -98,7 +101,7 @@ const ROUTE_COLUMNS: Column<RouteRow>[] = [
header: 'SLA%', header: 'SLA%',
sortable: true, sortable: true,
render: (_, row) => { render: (_, row) => {
const cls = row.slaCompliance >= 99 ? styles.rateGood : row.slaCompliance >= 95 ? styles.rateWarn : styles.rateBad; const cls = row.slaCompliance >= 99 ? rateStyles.rateGood : row.slaCompliance >= 95 ? rateStyles.rateWarn : rateStyles.rateBad;
return <MonoText size="sm" className={cls}>{formatSlaCompliance(row.slaCompliance)}</MonoText>; return <MonoText size="sm" className={cls}>{formatSlaCompliance(row.slaCompliance)}</MonoText>;
}, },
}, },
@@ -146,9 +149,9 @@ const ERROR_COLUMNS: Column<ErrorRow>[] = [
sortable: true, sortable: true,
render: (_, row) => { render: (_, row) => {
const arrow = trendArrow(row.trend); const arrow = trendArrow(row.trend);
const cls = row.trend === 'accelerating' ? styles.rateBad const cls = row.trend === 'accelerating' ? rateStyles.rateBad
: row.trend === 'decelerating' ? styles.rateGood : row.trend === 'decelerating' ? rateStyles.rateGood
: styles.rateNeutral; : rateStyles.rateNeutral;
return <MonoText size="sm" className={cls}>{row.velocity.toFixed(1)}/min {arrow}</MonoText>; return <MonoText size="sm" className={cls}>{row.velocity.toFixed(1)}/min {arrow}</MonoText>;
}, },
}, },
@@ -364,20 +367,20 @@ export default function DashboardL2() {
return ( return (
<div className={styles.content}> <div className={styles.content}>
<div className={styles.refreshIndicator}> <div className={refreshStyles.refreshIndicator}>
<span className={styles.refreshDot} /> <span className={refreshStyles.refreshDot} />
<span className={styles.refreshText}>Auto-refresh: 30s</span> <span className={refreshStyles.refreshText}>Auto-refresh: 30s</span>
</div> </div>
{/* KPI Strip */} {/* KPI Strip */}
<KpiStrip items={kpiItems} /> <KpiStrip items={kpiItems} />
{/* Route Performance Table */} {/* Route Performance Table */}
<div className={styles.tableSection}> <div className={tableStyles.tableSection}>
<div className={styles.tableHeader}> <div className={tableStyles.tableHeader}>
<span className={styles.tableTitle}>Route Performance</span> <span className={tableStyles.tableTitle}>Route Performance</span>
<div className={styles.tableRight}> <div className={tableStyles.tableRight}>
<span className={styles.tableMeta}>{routeRows.length} routes</span> <span className={tableStyles.tableMeta}>{routeRows.length} routes</span>
<Badge label="AUTO" color="success" /> <Badge label="AUTO" color="success" />
</div> </div>
</div> </div>
@@ -416,9 +419,9 @@ export default function DashboardL2() {
{/* Top 5 Errors — hidden when empty */} {/* Top 5 Errors — hidden when empty */}
{errorRows.length > 0 && ( {errorRows.length > 0 && (
<div className={styles.errorsSection}> <div className={styles.errorsSection}>
<div className={styles.tableHeader}> <div className={tableStyles.tableHeader}>
<span className={styles.tableTitle}>Top Errors</span> <span className={tableStyles.tableTitle}>Top Errors</span>
<span className={styles.tableMeta}>{errorRows.length} error types</span> <span className={tableStyles.tableMeta}>{errorRows.length} error types</span>
</div> </div>
<DataTable <DataTable
columns={ERROR_COLUMNS} columns={ERROR_COLUMNS}

View File

@@ -25,6 +25,9 @@ import {
trendIndicator, trendIndicator,
} from './dashboard-utils'; } from './dashboard-utils';
import styles from './DashboardTab.module.css'; import styles from './DashboardTab.module.css';
import tableStyles from '../../styles/table-section.module.css';
import refreshStyles from '../../styles/refresh-indicator.module.css';
import rateStyles from '../../styles/rate-colors.module.css';
// ── Row types ─────────────────────────────────────────────────────────────── // ── Row types ───────────────────────────────────────────────────────────────
@@ -80,10 +83,10 @@ const PROCESSOR_COLUMNS: Column<ProcessorRow>[] = [
sortable: true, sortable: true,
render: (_, row) => { render: (_, row) => {
const cls = row.p99DurationMs > 300 const cls = row.p99DurationMs > 300
? styles.rateBad ? rateStyles.rateBad
: row.p99DurationMs > 200 : row.p99DurationMs > 200
? styles.rateWarn ? rateStyles.rateWarn
: styles.rateGood; : rateStyles.rateGood;
return <MonoText size="sm" className={cls}>{Math.round(row.p99DurationMs)}</MonoText>; return <MonoText size="sm" className={cls}>{Math.round(row.p99DurationMs)}</MonoText>;
}, },
}, },
@@ -93,7 +96,7 @@ const PROCESSOR_COLUMNS: Column<ProcessorRow>[] = [
sortable: true, sortable: true,
render: (_, row) => { render: (_, row) => {
const pct = row.errorRate * 100; const pct = row.errorRate * 100;
const cls = pct > 5 ? styles.rateBad : pct > 1 ? styles.rateWarn : styles.rateGood; const cls = pct > 5 ? rateStyles.rateBad : pct > 1 ? rateStyles.rateWarn : rateStyles.rateGood;
return <MonoText size="sm" className={cls}>{pct.toFixed(2)}%</MonoText>; return <MonoText size="sm" className={cls}>{pct.toFixed(2)}%</MonoText>;
}, },
}, },
@@ -347,9 +350,9 @@ export default function DashboardL3() {
return ( return (
<div className={styles.content}> <div className={styles.content}>
<div className={styles.refreshIndicator}> <div className={refreshStyles.refreshIndicator}>
<span className={styles.refreshDot} /> <span className={refreshStyles.refreshDot} />
<span className={styles.refreshText}>Auto-refresh: 30s</span> <span className={refreshStyles.refreshText}>Auto-refresh: 30s</span>
</div> </div>
{/* KPI Strip */} {/* KPI Strip */}
@@ -398,11 +401,11 @@ export default function DashboardL3() {
)} )}
{/* Processor Metrics Table */} {/* Processor Metrics Table */}
<div className={styles.tableSection}> <div className={tableStyles.tableSection}>
<div className={styles.tableHeader}> <div className={tableStyles.tableHeader}>
<span className={styles.tableTitle}>Processor Metrics</span> <span className={tableStyles.tableTitle}>Processor Metrics</span>
<div> <div>
<span className={styles.tableMeta}> <span className={tableStyles.tableMeta}>
{processorRows.length} processor{processorRows.length !== 1 ? 's' : ''} {processorRows.length} processor{processorRows.length !== 1 ? 's' : ''}
</span> </span>
</div> </div>
@@ -417,8 +420,8 @@ export default function DashboardL3() {
{/* Top 5 Errors — hidden if empty */} {/* Top 5 Errors — hidden if empty */}
{errorRows.length > 0 && ( {errorRows.length > 0 && (
<div className={styles.errorsSection}> <div className={styles.errorsSection}>
<div className={styles.tableHeader}> <div className={tableStyles.tableHeader}>
<span className={styles.tableTitle}>Top 5 Errors</span> <span className={tableStyles.tableTitle}>Top 5 Errors</span>
<Badge label={`${errorRows.length}`} color="error" /> <Badge label={`${errorRows.length}`} color="error" />
</div> </div>
<DataTable <DataTable

View File

@@ -1,21 +1,19 @@
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { lazy, Suspense } from 'react'; import { lazy, Suspense } from 'react';
import { Spinner } from '@cameleer/design-system'; import { PageLoader } from '../../components/PageLoader';
const DashboardL1 = lazy(() => import('./DashboardL1')); const DashboardL1 = lazy(() => import('./DashboardL1'));
const DashboardL2 = lazy(() => import('./DashboardL2')); const DashboardL2 = lazy(() => import('./DashboardL2'));
const DashboardL3 = lazy(() => import('./DashboardL3')); const DashboardL3 = lazy(() => import('./DashboardL3'));
const Fallback = <div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}><Spinner size="lg" /></div>;
export default function DashboardPage() { export default function DashboardPage() {
const { appId, routeId } = useParams<{ appId?: string; routeId?: string }>(); const { appId, routeId } = useParams<{ appId?: string; routeId?: string }>();
if (routeId && appId) { if (routeId && appId) {
return <Suspense fallback={Fallback}><DashboardL3 /></Suspense>; return <Suspense fallback={<PageLoader />}><DashboardL3 /></Suspense>;
} }
if (appId) { if (appId) {
return <Suspense fallback={Fallback}><DashboardL2 /></Suspense>; return <Suspense fallback={<PageLoader />}><DashboardL2 /></Suspense>;
} }
return <Suspense fallback={Fallback}><DashboardL1 /></Suspense>; return <Suspense fallback={<PageLoader />}><DashboardL1 /></Suspense>;
} }

View File

@@ -8,61 +8,6 @@
padding-bottom: 20px; padding-bottom: 20px;
} }
.refreshIndicator {
display: flex;
align-items: center;
gap: 6px;
justify-content: flex-end;
}
.refreshDot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--success);
box-shadow: 0 0 4px rgba(61, 124, 71, 0.5);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.refreshText {
font-size: 12px;
color: var(--text-muted);
font-family: var(--font-mono);
}
/* Tables */
.tableSection {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
}
.tableHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
}
.tableTitle {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.tableMeta {
font-size: 12px;
color: var(--text-muted);
font-family: var(--font-mono);
}
/* Charts */ /* Charts */
.chartGrid { .chartGrid {
@@ -92,12 +37,6 @@
text-decoration: underline; text-decoration: underline;
} }
/* Rate coloring */
.rateGood { color: var(--success); }
.rateWarn { color: var(--warning); }
.rateBad { color: var(--error); }
.rateNeutral { color: var(--text-secondary); }
/* Diagram container */ /* Diagram container */
.diagramSection { .diagramSection {
background: var(--bg-surface); background: var(--bg-surface);
@@ -108,13 +47,6 @@
height: 280px; height: 280px;
} }
/* Table right side (meta + badge) */
.tableRight {
display: flex;
align-items: center;
gap: 10px;
}
/* Chart fill */ /* Chart fill */
.chart { .chart {
width: 100%; width: 100%;

View File

@@ -5,7 +5,7 @@
flex-direction: column; flex-direction: column;
gap: 0; gap: 0;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
background: var(--bg-raised, var(--surface)); background: var(--bg-raised);
flex-shrink: 0; flex-shrink: 0;
} }

View File

@@ -136,23 +136,6 @@
margin-top: 20px; margin-top: 20px;
} }
/* Rate color classes */
.rateGood {
color: var(--success);
}
.rateWarn {
color: var(--warning);
}
.rateBad {
color: var(--error);
}
.rateNeutral {
color: var(--text-secondary);
}
/* Route name in table */ /* Route name in table */
.routeNameCell { .routeNameCell {
font-size: 12px; font-size: 12px;
@@ -161,42 +144,10 @@
font-family: var(--font-mono); font-family: var(--font-mono);
} }
/* Table section (reused for processor table) */
.tableSection { .tableSection {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
margin-bottom: 20px; margin-bottom: 20px;
} }
.tableHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
}
.tableTitle {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.tableRight {
display: flex;
align-items: center;
gap: 10px;
}
.tableMeta {
font-size: 12px;
color: var(--text-muted);
font-family: var(--font-mono);
}
/* Chart grid */ /* Chart grid */
.chartGrid { .chartGrid {
display: grid; display: grid;
@@ -204,15 +155,6 @@
gap: 16px; gap: 16px;
} }
.chartCard {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 16px;
overflow: hidden;
}
.chartTitle { .chartTitle {
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
@@ -370,35 +312,6 @@
font-size: 12px; font-size: 12px;
} }
.typeSelector {
display: flex;
gap: 8px;
}
.typeOption {
padding: 4px 12px;
border-radius: var(--radius-sm);
font-size: 12px;
cursor: pointer;
border: 1px solid var(--border-subtle);
background: var(--bg-surface);
color: var(--text-muted);
font-family: var(--font-body);
transition: all 0.15s ease;
}
.typeOption:hover {
border-color: var(--border);
color: var(--text-secondary);
}
.typeOptionActive {
background: var(--amber-bg);
color: var(--amber-deep);
border-color: var(--amber);
font-weight: 600;
}
.tapModalFooter { .tapModalFooter {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
@@ -408,56 +321,3 @@
border-top: 1px solid var(--border-subtle); border-top: 1px solid var(--border-subtle);
} }
/* Test expression */
.testSection {
display: flex;
flex-direction: column;
gap: 8px;
}
.testTabs {
display: flex;
gap: 4px;
}
.testTabBtn {
padding: 4px 12px;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
border: 1px solid var(--border-subtle);
background: var(--bg-surface);
color: var(--text-muted);
}
.testTabBtnActive {
background: var(--bg-hover);
color: var(--text-primary);
border-color: var(--text-muted);
}
.testBody {
margin-top: 4px;
}
.testResult {
padding: 10px 14px;
border-radius: var(--radius-md);
font-family: var(--font-mono);
font-size: 12px;
margin-top: 8px;
white-space: pre-wrap;
word-break: break-all;
}
.testSuccess {
background: var(--success-bg);
border: 1px solid var(--success-border);
color: var(--success);
}
.testError {
background: var(--error-bg);
border: 1px solid var(--error-border);
color: var(--error);
}

View File

@@ -36,6 +36,10 @@ import type { ExecutionSummary } from '../../api/types';
import type { CatalogApp, CatalogRoute } from '../../api/queries/catalog'; import type { CatalogApp, CatalogRoute } from '../../api/queries/catalog';
import { buildFlowSegments } from '../../utils/diagram-mapping'; import { buildFlowSegments } from '../../utils/diagram-mapping';
import styles from './RouteDetail.module.css'; import styles from './RouteDetail.module.css';
import tableStyles from '../../styles/table-section.module.css';
import rateStyles from '../../styles/rate-colors.module.css';
import chartCardStyles from '../../styles/chart-card.module.css';
import tapModalStyles from '../../components/TapConfigModal.module.css';
// ── Row types ──────────────────────────────────────────────────────────────── // ── Row types ────────────────────────────────────────────────────────────────
@@ -714,11 +718,11 @@ export default function RouteDetail() {
</div> </div>
{/* Processor Performance table (full width) */} {/* Processor Performance table (full width) */}
<div className={styles.tableSection}> <div className={`${tableStyles.tableSection} ${styles.tableSection}`}>
<div className={styles.tableHeader}> <div className={tableStyles.tableHeader}>
<span className={styles.tableTitle}>Processor Performance</span> <span className={tableStyles.tableTitle}>Processor Performance</span>
<div className={styles.tableRight}> <div className={tableStyles.tableRight}>
<span className={styles.tableMeta}>{processorRows.length} processors</span> <span className={tableStyles.tableMeta}>{processorRows.length} processors</span>
<Badge label="AUTO" color="success" /> <Badge label="AUTO" color="success" />
</div> </div>
</div> </div>
@@ -732,8 +736,8 @@ export default function RouteDetail() {
{/* Route Flow section */} {/* Route Flow section */}
{diagramFlows.length > 0 && ( {diagramFlows.length > 0 && (
<div className={styles.routeFlowSection}> <div className={styles.routeFlowSection}>
<div className={styles.tableHeader}> <div className={tableStyles.tableHeader}>
<span className={styles.tableTitle}>Route Flow</span> <span className={tableStyles.tableTitle}>Route Flow</span>
</div> </div>
<RouteFlow flows={diagramFlows} /> <RouteFlow flows={diagramFlows} />
</div> </div>
@@ -745,7 +749,7 @@ export default function RouteDetail() {
{activeTab === 'performance' && ( {activeTab === 'performance' && (
<div className={styles.chartGrid} style={{ marginTop: 16 }}> <div className={styles.chartGrid} style={{ marginTop: 16 }}>
<div className={styles.chartCard}> <div className={chartCardStyles.chartCard}>
<div className={styles.chartTitle}>Throughput</div> <div className={styles.chartTitle}>Throughput</div>
<AreaChart <AreaChart
series={[{ series={[{
@@ -755,7 +759,7 @@ export default function RouteDetail() {
height={200} height={200}
/> />
</div> </div>
<div className={styles.chartCard}> <div className={chartCardStyles.chartCard}>
<div className={styles.chartTitle}>Latency</div> <div className={styles.chartTitle}>Latency</div>
<LineChart <LineChart
series={[{ series={[{
@@ -766,7 +770,7 @@ export default function RouteDetail() {
threshold={{ value: 300, label: 'SLA 300ms' }} threshold={{ value: 300, label: 'SLA 300ms' }}
/> />
</div> </div>
<div className={styles.chartCard}> <div className={chartCardStyles.chartCard}>
<div className={styles.chartTitle}>Errors</div> <div className={styles.chartTitle}>Errors</div>
<BarChart <BarChart
series={[{ series={[{
@@ -776,7 +780,7 @@ export default function RouteDetail() {
height={200} height={200}
/> />
</div> </div>
<div className={styles.chartCard}> <div className={chartCardStyles.chartCard}>
<div className={styles.chartTitle}>Success Rate</div> <div className={styles.chartTitle}>Success Rate</div>
<AreaChart <AreaChart
series={[{ series={[{
@@ -890,13 +894,13 @@ export default function RouteDetail() {
</FormField> </FormField>
<FormField label="Type"> <FormField label="Type">
<div className={styles.typeSelector}> <div className={tapModalStyles.typeSelector}>
{typeChoices.map(tc => ( {typeChoices.map(tc => (
<button <button
key={tc.value} key={tc.value}
type="button" type="button"
title={tc.tooltip} title={tc.tooltip}
className={`${styles.typeOption} ${tapType === tc.value ? styles.typeOptionActive : ''}`} className={`${tapModalStyles.typeOption} ${tapType === tc.value ? tapModalStyles.typeOptionActive : ''}`}
onClick={() => setTapType(tc.value)} onClick={() => setTapType(tc.value)}
> >
{tc.label} {tc.label}
@@ -913,18 +917,18 @@ export default function RouteDetail() {
{/* Test Expression */} {/* Test Expression */}
<Collapsible title="Test Expression" defaultOpen> <Collapsible title="Test Expression" defaultOpen>
<div className={styles.testSection}> <div className={tapModalStyles.testSection}>
<div className={styles.testTabs}> <div className={tapModalStyles.testTabs}>
<button <button
type="button" type="button"
className={`${styles.testTabBtn} ${testTab === 'recent' ? styles.testTabBtnActive : ''}`} className={`${tapModalStyles.testTabBtn} ${testTab === 'recent' ? tapModalStyles.testTabBtnActive : ''}`}
onClick={() => setTestTab('recent')} onClick={() => setTestTab('recent')}
> >
Recent Exchange Recent Exchange
</button> </button>
<button <button
type="button" type="button"
className={`${styles.testTabBtn} ${testTab === 'custom' ? styles.testTabBtnActive : ''}`} className={`${tapModalStyles.testTabBtn} ${testTab === 'custom' ? tapModalStyles.testTabBtnActive : ''}`}
onClick={() => setTestTab('custom')} onClick={() => setTestTab('custom')}
> >
Custom Payload Custom Payload
@@ -932,7 +936,7 @@ export default function RouteDetail() {
</div> </div>
{testTab === 'recent' && ( {testTab === 'recent' && (
<div className={styles.testBody}> <div className={tapModalStyles.testBody}>
<Select <Select
options={recentExchangeOptions.length > 0 ? recentExchangeOptions : [{ value: '', label: 'No recent exchanges' }]} options={recentExchangeOptions.length > 0 ? recentExchangeOptions : [{ value: '', label: 'No recent exchanges' }]}
value={testExchangeId} value={testExchangeId}
@@ -942,7 +946,7 @@ export default function RouteDetail() {
)} )}
{testTab === 'custom' && ( {testTab === 'custom' && (
<div className={styles.testBody}> <div className={tapModalStyles.testBody}>
<Textarea <Textarea
className={styles.monoTextarea} className={styles.monoTextarea}
value={testPayload} value={testPayload}
@@ -953,7 +957,7 @@ export default function RouteDetail() {
</div> </div>
)} )}
<div className={styles.testBody}> <div className={tapModalStyles.testBody}>
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
@@ -966,7 +970,7 @@ export default function RouteDetail() {
</div> </div>
{testResult && ( {testResult && (
<div className={`${styles.testResult} ${testResult.error ? styles.testError : styles.testSuccess}`}> <div className={`${tapModalStyles.testResult} ${testResult.error ? tapModalStyles.testError : tapModalStyles.testSuccess}`}>
{testResult.error ?? testResult.result ?? 'No result'} {testResult.error ?? testResult.result ?? 'No result'}
</div> </div>
)} )}

View File

@@ -5,68 +5,6 @@
gap: 20px; gap: 20px;
} }
.refreshIndicator {
display: flex;
align-items: center;
gap: 6px;
justify-content: flex-end;
}
.refreshDot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--success);
box-shadow: 0 0 4px rgba(61, 124, 71, 0.5);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.refreshText {
font-size: 12px;
color: var(--text-muted);
font-family: var(--font-mono);
}
/* Route performance table */
.tableSection {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
}
.tableHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
}
.tableTitle {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.tableRight {
display: flex;
align-items: center;
gap: 10px;
}
.tableMeta {
font-size: 12px;
color: var(--text-muted);
font-family: var(--font-mono);
}
/* Route name in table */ /* Route name in table */
.routeNameCell { .routeNameCell {
font-size: 12px; font-size: 12px;
@@ -81,23 +19,6 @@
color: var(--text-secondary); color: var(--text-secondary);
} }
/* Rate color classes */
.rateGood {
color: var(--success);
}
.rateWarn {
color: var(--warning);
}
.rateBad {
color: var(--error);
}
.rateNeutral {
color: var(--text-secondary);
}
/* 2x2 chart grid */ /* 2x2 chart grid */
.chartGrid { .chartGrid {
display: grid; display: grid;

View File

@@ -17,6 +17,9 @@ import { useRouteMetrics } from '../../api/queries/catalog';
import { useExecutionStats, useStatsTimeseries } from '../../api/queries/executions'; import { useExecutionStats, useStatsTimeseries } from '../../api/queries/executions';
import type { RouteMetrics } from '../../api/types'; import type { RouteMetrics } from '../../api/types';
import styles from './RoutesMetrics.module.css'; import styles from './RoutesMetrics.module.css';
import tableStyles from '../../styles/table-section.module.css';
import refreshStyles from '../../styles/refresh-indicator.module.css';
import rateStyles from '../../styles/rate-colors.module.css';
interface RouteRow { interface RouteRow {
id: string; id: string;
@@ -64,7 +67,7 @@ const ROUTE_COLUMNS: Column<RouteRow>[] = [
sortable: true, sortable: true,
render: (_, row) => { render: (_, row) => {
const pct = row.successRate * 100; const pct = row.successRate * 100;
const cls = pct >= 99 ? styles.rateGood : pct >= 97 ? styles.rateWarn : styles.rateBad; const cls = pct >= 99 ? rateStyles.rateGood : pct >= 97 ? rateStyles.rateWarn : rateStyles.rateBad;
return <MonoText size="sm" className={cls}>{pct.toFixed(1)}%</MonoText>; return <MonoText size="sm" className={cls}>{pct.toFixed(1)}%</MonoText>;
}, },
}, },
@@ -81,7 +84,7 @@ const ROUTE_COLUMNS: Column<RouteRow>[] = [
header: 'p99 Duration', header: 'p99 Duration',
sortable: true, sortable: true,
render: (_, row) => { render: (_, row) => {
const cls = row.p99DurationMs > 300 ? styles.rateBad : row.p99DurationMs > 200 ? styles.rateWarn : styles.rateGood; const cls = row.p99DurationMs > 300 ? rateStyles.rateBad : row.p99DurationMs > 200 ? rateStyles.rateWarn : rateStyles.rateGood;
return <MonoText size="sm" className={cls}>{Math.round(row.p99DurationMs)}ms</MonoText>; return <MonoText size="sm" className={cls}>{Math.round(row.p99DurationMs)}ms</MonoText>;
}, },
}, },
@@ -91,7 +94,7 @@ const ROUTE_COLUMNS: Column<RouteRow>[] = [
sortable: true, sortable: true,
render: (_, row) => { render: (_, row) => {
const pct = row.errorRate * 100; const pct = row.errorRate * 100;
const cls = pct > 5 ? styles.rateBad : pct > 1 ? styles.rateWarn : styles.rateGood; const cls = pct > 5 ? rateStyles.rateBad : pct > 1 ? rateStyles.rateWarn : rateStyles.rateGood;
return <MonoText size="sm" className={cls}>{pct.toFixed(1)}%</MonoText>; return <MonoText size="sm" className={cls}>{pct.toFixed(1)}%</MonoText>;
}, },
}, },
@@ -281,20 +284,20 @@ export default function RoutesMetrics() {
return ( return (
<div className={styles.content}> <div className={styles.content}>
<div className={styles.refreshIndicator}> <div className={refreshStyles.refreshIndicator}>
<span className={styles.refreshDot} /> <span className={refreshStyles.refreshDot} />
<span className={styles.refreshText}>Auto-refresh: 30s</span> <span className={refreshStyles.refreshText}>Auto-refresh: 30s</span>
</div> </div>
{/* KPI header cards */} {/* KPI header cards */}
<KpiStrip items={kpiItems} /> <KpiStrip items={kpiItems} />
{/* Per-route performance table */} {/* Per-route performance table */}
<div className={styles.tableSection}> <div className={tableStyles.tableSection}>
<div className={styles.tableHeader}> <div className={tableStyles.tableHeader}>
<span className={styles.tableTitle}>Per-Route Performance</span> <span className={tableStyles.tableTitle}>Per-Route Performance</span>
<div className={styles.tableRight}> <div className={tableStyles.tableRight}>
<span className={styles.tableMeta}>{rows.length} routes</span> <span className={tableStyles.tableMeta}>{rows.length} routes</span>
<Badge label="AUTO" color="success" /> <Badge label="AUTO" color="success" />
</div> </div>
</div> </div>

View File

@@ -1,17 +1,15 @@
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { lazy, Suspense } from 'react'; import { lazy, Suspense } from 'react';
import { Spinner } from '@cameleer/design-system'; import { PageLoader } from '../../components/PageLoader';
const AgentHealth = lazy(() => import('../AgentHealth/AgentHealth')); const AgentHealth = lazy(() => import('../AgentHealth/AgentHealth'));
const AgentInstance = lazy(() => import('../AgentInstance/AgentInstance')); const AgentInstance = lazy(() => import('../AgentInstance/AgentInstance'));
const Fallback = <div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}><Spinner size="lg" /></div>;
export default function RuntimePage() { export default function RuntimePage() {
const { instanceId } = useParams<{ appId?: string; instanceId?: string }>(); const { instanceId } = useParams<{ appId?: string; instanceId?: string }>();
if (instanceId) { if (instanceId) {
return <Suspense fallback={Fallback}><AgentInstance /></Suspense>; return <Suspense fallback={<PageLoader />}><AgentInstance /></Suspense>;
} }
return <Suspense fallback={Fallback}><AgentHealth /></Suspense>; return <Suspense fallback={<PageLoader />}><AgentHealth /></Suspense>;
} }

View File

@@ -6,7 +6,7 @@ import { OidcCallback } from './auth/OidcCallback';
import { LayoutShell } from './components/LayoutShell'; import { LayoutShell } from './components/LayoutShell';
import { config } from './config'; import { config } from './config';
import { lazy, Suspense } from 'react'; import { lazy, Suspense } from 'react';
import { Spinner } from '@cameleer/design-system'; import { PageLoader } from './components/PageLoader';
const ExchangesPage = lazy(() => import('./pages/Exchanges/ExchangesPage')); const ExchangesPage = lazy(() => import('./pages/Exchanges/ExchangesPage'));
const DashboardPage = lazy(() => import('./pages/DashboardTab/DashboardPage')); const DashboardPage = lazy(() => import('./pages/DashboardTab/DashboardPage'));
@@ -23,7 +23,7 @@ const SwaggerPage = lazy(() => import('./pages/Swagger/SwaggerPage'));
function SuspenseWrapper({ children }: { children: React.ReactNode }) { function SuspenseWrapper({ children }: { children: React.ReactNode }) {
return ( return (
<Suspense fallback={<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}><Spinner size="lg" /></div>}> <Suspense fallback={<PageLoader />}>
{children} {children}
</Suspense> </Suspense>
); );

View File

@@ -0,0 +1,8 @@
.chartCard {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 16px;
overflow: hidden;
}

View File

@@ -0,0 +1,112 @@
.logCard {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
display: flex;
flex-direction: column;
max-height: 420px;
}
.logHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
}
.logToolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--border-subtle);
background: var(--bg-surface);
}
.logSearchWrap {
position: relative;
flex: 1;
min-width: 0;
}
.logSearchInput {
width: 100%;
padding: 5px 28px 5px 10px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: var(--bg-body);
color: var(--text-primary);
font-size: 12px;
font-family: var(--font-body);
outline: none;
}
.logSearchInput:focus {
border-color: var(--amber);
}
.logSearchInput::placeholder {
color: var(--text-faint);
}
.logSearchClear {
position: absolute;
right: 4px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 14px;
padding: 0 4px;
line-height: 1;
}
.logClearFilters {
background: none;
border: none;
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
padding: 2px 6px;
white-space: nowrap;
}
.logClearFilters:hover {
color: var(--text-primary);
}
.logEmpty {
padding: 24px;
text-align: center;
color: var(--text-faint);
font-size: 12px;
}
.sortBtn,
.refreshBtn {
background: none;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
color: var(--text-muted);
cursor: pointer;
font-size: 13px;
padding: 2px 6px;
line-height: 1;
}
.sortBtn:hover,
.refreshBtn:hover {
color: var(--text-primary);
border-color: var(--amber);
}
.headerActions {
display: flex;
align-items: center;
gap: 6px;
}

View File

@@ -0,0 +1,4 @@
.rateGood { color: var(--success); }
.rateWarn { color: var(--warning); }
.rateBad { color: var(--error); }
.rateNeutral { color: var(--text-secondary); }

View File

@@ -0,0 +1,26 @@
.refreshIndicator {
display: flex;
align-items: center;
gap: 6px;
justify-content: flex-end;
}
.refreshDot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--success);
box-shadow: 0 0 4px color-mix(in srgb, var(--success) 50%, transparent);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.refreshText {
font-size: 12px;
color: var(--text-muted);
font-family: var(--font-mono);
}

View File

@@ -0,0 +1,11 @@
.section {
margin-bottom: 16px;
display: flex;
flex-direction: column;
gap: 12px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 16px 20px;
}

View File

@@ -0,0 +1,33 @@
.tableSection {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
}
.tableHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
}
.tableTitle {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.tableMeta {
font-size: 12px;
color: var(--text-muted);
font-family: var(--font-mono);
}
.tableRight {
display: flex;
align-items: center;
gap: 10px;
}