refactor: move Dashboard to /apps, add Metrics sidebar entry, scope by app

- Route / redirects to /apps, Dashboard serves both /apps and /apps/:id
- When appId is present, exchanges/routes/agents/search are scoped to that app
- Remove Dashboards sidebar link, add Metrics link
- Sidebar section labels (Applications, Agents) are now clickable nav links
  with separate chevron for collapse toggle
- Update all breadcrumbs from Dashboard/href:'/' to Applications/href:'/apps'
- Remove AppDetail page (replaced by scoped Dashboard)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-18 19:26:27 +01:00
parent 674444682e
commit 7cd8864f2c
11 changed files with 141 additions and 103 deletions

View File

@@ -1,23 +1,23 @@
import { Routes, Route } from 'react-router-dom'
import { Routes, Route, Navigate } from 'react-router-dom'
import { Dashboard } from './pages/Dashboard/Dashboard'
import { Metrics } from './pages/Metrics/Metrics'
import { RouteDetail } from './pages/RouteDetail/RouteDetail'
import { ExchangeDetail } from './pages/ExchangeDetail/ExchangeDetail'
import { AgentHealth } from './pages/AgentHealth/AgentHealth'
import { Inventory } from './pages/Inventory/Inventory'
import { AppDetail } from './pages/AppDetail/AppDetail'
import { Admin } from './pages/Admin/Admin'
import { ApiDocs } from './pages/ApiDocs/ApiDocs'
export default function App() {
return (
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/" element={<Navigate to="/apps" replace />} />
<Route path="/apps" element={<Dashboard />} />
<Route path="/apps/:id" element={<Dashboard />} />
<Route path="/metrics" element={<Metrics />} />
<Route path="/routes/:id" element={<RouteDetail />} />
<Route path="/exchanges/:id" element={<ExchangeDetail />} />
<Route path="/agents/*" element={<AgentHealth />} />
<Route path="/apps/:id" element={<AppDetail />} />
<Route path="/admin" element={<Admin />} />
<Route path="/api-docs" element={<ApiDocs />} />
<Route path="/inventory" element={<Inventory />} />

View File

@@ -217,44 +217,6 @@
gap: 6px;
width: 100%;
padding: 8px 12px 4px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--sidebar-muted);
background: none;
border: none;
cursor: pointer;
text-align: left;
transition: color 0.12s;
}
.treeSectionToggle:hover {
color: var(--sidebar-text);
}
.treeSectionChevron {
font-size: 9px;
width: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
}
/* Section header as link (for Agents nav) */
.treeSectionLink {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--sidebar-muted);
text-decoration: none;
flex: 1;
transition: color 0.12s;
}
.treeSectionLink:hover {
color: var(--amber-light);
}
.treeSectionChevronBtn {
@@ -275,6 +237,24 @@
color: var(--sidebar-text);
}
.treeSectionLabel {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--sidebar-muted);
cursor: pointer;
transition: color 0.12s;
}
.treeSectionLabel:hover {
color: var(--amber-light);
}
.treeSectionLabelActive {
color: var(--amber-light);
}
.tree {
list-style: none;
margin: 0;

View File

@@ -74,9 +74,9 @@ describe('Sidebar', () => {
expect(screen.getByText('Agents')).toBeInTheDocument()
})
it('renders Dashboards nav link', () => {
it('renders Metrics nav link', () => {
renderSidebar()
expect(screen.getByText('Dashboards')).toBeInTheDocument()
expect(screen.getByText('Metrics')).toBeInTheDocument()
})
it('renders bottom links', () => {

View File

@@ -234,7 +234,7 @@ export function Sidebar({ apps, className }: SidebarProps) {
return (
<aside className={`${styles.sidebar} ${className ?? ''}`}>
{/* Logo */}
<div className={styles.logo} onClick={() => navigate('/')} style={{ cursor: 'pointer' }}>
<div className={styles.logo} onClick={() => navigate('/apps')} style={{ cursor: 'pointer' }}>
<img src={camelLogoUrl} alt="" aria-hidden="true" className={styles.logoImg} />
<div>
<span className={styles.brand}>cameleer</span>
@@ -275,16 +275,27 @@ export function Sidebar({ apps, className }: SidebarProps) {
<div className={styles.navArea}>
<div className={styles.section}>Navigation</div>
{/* Applications tree (collapsible) */}
{/* Applications tree (collapsible, label navigates to /apps) */}
<div className={styles.treeSection}>
<button
className={styles.treeSectionToggle}
onClick={() => setAppsCollapsed((v) => !v)}
aria-expanded={!appsCollapsed}
>
<span className={styles.treeSectionChevron}>{appsCollapsed ? '▸' : '▾'}</span>
<span>Applications</span>
</button>
<div className={styles.treeSectionToggle}>
<button
className={styles.treeSectionChevronBtn}
onClick={() => setAppsCollapsed((v) => !v)}
aria-expanded={!appsCollapsed}
aria-label={appsCollapsed ? 'Expand Applications' : 'Collapse Applications'}
>
{appsCollapsed ? '▸' : '▾'}
</button>
<span
className={`${styles.treeSectionLabel} ${location.pathname === '/apps' ? styles.treeSectionLabelActive : ''}`}
onClick={() => navigate('/apps')}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/apps') }}
>
Applications
</span>
</div>
{!appsCollapsed && (
<SidebarTree
nodes={appNodes}
@@ -297,16 +308,27 @@ export function Sidebar({ apps, className }: SidebarProps) {
)}
</div>
{/* Agents tree (collapsible) */}
{/* Agents tree (collapsible, label navigates to /agents) */}
<div className={styles.treeSection}>
<button
className={styles.treeSectionToggle}
onClick={() => setAgentsCollapsed((v) => !v)}
aria-expanded={!agentsCollapsed}
>
<span className={styles.treeSectionChevron}>{agentsCollapsed ? '▸' : '▾'}</span>
<span>Agents</span>
</button>
<div className={styles.treeSectionToggle}>
<button
className={styles.treeSectionChevronBtn}
onClick={() => setAgentsCollapsed((v) => !v)}
aria-expanded={!agentsCollapsed}
aria-label={agentsCollapsed ? 'Expand Agents' : 'Collapse Agents'}
>
{agentsCollapsed ? '▸' : '▾'}
</button>
<span
className={`${styles.treeSectionLabel} ${location.pathname.startsWith('/agents') ? styles.treeSectionLabelActive : ''}`}
onClick={() => navigate('/agents')}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/agents') }}
>
Agents
</span>
</div>
{!agentsCollapsed && (
<SidebarTree
nodes={agentNodes}
@@ -319,21 +341,21 @@ export function Sidebar({ apps, className }: SidebarProps) {
)}
</div>
{/* Dashboards flat link */}
{/* Flat nav links */}
<div className={styles.items}>
<div
className={[
styles.item,
location.pathname === '/' ? styles.active : '',
location.pathname === '/metrics' ? styles.active : '',
].filter(Boolean).join(' ')}
onClick={() => navigate('/')}
onClick={() => navigate('/metrics')}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/') }}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/metrics') }}
>
<span className={styles.navIcon}></span>
<span className={styles.navIcon}></span>
<div className={styles.itemInfo}>
<div className={styles.itemName}>Dashboards</div>
<div className={styles.itemName}>Metrics</div>
</div>
</div>
</div>

View File

@@ -100,7 +100,7 @@ function buildTrendData(agent: AgentHealthData) {
function buildBreadcrumb(scope: Scope) {
const crumbs: { label: string; href?: string }[] = [
{ label: 'System', href: '/' },
{ label: 'Applications', href: '/apps' },
{ label: 'Agents', href: '/agents' },
]
if (scope.level === 'app' || scope.level === 'instance') {

View File

@@ -12,7 +12,7 @@ export function AppDetail() {
<AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}>
<TopBar
breadcrumb={[
{ label: 'Applications', href: '/' },
{ label: 'Applications', href: '/apps' },
{ label: id ?? '' },
]}
environment="PRODUCTION"

View File

@@ -1,4 +1,5 @@
import { useState, useMemo } from 'react'
import { useParams } from 'react-router-dom'
import styles from './Dashboard.module.css'
// Layout
@@ -137,10 +138,14 @@ function durationClass(ms: number, status: Exchange['status']): string {
}
// ─── Build CommandPalette search data ────────────────────────────────────────
function buildSearchData(): SearchResult[] {
function buildSearchData(
exs: Exchange[],
rts: typeof routes,
ags: typeof agents,
): SearchResult[] {
const results: SearchResult[] = []
for (const exec of exchanges) {
for (const exec of exs) {
results.push({
id: exec.id,
category: 'exchange',
@@ -151,7 +156,7 @@ function buildSearchData(): SearchResult[] {
})
}
for (const route of routes) {
for (const route of rts) {
results.push({
id: route.id,
category: 'route',
@@ -161,7 +166,7 @@ function buildSearchData(): SearchResult[] {
})
}
for (const agent of agents) {
for (const agent of ags) {
results.push({
id: agent.id,
category: 'agent',
@@ -174,16 +179,15 @@ function buildSearchData(): SearchResult[] {
return results
}
const SEARCH_DATA = buildSearchData()
// ─── Filter options ───────────────────────────────────────────────────────────
const STATUS_FILTERS = [
{ label: 'All', value: 'all', count: exchanges.length },
{ label: 'OK', value: 'completed', count: exchanges.filter((e) => e.status === 'completed').length, color: 'success' as const },
{ label: 'Warn', value: 'warning', count: exchanges.filter((e) => e.status === 'warning').length },
{ label: 'Error', value: 'failed', count: exchanges.filter((e) => e.status === 'failed').length, color: 'error' as const },
{ label: 'Running', value: 'running', count: exchanges.filter((e) => e.status === 'running').length, color: 'running' as const },
]
function buildStatusFilters(exs: Exchange[]) {
return [
{ label: 'All', value: 'all', count: exs.length },
{ label: 'OK', value: 'completed', count: exs.filter((e) => e.status === 'completed').length, color: 'success' as const },
{ label: 'Warn', value: 'warning', count: exs.filter((e) => e.status === 'warning').length },
{ label: 'Error', value: 'failed', count: exs.filter((e) => e.status === 'failed').length, color: 'error' as const },
{ label: 'Running', value: 'running', count: exs.filter((e) => e.status === 'running').length, color: 'running' as const },
]
}
const SHORTCUTS = [
{ keys: 'Ctrl+K', label: 'Search' },
@@ -194,6 +198,7 @@ const SHORTCUTS = [
// ─── Dashboard component ──────────────────────────────────────────────────────
export function Dashboard() {
const { id: appId } = useParams<{ id: string }>()
const [activeFilters, setActiveFilters] = useState<ActiveFilter[]>([])
const [search, setSearch] = useState('')
const [selectedId, setSelectedId] = useState<string | undefined>()
@@ -201,9 +206,36 @@ export function Dashboard() {
const [selectedExchange, setSelectedExchange] = useState<Exchange | null>(null)
const [paletteOpen, setPaletteOpen] = useState(false)
// Filter exchanges
// Build set of route IDs belonging to the selected app (if any)
const appRouteIds = useMemo(() => {
if (!appId) return null
const app = SIDEBAR_APPS.find((a) => a.id === appId)
if (!app) return null
return new Set(app.routes.map((r) => r.id))
}, [appId])
const selectedApp = appId ? SIDEBAR_APPS.find((a) => a.id === appId) : null
// Scope all data to the selected app
const scopedExchanges = useMemo(() => {
if (!appRouteIds) return exchanges
return exchanges.filter((e) => appRouteIds.has(e.route))
}, [appRouteIds])
const scopedRoutes = useMemo(() => {
if (!appRouteIds) return routes
return routes.filter((r) => appRouteIds.has(r.id))
}, [appRouteIds])
const scopedAgents = useMemo(() => {
if (!selectedApp) return agents
const agentIds = new Set(selectedApp.agents.map((a) => a.id))
return agents.filter((a) => agentIds.has(a.id))
}, [selectedApp])
// Filter exchanges (scoped + user filters)
const filteredExchanges = useMemo(() => {
let data = exchanges
let data = scopedExchanges
const statusFilter = activeFilters.find((f) =>
['completed', 'failed', 'running', 'warning', 'all'].includes(f.value),
@@ -225,7 +257,12 @@ export function Dashboard() {
}
return data
}, [activeFilters, search])
}, [activeFilters, search, scopedExchanges])
const searchData = useMemo(
() => buildSearchData(scopedExchanges, scopedRoutes, scopedAgents),
[scopedExchanges, scopedRoutes, scopedAgents],
)
function handleRowClick(row: Exchange) {
setSelectedId(row.id)
@@ -349,12 +386,11 @@ export function Dashboard() {
>
{/* Top bar */}
<TopBar
breadcrumb={[
{ label: 'Applications', href: '/' },
{ label: 'order-service' },
]}
breadcrumb={appId
? [{ label: 'Applications', href: '/apps' }, { label: appId }]
: [{ label: 'Applications' }]
}
environment="PRODUCTION"
shift="Day (06:00-18:00)"
user={{ name: 'hendrik' }}
onSearchClick={() => setPaletteOpen(true)}
/>
@@ -380,7 +416,7 @@ export function Dashboard() {
{/* Filter bar */}
<FilterBar
filters={STATUS_FILTERS}
filters={buildStatusFilters(scopedExchanges)}
activeFilters={activeFilters}
onFilterChange={setActiveFilters}
searchPlaceholder="Search by Order ID, correlation ID, error message..."
@@ -395,7 +431,7 @@ export function Dashboard() {
<span className={styles.tableTitle}>Recent Exchanges</span>
<div className={styles.tableRight}>
<span className={styles.tableMeta}>
{filteredExchanges.length.toLocaleString()} of {exchanges.length.toLocaleString()} exchanges
{filteredExchanges.length.toLocaleString()} of {scopedExchanges.length.toLocaleString()} exchanges
</span>
<Badge label="LIVE" color="success" />
</div>
@@ -428,7 +464,7 @@ export function Dashboard() {
open={paletteOpen}
onClose={() => setPaletteOpen(false)}
onSelect={() => setPaletteOpen(false)}
data={SEARCH_DATA}
data={searchData}
onOpen={() => setPaletteOpen(true)}
/>

View File

@@ -117,7 +117,7 @@ export function ExchangeDetail() {
>
<TopBar
breadcrumb={[
{ label: 'Dashboard', href: '/' },
{ label: 'Applications', href: '/apps' },
{ label: 'Exchanges' },
{ label: id ?? 'Unknown' },
]}
@@ -144,7 +144,7 @@ export function ExchangeDetail() {
{/* Top bar */}
<TopBar
breadcrumb={[
{ label: 'Dashboard', href: '/' },
{ label: 'Applications', href: '/apps' },
{ label: exchange.route, href: `/routes/${exchange.route}` },
{ label: exchange.id },
]}

View File

@@ -15,7 +15,7 @@ export function Inventory() {
<div className={styles.page}>
<header className={styles.header}>
<h1 className={styles.headerTitle}>Component Inventory</h1>
<Link to="/" className={styles.backLink}> Back to app</Link>
<Link to="/apps" className={styles.backLink}> Back to app</Link>
</header>
<div className={styles.body}>

View File

@@ -206,7 +206,7 @@ export function Metrics() {
{/* Top bar */}
<TopBar
breadcrumb={[
{ label: 'Dashboard', href: '/' },
{ label: 'Applications', href: '/apps' },
{ label: 'Metrics' },
]}
environment="PRODUCTION"

View File

@@ -205,7 +205,7 @@ export function RouteDetail() {
>
<TopBar
breadcrumb={[
{ label: 'Dashboard', href: '/' },
{ label: 'Applications', href: '/apps' },
{ label: 'Routes' },
{ label: id ?? 'Unknown' },
]}
@@ -231,8 +231,8 @@ export function RouteDetail() {
{/* Top bar */}
<TopBar
breadcrumb={[
{ label: 'Dashboard', href: '/' },
{ label: 'Routes', href: '/' },
{ label: 'Applications', href: '/apps' },
{ label: 'Routes', href: '/apps' },
{ label: route.name },
]}
environment="PRODUCTION"