{user.name}
diff --git a/src/design-system/primitives/DateRangePicker/DateRangePicker.tsx b/src/design-system/primitives/DateRangePicker/DateRangePicker.tsx
index 109178f..78638ff 100644
--- a/src/design-system/primitives/DateRangePicker/DateRangePicker.tsx
+++ b/src/design-system/primitives/DateRangePicker/DateRangePicker.tsx
@@ -2,53 +2,7 @@ import { useState } from 'react'
import styles from './DateRangePicker.module.css'
import { DateTimePicker } from '../DateTimePicker/DateTimePicker'
import { FilterPill } from '../FilterPill/FilterPill'
-
-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 }
- }
-}
+import { DEFAULT_PRESETS, computePresetRange, type DateRange, type Preset } from '../../utils/timePresets'
interface DateRangePickerProps {
value: DateRange
diff --git a/src/design-system/primitives/TimeRangeDropdown/TimeRangeDropdown.module.css b/src/design-system/primitives/TimeRangeDropdown/TimeRangeDropdown.module.css
new file mode 100644
index 0000000..5f7a8f2
--- /dev/null
+++ b/src/design-system/primitives/TimeRangeDropdown/TimeRangeDropdown.module.css
@@ -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;
+}
diff --git a/src/design-system/primitives/TimeRangeDropdown/TimeRangeDropdown.tsx b/src/design-system/primitives/TimeRangeDropdown/TimeRangeDropdown.tsx
new file mode 100644
index 0000000..b3fa356
--- /dev/null
+++ b/src/design-system/primitives/TimeRangeDropdown/TimeRangeDropdown.tsx
@@ -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 (
+
+ ⏱
+ {activeLabel}
+ ▾
+
+ }
+ content={
+
+ {DROPDOWN_PRESETS.map((preset) => (
+ {
+ const range = computePresetRange(preset.value)
+ onChange({ ...range, preset: preset.value })
+ }}
+ />
+ ))}
+
+ }
+ />
+ )
+}
diff --git a/src/design-system/primitives/index.ts b/src/design-system/primitives/index.ts
index d610f49..97aed32 100644
--- a/src/design-system/primitives/index.ts
+++ b/src/design-system/primitives/index.ts
@@ -28,5 +28,6 @@ export { StatCard } from './StatCard/StatCard'
export { StatusDot } from './StatusDot/StatusDot'
export { Tag } from './Tag/Tag'
export { Textarea } from './Textarea/Textarea'
+export { TimeRangeDropdown } from './TimeRangeDropdown/TimeRangeDropdown'
export { Toggle } from './Toggle/Toggle'
export { Tooltip } from './Tooltip/Tooltip'
diff --git a/src/design-system/providers/CommandPaletteProvider.tsx b/src/design-system/providers/CommandPaletteProvider.tsx
new file mode 100644
index 0000000..aba6ba4
--- /dev/null
+++ b/src/design-system/providers/CommandPaletteProvider.tsx
@@ -0,0 +1,28 @@
+import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'
+
+interface CommandPaletteContextValue {
+ open: boolean
+ setOpen: (open: boolean) => void
+}
+
+const CommandPaletteContext = createContext(null)
+
+export function CommandPaletteProvider({ children }: { children: ReactNode }) {
+ const [open, setOpenState] = useState(false)
+
+ const setOpen = useCallback((value: boolean) => {
+ setOpenState(value)
+ }, [])
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function useCommandPalette(): CommandPaletteContextValue {
+ const ctx = useContext(CommandPaletteContext)
+ if (!ctx) throw new Error('useCommandPalette must be used within CommandPaletteProvider')
+ return ctx
+}
diff --git a/src/design-system/providers/GlobalFilterProvider.tsx b/src/design-system/providers/GlobalFilterProvider.tsx
new file mode 100644
index 0000000..024db38
--- /dev/null
+++ b/src/design-system/providers/GlobalFilterProvider.tsx
@@ -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
+ toggleStatus: (status: ExchangeStatus) => void
+ clearStatusFilters: () => void
+ isInTimeRange: (timestamp: Date) => boolean
+}
+
+const GlobalFilterContext = createContext(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(getDefaultTimeRange)
+ const [statusFilters, setStatusFilters] = useState>(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 (
+
+ {children}
+
+ )
+}
+
+export function useGlobalFilters(): GlobalFilterContextValue {
+ const ctx = useContext(GlobalFilterContext)
+ if (!ctx) throw new Error('useGlobalFilters must be used within GlobalFilterProvider')
+ return ctx
+}
diff --git a/src/design-system/utils/timePresets.ts b/src/design-system/utils/timePresets.ts
new file mode 100644
index 0000000..fd9895d
--- /dev/null
+++ b/src/design-system/utils/timePresets.ts
@@ -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 = {
+ '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 }
+ }
+}
diff --git a/src/main.tsx b/src/main.tsx
index 83eded7..2c7c021 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -2,6 +2,8 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
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 './index.css'
@@ -9,7 +11,11 @@ createRoot(document.getElementById('root')!).render(
-
+
+
+
+
+
,
diff --git a/src/mocks/searchData.tsx b/src/mocks/searchData.tsx
new file mode 100644
index 0000000..a0da27a
--- /dev/null
+++ b/src/mocks/searchData.tsx
@@ -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
+}
diff --git a/src/pages/Admin/Admin.tsx b/src/pages/Admin/Admin.tsx
index d1245ad..e74e809 100644
--- a/src/pages/Admin/Admin.tsx
+++ b/src/pages/Admin/Admin.tsx
@@ -10,7 +10,7 @@ export function Admin() {
{
@@ -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
diff --git a/src/pages/ApiDocs/ApiDocs.tsx b/src/pages/ApiDocs/ApiDocs.tsx
index 1a1edeb..9cbd739 100644
--- a/src/pages/ApiDocs/ApiDocs.tsx
+++ b/src/pages/ApiDocs/ApiDocs.tsx
@@ -10,7 +10,7 @@ export function ApiDocs() {
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([])
- const [search, setSearch] = useState('')
const [selectedId, setSelectedId] = useState()
const [panelOpen, setPanelOpen] = useState(false)
const [selectedExchange, setSelectedExchange] = useState(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() {
))}