From 7fd8a787d0b3e244f4b9fd71ac6335393db27f26 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 15 Mar 2026 15:47:33 +0100 Subject: [PATCH] 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) --- ui/src/components/layout/AppShell.module.css | 13 +- ui/src/components/layout/AppShell.tsx | 40 ++- .../components/layout/AppSidebar.module.css | 245 ++++++++++++++++++ ui/src/components/layout/AppSidebar.tsx | 129 +++++++++ ui/src/components/layout/TopNav.module.css | 91 +++++-- ui/src/components/layout/TopNav.tsx | 44 ++-- ui/src/components/shared/ResizableDivider.tsx | 73 ++++++ ui/src/pages/apps/ApplicationsPage.module.css | 132 ---------- ui/src/pages/apps/ApplicationsPage.tsx | 107 -------- .../pages/dashboard/AppScopedView.module.css | 214 +++++++++++++++ ui/src/pages/dashboard/AppScopedView.tsx | 184 +++++++++++++ ui/src/pages/routes/DiagramTab.tsx | 73 +++++- ui/src/pages/routes/RoutePage.tsx | 27 +- .../pages/routes/diagram/diagram.module.css | 59 ++++- ui/src/pages/swagger/SwaggerPage.module.css | 3 +- ui/src/router.tsx | 4 +- 16 files changed, 1111 insertions(+), 327 deletions(-) create mode 100644 ui/src/components/layout/AppSidebar.module.css create mode 100644 ui/src/components/layout/AppSidebar.tsx create mode 100644 ui/src/components/shared/ResizableDivider.tsx delete mode 100644 ui/src/pages/apps/ApplicationsPage.module.css delete mode 100644 ui/src/pages/apps/ApplicationsPage.tsx create mode 100644 ui/src/pages/dashboard/AppScopedView.module.css create mode 100644 ui/src/pages/dashboard/AppScopedView.tsx diff --git a/ui/src/components/layout/AppShell.module.css b/ui/src/components/layout/AppShell.module.css index d66608c0..2c2cc0b4 100644 --- a/ui/src/components/layout/AppShell.module.css +++ b/ui/src/components/layout/AppShell.module.css @@ -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); } diff --git a/ui/src/components/layout/AppShell.tsx b/ui/src/components/layout/AppShell.tsx index 2847c9d8..5c6de3bc 100644 --- a/ui/src/components/layout/AppShell.tsx +++ b/ui/src/components/layout/AppShell.tsx @@ -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 ( <> - -
- -
+ +
+ +
+ +
+
); diff --git a/ui/src/components/layout/AppSidebar.module.css b/ui/src/components/layout/AppSidebar.module.css new file mode 100644 index 00000000..eba3e514 --- /dev/null +++ b/ui/src/components/layout/AppSidebar.module.css @@ -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; + } +} diff --git a/ui/src/components/layout/AppSidebar.tsx b/ui/src/components/layout/AppSidebar.tsx new file mode 100644 index 00000000..1ebfa9f8 --- /dev/null +++ b/ui/src/components/layout/AppSidebar.tsx @@ -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(); + 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 ( + + ); +} diff --git a/ui/src/components/layout/TopNav.module.css b/ui/src/components/layout/TopNav.module.css index d87c9cb0..9db6a912 100644 --- a/ui/src/components/layout/TopNav.module.css +++ b/ui/src/components/layout/TopNav.module.css @@ -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; + } +} diff --git a/ui/src/components/layout/TopNav.tsx b/ui/src/components/layout/TopNav.tsx index 89f27dfa..a2ecad6a 100644 --- a/ui/src/components/layout/TopNav.tsx +++ b/ui/src/components/layout/TopNav.tsx @@ -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 (