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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user