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

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