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:
76
src/App.tsx
76
src/App.tsx
@@ -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 { Metrics } from './pages/Metrics/Metrics'
|
||||
import { RouteDetail } from './pages/RouteDetail/RouteDetail'
|
||||
@@ -8,8 +9,73 @@ import { Inventory } from './pages/Inventory/Inventory'
|
||||
import { Admin } from './pages/Admin/Admin'
|
||||
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() {
|
||||
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 (
|
||||
<>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/apps" replace />} />
|
||||
<Route path="/apps" element={<Dashboard />} />
|
||||
@@ -22,5 +88,13 @@ export default function App() {
|
||||
<Route path="/api-docs" element={<ApiDocs />} />
|
||||
<Route path="/inventory" element={<Inventory />} />
|
||||
</Routes>
|
||||
<CommandPalette
|
||||
open={paletteOpen}
|
||||
onClose={() => setOpen(false)}
|
||||
onOpen={() => setOpen(true)}
|
||||
data={filteredSearchData}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ interface CommandPaletteProps {
|
||||
|
||||
const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
|
||||
all: 'All',
|
||||
application: 'Applications',
|
||||
exchange: 'Exchanges',
|
||||
route: 'Routes',
|
||||
agent: 'Agents',
|
||||
@@ -23,6 +24,7 @@ const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
|
||||
|
||||
const ALL_CATEGORIES: Array<SearchCategory | 'all'> = [
|
||||
'all',
|
||||
'application',
|
||||
'exchange',
|
||||
'route',
|
||||
'agent',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export type SearchCategory = 'exchange' | 'route' | 'agent'
|
||||
export type SearchCategory = 'application' | 'exchange' | 'route' | 'agent'
|
||||
|
||||
export interface SearchResult {
|
||||
id: string
|
||||
@@ -10,6 +10,7 @@ export interface SearchResult {
|
||||
meta: string
|
||||
timestamp?: string
|
||||
icon?: ReactNode
|
||||
path?: string
|
||||
expandedContent?: string
|
||||
matchRanges?: [number, number][]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import styles from './Sidebar.module.css'
|
||||
import camelLogoUrl from '../../../assets/camel-logo.svg'
|
||||
@@ -220,6 +220,31 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
||||
const appNodes = useMemo(() => buildAppTreeNodes(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
|
||||
const starredItems = useMemo(
|
||||
() => collectStarredItems(apps, starredIds),
|
||||
@@ -231,6 +256,12 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
||||
const starredAgents = starredItems.filter((i) => i.type === 'agent')
|
||||
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 (
|
||||
<aside className={`${styles.sidebar} ${className ?? ''}`}>
|
||||
{/* Logo */}
|
||||
@@ -299,11 +330,12 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
||||
{!appsCollapsed && (
|
||||
<SidebarTree
|
||||
nodes={appNodes}
|
||||
selectedPath={location.pathname}
|
||||
selectedPath={effectiveSelectedPath}
|
||||
isStarred={isStarred}
|
||||
onToggleStar={toggleStar}
|
||||
filterQuery={search}
|
||||
persistKey="cameleer:expanded:apps"
|
||||
autoRevealPath={sidebarRevealPath}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -332,11 +364,12 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
||||
{!agentsCollapsed && (
|
||||
<SidebarTree
|
||||
nodes={agentNodes}
|
||||
selectedPath={location.pathname}
|
||||
selectedPath={effectiveSelectedPath}
|
||||
isStarred={isStarred}
|
||||
onToggleStar={toggleStar}
|
||||
filterQuery={search}
|
||||
persistKey="cameleer:expanded:agents"
|
||||
autoRevealPath={sidebarRevealPath}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
useState,
|
||||
useRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
type ReactNode,
|
||||
type KeyboardEvent,
|
||||
@@ -31,6 +32,7 @@ export interface SidebarTreeProps {
|
||||
className?: string
|
||||
filterQuery?: string
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
@@ -138,6 +140,7 @@ export function SidebarTree({
|
||||
className,
|
||||
filterQuery,
|
||||
persistKey,
|
||||
autoRevealPath,
|
||||
}: SidebarTreeProps) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
@@ -146,6 +149,27 @@ export function SidebarTree({
|
||||
() => 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
|
||||
const { filtered, matchedParentIds } = useMemo(
|
||||
() => filterNodes(nodes, filterQuery ?? ''),
|
||||
|
||||
@@ -14,6 +14,14 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Filters group: time range + status pills */
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Center search trigger */
|
||||
.search {
|
||||
display: flex;
|
||||
@@ -28,9 +36,9 @@
|
||||
font-family: var(--font-body);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
min-width: 280px;
|
||||
min-width: 180px;
|
||||
flex: 1;
|
||||
max-width: 400px;
|
||||
max-width: 280px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@@ -86,16 +94,6 @@
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import styles from './TopBar.module.css'
|
||||
import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb'
|
||||
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 {
|
||||
label: string
|
||||
@@ -10,29 +14,51 @@ interface BreadcrumbItem {
|
||||
interface TopBarProps {
|
||||
breadcrumb: BreadcrumbItem[]
|
||||
environment?: string
|
||||
shift?: string
|
||||
user?: { name: string }
|
||||
onSearchClick?: () => void
|
||||
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({
|
||||
breadcrumb,
|
||||
environment,
|
||||
shift,
|
||||
user,
|
||||
onSearchClick,
|
||||
className,
|
||||
}: TopBarProps) {
|
||||
const globalFilters = useGlobalFilters()
|
||||
const commandPalette = useCommandPalette()
|
||||
|
||||
return (
|
||||
<header className={`${styles.topbar} ${className ?? ''}`}>
|
||||
{/* Left: 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 */}
|
||||
<button
|
||||
className={styles.search}
|
||||
onClick={onSearchClick}
|
||||
onClick={() => commandPalette.setOpen(true)}
|
||||
type="button"
|
||||
aria-label="Open search"
|
||||
>
|
||||
@@ -42,18 +68,15 @@ export function TopBar({
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className={styles.searchPlaceholder}>Search by Order ID, route, error...</span>
|
||||
<span className={styles.searchPlaceholder}>Search... ⌘K</span>
|
||||
<span className={styles.kbd}>Ctrl+K</span>
|
||||
</button>
|
||||
|
||||
{/* Right: env badge, shift, user */}
|
||||
{/* Right: env badge, user */}
|
||||
<div className={styles.right}>
|
||||
{environment && (
|
||||
<span className={styles.env}>{environment}</span>
|
||||
)}
|
||||
{shift && (
|
||||
<span className={styles.shift}>Shift: {shift}</span>
|
||||
)}
|
||||
{user && (
|
||||
<div className={styles.user}>
|
||||
<span className={styles.userName}>{user.name}</span>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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">⏱</span>
|
||||
<span className={styles.label}>{activeLabel}</span>
|
||||
<span className={styles.caret} aria-hidden="true">▾</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>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
28
src/design-system/providers/CommandPaletteProvider.tsx
Normal file
28
src/design-system/providers/CommandPaletteProvider.tsx
Normal 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
|
||||
}
|
||||
79
src/design-system/providers/GlobalFilterProvider.tsx
Normal file
79
src/design-system/providers/GlobalFilterProvider.tsx
Normal 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
|
||||
}
|
||||
59
src/design-system/utils/timePresets.ts
Normal file
59
src/design-system/utils/timePresets.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<ThemeProvider>
|
||||
<GlobalFilterProvider>
|
||||
<CommandPaletteProvider>
|
||||
<App />
|
||||
</CommandPaletteProvider>
|
||||
</GlobalFilterProvider>
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
|
||||
98
src/mocks/searchData.tsx
Normal file
98
src/mocks/searchData.tsx
Normal 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
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export function Admin() {
|
||||
<TopBar
|
||||
breadcrumb={[{ label: 'Admin' }]}
|
||||
environment="PRODUCTION"
|
||||
shift="Day (06:00-18:00)"
|
||||
|
||||
user={{ name: 'hendrik' }}
|
||||
/>
|
||||
<EmptyState
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,7 +10,7 @@ export function ApiDocs() {
|
||||
<TopBar
|
||||
breadcrumb={[{ label: 'API Documentation' }]}
|
||||
environment="PRODUCTION"
|
||||
shift="Day (06:00-18:00)"
|
||||
|
||||
user={{ name: 'hendrik' }}
|
||||
/>
|
||||
<EmptyState
|
||||
|
||||
@@ -16,7 +16,7 @@ export function AppDetail() {
|
||||
{ label: id ?? '' },
|
||||
]}
|
||||
environment="PRODUCTION"
|
||||
shift="Day (06:00-18:00)"
|
||||
|
||||
user={{ name: 'hendrik' }}
|
||||
/>
|
||||
<EmptyState
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' }}
|
||||
/>
|
||||
|
||||
|
||||
@@ -120,9 +120,8 @@ export function LayoutSection() {
|
||||
{ label: 'order-ingest' },
|
||||
]}
|
||||
environment="production"
|
||||
shift="Morning"
|
||||
|
||||
user={{ name: 'Hendrik' }}
|
||||
onSearchClick={() => undefined}
|
||||
/>
|
||||
</div>
|
||||
</DemoCard>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,21 +204,17 @@ 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} />
|
||||
{/* Auto-refresh indicator */}
|
||||
<div className={styles.refreshIndicator}>
|
||||
<span className={styles.refreshDot} />
|
||||
<span className={styles.refreshText}>Auto-refresh: 30s</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI stat cards (5) */}
|
||||
<div className={styles.kpiStrip}>
|
||||
|
||||
@@ -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' }}
|
||||
/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user