feat: unified global search & filter system with Cmd-K navigation

Replace per-page filtering with a single GlobalFilterProvider (time range +
status) consumed by a redesigned TopBar across all pages. Lift CommandPalette
to App level so Cmd-K works globally with filtered results that navigate to
exchanges, routes, agents, and applications. Sidebar auto-reveals and selects
the target entry on Cmd-K navigation via location state.

- Extract shared time preset utilities (computePresetRange, DEFAULT_PRESETS)
- Add GlobalFilterProvider (time range + status) and CommandPaletteProvider
- Add TimeRangeDropdown primitive with Popover preset list
- Redesign TopBar: breadcrumb | time dropdown | status pills | search | env
- Add application category to Cmd-K search
- Remove FilterBar and local DateRangePicker from Dashboard/Metrics pages
- Filter AgentHealth EventFeed by global time range
- Remove shift/onSearchClick props from TopBar

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-18 20:06:25 +01:00
parent 7cd8864f2c
commit 5de97dab14
26 changed files with 598 additions and 244 deletions

View File

@@ -1,4 +1,5 @@
import { Routes, Route, Navigate } from 'react-router-dom' import { useMemo, useCallback } from 'react'
import { Routes, Route, Navigate, useNavigate } from 'react-router-dom'
import { Dashboard } from './pages/Dashboard/Dashboard' import { Dashboard } from './pages/Dashboard/Dashboard'
import { Metrics } from './pages/Metrics/Metrics' import { Metrics } from './pages/Metrics/Metrics'
import { RouteDetail } from './pages/RouteDetail/RouteDetail' import { RouteDetail } from './pages/RouteDetail/RouteDetail'
@@ -8,8 +9,73 @@ import { Inventory } from './pages/Inventory/Inventory'
import { Admin } from './pages/Admin/Admin' import { Admin } from './pages/Admin/Admin'
import { ApiDocs } from './pages/ApiDocs/ApiDocs' import { ApiDocs } from './pages/ApiDocs/ApiDocs'
import { CommandPalette } from './design-system/composites/CommandPalette/CommandPalette'
import type { SearchResult } from './design-system/composites/CommandPalette/types'
import { useCommandPalette } from './design-system/providers/CommandPaletteProvider'
import { useGlobalFilters } from './design-system/providers/GlobalFilterProvider'
import { buildSearchData } from './mocks/searchData'
import { exchanges } from './mocks/exchanges'
import { routes } from './mocks/routes'
import { agents } from './mocks/agents'
import { SIDEBAR_APPS } from './mocks/sidebar'
/** Compute which sidebar path to reveal for a given search result */
function computeSidebarRevealPath(result: SearchResult): string | undefined {
if (!result.path) return undefined
if (result.category === 'application') {
// /apps/:id — already a sidebar node path
return result.path
}
if (result.category === 'route') {
// /routes/:id — already a sidebar node path
return result.path
}
if (result.category === 'agent') {
// /agents/:appId/:agentId — already a sidebar node path
return result.path
}
if (result.category === 'exchange') {
// /exchanges/:id — no sidebar entry; resolve to the parent route
const exchange = exchanges.find((e) => e.id === result.id)
if (exchange) {
return `/routes/${exchange.route}`
}
}
return result.path
}
export default function App() { export default function App() {
const navigate = useNavigate()
const { open: paletteOpen, setOpen } = useCommandPalette()
const { isInTimeRange, statusFilters } = useGlobalFilters()
const filteredSearchData = useMemo(() => {
// Filter exchanges by time range and status
let filteredExchanges = exchanges.filter((e) => isInTimeRange(e.timestamp))
if (statusFilters.size > 0) {
filteredExchanges = filteredExchanges.filter((e) => statusFilters.has(e.status))
}
return buildSearchData(filteredExchanges, routes, agents)
}, [isInTimeRange, statusFilters])
const handleSelect = useCallback(
(result: SearchResult) => {
if (result.path) {
const sidebarReveal = computeSidebarRevealPath(result)
navigate(result.path, { state: sidebarReveal ? { sidebarReveal } : undefined })
}
setOpen(false)
},
[navigate, setOpen],
)
return ( return (
<>
<Routes> <Routes>
<Route path="/" element={<Navigate to="/apps" replace />} /> <Route path="/" element={<Navigate to="/apps" replace />} />
<Route path="/apps" element={<Dashboard />} /> <Route path="/apps" element={<Dashboard />} />
@@ -22,5 +88,13 @@ export default function App() {
<Route path="/api-docs" element={<ApiDocs />} /> <Route path="/api-docs" element={<ApiDocs />} />
<Route path="/inventory" element={<Inventory />} /> <Route path="/inventory" element={<Inventory />} />
</Routes> </Routes>
<CommandPalette
open={paletteOpen}
onClose={() => setOpen(false)}
onOpen={() => setOpen(true)}
data={filteredSearchData}
onSelect={handleSelect}
/>
</>
) )
} }

View File

@@ -16,6 +16,7 @@ interface CommandPaletteProps {
const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = { const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
all: 'All', all: 'All',
application: 'Applications',
exchange: 'Exchanges', exchange: 'Exchanges',
route: 'Routes', route: 'Routes',
agent: 'Agents', agent: 'Agents',
@@ -23,6 +24,7 @@ const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
const ALL_CATEGORIES: Array<SearchCategory | 'all'> = [ const ALL_CATEGORIES: Array<SearchCategory | 'all'> = [
'all', 'all',
'application',
'exchange', 'exchange',
'route', 'route',
'agent', 'agent',

View File

@@ -1,6 +1,6 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
export type SearchCategory = 'exchange' | 'route' | 'agent' export type SearchCategory = 'application' | 'exchange' | 'route' | 'agent'
export interface SearchResult { export interface SearchResult {
id: string id: string
@@ -10,6 +10,7 @@ export interface SearchResult {
meta: string meta: string
timestamp?: string timestamp?: string
icon?: ReactNode icon?: ReactNode
path?: string
expandedContent?: string expandedContent?: string
matchRanges?: [number, number][] matchRanges?: [number, number][]
} }

View File

@@ -1,4 +1,4 @@
import { useState, useMemo } from 'react' import { useState, useEffect, useMemo } from 'react'
import { useNavigate, useLocation } from 'react-router-dom' import { useNavigate, useLocation } from 'react-router-dom'
import styles from './Sidebar.module.css' import styles from './Sidebar.module.css'
import camelLogoUrl from '../../../assets/camel-logo.svg' import camelLogoUrl from '../../../assets/camel-logo.svg'
@@ -220,6 +220,31 @@ export function Sidebar({ apps, className }: SidebarProps) {
const appNodes = useMemo(() => buildAppTreeNodes(apps), [apps]) const appNodes = useMemo(() => buildAppTreeNodes(apps), [apps])
const agentNodes = useMemo(() => buildAgentTreeNodes(apps), [apps]) const agentNodes = useMemo(() => buildAgentTreeNodes(apps), [apps])
// Sidebar reveal from Cmd-K navigation (passed via location state)
const sidebarRevealPath = (location.state as { sidebarReveal?: string } | null)?.sidebarReveal ?? null
useEffect(() => {
if (!sidebarRevealPath) return
// Uncollapse Applications section if reveal path matches an apps tree node
const matchesAppTree = appNodes.some((node) =>
node.path === sidebarRevealPath || node.children?.some((child) => child.path === sidebarRevealPath),
)
if (matchesAppTree && appsCollapsed) {
_setAppsCollapsed(false)
localStorage.setItem('cameleer:sidebar:apps-collapsed', 'false')
}
// Uncollapse Agents section if reveal path matches an agents tree node
const matchesAgentTree = agentNodes.some((node) =>
node.path === sidebarRevealPath || node.children?.some((child) => child.path === sidebarRevealPath),
)
if (matchesAgentTree && agentsCollapsed) {
_setAgentsCollapsed(false)
localStorage.setItem('cameleer:sidebar:agents-collapsed', 'false')
}
}, [sidebarRevealPath]) // eslint-disable-line react-hooks/exhaustive-deps
// Build starred items // Build starred items
const starredItems = useMemo( const starredItems = useMemo(
() => collectStarredItems(apps, starredIds), () => collectStarredItems(apps, starredIds),
@@ -231,6 +256,12 @@ export function Sidebar({ apps, className }: SidebarProps) {
const starredAgents = starredItems.filter((i) => i.type === 'agent') const starredAgents = starredItems.filter((i) => i.type === 'agent')
const hasStarred = starredItems.length > 0 const hasStarred = starredItems.length > 0
// For exchange detail pages, use the reveal path for sidebar selection so
// the parent route is highlighted (exchanges have no sidebar entry of their own)
const effectiveSelectedPath = location.pathname.startsWith('/exchanges/') && sidebarRevealPath
? sidebarRevealPath
: location.pathname
return ( return (
<aside className={`${styles.sidebar} ${className ?? ''}`}> <aside className={`${styles.sidebar} ${className ?? ''}`}>
{/* Logo */} {/* Logo */}
@@ -299,11 +330,12 @@ export function Sidebar({ apps, className }: SidebarProps) {
{!appsCollapsed && ( {!appsCollapsed && (
<SidebarTree <SidebarTree
nodes={appNodes} nodes={appNodes}
selectedPath={location.pathname} selectedPath={effectiveSelectedPath}
isStarred={isStarred} isStarred={isStarred}
onToggleStar={toggleStar} onToggleStar={toggleStar}
filterQuery={search} filterQuery={search}
persistKey="cameleer:expanded:apps" persistKey="cameleer:expanded:apps"
autoRevealPath={sidebarRevealPath}
/> />
)} )}
</div> </div>
@@ -332,11 +364,12 @@ export function Sidebar({ apps, className }: SidebarProps) {
{!agentsCollapsed && ( {!agentsCollapsed && (
<SidebarTree <SidebarTree
nodes={agentNodes} nodes={agentNodes}
selectedPath={location.pathname} selectedPath={effectiveSelectedPath}
isStarred={isStarred} isStarred={isStarred}
onToggleStar={toggleStar} onToggleStar={toggleStar}
filterQuery={search} filterQuery={search}
persistKey="cameleer:expanded:agents" persistKey="cameleer:expanded:agents"
autoRevealPath={sidebarRevealPath}
/> />
)} )}
</div> </div>

View File

@@ -2,6 +2,7 @@ import {
useState, useState,
useRef, useRef,
useCallback, useCallback,
useEffect,
useMemo, useMemo,
type ReactNode, type ReactNode,
type KeyboardEvent, type KeyboardEvent,
@@ -31,6 +32,7 @@ export interface SidebarTreeProps {
className?: string className?: string
filterQuery?: string filterQuery?: string
persistKey?: string // sessionStorage key to persist expand state across remounts persistKey?: string // sessionStorage key to persist expand state across remounts
autoRevealPath?: string | null // when set, auto-expand the parent of the matching node
} }
// ── Star icon SVGs ─────────────────────────────────────────────────────────── // ── Star icon SVGs ───────────────────────────────────────────────────────────
@@ -138,6 +140,7 @@ export function SidebarTree({
className, className,
filterQuery, filterQuery,
persistKey, persistKey,
autoRevealPath,
}: SidebarTreeProps) { }: SidebarTreeProps) {
const navigate = useNavigate() const navigate = useNavigate()
@@ -146,6 +149,27 @@ export function SidebarTree({
() => persistKey ? readExpandState(persistKey) : new Set(), () => persistKey ? readExpandState(persistKey) : new Set(),
) )
// Auto-expand parent when autoRevealPath changes (e.g. from Cmd-K navigation)
useEffect(() => {
if (!autoRevealPath) return
for (const node of nodes) {
// Check if a child of this node matches the reveal path
if (node.children?.some((child) => child.path === autoRevealPath)) {
if (!userExpandedIds.has(node.id)) {
setUserExpandedIds((prev) => {
const next = new Set(prev)
next.add(node.id)
if (persistKey) writeExpandState(persistKey, next)
return next
})
}
break
}
// Also check if the node itself matches (top-level node, no parent to expand)
if (node.path === autoRevealPath) break
}
}, [autoRevealPath]) // eslint-disable-line react-hooks/exhaustive-deps
// Filter // Filter
const { filtered, matchedParentIds } = useMemo( const { filtered, matchedParentIds } = useMemo(
() => filterNodes(nodes, filterQuery ?? ''), () => filterNodes(nodes, filterQuery ?? ''),

View File

@@ -14,6 +14,14 @@
flex-shrink: 0; flex-shrink: 0;
} }
/* Filters group: time range + status pills */
.filters {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
/* Center search trigger */ /* Center search trigger */
.search { .search {
display: flex; display: flex;
@@ -28,9 +36,9 @@
font-family: var(--font-body); font-family: var(--font-body);
cursor: pointer; cursor: pointer;
transition: border-color 0.15s; transition: border-color 0.15s;
min-width: 280px; min-width: 180px;
flex: 1; flex: 1;
max-width: 400px; max-width: 280px;
text-align: left; text-align: left;
} }
@@ -86,16 +94,6 @@
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
.shift {
font-family: var(--font-mono);
font-size: 10px;
padding: 3px 10px;
border-radius: 10px;
background: var(--running-bg);
color: var(--running);
border: 1px solid var(--running-border);
}
.user { .user {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -1,6 +1,10 @@
import styles from './TopBar.module.css' import styles from './TopBar.module.css'
import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb' import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb'
import { Avatar } from '../../primitives/Avatar/Avatar' import { Avatar } from '../../primitives/Avatar/Avatar'
import { FilterPill } from '../../primitives/FilterPill/FilterPill'
import { TimeRangeDropdown } from '../../primitives/TimeRangeDropdown/TimeRangeDropdown'
import { useGlobalFilters, type ExchangeStatus } from '../../providers/GlobalFilterProvider'
import { useCommandPalette } from '../../providers/CommandPaletteProvider'
interface BreadcrumbItem { interface BreadcrumbItem {
label: string label: string
@@ -10,29 +14,51 @@ interface BreadcrumbItem {
interface TopBarProps { interface TopBarProps {
breadcrumb: BreadcrumbItem[] breadcrumb: BreadcrumbItem[]
environment?: string environment?: string
shift?: string
user?: { name: string } user?: { name: string }
onSearchClick?: () => void
className?: string className?: string
} }
const STATUS_PILLS: { status: ExchangeStatus; label: string }[] = [
{ status: 'completed', label: 'OK' },
{ status: 'warning', label: 'Warn' },
{ status: 'failed', label: 'Error' },
{ status: 'running', label: 'Running' },
]
export function TopBar({ export function TopBar({
breadcrumb, breadcrumb,
environment, environment,
shift,
user, user,
onSearchClick,
className, className,
}: TopBarProps) { }: TopBarProps) {
const globalFilters = useGlobalFilters()
const commandPalette = useCommandPalette()
return ( return (
<header className={`${styles.topbar} ${className ?? ''}`}> <header className={`${styles.topbar} ${className ?? ''}`}>
{/* Left: Breadcrumb */} {/* Left: Breadcrumb */}
<Breadcrumb items={breadcrumb} className={styles.breadcrumb} /> <Breadcrumb items={breadcrumb} className={styles.breadcrumb} />
{/* Filters: time range + status pills */}
<div className={styles.filters}>
<TimeRangeDropdown
value={globalFilters.timeRange}
onChange={globalFilters.setTimeRange}
/>
{STATUS_PILLS.map(({ status, label }) => (
<FilterPill
key={status}
label={label}
active={globalFilters.statusFilters.has(status)}
onClick={() => globalFilters.toggleStatus(status)}
/>
))}
</div>
{/* Center: Search trigger */} {/* Center: Search trigger */}
<button <button
className={styles.search} className={styles.search}
onClick={onSearchClick} onClick={() => commandPalette.setOpen(true)}
type="button" type="button"
aria-label="Open search" aria-label="Open search"
> >
@@ -42,18 +68,15 @@ export function TopBar({
<line x1="21" y1="21" x2="16.65" y2="16.65" /> <line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg> </svg>
</span> </span>
<span className={styles.searchPlaceholder}>Search by Order ID, route, error...</span> <span className={styles.searchPlaceholder}>Search... &#8984;K</span>
<span className={styles.kbd}>Ctrl+K</span> <span className={styles.kbd}>Ctrl+K</span>
</button> </button>
{/* Right: env badge, shift, user */} {/* Right: env badge, user */}
<div className={styles.right}> <div className={styles.right}>
{environment && ( {environment && (
<span className={styles.env}>{environment}</span> <span className={styles.env}>{environment}</span>
)} )}
{shift && (
<span className={styles.shift}>Shift: {shift}</span>
)}
{user && ( {user && (
<div className={styles.user}> <div className={styles.user}>
<span className={styles.userName}>{user.name}</span> <span className={styles.userName}>{user.name}</span>

View File

@@ -2,53 +2,7 @@ import { useState } from 'react'
import styles from './DateRangePicker.module.css' import styles from './DateRangePicker.module.css'
import { DateTimePicker } from '../DateTimePicker/DateTimePicker' import { DateTimePicker } from '../DateTimePicker/DateTimePicker'
import { FilterPill } from '../FilterPill/FilterPill' import { FilterPill } from '../FilterPill/FilterPill'
import { DEFAULT_PRESETS, computePresetRange, type DateRange, type Preset } from '../../utils/timePresets'
interface DateRange {
start: Date
end: Date
}
interface Preset {
label: string
value: string
}
const DEFAULT_PRESETS: Preset[] = [
{ label: 'Last 1h', value: 'last-1h' },
{ label: 'Last 6h', value: 'last-6h' },
{ label: 'Today', value: 'today' },
{ label: 'This shift', value: 'shift' },
{ label: 'Last 24h', value: 'last-24h' },
{ label: 'Last 7d', value: 'last-7d' },
{ label: 'Custom', value: 'custom' },
]
function computePresetRange(preset: string): DateRange {
const now = new Date()
const end = now
switch (preset) {
case 'last-1h':
return { start: new Date(now.getTime() - 60 * 60 * 1000), end }
case 'last-6h':
return { start: new Date(now.getTime() - 6 * 60 * 60 * 1000), end }
case 'today': {
const start = new Date(now)
start.setHours(0, 0, 0, 0)
return { start, end }
}
case 'shift': {
// "This shift" = last 8 hours
return { start: new Date(now.getTime() - 8 * 60 * 60 * 1000), end }
}
case 'last-24h':
return { start: new Date(now.getTime() - 24 * 60 * 60 * 1000), end }
case 'last-7d':
return { start: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000), end }
default:
return { start: new Date(now.getTime() - 60 * 60 * 1000), end }
}
}
interface DateRangePickerProps { interface DateRangePickerProps {
value: DateRange value: DateRange

View File

@@ -0,0 +1,45 @@
.trigger {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
height: 28px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-raised);
color: var(--amber, var(--warning));
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
white-space: nowrap;
}
.trigger:hover {
border-color: var(--text-faint);
background: var(--bg-surface);
}
.icon {
font-size: 13px;
line-height: 1;
}
.label {
line-height: 1;
}
.caret {
font-size: 9px;
opacity: 0.7;
line-height: 1;
}
.presetList {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
min-width: 100px;
}

View File

@@ -0,0 +1,55 @@
import styles from './TimeRangeDropdown.module.css'
import { Popover } from '../../composites/Popover/Popover'
import { FilterPill } from '../FilterPill/FilterPill'
import { computePresetRange, PRESET_SHORT_LABELS } from '../../utils/timePresets'
import type { TimeRange } from '../../providers/GlobalFilterProvider'
const DROPDOWN_PRESETS = [
{ value: 'last-1h', label: '1h' },
{ value: 'last-3h', label: '3h' },
{ value: 'last-6h', label: '6h' },
{ value: 'today', label: 'Today' },
{ value: 'shift', label: 'Shift' },
{ value: 'last-24h', label: '24h' },
{ value: 'last-7d', label: '7d' },
]
interface TimeRangeDropdownProps {
value: TimeRange
onChange: (range: TimeRange) => void
className?: string
}
export function TimeRangeDropdown({ value, onChange, className }: TimeRangeDropdownProps) {
const activeLabel = value.preset ? (PRESET_SHORT_LABELS[value.preset] ?? value.preset) : 'Custom'
return (
<Popover
className={className}
position="bottom"
align="start"
trigger={
<button className={styles.trigger} type="button" aria-label="Select time range">
<span className={styles.icon} aria-hidden="true">&#9201;</span>
<span className={styles.label}>{activeLabel}</span>
<span className={styles.caret} aria-hidden="true">&#9662;</span>
</button>
}
content={
<div className={styles.presetList}>
{DROPDOWN_PRESETS.map((preset) => (
<FilterPill
key={preset.value}
label={preset.label}
active={value.preset === preset.value}
onClick={() => {
const range = computePresetRange(preset.value)
onChange({ ...range, preset: preset.value })
}}
/>
))}
</div>
}
/>
)
}

View File

@@ -28,5 +28,6 @@ export { StatCard } from './StatCard/StatCard'
export { StatusDot } from './StatusDot/StatusDot' export { StatusDot } from './StatusDot/StatusDot'
export { Tag } from './Tag/Tag' export { Tag } from './Tag/Tag'
export { Textarea } from './Textarea/Textarea' export { Textarea } from './Textarea/Textarea'
export { TimeRangeDropdown } from './TimeRangeDropdown/TimeRangeDropdown'
export { Toggle } from './Toggle/Toggle' export { Toggle } from './Toggle/Toggle'
export { Tooltip } from './Tooltip/Tooltip' export { Tooltip } from './Tooltip/Tooltip'

View File

@@ -0,0 +1,28 @@
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'
interface CommandPaletteContextValue {
open: boolean
setOpen: (open: boolean) => void
}
const CommandPaletteContext = createContext<CommandPaletteContextValue | null>(null)
export function CommandPaletteProvider({ children }: { children: ReactNode }) {
const [open, setOpenState] = useState(false)
const setOpen = useCallback((value: boolean) => {
setOpenState(value)
}, [])
return (
<CommandPaletteContext.Provider value={{ open, setOpen }}>
{children}
</CommandPaletteContext.Provider>
)
}
export function useCommandPalette(): CommandPaletteContextValue {
const ctx = useContext(CommandPaletteContext)
if (!ctx) throw new Error('useCommandPalette must be used within CommandPaletteProvider')
return ctx
}

View File

@@ -0,0 +1,79 @@
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'
import { computePresetRange } from '../utils/timePresets'
export interface TimeRange {
start: Date
end: Date
preset: string | null
}
export type ExchangeStatus = 'completed' | 'failed' | 'running' | 'warning'
interface GlobalFilterContextValue {
timeRange: TimeRange
setTimeRange: (range: TimeRange) => void
statusFilters: Set<ExchangeStatus>
toggleStatus: (status: ExchangeStatus) => void
clearStatusFilters: () => void
isInTimeRange: (timestamp: Date) => boolean
}
const GlobalFilterContext = createContext<GlobalFilterContextValue | null>(null)
const DEFAULT_PRESET = 'last-3h'
function getDefaultTimeRange(): TimeRange {
const { start, end } = computePresetRange(DEFAULT_PRESET)
return { start, end, preset: DEFAULT_PRESET }
}
export function GlobalFilterProvider({ children }: { children: ReactNode }) {
const [timeRange, setTimeRangeState] = useState<TimeRange>(getDefaultTimeRange)
const [statusFilters, setStatusFilters] = useState<Set<ExchangeStatus>>(new Set())
const setTimeRange = useCallback((range: TimeRange) => {
setTimeRangeState(range)
}, [])
const toggleStatus = useCallback((status: ExchangeStatus) => {
setStatusFilters((prev) => {
const next = new Set(prev)
if (next.has(status)) {
next.delete(status)
} else {
next.add(status)
}
return next
})
}, [])
const clearStatusFilters = useCallback(() => {
setStatusFilters(new Set())
}, [])
const isInTimeRange = useCallback(
(timestamp: Date) => {
if (timeRange.preset) {
// Recompute from now so the window stays fresh
const { start } = computePresetRange(timeRange.preset)
return timestamp >= start
}
return timestamp >= timeRange.start && timestamp <= timeRange.end
},
[timeRange],
)
return (
<GlobalFilterContext.Provider
value={{ timeRange, setTimeRange, statusFilters, toggleStatus, clearStatusFilters, isInTimeRange }}
>
{children}
</GlobalFilterContext.Provider>
)
}
export function useGlobalFilters(): GlobalFilterContextValue {
const ctx = useContext(GlobalFilterContext)
if (!ctx) throw new Error('useGlobalFilters must be used within GlobalFilterProvider')
return ctx
}

View File

@@ -0,0 +1,59 @@
export interface DateRange {
start: Date
end: Date
}
export interface Preset {
label: string
value: string
}
export const DEFAULT_PRESETS: Preset[] = [
{ label: 'Last 1h', value: 'last-1h' },
{ label: 'Last 6h', value: 'last-6h' },
{ label: 'Today', value: 'today' },
{ label: 'This shift', value: 'shift' },
{ label: 'Last 24h', value: 'last-24h' },
{ label: 'Last 7d', value: 'last-7d' },
{ label: 'Custom', value: 'custom' },
]
export const PRESET_SHORT_LABELS: Record<string, string> = {
'last-1h': '1h',
'last-3h': '3h',
'last-6h': '6h',
'today': 'Today',
'shift': 'Shift',
'last-24h': '24h',
'last-7d': '7d',
'custom': 'Custom',
}
export function computePresetRange(preset: string): DateRange {
const now = new Date()
const end = now
switch (preset) {
case 'last-1h':
return { start: new Date(now.getTime() - 60 * 60 * 1000), end }
case 'last-3h':
return { start: new Date(now.getTime() - 3 * 60 * 60 * 1000), end }
case 'last-6h':
return { start: new Date(now.getTime() - 6 * 60 * 60 * 1000), end }
case 'today': {
const start = new Date(now)
start.setHours(0, 0, 0, 0)
return { start, end }
}
case 'shift': {
// "This shift" = last 8 hours
return { start: new Date(now.getTime() - 8 * 60 * 60 * 1000), end }
}
case 'last-24h':
return { start: new Date(now.getTime() - 24 * 60 * 60 * 1000), end }
case 'last-7d':
return { start: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000), end }
default:
return { start: new Date(now.getTime() - 60 * 60 * 1000), end }
}
}

View File

@@ -2,6 +2,8 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom'
import { ThemeProvider } from './design-system/providers/ThemeProvider' import { ThemeProvider } from './design-system/providers/ThemeProvider'
import { GlobalFilterProvider } from './design-system/providers/GlobalFilterProvider'
import { CommandPaletteProvider } from './design-system/providers/CommandPaletteProvider'
import App from './App' import App from './App'
import './index.css' import './index.css'
@@ -9,7 +11,11 @@ createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<BrowserRouter> <BrowserRouter>
<ThemeProvider> <ThemeProvider>
<GlobalFilterProvider>
<CommandPaletteProvider>
<App /> <App />
</CommandPaletteProvider>
</GlobalFilterProvider>
</ThemeProvider> </ThemeProvider>
</BrowserRouter> </BrowserRouter>
</StrictMode>, </StrictMode>,

98
src/mocks/searchData.tsx Normal file
View File

@@ -0,0 +1,98 @@
import type { SearchResult } from '../design-system/composites/CommandPalette/types'
import { exchanges, type Exchange } from './exchanges'
import { routes } from './routes'
import { agents } from './agents'
import { SIDEBAR_APPS, type SidebarApp } from './sidebar'
function formatDuration(ms: number): string {
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`
return `${ms}ms`
}
function statusLabel(status: Exchange['status']): string {
switch (status) {
case 'completed': return 'OK'
case 'failed': return 'ERR'
case 'running': return 'RUN'
case 'warning': return 'WARN'
}
}
function statusToVariant(status: Exchange['status']): string {
switch (status) {
case 'completed': return 'success'
case 'failed': return 'error'
case 'running': return 'running'
case 'warning': return 'warning'
}
}
function formatTimestamp(date: Date): string {
return date.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
function healthToColor(health: SidebarApp['health']): string {
switch (health) {
case 'live': return 'success'
case 'stale': return 'warning'
case 'dead': return 'error'
}
}
export function buildSearchData(
exs: Exchange[] = exchanges,
rts: typeof routes = routes,
ags: typeof agents = agents,
apps: SidebarApp[] = SIDEBAR_APPS,
): SearchResult[] {
const results: SearchResult[] = []
for (const app of apps) {
const liveAgents = app.agents.filter((a) => a.status === 'live').length
results.push({
id: app.id,
category: 'application',
title: app.name,
badges: [{ label: app.health.toUpperCase(), color: healthToColor(app.health) }],
meta: `${app.routes.length} routes · ${app.agents.length} agents (${liveAgents} live) · ${app.exchangeCount.toLocaleString()} exchanges`,
path: `/apps/${app.id}`,
})
}
for (const exec of exs) {
results.push({
id: exec.id,
category: 'exchange',
title: `${exec.orderId}${exec.route}`,
badges: [{ label: statusLabel(exec.status), color: statusToVariant(exec.status) }],
meta: `${exec.correlationId} · ${formatDuration(exec.durationMs)} · ${exec.customer}`,
timestamp: formatTimestamp(exec.timestamp),
path: `/exchanges/${exec.id}`,
})
}
for (const route of rts) {
results.push({
id: route.id,
category: 'route',
title: route.name,
badges: [{ label: route.group }],
meta: `${route.exchangeCount.toLocaleString()} exchanges · ${route.successRate}% success`,
path: `/routes/${route.id}`,
})
}
for (const agent of ags) {
results.push({
id: agent.id,
category: 'agent',
title: agent.name,
badges: [{ label: agent.status }],
meta: `${agent.service} ${agent.version} · ${agent.tps} · ${agent.lastSeen}`,
path: `/agents/${agent.appId}/${agent.id}`,
})
}
return results
}

View File

@@ -10,7 +10,7 @@ export function Admin() {
<TopBar <TopBar
breadcrumb={[{ label: 'Admin' }]} breadcrumb={[{ label: 'Admin' }]}
environment="PRODUCTION" environment="PRODUCTION"
shift="Day (06:00-18:00)"
user={{ name: 'hendrik' }} user={{ name: 'hendrik' }}
/> />
<EmptyState <EmptyState

View File

@@ -18,6 +18,9 @@ import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
import { Badge } from '../../design-system/primitives/Badge/Badge' import { Badge } from '../../design-system/primitives/Badge/Badge'
import { StatCard } from '../../design-system/primitives/StatCard/StatCard' import { StatCard } from '../../design-system/primitives/StatCard/StatCard'
// Global filters
import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProvider'
// Mock data // Mock data
import { agents, type AgentHealth as AgentHealthData } from '../../mocks/agents' import { agents, type AgentHealth as AgentHealthData } from '../../mocks/agents'
import { SIDEBAR_APPS } from '../../mocks/sidebar' import { SIDEBAR_APPS } from '../../mocks/sidebar'
@@ -117,6 +120,7 @@ function buildBreadcrumb(scope: Scope) {
export function AgentHealth() { export function AgentHealth() {
const scope = useScope() const scope = useScope()
const navigate = useNavigate() const navigate = useNavigate()
const { isInTimeRange } = useGlobalFilters()
// Filter agents by scope // Filter agents by scope
const filteredAgents = useMemo(() => { const filteredAgents = useMemo(() => {
@@ -135,8 +139,8 @@ export function AgentHealth() {
const totalTps = filteredAgents.reduce((s, a) => s + a.tps, 0) const totalTps = filteredAgents.reduce((s, a) => s + a.tps, 0)
const totalActiveRoutes = filteredAgents.reduce((s, a) => s + a.activeRoutes, 0) const totalActiveRoutes = filteredAgents.reduce((s, a) => s + a.activeRoutes, 0)
// Events are a global timeline feed — show all regardless of scope // Filter events by global time range
const filteredEvents = agentEvents const filteredEvents = agentEvents.filter((e) => isInTimeRange(e.timestamp))
// Single instance for expanded charts // Single instance for expanded charts
const singleInstance = scope.level === 'instance' ? filteredAgents[0] : null const singleInstance = scope.level === 'instance' ? filteredAgents[0] : null

View File

@@ -10,7 +10,7 @@ export function ApiDocs() {
<TopBar <TopBar
breadcrumb={[{ label: 'API Documentation' }]} breadcrumb={[{ label: 'API Documentation' }]}
environment="PRODUCTION" environment="PRODUCTION"
shift="Day (06:00-18:00)"
user={{ name: 'hendrik' }} user={{ name: 'hendrik' }}
/> />
<EmptyState <EmptyState

View File

@@ -16,7 +16,7 @@ export function AppDetail() {
{ label: id ?? '' }, { label: id ?? '' },
]} ]}
environment="PRODUCTION" environment="PRODUCTION"
shift="Day (06:00-18:00)"
user={{ name: 'hendrik' }} user={{ name: 'hendrik' }}
/> />
<EmptyState <EmptyState

View File

@@ -8,13 +8,9 @@ import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
import { TopBar } from '../../design-system/layout/TopBar/TopBar' import { TopBar } from '../../design-system/layout/TopBar/TopBar'
// Composites // Composites
import { FilterBar } from '../../design-system/composites/FilterBar/FilterBar'
import type { ActiveFilter } from '../../design-system/composites/FilterBar/FilterBar'
import { DataTable } from '../../design-system/composites/DataTable/DataTable' import { DataTable } from '../../design-system/composites/DataTable/DataTable'
import type { Column } from '../../design-system/composites/DataTable/types' import type { Column } from '../../design-system/composites/DataTable/types'
import { DetailPanel } from '../../design-system/composites/DetailPanel/DetailPanel' import { DetailPanel } from '../../design-system/composites/DetailPanel/DetailPanel'
import { CommandPalette } from '../../design-system/composites/CommandPalette/CommandPalette'
import type { SearchResult } from '../../design-system/composites/CommandPalette/types'
import { ShortcutsBar } from '../../design-system/composites/ShortcutsBar/ShortcutsBar' import { ShortcutsBar } from '../../design-system/composites/ShortcutsBar/ShortcutsBar'
import { ProcessorTimeline } from '../../design-system/composites/ProcessorTimeline/ProcessorTimeline' import { ProcessorTimeline } from '../../design-system/composites/ProcessorTimeline/ProcessorTimeline'
@@ -24,10 +20,11 @@ import { StatusDot } from '../../design-system/primitives/StatusDot/StatusDot'
import { MonoText } from '../../design-system/primitives/MonoText/MonoText' import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
import { Badge } from '../../design-system/primitives/Badge/Badge' import { Badge } from '../../design-system/primitives/Badge/Badge'
// Global filters
import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProvider'
// Mock data // Mock data
import { exchanges, type Exchange } from '../../mocks/exchanges' import { exchanges, type Exchange } from '../../mocks/exchanges'
import { routes } from '../../mocks/routes'
import { agents } from '../../mocks/agents'
import { kpiMetrics } from '../../mocks/metrics' import { kpiMetrics } from '../../mocks/metrics'
import { SIDEBAR_APPS } from '../../mocks/sidebar' import { SIDEBAR_APPS } from '../../mocks/sidebar'
@@ -137,58 +134,6 @@ function durationClass(ms: number, status: Exchange['status']): string {
return styles.durBreach return styles.durBreach
} }
// ─── Build CommandPalette search data ────────────────────────────────────────
function buildSearchData(
exs: Exchange[],
rts: typeof routes,
ags: typeof agents,
): SearchResult[] {
const results: SearchResult[] = []
for (const exec of exs) {
results.push({
id: exec.id,
category: 'exchange',
title: `${exec.orderId}${exec.route}`,
badges: [{ label: statusLabel(exec.status), color: statusToVariant(exec.status) }],
meta: `${exec.correlationId} · ${formatDuration(exec.durationMs)} · ${exec.customer}`,
timestamp: formatTimestamp(exec.timestamp),
})
}
for (const route of rts) {
results.push({
id: route.id,
category: 'route',
title: route.name,
badges: [{ label: route.group }],
meta: `${route.exchangeCount.toLocaleString()} exchanges · ${route.successRate}% success`,
})
}
for (const agent of ags) {
results.push({
id: agent.id,
category: 'agent',
title: agent.name,
badges: [{ label: agent.status }],
meta: `${agent.service} ${agent.version} · ${agent.tps} · ${agent.lastSeen}`,
})
}
return results
}
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 = [ const SHORTCUTS = [
{ keys: 'Ctrl+K', label: 'Search' }, { keys: 'Ctrl+K', label: 'Search' },
{ keys: '↑↓', label: 'Navigate rows' }, { keys: '↑↓', label: 'Navigate rows' },
@@ -199,12 +144,11 @@ const SHORTCUTS = [
// ─── Dashboard component ────────────────────────────────────────────────────── // ─── Dashboard component ──────────────────────────────────────────────────────
export function Dashboard() { export function Dashboard() {
const { id: appId } = useParams<{ id: string }>() const { id: appId } = useParams<{ id: string }>()
const [activeFilters, setActiveFilters] = useState<ActiveFilter[]>([])
const [search, setSearch] = useState('')
const [selectedId, setSelectedId] = useState<string | undefined>() const [selectedId, setSelectedId] = useState<string | undefined>()
const [panelOpen, setPanelOpen] = useState(false) const [panelOpen, setPanelOpen] = useState(false)
const [selectedExchange, setSelectedExchange] = useState<Exchange | null>(null) const [selectedExchange, setSelectedExchange] = useState<Exchange | null>(null)
const [paletteOpen, setPaletteOpen] = useState(false)
const { isInTimeRange, statusFilters } = useGlobalFilters()
// Build set of route IDs belonging to the selected app (if any) // Build set of route IDs belonging to the selected app (if any)
const appRouteIds = useMemo(() => { const appRouteIds = useMemo(() => {
@@ -214,55 +158,26 @@ export function Dashboard() {
return new Set(app.routes.map((r) => r.id)) return new Set(app.routes.map((r) => r.id))
}, [appId]) }, [appId])
const selectedApp = appId ? SIDEBAR_APPS.find((a) => a.id === appId) : null
// Scope all data to the selected app // Scope all data to the selected app
const scopedExchanges = useMemo(() => { const scopedExchanges = useMemo(() => {
if (!appRouteIds) return exchanges if (!appRouteIds) return exchanges
return exchanges.filter((e) => appRouteIds.has(e.route)) return exchanges.filter((e) => appRouteIds.has(e.route))
}, [appRouteIds]) }, [appRouteIds])
const scopedRoutes = useMemo(() => { // Filter exchanges (scoped + global filters)
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(() => { const filteredExchanges = useMemo(() => {
let data = scopedExchanges let data = scopedExchanges
const statusFilter = activeFilters.find((f) => // Time range filter
['completed', 'failed', 'running', 'warning', 'all'].includes(f.value), data = data.filter((e) => isInTimeRange(e.timestamp))
)
if (statusFilter && statusFilter.value !== 'all') {
data = data.filter((e) => e.status === statusFilter.value)
}
if (search.trim()) { // Status filter
const q = search.toLowerCase() if (statusFilters.size > 0) {
data = data.filter( data = data.filter((e) => statusFilters.has(e.status))
(e) =>
e.orderId.toLowerCase().includes(q) ||
e.route.toLowerCase().includes(q) ||
e.customer.toLowerCase().includes(q) ||
e.correlationId.toLowerCase().includes(q) ||
(e.errorMessage?.toLowerCase().includes(q) ?? false),
)
} }
return data return data
}, [activeFilters, search, scopedExchanges]) }, [scopedExchanges, isInTimeRange, statusFilters])
const searchData = useMemo(
() => buildSearchData(scopedExchanges, scopedRoutes, scopedAgents),
[scopedExchanges, scopedRoutes, scopedAgents],
)
function handleRowClick(row: Exchange) { function handleRowClick(row: Exchange) {
setSelectedId(row.id) setSelectedId(row.id)
@@ -392,7 +307,6 @@ export function Dashboard() {
} }
environment="PRODUCTION" environment="PRODUCTION"
user={{ name: 'hendrik' }} user={{ name: 'hendrik' }}
onSearchClick={() => setPaletteOpen(true)}
/> />
{/* Scrollable content */} {/* Scrollable content */}
@@ -414,17 +328,6 @@ export function Dashboard() {
))} ))}
</div> </div>
{/* Filter bar */}
<FilterBar
filters={buildStatusFilters(scopedExchanges)}
activeFilters={activeFilters}
onFilterChange={setActiveFilters}
searchPlaceholder="Search by Order ID, correlation ID, error message..."
searchValue={search}
onSearchChange={setSearch}
className={styles.filterBar}
/>
{/* Exchanges table */} {/* Exchanges table */}
<div className={styles.tableSection}> <div className={styles.tableSection}>
<div className={styles.tableHeader}> <div className={styles.tableHeader}>
@@ -459,15 +362,6 @@ export function Dashboard() {
</div> </div>
</div> </div>
{/* Command palette */}
<CommandPalette
open={paletteOpen}
onClose={() => setPaletteOpen(false)}
onSelect={() => setPaletteOpen(false)}
data={searchData}
onOpen={() => setPaletteOpen(true)}
/>
{/* Shortcuts bar */} {/* Shortcuts bar */}
<ShortcutsBar shortcuts={SHORTCUTS} /> <ShortcutsBar shortcuts={SHORTCUTS} />
</AppShell> </AppShell>

View File

@@ -122,7 +122,7 @@ export function ExchangeDetail() {
{ label: id ?? 'Unknown' }, { label: id ?? 'Unknown' },
]} ]}
environment="PRODUCTION" environment="PRODUCTION"
shift="Day (06:00-18:00)"
user={{ name: 'hendrik' }} user={{ name: 'hendrik' }}
/> />
<div className={styles.content}> <div className={styles.content}>
@@ -149,7 +149,6 @@ export function ExchangeDetail() {
{ label: exchange.id }, { label: exchange.id },
]} ]}
environment="PRODUCTION" environment="PRODUCTION"
shift="Day (06:00-18:00)"
user={{ name: 'hendrik' }} user={{ name: 'hendrik' }}
/> />

View File

@@ -120,9 +120,8 @@ export function LayoutSection() {
{ label: 'order-ingest' }, { label: 'order-ingest' },
]} ]}
environment="production" environment="production"
shift="Morning"
user={{ name: 'Hendrik' }} user={{ name: 'Hendrik' }}
onSearchClick={() => undefined}
/> />
</div> </div>
</DemoCard> </DemoCard>

View File

@@ -7,24 +7,12 @@
background: var(--bg-body); background: var(--bg-body);
} }
/* Date range picker bar */
.dateRangeBar {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
padding: 10px 16px;
margin-bottom: 16px;
box-shadow: var(--shadow-card);
}
.refreshIndicator { .refreshIndicator {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
flex-shrink: 0; margin-bottom: 12px;
justify-content: flex-end;
} }
.refreshDot { .refreshDot {

View File

@@ -1,4 +1,3 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import styles from './Metrics.module.css' import styles from './Metrics.module.css'
@@ -16,7 +15,6 @@ import type { Column } from '../../design-system/composites/DataTable/types'
// Primitives // Primitives
import { StatCard } from '../../design-system/primitives/StatCard/StatCard' import { StatCard } from '../../design-system/primitives/StatCard/StatCard'
import { DateRangePicker } from '../../design-system/primitives/DateRangePicker/DateRangePicker'
import { Sparkline } from '../../design-system/primitives/Sparkline/Sparkline' import { Sparkline } from '../../design-system/primitives/Sparkline/Sparkline'
import { MonoText } from '../../design-system/primitives/MonoText/MonoText' import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
import { Badge } from '../../design-system/primitives/Badge/Badge' import { Badge } from '../../design-system/primitives/Badge/Badge'
@@ -192,10 +190,6 @@ function convertSeries(series: typeof throughputSeries) {
// ─── Metrics page ───────────────────────────────────────────────────────────── // ─── Metrics page ─────────────────────────────────────────────────────────────
export function Metrics() { export function Metrics() {
const navigate = useNavigate() const navigate = useNavigate()
const [dateRange, setDateRange] = useState({
start: new Date('2026-03-18T06:00:00'),
end: new Date('2026-03-18T09:15:00'),
})
return ( return (
<AppShell <AppShell
@@ -210,21 +204,17 @@ export function Metrics() {
{ label: 'Metrics' }, { label: 'Metrics' },
]} ]}
environment="PRODUCTION" environment="PRODUCTION"
shift="Day (06:00-18:00)"
user={{ name: 'hendrik' }} user={{ name: 'hendrik' }}
/> />
{/* Scrollable content */} {/* Scrollable content */}
<div className={styles.content}> <div className={styles.content}>
{/* Date range picker bar */} {/* Auto-refresh indicator */}
<div className={styles.dateRangeBar}>
<DateRangePicker value={dateRange} onChange={setDateRange} />
<div className={styles.refreshIndicator}> <div className={styles.refreshIndicator}>
<span className={styles.refreshDot} /> <span className={styles.refreshDot} />
<span className={styles.refreshText}>Auto-refresh: 30s</span> <span className={styles.refreshText}>Auto-refresh: 30s</span>
</div> </div>
</div>
{/* KPI stat cards (5) */} {/* KPI stat cards (5) */}
<div className={styles.kpiStrip}> <div className={styles.kpiStrip}>

View File

@@ -210,7 +210,7 @@ export function RouteDetail() {
{ label: id ?? 'Unknown' }, { label: id ?? 'Unknown' },
]} ]}
environment="PRODUCTION" environment="PRODUCTION"
shift="Day (06:00-18:00)"
user={{ name: 'hendrik' }} user={{ name: 'hendrik' }}
/> />
<div className={styles.content}> <div className={styles.content}>
@@ -236,7 +236,7 @@ export function RouteDetail() {
{ label: route.name }, { label: route.name },
]} ]}
environment="PRODUCTION" environment="PRODUCTION"
shift="Day (06:00-18:00)"
user={{ name: 'hendrik' }} user={{ name: 'hendrik' }}
/> />