UI overhaul: unified sidebar layout with app-scoped views
Replace disconnected Transactions/Applications pages with a persistent collapsible sidebar listing apps by health status. Add app-scoped view (/apps/:group) with filtered stats, route chips, and scoped table. Merge Processor Tree into diagram detail panel with Inspector/Tree toggle and resizable divider. Remove max-width constraint for full viewport usage. All view states are deep-linkable via URL. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,12 @@
|
||||
.main {
|
||||
.layout {
|
||||
display: flex;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 24px;
|
||||
min-height: calc(100vh - 56px);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,47 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Outlet } from 'react-router';
|
||||
import { TopNav } from './TopNav';
|
||||
import { AppSidebar } from './AppSidebar';
|
||||
import { CommandPalette } from '../command-palette/CommandPalette';
|
||||
import styles from './AppShell.module.css';
|
||||
|
||||
const COLLAPSED_KEY = 'cameleer-sidebar-collapsed';
|
||||
|
||||
export function AppShell() {
|
||||
const [collapsed, setCollapsed] = useState(() => {
|
||||
try { return localStorage.getItem(COLLAPSED_KEY) === 'true'; }
|
||||
catch { return false; }
|
||||
});
|
||||
|
||||
// Auto-collapse on small screens
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(max-width: 1024px)');
|
||||
function handleChange(e: MediaQueryListEvent | MediaQueryList) {
|
||||
if (e.matches) setCollapsed(true);
|
||||
}
|
||||
handleChange(mq);
|
||||
mq.addEventListener('change', handleChange);
|
||||
return () => mq.removeEventListener('change', handleChange);
|
||||
}, []);
|
||||
|
||||
function toggleSidebar() {
|
||||
setCollapsed((prev) => {
|
||||
const next = !prev;
|
||||
try { localStorage.setItem(COLLAPSED_KEY, String(next)); }
|
||||
catch { /* ignore */ }
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopNav />
|
||||
<main className={styles.main}>
|
||||
<Outlet />
|
||||
</main>
|
||||
<TopNav onToggleSidebar={toggleSidebar} />
|
||||
<div className={styles.layout}>
|
||||
<AppSidebar collapsed={collapsed} />
|
||||
<main className={styles.main}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
<CommandPalette />
|
||||
</>
|
||||
);
|
||||
|
||||
245
ui/src/components/layout/AppSidebar.module.css
Normal file
245
ui/src/components/layout/AppSidebar.module.css
Normal file
@@ -0,0 +1,245 @@
|
||||
/* ─── Sidebar Container ─── */
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-surface);
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
height: calc(100vh - 56px);
|
||||
position: sticky;
|
||||
top: 56px;
|
||||
overflow: hidden;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebarCollapsed {
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
/* ─── Search ─── */
|
||||
.search {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.sidebarCollapsed .search {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-body);
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.searchInput::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.searchInput:focus {
|
||||
border-color: var(--amber);
|
||||
}
|
||||
|
||||
/* ─── App List ─── */
|
||||
.appList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* ─── Section Divider ─── */
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--border-subtle);
|
||||
margin: 4px 12px;
|
||||
}
|
||||
|
||||
.sidebarCollapsed .divider {
|
||||
margin: 4px 8px;
|
||||
}
|
||||
|
||||
/* ─── App Item ─── */
|
||||
.appItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-family: var(--font-body);
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.appItem:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.appItemActive {
|
||||
background: var(--amber-glow);
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.sidebarCollapsed .appItem {
|
||||
padding: 8px 0;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* ─── Health Dot ─── */
|
||||
.healthDot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dotLive { background: var(--green); }
|
||||
.dotStale { background: var(--amber); }
|
||||
.dotDead { background: var(--text-muted); }
|
||||
|
||||
/* ─── App Info (hidden when collapsed) ─── */
|
||||
.appInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebarCollapsed .appInfo {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.appName {
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.appMeta {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ─── All Item icon ─── */
|
||||
.allIcon {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.appItemActive .allIcon {
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
/* ─── Bottom Section ─── */
|
||||
.bottom {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.bottomItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-body);
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bottomItem:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.bottomItemActive {
|
||||
color: var(--amber);
|
||||
background: var(--amber-glow);
|
||||
}
|
||||
|
||||
.sidebarCollapsed .bottomItem {
|
||||
padding: 8px 0;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.bottomLabel {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sidebarCollapsed .bottomLabel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bottomIcon {
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ─── Responsive ─── */
|
||||
@media (max-width: 1024px) {
|
||||
.sidebar {
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
.sidebar .search {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar .appInfo {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar .appItem {
|
||||
padding: 8px 0;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.sidebar .divider {
|
||||
margin: 4px 8px;
|
||||
}
|
||||
|
||||
.sidebar .bottomItem {
|
||||
padding: 8px 0;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.sidebar .bottomLabel {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
129
ui/src/components/layout/AppSidebar.tsx
Normal file
129
ui/src/components/layout/AppSidebar.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { NavLink, useParams } from 'react-router';
|
||||
import { useAgents } from '../../api/queries/agents';
|
||||
import { useAuthStore } from '../../auth/auth-store';
|
||||
import type { AgentInstance } from '../../api/types';
|
||||
import styles from './AppSidebar.module.css';
|
||||
|
||||
interface GroupInfo {
|
||||
group: string;
|
||||
agents: AgentInstance[];
|
||||
liveCount: number;
|
||||
staleCount: number;
|
||||
deadCount: number;
|
||||
}
|
||||
|
||||
function healthStatus(g: GroupInfo): 'live' | 'stale' | 'dead' {
|
||||
if (g.liveCount > 0) return 'live';
|
||||
if (g.staleCount > 0) return 'stale';
|
||||
return 'dead';
|
||||
}
|
||||
|
||||
interface AppSidebarProps {
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
export function AppSidebar({ collapsed }: AppSidebarProps) {
|
||||
const { group: activeGroup } = useParams<{ group: string }>();
|
||||
const { data: agents } = useAgents();
|
||||
const { roles } = useAuthStore();
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
const groups = useMemo(() => {
|
||||
if (!agents) return [];
|
||||
const map = new Map<string, GroupInfo>();
|
||||
for (const agent of agents) {
|
||||
const key = agent.group ?? 'default';
|
||||
let entry = map.get(key);
|
||||
if (!entry) {
|
||||
entry = { group: key, agents: [], liveCount: 0, staleCount: 0, deadCount: 0 };
|
||||
map.set(key, entry);
|
||||
}
|
||||
entry.agents.push(agent);
|
||||
if (agent.status === 'LIVE') entry.liveCount++;
|
||||
else if (agent.status === 'STALE') entry.staleCount++;
|
||||
else entry.deadCount++;
|
||||
}
|
||||
return Array.from(map.values()).sort((a, b) => a.group.localeCompare(b.group));
|
||||
}, [agents]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!filter) return groups;
|
||||
const lower = filter.toLowerCase();
|
||||
return groups.filter((g) => g.group.toLowerCase().includes(lower));
|
||||
}, [groups, filter]);
|
||||
|
||||
const sidebarClass = `${styles.sidebar} ${collapsed ? styles.sidebarCollapsed : ''}`;
|
||||
|
||||
return (
|
||||
<aside className={sidebarClass}>
|
||||
{/* Search */}
|
||||
<div className={styles.search}>
|
||||
<input
|
||||
className={styles.searchInput}
|
||||
type="text"
|
||||
placeholder="Filter apps..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* App List */}
|
||||
<div className={styles.appList}>
|
||||
{/* All (unscoped) */}
|
||||
<NavLink
|
||||
to="/executions"
|
||||
className={({ isActive }) =>
|
||||
`${styles.appItem} ${isActive && !activeGroup ? styles.appItemActive : ''}`
|
||||
}
|
||||
title="All Applications"
|
||||
>
|
||||
<span className={styles.allIcon}>*</span>
|
||||
<span className={styles.appInfo}>
|
||||
<span className={styles.appName}>All</span>
|
||||
</span>
|
||||
</NavLink>
|
||||
|
||||
<div className={styles.divider} />
|
||||
|
||||
{/* App entries */}
|
||||
{filtered.map((g) => {
|
||||
const status = healthStatus(g);
|
||||
const isActive = activeGroup === g.group;
|
||||
return (
|
||||
<NavLink
|
||||
key={g.group}
|
||||
to={`/apps/${encodeURIComponent(g.group)}`}
|
||||
className={`${styles.appItem} ${isActive ? styles.appItemActive : ''}`}
|
||||
title={g.group}
|
||||
>
|
||||
<span className={`${styles.healthDot} ${styles[`dot${status.charAt(0).toUpperCase()}${status.slice(1)}`]}`} />
|
||||
<span className={styles.appInfo}>
|
||||
<span className={styles.appName}>{g.group}</span>
|
||||
<span className={styles.appMeta}>
|
||||
{g.agents.length} agent{g.agents.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Bottom: Admin */}
|
||||
{roles.includes('ADMIN') && (
|
||||
<div className={styles.bottom}>
|
||||
<NavLink
|
||||
to="/admin/oidc"
|
||||
className={({ isActive }) =>
|
||||
`${styles.bottomItem} ${isActive ? styles.bottomItemActive : ''}`
|
||||
}
|
||||
title="Admin"
|
||||
>
|
||||
<span className={styles.bottomIcon}>⚙</span>
|
||||
<span className={styles.bottomLabel}>Admin</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -5,11 +5,32 @@
|
||||
background: var(--topnav-bg);
|
||||
backdrop-filter: blur(20px) saturate(1.2);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
padding: 0 24px;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 56px;
|
||||
gap: 32px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.hamburger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hamburger:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-muted);
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
|
||||
.logo {
|
||||
@@ -27,38 +48,57 @@
|
||||
|
||||
.logo:hover { color: var(--amber); }
|
||||
|
||||
.navLinks {
|
||||
/* ─── Search Bar ─── */
|
||||
.searchBar {
|
||||
flex: 1;
|
||||
max-width: 480px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.navLink {
|
||||
padding: 8px 16px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.15s;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.navLink:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-raised);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.navLinkActive {
|
||||
composes: navLink;
|
||||
color: var(--amber);
|
||||
background: var(--amber-glow);
|
||||
.searchBar:hover {
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.searchPlaceholder {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.searchKbd {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ─── Right Section ─── */
|
||||
.navRight {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.utilLink {
|
||||
@@ -136,3 +176,10 @@
|
||||
.logoutBtn:hover {
|
||||
color: var(--rose);
|
||||
}
|
||||
|
||||
/* ─── Responsive ─── */
|
||||
@media (max-width: 768px) {
|
||||
.searchBar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
import { NavLink } from 'react-router';
|
||||
import { useThemeStore } from '../../theme/theme-store';
|
||||
import { useAuthStore } from '../../auth/auth-store';
|
||||
import { useCommandPalette } from '../command-palette/use-command-palette';
|
||||
import styles from './TopNav.module.css';
|
||||
|
||||
export function TopNav() {
|
||||
interface TopNavProps {
|
||||
onToggleSidebar: () => void;
|
||||
}
|
||||
|
||||
export function TopNav({ onToggleSidebar }: TopNavProps) {
|
||||
const { theme, toggle } = useThemeStore();
|
||||
const { username, roles, logout } = useAuthStore();
|
||||
const { username, logout } = useAuthStore();
|
||||
const openPalette = useCommandPalette((s) => s.open);
|
||||
|
||||
return (
|
||||
<nav className={styles.topnav}>
|
||||
<button className={styles.hamburger} onClick={onToggleSidebar} title="Toggle sidebar">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M3 12h18M3 6h18M3 18h18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<NavLink to="/" className={styles.logo}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2" />
|
||||
@@ -17,25 +29,15 @@ export function TopNav() {
|
||||
cameleer3
|
||||
</NavLink>
|
||||
|
||||
<ul className={styles.navLinks}>
|
||||
<li>
|
||||
<NavLink to="/executions" className={({ isActive }) => isActive ? styles.navLinkActive : styles.navLink}>
|
||||
Transactions
|
||||
</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink to="/apps" className={({ isActive }) => isActive ? styles.navLinkActive : styles.navLink}>
|
||||
Applications
|
||||
</NavLink>
|
||||
</li>
|
||||
{roles.includes('ADMIN') && (
|
||||
<li>
|
||||
<NavLink to="/admin/oidc" className={({ isActive }) => isActive ? styles.navLinkActive : styles.navLink}>
|
||||
Admin
|
||||
</NavLink>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
{/* Visible search bar */}
|
||||
<div className={styles.searchBar} onClick={openPalette} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') openPalette(); }}>
|
||||
<svg className={styles.searchIcon} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="M21 21l-4.35-4.35" />
|
||||
</svg>
|
||||
<span className={styles.searchPlaceholder}>Search executions, orders...</span>
|
||||
<kbd className={styles.searchKbd}>⌘K</kbd>
|
||||
</div>
|
||||
|
||||
<div className={styles.navRight}>
|
||||
<NavLink to="/swagger" className={({ isActive }) => isActive ? styles.utilLinkActive : styles.utilLink} title="API Documentation">
|
||||
|
||||
73
ui/src/components/shared/ResizableDivider.tsx
Normal file
73
ui/src/components/shared/ResizableDivider.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useCallback, useRef, useEffect } from 'react';
|
||||
|
||||
interface ResizableDividerProps {
|
||||
/** Current panel width in pixels */
|
||||
panelWidth: number;
|
||||
/** Called with new width */
|
||||
onResize: (width: number) => void;
|
||||
/** Min panel width */
|
||||
minWidth?: number;
|
||||
/** Max panel width */
|
||||
maxWidth?: number;
|
||||
}
|
||||
|
||||
export function ResizableDivider({
|
||||
panelWidth,
|
||||
onResize,
|
||||
minWidth = 200,
|
||||
maxWidth = 600,
|
||||
}: ResizableDividerProps) {
|
||||
const dragging = useRef(false);
|
||||
const startX = useRef(0);
|
||||
const startWidth = useRef(0);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
dragging.current = true;
|
||||
startX.current = e.clientX;
|
||||
startWidth.current = panelWidth;
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
}, [panelWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleMouseMove(e: MouseEvent) {
|
||||
if (!dragging.current) return;
|
||||
// Dragging left increases panel width (panel is on the right)
|
||||
const delta = startX.current - e.clientX;
|
||||
const newWidth = Math.min(maxWidth, Math.max(minWidth, startWidth.current + delta));
|
||||
onResize(newWidth);
|
||||
}
|
||||
|
||||
function handleMouseUp() {
|
||||
if (!dragging.current) return;
|
||||
dragging.current = false;
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [onResize, minWidth, maxWidth]);
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{
|
||||
width: 6,
|
||||
cursor: 'col-resize',
|
||||
background: 'var(--border-subtle)',
|
||||
flexShrink: 0,
|
||||
position: 'relative',
|
||||
zIndex: 5,
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = 'var(--amber)'; }}
|
||||
onMouseLeave={(e) => { if (!dragging.current) (e.currentTarget as HTMLElement).style.background = 'var(--border-subtle)'; }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
.pageHeader {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.pageHeader h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 20px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.cardHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.statusDot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.live { background: var(--green); }
|
||||
.stale { background: var(--amber); }
|
||||
.dead { background: var(--text-muted); }
|
||||
|
||||
.groupName {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.agentCount {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.statusBar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.statusLive { color: var(--green); }
|
||||
.statusStale { color: var(--amber); }
|
||||
.statusDead { color: var(--text-muted); }
|
||||
|
||||
.routes {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.routesLabel {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
color: var(--text-muted);
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.routeList {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.routeLink {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-subtle);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.routeLink:hover {
|
||||
color: var(--amber);
|
||||
border-color: var(--amber-dim);
|
||||
background: var(--amber-glow);
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { useAgents } from '../../api/queries/agents';
|
||||
import type { AgentInstance } from '../../api/types';
|
||||
import styles from './ApplicationsPage.module.css';
|
||||
|
||||
interface GroupInfo {
|
||||
group: string;
|
||||
agents: AgentInstance[];
|
||||
routeIds: string[];
|
||||
liveCount: number;
|
||||
staleCount: number;
|
||||
deadCount: number;
|
||||
}
|
||||
|
||||
function groupStatus(g: GroupInfo): 'live' | 'stale' | 'dead' {
|
||||
if (g.liveCount > 0) return 'live';
|
||||
if (g.staleCount > 0) return 'stale';
|
||||
return 'dead';
|
||||
}
|
||||
|
||||
export function ApplicationsPage() {
|
||||
const { data: agents, isLoading } = useAgents();
|
||||
|
||||
const groups = useMemo(() => {
|
||||
if (!agents) return [];
|
||||
const map = new Map<string, GroupInfo>();
|
||||
for (const agent of agents) {
|
||||
const key = agent.group ?? 'default';
|
||||
let entry = map.get(key);
|
||||
if (!entry) {
|
||||
entry = { group: key, agents: [], routeIds: [], liveCount: 0, staleCount: 0, deadCount: 0 };
|
||||
map.set(key, entry);
|
||||
}
|
||||
entry.agents.push(agent);
|
||||
if (agent.status === 'LIVE') entry.liveCount++;
|
||||
else if (agent.status === 'STALE') entry.staleCount++;
|
||||
else entry.deadCount++;
|
||||
|
||||
// Collect unique routeIds
|
||||
if (agent.routeIds) {
|
||||
for (const rid of agent.routeIds) {
|
||||
if (!entry.routeIds.includes(rid)) entry.routeIds.push(rid);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(map.values()).sort((a, b) => a.group.localeCompare(b.group));
|
||||
}, [agents]);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className={styles.loading}>Loading applications...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`${styles.pageHeader} animate-in`}>
|
||||
<h1>Applications</h1>
|
||||
<div className={styles.subtitle}>Monitored Camel applications and their routes</div>
|
||||
</div>
|
||||
|
||||
{groups.length === 0 ? (
|
||||
<div className={styles.empty}>No applications found. Agents will appear here once they connect.</div>
|
||||
) : (
|
||||
<div className={styles.grid}>
|
||||
{groups.map((g) => {
|
||||
const status = groupStatus(g);
|
||||
return (
|
||||
<div key={g.group} className={styles.card}>
|
||||
<div className={styles.cardHeader}>
|
||||
<div className={`${styles.statusDot} ${styles[status]}`} />
|
||||
<span className={styles.groupName}>{g.group}</span>
|
||||
<span className={styles.agentCount}>
|
||||
{g.agents.length} instance{g.agents.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.statusBar}>
|
||||
{g.liveCount > 0 && <span className={styles.statusLive}>{g.liveCount} live</span>}
|
||||
{g.staleCount > 0 && <span className={styles.statusStale}>{g.staleCount} stale</span>}
|
||||
{g.deadCount > 0 && <span className={styles.statusDead}>{g.deadCount} dead</span>}
|
||||
</div>
|
||||
|
||||
{g.routeIds.length > 0 && (
|
||||
<div className={styles.routes}>
|
||||
<span className={styles.routesLabel}>Routes</span>
|
||||
<ul className={styles.routeList}>
|
||||
{g.routeIds.map((rid) => (
|
||||
<li key={rid}>
|
||||
<Link
|
||||
to={`/apps/${encodeURIComponent(g.group)}/routes/${encodeURIComponent(rid)}`}
|
||||
className={styles.routeLink}
|
||||
>
|
||||
{rid}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
214
ui/src/pages/dashboard/AppScopedView.module.css
Normal file
214
ui/src/pages/dashboard/AppScopedView.module.css
Normal file
@@ -0,0 +1,214 @@
|
||||
/* ─── Breadcrumb ─── */
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.breadcrumbLink {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.breadcrumbLink:hover {
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.breadcrumbSep {
|
||||
color: var(--text-muted);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.breadcrumbCurrent {
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ─── App Header ─── */
|
||||
.appHeader {
|
||||
position: relative;
|
||||
margin-bottom: 20px;
|
||||
padding: 16px 20px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.appHeader::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--amber), var(--cyan));
|
||||
}
|
||||
|
||||
.appTitle {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.agentSummary {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.agentLive { color: var(--green); }
|
||||
.agentStale { color: var(--amber); }
|
||||
.agentDead { color: var(--text-muted); }
|
||||
|
||||
/* ─── Stats Bar ─── */
|
||||
.statsBar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* ─── Route Chips ─── */
|
||||
.routeChips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.routeChip {
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 99px;
|
||||
background: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.routeChip:hover {
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.routeChipActive {
|
||||
background: var(--amber-glow);
|
||||
color: var(--amber);
|
||||
border-color: rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
/* ─── Results Header ─── */
|
||||
.resultsHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.resultsCount {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.resultsCount strong {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ─── Filter Bar ─── */
|
||||
.filterBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filterGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.filterLabel {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ─── Live Toggle ─── */
|
||||
.liveToggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 500;
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 6px 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.liveOn {
|
||||
color: var(--green);
|
||||
border-color: var(--green);
|
||||
}
|
||||
|
||||
.liveOff {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.liveDot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.liveOn .liveDot {
|
||||
background: var(--green);
|
||||
animation: livePulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.liveOff .liveDot {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
@keyframes livePulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); }
|
||||
50% { box-shadow: 0 0 0 6px rgba(16, 185, 129, 0); }
|
||||
}
|
||||
|
||||
/* ─── Loading / Empty ─── */
|
||||
.loading {
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ─── Responsive ─── */
|
||||
@media (max-width: 1200px) {
|
||||
.statsBar { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.statsBar { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
184
ui/src/pages/dashboard/AppScopedView.tsx
Normal file
184
ui/src/pages/dashboard/AppScopedView.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useParams, useNavigate, NavLink } from 'react-router';
|
||||
import { useAgents } from '../../api/queries/agents';
|
||||
import { useSearchExecutions, useExecutionStats, useStatsTimeseries } from '../../api/queries/executions';
|
||||
import { StatCard } from '../../components/shared/StatCard';
|
||||
import { ResultsTable } from '../executions/ResultsTable';
|
||||
import { Pagination } from '../../components/shared/Pagination';
|
||||
import { FilterChip } from '../../components/shared/FilterChip';
|
||||
import type { SearchRequest } from '../../api/types';
|
||||
import styles from './AppScopedView.module.css';
|
||||
|
||||
function todayMidnight(): string {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T00:00`;
|
||||
}
|
||||
|
||||
function formatCompact(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
function pctChange(current: number, previous: number): { text: string; direction: 'up' | 'down' | 'neutral' } {
|
||||
if (previous === 0) return { text: 'no prior data', direction: 'neutral' };
|
||||
const pct = ((current - previous) / previous) * 100;
|
||||
if (Math.abs(pct) < 0.5) return { text: '~0% vs yesterday', direction: 'neutral' };
|
||||
const arrow = pct > 0 ? '\u2191' : '\u2193';
|
||||
return { text: `${arrow} ${Math.abs(pct).toFixed(1)}% vs yesterday`, direction: pct > 0 ? 'up' : 'down' };
|
||||
}
|
||||
|
||||
export function AppScopedView() {
|
||||
const { group } = useParams<{ group: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { data: agents } = useAgents();
|
||||
const [selectedRoute, setSelectedRoute] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<string[]>(['COMPLETED', 'FAILED']);
|
||||
const [live, setLive] = useState(true);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const limit = 25;
|
||||
|
||||
// Find agents belonging to this group
|
||||
const groupAgents = useMemo(() => {
|
||||
if (!agents || !group) return [];
|
||||
return agents.filter((a) => (a.group ?? 'default') === group);
|
||||
}, [agents, group]);
|
||||
|
||||
const liveCount = groupAgents.filter((a) => a.status === 'LIVE').length;
|
||||
const staleCount = groupAgents.filter((a) => a.status === 'STALE').length;
|
||||
const deadCount = groupAgents.filter((a) => a.status === 'DEAD').length;
|
||||
|
||||
// Collect unique routes from agents
|
||||
const routeIds = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const a of groupAgents) {
|
||||
if (a.routeIds) for (const rid of a.routeIds) set.add(rid);
|
||||
}
|
||||
return Array.from(set).sort();
|
||||
}, [groupAgents]);
|
||||
|
||||
// Build search request scoped to this group
|
||||
const timeFrom = todayMidnight();
|
||||
const timeFromIso = new Date(timeFrom).toISOString();
|
||||
|
||||
const searchRequest: SearchRequest = useMemo(() => ({
|
||||
group: group || undefined,
|
||||
routeId: selectedRoute || undefined,
|
||||
status: status.length > 0 && status.length < 3 ? status.join(',') : undefined,
|
||||
timeFrom: timeFromIso,
|
||||
offset,
|
||||
limit,
|
||||
sortField: 'startTime',
|
||||
sortDir: 'desc',
|
||||
}), [group, selectedRoute, status, timeFromIso, offset, limit]);
|
||||
|
||||
const { data, isLoading, isFetching } = useSearchExecutions(searchRequest, live);
|
||||
const { data: stats } = useExecutionStats(timeFromIso, undefined, selectedRoute || undefined, group);
|
||||
const { data: timeseries } = useStatsTimeseries(timeFromIso, undefined, selectedRoute || undefined, group);
|
||||
|
||||
const sparkTotal = timeseries?.buckets.map((b) => b.totalCount) ?? [];
|
||||
const sparkFailed = timeseries?.buckets.map((b) => b.failedCount) ?? [];
|
||||
const sparkAvgDuration = timeseries?.buckets.map((b) => b.avgDurationMs) ?? [];
|
||||
const sparkP99 = timeseries?.buckets.map((b) => b.p99DurationMs) ?? [];
|
||||
const sparkActive = timeseries?.buckets.map((b) => b.activeCount) ?? [];
|
||||
|
||||
const total = data?.total ?? 0;
|
||||
const results = data?.data ?? [];
|
||||
|
||||
const failureRate = stats && stats.totalCount > 0
|
||||
? (stats.failedCount / stats.totalCount) * 100 : 0;
|
||||
const prevFailureRate = stats && stats.prevTotalCount > 0
|
||||
? (stats.prevFailedCount / stats.prevTotalCount) * 100 : 0;
|
||||
|
||||
const avgChange = stats ? pctChange(stats.avgDurationMs, stats.prevAvgDurationMs) : null;
|
||||
const failRateChange = stats ? pctChange(failureRate, prevFailureRate) : null;
|
||||
const p99Change = stats ? pctChange(stats.p99LatencyMs, stats.prevP99LatencyMs) : null;
|
||||
|
||||
const showFrom = total > 0 ? offset + 1 : 0;
|
||||
const showTo = Math.min(offset + limit, total);
|
||||
|
||||
const toggleRoute = useCallback((rid: string) => {
|
||||
setSelectedRoute((prev) => prev === rid ? null : rid);
|
||||
setOffset(0);
|
||||
}, []);
|
||||
|
||||
if (!group) {
|
||||
return <div className={styles.loading}>Missing group parameter</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Breadcrumb */}
|
||||
<nav className={styles.breadcrumb}>
|
||||
<NavLink to="/executions" className={styles.breadcrumbLink}>All</NavLink>
|
||||
<span className={styles.breadcrumbSep}>/</span>
|
||||
<span className={styles.breadcrumbCurrent}>{group}</span>
|
||||
</nav>
|
||||
|
||||
{/* App Header */}
|
||||
<div className={styles.appHeader}>
|
||||
<div className={styles.appTitle}>{group}</div>
|
||||
<div className={styles.agentSummary}>
|
||||
{liveCount > 0 && <span className={styles.agentLive}>{liveCount} live</span>}
|
||||
{staleCount > 0 && <span className={styles.agentStale}>{staleCount} stale</span>}
|
||||
{deadCount > 0 && <span className={styles.agentDead}>{deadCount} dead</span>}
|
||||
{groupAgents.length === 0 && <span className={styles.agentDead}>no agents</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Bar */}
|
||||
<div className={styles.statsBar}>
|
||||
<StatCard label="Total Matches" value={total.toLocaleString()} accent="amber" change={stats ? `of ${formatCompact(stats.totalToday)} today` : 'from current search'} sparkData={sparkTotal} />
|
||||
<StatCard label="Avg Duration" value={stats ? `${stats.avgDurationMs.toLocaleString()}ms` : '--'} accent="cyan" change={avgChange?.text} changeDirection={avgChange?.direction} sparkData={sparkAvgDuration} />
|
||||
<StatCard label="Failure Rate" value={stats ? `${failureRate.toFixed(1)}%` : '--'} accent="rose" change={failRateChange?.text} changeDirection={failRateChange?.direction} sparkData={sparkFailed} />
|
||||
<StatCard label="P99 Latency" value={stats ? `${stats.p99LatencyMs.toLocaleString()}ms` : '--'} accent="green" change={p99Change?.text} changeDirection={p99Change?.direction} sparkData={sparkP99} />
|
||||
<StatCard label="In-Flight" value={stats ? stats.activeCount.toLocaleString() : '--'} accent="blue" change="running executions" sparkData={sparkActive} />
|
||||
</div>
|
||||
|
||||
{/* Route Chips + Status Filters */}
|
||||
<div className={styles.filterBar}>
|
||||
<div className={styles.filterGroup}>
|
||||
<label className={styles.filterLabel}>Status</label>
|
||||
<FilterChip label="Completed" accent="green" active={status.includes('COMPLETED')} onClick={() => setStatus((s) => s.includes('COMPLETED') ? s.filter((x) => x !== 'COMPLETED') : [...s, 'COMPLETED'])} />
|
||||
<FilterChip label="Failed" accent="rose" active={status.includes('FAILED')} onClick={() => setStatus((s) => s.includes('FAILED') ? s.filter((x) => x !== 'FAILED') : [...s, 'FAILED'])} />
|
||||
<FilterChip label="Running" accent="blue" active={status.includes('RUNNING')} onClick={() => setStatus((s) => s.includes('RUNNING') ? s.filter((x) => x !== 'RUNNING') : [...s, 'RUNNING'])} />
|
||||
</div>
|
||||
<button className={`${styles.liveToggle} ${live ? styles.liveOn : styles.liveOff}`} onClick={() => setLive(!live)}>
|
||||
<span className={styles.liveDot} />
|
||||
{live ? 'LIVE' : 'PAUSED'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Route Chips */}
|
||||
{routeIds.length > 0 && (
|
||||
<div className={styles.routeChips}>
|
||||
{routeIds.map((rid) => (
|
||||
<button
|
||||
key={rid}
|
||||
className={`${styles.routeChip} ${selectedRoute === rid ? styles.routeChipActive : ''}`}
|
||||
onClick={() => toggleRoute(rid)}
|
||||
>
|
||||
{rid}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results Header */}
|
||||
<div className={styles.resultsHeader}>
|
||||
<span className={styles.resultsCount}>
|
||||
Showing <strong>{showFrom}–{showTo}</strong> of <strong>{total.toLocaleString()}</strong> results
|
||||
{isFetching && !isLoading && ' · updating...'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Results Table */}
|
||||
<ResultsTable results={results} loading={isLoading} />
|
||||
|
||||
{/* Pagination */}
|
||||
<Pagination total={total} offset={offset} limit={limit} onChange={setOffset} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,87 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { DiagramLayout, ExecutionDetail } from '../../api/types';
|
||||
import type { OverlayState } from '../../hooks/useExecutionOverlay';
|
||||
import { DiagramCanvas } from './diagram/DiagramCanvas';
|
||||
import { ProcessorDetailPanel } from './diagram/ProcessorDetailPanel';
|
||||
import { ProcessorTree } from '../executions/ProcessorTree';
|
||||
import { ResizableDivider } from '../../components/shared/ResizableDivider';
|
||||
import styles from './diagram/diagram.module.css';
|
||||
|
||||
const PANEL_WIDTH_KEY = 'cameleer-diagram-panel-width';
|
||||
const DEFAULT_WIDTH = 340;
|
||||
|
||||
type DetailMode = 'inspector' | 'tree';
|
||||
|
||||
interface DiagramTabProps {
|
||||
layout: DiagramLayout;
|
||||
overlay: OverlayState;
|
||||
execution: ExecutionDetail | null | undefined;
|
||||
executionId?: string | null;
|
||||
}
|
||||
|
||||
export function DiagramTab({ layout, overlay, execution }: DiagramTabProps) {
|
||||
export function DiagramTab({ layout, overlay, execution, executionId }: DiagramTabProps) {
|
||||
const [panelWidth, setPanelWidth] = useState(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(PANEL_WIDTH_KEY);
|
||||
return saved ? Number(saved) : DEFAULT_WIDTH;
|
||||
} catch { return DEFAULT_WIDTH; }
|
||||
});
|
||||
const [detailMode, setDetailMode] = useState<DetailMode>('inspector');
|
||||
|
||||
const handleResize = useCallback((width: number) => {
|
||||
setPanelWidth(width);
|
||||
try { localStorage.setItem(PANEL_WIDTH_KEY, String(width)); }
|
||||
catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
const showPanel = overlay.isActive && execution;
|
||||
|
||||
return (
|
||||
<div className={styles.splitLayout}>
|
||||
<div className={styles.diagramSide}>
|
||||
<DiagramCanvas layout={layout} overlay={overlay} />
|
||||
</div>
|
||||
{overlay.isActive && execution && (
|
||||
<ProcessorDetailPanel
|
||||
execution={execution}
|
||||
selectedNodeId={overlay.selectedNodeId}
|
||||
/>
|
||||
{showPanel && (
|
||||
<>
|
||||
<ResizableDivider
|
||||
panelWidth={panelWidth}
|
||||
onResize={handleResize}
|
||||
minWidth={240}
|
||||
maxWidth={600}
|
||||
/>
|
||||
<div className={styles.sidePanel} style={{ width: panelWidth }}>
|
||||
{/* Mode toggle */}
|
||||
<div className={styles.detailModeTabs}>
|
||||
<button
|
||||
className={`${styles.detailModeTab} ${detailMode === 'inspector' ? styles.detailModeTabActive : ''}`}
|
||||
onClick={() => setDetailMode('inspector')}
|
||||
>
|
||||
Inspector
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.detailModeTab} ${detailMode === 'tree' ? styles.detailModeTabActive : ''}`}
|
||||
onClick={() => setDetailMode('tree')}
|
||||
>
|
||||
Tree
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{detailMode === 'inspector' ? (
|
||||
<ProcessorDetailPanel
|
||||
execution={execution}
|
||||
selectedNodeId={overlay.selectedNodeId}
|
||||
/>
|
||||
) : (
|
||||
executionId ? (
|
||||
<div className={styles.treeContainer}>
|
||||
<ProcessorTree executionId={executionId} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.detailEmpty}>Select an execution to view the processor tree</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,12 +6,11 @@ import { useExecutionOverlay } from '../../hooks/useExecutionOverlay';
|
||||
import { RouteHeader } from './RouteHeader';
|
||||
import { DiagramTab } from './DiagramTab';
|
||||
import { PerformanceTab } from './PerformanceTab';
|
||||
import { ProcessorTree } from '../executions/ProcessorTree';
|
||||
import { ExchangeTab } from './ExchangeTab';
|
||||
import { ExecutionPicker } from './diagram/ExecutionPicker';
|
||||
import styles from './RoutePage.module.css';
|
||||
|
||||
type Tab = 'diagram' | 'performance' | 'processors' | 'exchange';
|
||||
type Tab = 'diagram' | 'performance' | 'exchange';
|
||||
|
||||
export function RoutePage() {
|
||||
const { group, routeId } = useParams<{ group: string; routeId: string }>();
|
||||
@@ -54,16 +53,16 @@ export function RoutePage() {
|
||||
return <div className={styles.error}>Missing group or routeId parameters</div>;
|
||||
}
|
||||
|
||||
const needsExecPicker = activeTab === 'diagram' || activeTab === 'processors' || activeTab === 'exchange';
|
||||
const needsExecPicker = activeTab === 'diagram' || activeTab === 'exchange';
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Breadcrumb */}
|
||||
<nav className={styles.breadcrumb}>
|
||||
<button className={styles.backBtn} onClick={goBack} title="Back (Backspace)">←</button>
|
||||
<NavLink to="/executions" className={styles.breadcrumbLink}>Transactions</NavLink>
|
||||
<NavLink to="/executions" className={styles.breadcrumbLink}>All</NavLink>
|
||||
<span className={styles.breadcrumbSep}>/</span>
|
||||
<span className={styles.breadcrumbText}>{group}</span>
|
||||
<NavLink to={`/apps/${encodeURIComponent(group)}`} className={styles.breadcrumbLink}>{group}</NavLink>
|
||||
<span className={styles.breadcrumbSep}>/</span>
|
||||
<span className={styles.breadcrumbCurrent}>{routeId}</span>
|
||||
</nav>
|
||||
@@ -86,12 +85,6 @@ export function RoutePage() {
|
||||
>
|
||||
Performance
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.tab} ${activeTab === 'processors' ? styles.tabActive : ''}`}
|
||||
onClick={() => setActiveTab('processors')}
|
||||
>
|
||||
Processor Tree
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.tab} ${activeTab === 'exchange' ? styles.tabActive : ''}`}
|
||||
onClick={() => setActiveTab('exchange')}
|
||||
@@ -128,7 +121,7 @@ export function RoutePage() {
|
||||
layoutLoading ? (
|
||||
<div className={styles.loading}>Loading diagram...</div>
|
||||
) : layout ? (
|
||||
<DiagramTab layout={layout} overlay={overlay} execution={execution} />
|
||||
<DiagramTab layout={layout} overlay={overlay} execution={execution} executionId={execId} />
|
||||
) : (
|
||||
<div className={styles.emptyState}>No diagram available for this route</div>
|
||||
)
|
||||
@@ -138,16 +131,6 @@ export function RoutePage() {
|
||||
<PerformanceTab group={group} routeId={routeId} />
|
||||
)}
|
||||
|
||||
{activeTab === 'processors' && execId && (
|
||||
<ProcessorTree executionId={execId} />
|
||||
)}
|
||||
|
||||
{activeTab === 'processors' && !execId && (
|
||||
<div className={styles.emptyState}>
|
||||
Select an execution to view the processor tree
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'exchange' && execId && (
|
||||
<ExchangeTab executionId={execId} />
|
||||
)}
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
.splitLayout {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
height: 100%;
|
||||
height: calc(100vh - 56px - 200px);
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
@@ -111,12 +111,20 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ─── Processor Detail Panel ─── */
|
||||
.detailPanel {
|
||||
width: 340px;
|
||||
/* ─── Side Panel (wraps mode tabs + detail/tree) ─── */
|
||||
.sidePanel {
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-surface);
|
||||
border-left: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ─── Processor Detail Panel ─── */
|
||||
.detailPanel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: var(--bg-surface);
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
@@ -124,6 +132,41 @@
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* ─── Detail Mode Tabs ─── */
|
||||
.detailModeTabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detailModeTab {
|
||||
flex: 1;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.detailModeTab:hover {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.detailModeTabActive {
|
||||
color: var(--amber);
|
||||
border-bottom-color: var(--amber);
|
||||
}
|
||||
|
||||
.treeContainer {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.detailEmpty {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
@@ -584,6 +627,12 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidePanel {
|
||||
width: 100% !important;
|
||||
max-height: 300px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.detailPanel {
|
||||
width: 100%;
|
||||
max-height: 300px;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
.container {
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { OidcCallback } from './auth/OidcCallback';
|
||||
import { ExecutionExplorer } from './pages/executions/ExecutionExplorer';
|
||||
import { OidcAdminPage } from './pages/admin/OidcAdminPage';
|
||||
import { RoutePage } from './pages/routes/RoutePage';
|
||||
import { ApplicationsPage } from './pages/apps/ApplicationsPage';
|
||||
import { AppScopedView } from './pages/dashboard/AppScopedView';
|
||||
|
||||
const SwaggerPage = lazy(() => import('./pages/swagger/SwaggerPage').then(m => ({ default: m.SwaggerPage })));
|
||||
|
||||
@@ -28,7 +28,7 @@ export const router = createBrowserRouter([
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/executions" replace /> },
|
||||
{ path: 'executions', element: <ExecutionExplorer /> },
|
||||
{ path: 'apps', element: <ApplicationsPage /> },
|
||||
{ path: 'apps/:group', element: <AppScopedView /> },
|
||||
{ path: 'apps/:group/routes/:routeId', element: <RoutePage /> },
|
||||
{ path: 'admin/oidc', element: <OidcAdminPage /> },
|
||||
{ path: 'swagger', element: <Suspense fallback={null}><SwaggerPage /></Suspense> },
|
||||
|
||||
Reference in New Issue
Block a user