UI overhaul: unified sidebar layout with app-scoped views
Some checks failed
CI / build (push) Failing after 48s
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped

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:
hsiegeln
2026-03-15 15:47:33 +01:00
parent 0b56590e3f
commit 7fd8a787d0
16 changed files with 1111 additions and 327 deletions

View File

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

View File

@@ -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 />
</>
);

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

View 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}>&#9881;</span>
<span className={styles.bottomLabel}>Admin</span>
</NavLink>
</div>
)}
</aside>
);
}

View File

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

View File

@@ -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}>&#8984;K</kbd>
</div>
<div className={styles.navRight}>
<NavLink to="/swagger" className={({ isActive }) => isActive ? styles.utilLinkActive : styles.utilLink} title="API Documentation">

View 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)'; }}
/>
);
}