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

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

View File

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

View File

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

View File

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

View File

@@ -8,13 +8,9 @@ import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
// 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 type { Column } from '../../design-system/composites/DataTable/types'
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 { 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 { Badge } from '../../design-system/primitives/Badge/Badge'
// Global filters
import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProvider'
// Mock data
import { exchanges, type Exchange } from '../../mocks/exchanges'
import { routes } from '../../mocks/routes'
import { agents } from '../../mocks/agents'
import { kpiMetrics } from '../../mocks/metrics'
import { SIDEBAR_APPS } from '../../mocks/sidebar'
@@ -137,58 +134,6 @@ function durationClass(ms: number, status: Exchange['status']): string {
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 = [
{ keys: 'Ctrl+K', label: 'Search' },
{ keys: '↑↓', label: 'Navigate rows' },
@@ -199,12 +144,11 @@ 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>()
const [panelOpen, setPanelOpen] = useState(false)
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)
const appRouteIds = useMemo(() => {
@@ -214,55 +158,26 @@ export function Dashboard() {
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)
// Filter exchanges (scoped + global filters)
const filteredExchanges = useMemo(() => {
let data = scopedExchanges
const statusFilter = activeFilters.find((f) =>
['completed', 'failed', 'running', 'warning', 'all'].includes(f.value),
)
if (statusFilter && statusFilter.value !== 'all') {
data = data.filter((e) => e.status === statusFilter.value)
}
// Time range filter
data = data.filter((e) => isInTimeRange(e.timestamp))
if (search.trim()) {
const q = search.toLowerCase()
data = data.filter(
(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),
)
// Status filter
if (statusFilters.size > 0) {
data = data.filter((e) => statusFilters.has(e.status))
}
return data
}, [activeFilters, search, scopedExchanges])
const searchData = useMemo(
() => buildSearchData(scopedExchanges, scopedRoutes, scopedAgents),
[scopedExchanges, scopedRoutes, scopedAgents],
)
}, [scopedExchanges, isInTimeRange, statusFilters])
function handleRowClick(row: Exchange) {
setSelectedId(row.id)
@@ -392,7 +307,6 @@ export function Dashboard() {
}
environment="PRODUCTION"
user={{ name: 'hendrik' }}
onSearchClick={() => setPaletteOpen(true)}
/>
{/* Scrollable content */}
@@ -414,17 +328,6 @@ export function Dashboard() {
))}
</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 */}
<div className={styles.tableSection}>
<div className={styles.tableHeader}>
@@ -459,15 +362,6 @@ export function Dashboard() {
</div>
</div>
{/* Command palette */}
<CommandPalette
open={paletteOpen}
onClose={() => setPaletteOpen(false)}
onSelect={() => setPaletteOpen(false)}
data={searchData}
onOpen={() => setPaletteOpen(true)}
/>
{/* Shortcuts bar */}
<ShortcutsBar shortcuts={SHORTCUTS} />
</AppShell>

View File

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

View File

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

View File

@@ -7,24 +7,12 @@
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 {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
margin-bottom: 12px;
justify-content: flex-end;
}
.refreshDot {

View File

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

View File

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