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:
100
src/App.tsx
100
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 { Dashboard } from './pages/Dashboard/Dashboard'
|
||||||
import { Metrics } from './pages/Metrics/Metrics'
|
import { Metrics } from './pages/Metrics/Metrics'
|
||||||
import { RouteDetail } from './pages/RouteDetail/RouteDetail'
|
import { RouteDetail } from './pages/RouteDetail/RouteDetail'
|
||||||
@@ -8,19 +9,92 @@ import { Inventory } from './pages/Inventory/Inventory'
|
|||||||
import { Admin } from './pages/Admin/Admin'
|
import { Admin } from './pages/Admin/Admin'
|
||||||
import { ApiDocs } from './pages/ApiDocs/ApiDocs'
|
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() {
|
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 (
|
return (
|
||||||
<Routes>
|
<>
|
||||||
<Route path="/" element={<Navigate to="/apps" replace />} />
|
<Routes>
|
||||||
<Route path="/apps" element={<Dashboard />} />
|
<Route path="/" element={<Navigate to="/apps" replace />} />
|
||||||
<Route path="/apps/:id" element={<Dashboard />} />
|
<Route path="/apps" element={<Dashboard />} />
|
||||||
<Route path="/metrics" element={<Metrics />} />
|
<Route path="/apps/:id" element={<Dashboard />} />
|
||||||
<Route path="/routes/:id" element={<RouteDetail />} />
|
<Route path="/metrics" element={<Metrics />} />
|
||||||
<Route path="/exchanges/:id" element={<ExchangeDetail />} />
|
<Route path="/routes/:id" element={<RouteDetail />} />
|
||||||
<Route path="/agents/*" element={<AgentHealth />} />
|
<Route path="/exchanges/:id" element={<ExchangeDetail />} />
|
||||||
<Route path="/admin" element={<Admin />} />
|
<Route path="/agents/*" element={<AgentHealth />} />
|
||||||
<Route path="/api-docs" element={<ApiDocs />} />
|
<Route path="/admin" element={<Admin />} />
|
||||||
<Route path="/inventory" element={<Inventory />} />
|
<Route path="/api-docs" element={<ApiDocs />} />
|
||||||
</Routes>
|
<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> = {
|
const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
|
||||||
all: 'All',
|
all: 'All',
|
||||||
|
application: 'Applications',
|
||||||
exchange: 'Exchanges',
|
exchange: 'Exchanges',
|
||||||
route: 'Routes',
|
route: 'Routes',
|
||||||
agent: 'Agents',
|
agent: 'Agents',
|
||||||
@@ -23,6 +24,7 @@ const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
|
|||||||
|
|
||||||
const ALL_CATEGORIES: Array<SearchCategory | 'all'> = [
|
const ALL_CATEGORIES: Array<SearchCategory | 'all'> = [
|
||||||
'all',
|
'all',
|
||||||
|
'application',
|
||||||
'exchange',
|
'exchange',
|
||||||
'route',
|
'route',
|
||||||
'agent',
|
'agent',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
export type SearchCategory = 'exchange' | 'route' | 'agent'
|
export type SearchCategory = 'application' | 'exchange' | 'route' | 'agent'
|
||||||
|
|
||||||
export interface SearchResult {
|
export interface SearchResult {
|
||||||
id: string
|
id: string
|
||||||
@@ -10,6 +10,7 @@ export interface SearchResult {
|
|||||||
meta: string
|
meta: string
|
||||||
timestamp?: string
|
timestamp?: string
|
||||||
icon?: ReactNode
|
icon?: ReactNode
|
||||||
|
path?: string
|
||||||
expandedContent?: string
|
expandedContent?: string
|
||||||
matchRanges?: [number, number][]
|
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 { useNavigate, useLocation } from 'react-router-dom'
|
||||||
import styles from './Sidebar.module.css'
|
import styles from './Sidebar.module.css'
|
||||||
import camelLogoUrl from '../../../assets/camel-logo.svg'
|
import camelLogoUrl from '../../../assets/camel-logo.svg'
|
||||||
@@ -220,6 +220,31 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
const appNodes = useMemo(() => buildAppTreeNodes(apps), [apps])
|
const appNodes = useMemo(() => buildAppTreeNodes(apps), [apps])
|
||||||
const agentNodes = useMemo(() => buildAgentTreeNodes(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
|
// Build starred items
|
||||||
const starredItems = useMemo(
|
const starredItems = useMemo(
|
||||||
() => collectStarredItems(apps, starredIds),
|
() => collectStarredItems(apps, starredIds),
|
||||||
@@ -231,6 +256,12 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
const starredAgents = starredItems.filter((i) => i.type === 'agent')
|
const starredAgents = starredItems.filter((i) => i.type === 'agent')
|
||||||
const hasStarred = starredItems.length > 0
|
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 (
|
return (
|
||||||
<aside className={`${styles.sidebar} ${className ?? ''}`}>
|
<aside className={`${styles.sidebar} ${className ?? ''}`}>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
@@ -299,11 +330,12 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
{!appsCollapsed && (
|
{!appsCollapsed && (
|
||||||
<SidebarTree
|
<SidebarTree
|
||||||
nodes={appNodes}
|
nodes={appNodes}
|
||||||
selectedPath={location.pathname}
|
selectedPath={effectiveSelectedPath}
|
||||||
isStarred={isStarred}
|
isStarred={isStarred}
|
||||||
onToggleStar={toggleStar}
|
onToggleStar={toggleStar}
|
||||||
filterQuery={search}
|
filterQuery={search}
|
||||||
persistKey="cameleer:expanded:apps"
|
persistKey="cameleer:expanded:apps"
|
||||||
|
autoRevealPath={sidebarRevealPath}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -332,11 +364,12 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
{!agentsCollapsed && (
|
{!agentsCollapsed && (
|
||||||
<SidebarTree
|
<SidebarTree
|
||||||
nodes={agentNodes}
|
nodes={agentNodes}
|
||||||
selectedPath={location.pathname}
|
selectedPath={effectiveSelectedPath}
|
||||||
isStarred={isStarred}
|
isStarred={isStarred}
|
||||||
onToggleStar={toggleStar}
|
onToggleStar={toggleStar}
|
||||||
filterQuery={search}
|
filterQuery={search}
|
||||||
persistKey="cameleer:expanded:agents"
|
persistKey="cameleer:expanded:agents"
|
||||||
|
autoRevealPath={sidebarRevealPath}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
useRef,
|
useRef,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
type KeyboardEvent,
|
type KeyboardEvent,
|
||||||
@@ -31,6 +32,7 @@ export interface SidebarTreeProps {
|
|||||||
className?: string
|
className?: string
|
||||||
filterQuery?: string
|
filterQuery?: string
|
||||||
persistKey?: string // sessionStorage key to persist expand state across remounts
|
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 ───────────────────────────────────────────────────────────
|
// ── Star icon SVGs ───────────────────────────────────────────────────────────
|
||||||
@@ -138,6 +140,7 @@ export function SidebarTree({
|
|||||||
className,
|
className,
|
||||||
filterQuery,
|
filterQuery,
|
||||||
persistKey,
|
persistKey,
|
||||||
|
autoRevealPath,
|
||||||
}: SidebarTreeProps) {
|
}: SidebarTreeProps) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
@@ -146,6 +149,27 @@ export function SidebarTree({
|
|||||||
() => persistKey ? readExpandState(persistKey) : new Set(),
|
() => 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
|
// Filter
|
||||||
const { filtered, matchedParentIds } = useMemo(
|
const { filtered, matchedParentIds } = useMemo(
|
||||||
() => filterNodes(nodes, filterQuery ?? ''),
|
() => filterNodes(nodes, filterQuery ?? ''),
|
||||||
|
|||||||
@@ -14,6 +14,14 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Filters group: time range + status pills */
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Center search trigger */
|
/* Center search trigger */
|
||||||
.search {
|
.search {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -28,9 +36,9 @@
|
|||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.15s;
|
transition: border-color 0.15s;
|
||||||
min-width: 280px;
|
min-width: 180px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
max-width: 400px;
|
max-width: 280px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,16 +94,6 @@
|
|||||||
letter-spacing: 0.5px;
|
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 {
|
.user {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import styles from './TopBar.module.css'
|
import styles from './TopBar.module.css'
|
||||||
import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb'
|
import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb'
|
||||||
import { Avatar } from '../../primitives/Avatar/Avatar'
|
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 {
|
interface BreadcrumbItem {
|
||||||
label: string
|
label: string
|
||||||
@@ -10,29 +14,51 @@ interface BreadcrumbItem {
|
|||||||
interface TopBarProps {
|
interface TopBarProps {
|
||||||
breadcrumb: BreadcrumbItem[]
|
breadcrumb: BreadcrumbItem[]
|
||||||
environment?: string
|
environment?: string
|
||||||
shift?: string
|
|
||||||
user?: { name: string }
|
user?: { name: string }
|
||||||
onSearchClick?: () => void
|
|
||||||
className?: string
|
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({
|
export function TopBar({
|
||||||
breadcrumb,
|
breadcrumb,
|
||||||
environment,
|
environment,
|
||||||
shift,
|
|
||||||
user,
|
user,
|
||||||
onSearchClick,
|
|
||||||
className,
|
className,
|
||||||
}: TopBarProps) {
|
}: TopBarProps) {
|
||||||
|
const globalFilters = useGlobalFilters()
|
||||||
|
const commandPalette = useCommandPalette()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className={`${styles.topbar} ${className ?? ''}`}>
|
<header className={`${styles.topbar} ${className ?? ''}`}>
|
||||||
{/* Left: Breadcrumb */}
|
{/* Left: Breadcrumb */}
|
||||||
<Breadcrumb items={breadcrumb} className={styles.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 */}
|
{/* Center: Search trigger */}
|
||||||
<button
|
<button
|
||||||
className={styles.search}
|
className={styles.search}
|
||||||
onClick={onSearchClick}
|
onClick={() => commandPalette.setOpen(true)}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Open search"
|
aria-label="Open search"
|
||||||
>
|
>
|
||||||
@@ -42,18 +68,15 @@ export function TopBar({
|
|||||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</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>
|
<span className={styles.kbd}>Ctrl+K</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Right: env badge, shift, user */}
|
{/* Right: env badge, user */}
|
||||||
<div className={styles.right}>
|
<div className={styles.right}>
|
||||||
{environment && (
|
{environment && (
|
||||||
<span className={styles.env}>{environment}</span>
|
<span className={styles.env}>{environment}</span>
|
||||||
)}
|
)}
|
||||||
{shift && (
|
|
||||||
<span className={styles.shift}>Shift: {shift}</span>
|
|
||||||
)}
|
|
||||||
{user && (
|
{user && (
|
||||||
<div className={styles.user}>
|
<div className={styles.user}>
|
||||||
<span className={styles.userName}>{user.name}</span>
|
<span className={styles.userName}>{user.name}</span>
|
||||||
|
|||||||
@@ -2,53 +2,7 @@ import { useState } from 'react'
|
|||||||
import styles from './DateRangePicker.module.css'
|
import styles from './DateRangePicker.module.css'
|
||||||
import { DateTimePicker } from '../DateTimePicker/DateTimePicker'
|
import { DateTimePicker } from '../DateTimePicker/DateTimePicker'
|
||||||
import { FilterPill } from '../FilterPill/FilterPill'
|
import { FilterPill } from '../FilterPill/FilterPill'
|
||||||
|
import { DEFAULT_PRESETS, computePresetRange, type DateRange, type Preset } from '../../utils/timePresets'
|
||||||
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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DateRangePickerProps {
|
interface DateRangePickerProps {
|
||||||
value: DateRange
|
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 { StatusDot } from './StatusDot/StatusDot'
|
||||||
export { Tag } from './Tag/Tag'
|
export { Tag } from './Tag/Tag'
|
||||||
export { Textarea } from './Textarea/Textarea'
|
export { Textarea } from './Textarea/Textarea'
|
||||||
|
export { TimeRangeDropdown } from './TimeRangeDropdown/TimeRangeDropdown'
|
||||||
export { Toggle } from './Toggle/Toggle'
|
export { Toggle } from './Toggle/Toggle'
|
||||||
export { Tooltip } from './Tooltip/Tooltip'
|
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 { createRoot } from 'react-dom/client'
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import { ThemeProvider } from './design-system/providers/ThemeProvider'
|
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 App from './App'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
@@ -9,7 +11,11 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<App />
|
<GlobalFilterProvider>
|
||||||
|
<CommandPaletteProvider>
|
||||||
|
<App />
|
||||||
|
</CommandPaletteProvider>
|
||||||
|
</GlobalFilterProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</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
|
<TopBar
|
||||||
breadcrumb={[{ label: 'Admin' }]}
|
breadcrumb={[{ label: 'Admin' }]}
|
||||||
environment="PRODUCTION"
|
environment="PRODUCTION"
|
||||||
shift="Day (06:00-18:00)"
|
|
||||||
user={{ name: 'hendrik' }}
|
user={{ name: 'hendrik' }}
|
||||||
/>
|
/>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
|
|||||||
import { Badge } from '../../design-system/primitives/Badge/Badge'
|
import { Badge } from '../../design-system/primitives/Badge/Badge'
|
||||||
import { StatCard } from '../../design-system/primitives/StatCard/StatCard'
|
import { StatCard } from '../../design-system/primitives/StatCard/StatCard'
|
||||||
|
|
||||||
|
// Global filters
|
||||||
|
import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProvider'
|
||||||
|
|
||||||
// Mock data
|
// Mock data
|
||||||
import { agents, type AgentHealth as AgentHealthData } from '../../mocks/agents'
|
import { agents, type AgentHealth as AgentHealthData } from '../../mocks/agents'
|
||||||
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
||||||
@@ -117,6 +120,7 @@ function buildBreadcrumb(scope: Scope) {
|
|||||||
export function AgentHealth() {
|
export function AgentHealth() {
|
||||||
const scope = useScope()
|
const scope = useScope()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { isInTimeRange } = useGlobalFilters()
|
||||||
|
|
||||||
// Filter agents by scope
|
// Filter agents by scope
|
||||||
const filteredAgents = useMemo(() => {
|
const filteredAgents = useMemo(() => {
|
||||||
@@ -135,8 +139,8 @@ export function AgentHealth() {
|
|||||||
const totalTps = filteredAgents.reduce((s, a) => s + a.tps, 0)
|
const totalTps = filteredAgents.reduce((s, a) => s + a.tps, 0)
|
||||||
const totalActiveRoutes = filteredAgents.reduce((s, a) => s + a.activeRoutes, 0)
|
const totalActiveRoutes = filteredAgents.reduce((s, a) => s + a.activeRoutes, 0)
|
||||||
|
|
||||||
// Events are a global timeline feed — show all regardless of scope
|
// Filter events by global time range
|
||||||
const filteredEvents = agentEvents
|
const filteredEvents = agentEvents.filter((e) => isInTimeRange(e.timestamp))
|
||||||
|
|
||||||
// Single instance for expanded charts
|
// Single instance for expanded charts
|
||||||
const singleInstance = scope.level === 'instance' ? filteredAgents[0] : null
|
const singleInstance = scope.level === 'instance' ? filteredAgents[0] : null
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export function ApiDocs() {
|
|||||||
<TopBar
|
<TopBar
|
||||||
breadcrumb={[{ label: 'API Documentation' }]}
|
breadcrumb={[{ label: 'API Documentation' }]}
|
||||||
environment="PRODUCTION"
|
environment="PRODUCTION"
|
||||||
shift="Day (06:00-18:00)"
|
|
||||||
user={{ name: 'hendrik' }}
|
user={{ name: 'hendrik' }}
|
||||||
/>
|
/>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export function AppDetail() {
|
|||||||
{ label: id ?? '' },
|
{ label: id ?? '' },
|
||||||
]}
|
]}
|
||||||
environment="PRODUCTION"
|
environment="PRODUCTION"
|
||||||
shift="Day (06:00-18:00)"
|
|
||||||
user={{ name: 'hendrik' }}
|
user={{ name: 'hendrik' }}
|
||||||
/>
|
/>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
|
|||||||
@@ -8,13 +8,9 @@ import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
|
|||||||
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||||
|
|
||||||
// Composites
|
// 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 { DataTable } from '../../design-system/composites/DataTable/DataTable'
|
||||||
import type { Column } from '../../design-system/composites/DataTable/types'
|
import type { Column } from '../../design-system/composites/DataTable/types'
|
||||||
import { DetailPanel } from '../../design-system/composites/DetailPanel/DetailPanel'
|
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 { ShortcutsBar } from '../../design-system/composites/ShortcutsBar/ShortcutsBar'
|
||||||
import { ProcessorTimeline } from '../../design-system/composites/ProcessorTimeline/ProcessorTimeline'
|
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 { MonoText } from '../../design-system/primitives/MonoText/MonoText'
|
||||||
import { Badge } from '../../design-system/primitives/Badge/Badge'
|
import { Badge } from '../../design-system/primitives/Badge/Badge'
|
||||||
|
|
||||||
|
// Global filters
|
||||||
|
import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProvider'
|
||||||
|
|
||||||
// Mock data
|
// Mock data
|
||||||
import { exchanges, type Exchange } from '../../mocks/exchanges'
|
import { exchanges, type Exchange } from '../../mocks/exchanges'
|
||||||
import { routes } from '../../mocks/routes'
|
|
||||||
import { agents } from '../../mocks/agents'
|
|
||||||
import { kpiMetrics } from '../../mocks/metrics'
|
import { kpiMetrics } from '../../mocks/metrics'
|
||||||
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
||||||
|
|
||||||
@@ -137,58 +134,6 @@ function durationClass(ms: number, status: Exchange['status']): string {
|
|||||||
return styles.durBreach
|
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 = [
|
const SHORTCUTS = [
|
||||||
{ keys: 'Ctrl+K', label: 'Search' },
|
{ keys: 'Ctrl+K', label: 'Search' },
|
||||||
{ keys: '↑↓', label: 'Navigate rows' },
|
{ keys: '↑↓', label: 'Navigate rows' },
|
||||||
@@ -199,12 +144,11 @@ const SHORTCUTS = [
|
|||||||
// ─── Dashboard component ──────────────────────────────────────────────────────
|
// ─── Dashboard component ──────────────────────────────────────────────────────
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
const { id: appId } = useParams<{ id: string }>()
|
const { id: appId } = useParams<{ id: string }>()
|
||||||
const [activeFilters, setActiveFilters] = useState<ActiveFilter[]>([])
|
|
||||||
const [search, setSearch] = useState('')
|
|
||||||
const [selectedId, setSelectedId] = useState<string | undefined>()
|
const [selectedId, setSelectedId] = useState<string | undefined>()
|
||||||
const [panelOpen, setPanelOpen] = useState(false)
|
const [panelOpen, setPanelOpen] = useState(false)
|
||||||
const [selectedExchange, setSelectedExchange] = useState<Exchange | null>(null)
|
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)
|
// Build set of route IDs belonging to the selected app (if any)
|
||||||
const appRouteIds = useMemo(() => {
|
const appRouteIds = useMemo(() => {
|
||||||
@@ -214,55 +158,26 @@ export function Dashboard() {
|
|||||||
return new Set(app.routes.map((r) => r.id))
|
return new Set(app.routes.map((r) => r.id))
|
||||||
}, [appId])
|
}, [appId])
|
||||||
|
|
||||||
const selectedApp = appId ? SIDEBAR_APPS.find((a) => a.id === appId) : null
|
|
||||||
|
|
||||||
// Scope all data to the selected app
|
// Scope all data to the selected app
|
||||||
const scopedExchanges = useMemo(() => {
|
const scopedExchanges = useMemo(() => {
|
||||||
if (!appRouteIds) return exchanges
|
if (!appRouteIds) return exchanges
|
||||||
return exchanges.filter((e) => appRouteIds.has(e.route))
|
return exchanges.filter((e) => appRouteIds.has(e.route))
|
||||||
}, [appRouteIds])
|
}, [appRouteIds])
|
||||||
|
|
||||||
const scopedRoutes = useMemo(() => {
|
// Filter exchanges (scoped + global filters)
|
||||||
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)
|
|
||||||
const filteredExchanges = useMemo(() => {
|
const filteredExchanges = useMemo(() => {
|
||||||
let data = scopedExchanges
|
let data = scopedExchanges
|
||||||
|
|
||||||
const statusFilter = activeFilters.find((f) =>
|
// Time range filter
|
||||||
['completed', 'failed', 'running', 'warning', 'all'].includes(f.value),
|
data = data.filter((e) => isInTimeRange(e.timestamp))
|
||||||
)
|
|
||||||
if (statusFilter && statusFilter.value !== 'all') {
|
|
||||||
data = data.filter((e) => e.status === statusFilter.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (search.trim()) {
|
// Status filter
|
||||||
const q = search.toLowerCase()
|
if (statusFilters.size > 0) {
|
||||||
data = data.filter(
|
data = data.filter((e) => statusFilters.has(e.status))
|
||||||
(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),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return data
|
return data
|
||||||
}, [activeFilters, search, scopedExchanges])
|
}, [scopedExchanges, isInTimeRange, statusFilters])
|
||||||
|
|
||||||
const searchData = useMemo(
|
|
||||||
() => buildSearchData(scopedExchanges, scopedRoutes, scopedAgents),
|
|
||||||
[scopedExchanges, scopedRoutes, scopedAgents],
|
|
||||||
)
|
|
||||||
|
|
||||||
function handleRowClick(row: Exchange) {
|
function handleRowClick(row: Exchange) {
|
||||||
setSelectedId(row.id)
|
setSelectedId(row.id)
|
||||||
@@ -392,7 +307,6 @@ export function Dashboard() {
|
|||||||
}
|
}
|
||||||
environment="PRODUCTION"
|
environment="PRODUCTION"
|
||||||
user={{ name: 'hendrik' }}
|
user={{ name: 'hendrik' }}
|
||||||
onSearchClick={() => setPaletteOpen(true)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Scrollable content */}
|
{/* Scrollable content */}
|
||||||
@@ -414,17 +328,6 @@ export function Dashboard() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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 */}
|
{/* Exchanges table */}
|
||||||
<div className={styles.tableSection}>
|
<div className={styles.tableSection}>
|
||||||
<div className={styles.tableHeader}>
|
<div className={styles.tableHeader}>
|
||||||
@@ -459,15 +362,6 @@ export function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Command palette */}
|
|
||||||
<CommandPalette
|
|
||||||
open={paletteOpen}
|
|
||||||
onClose={() => setPaletteOpen(false)}
|
|
||||||
onSelect={() => setPaletteOpen(false)}
|
|
||||||
data={searchData}
|
|
||||||
onOpen={() => setPaletteOpen(true)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Shortcuts bar */}
|
{/* Shortcuts bar */}
|
||||||
<ShortcutsBar shortcuts={SHORTCUTS} />
|
<ShortcutsBar shortcuts={SHORTCUTS} />
|
||||||
</AppShell>
|
</AppShell>
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export function ExchangeDetail() {
|
|||||||
{ label: id ?? 'Unknown' },
|
{ label: id ?? 'Unknown' },
|
||||||
]}
|
]}
|
||||||
environment="PRODUCTION"
|
environment="PRODUCTION"
|
||||||
shift="Day (06:00-18:00)"
|
|
||||||
user={{ name: 'hendrik' }}
|
user={{ name: 'hendrik' }}
|
||||||
/>
|
/>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
@@ -149,7 +149,6 @@ export function ExchangeDetail() {
|
|||||||
{ label: exchange.id },
|
{ label: exchange.id },
|
||||||
]}
|
]}
|
||||||
environment="PRODUCTION"
|
environment="PRODUCTION"
|
||||||
shift="Day (06:00-18:00)"
|
|
||||||
user={{ name: 'hendrik' }}
|
user={{ name: 'hendrik' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -120,9 +120,8 @@ export function LayoutSection() {
|
|||||||
{ label: 'order-ingest' },
|
{ label: 'order-ingest' },
|
||||||
]}
|
]}
|
||||||
environment="production"
|
environment="production"
|
||||||
shift="Morning"
|
|
||||||
user={{ name: 'Hendrik' }}
|
user={{ name: 'Hendrik' }}
|
||||||
onSearchClick={() => undefined}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
|
|||||||
@@ -7,24 +7,12 @@
|
|||||||
background: var(--bg-body);
|
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 {
|
.refreshIndicator {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
flex-shrink: 0;
|
margin-bottom: 12px;
|
||||||
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.refreshDot {
|
.refreshDot {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import styles from './Metrics.module.css'
|
import styles from './Metrics.module.css'
|
||||||
|
|
||||||
@@ -16,7 +15,6 @@ import type { Column } from '../../design-system/composites/DataTable/types'
|
|||||||
|
|
||||||
// Primitives
|
// Primitives
|
||||||
import { StatCard } from '../../design-system/primitives/StatCard/StatCard'
|
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 { Sparkline } from '../../design-system/primitives/Sparkline/Sparkline'
|
||||||
import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
|
import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
|
||||||
import { Badge } from '../../design-system/primitives/Badge/Badge'
|
import { Badge } from '../../design-system/primitives/Badge/Badge'
|
||||||
@@ -192,10 +190,6 @@ function convertSeries(series: typeof throughputSeries) {
|
|||||||
// ─── Metrics page ─────────────────────────────────────────────────────────────
|
// ─── Metrics page ─────────────────────────────────────────────────────────────
|
||||||
export function Metrics() {
|
export function Metrics() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [dateRange, setDateRange] = useState({
|
|
||||||
start: new Date('2026-03-18T06:00:00'),
|
|
||||||
end: new Date('2026-03-18T09:15:00'),
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
@@ -210,20 +204,16 @@ export function Metrics() {
|
|||||||
{ label: 'Metrics' },
|
{ label: 'Metrics' },
|
||||||
]}
|
]}
|
||||||
environment="PRODUCTION"
|
environment="PRODUCTION"
|
||||||
shift="Day (06:00-18:00)"
|
|
||||||
user={{ name: 'hendrik' }}
|
user={{ name: 'hendrik' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Scrollable content */}
|
{/* Scrollable content */}
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
|
|
||||||
{/* Date range picker bar */}
|
{/* Auto-refresh indicator */}
|
||||||
<div className={styles.dateRangeBar}>
|
<div className={styles.refreshIndicator}>
|
||||||
<DateRangePicker value={dateRange} onChange={setDateRange} />
|
<span className={styles.refreshDot} />
|
||||||
<div className={styles.refreshIndicator}>
|
<span className={styles.refreshText}>Auto-refresh: 30s</span>
|
||||||
<span className={styles.refreshDot} />
|
|
||||||
<span className={styles.refreshText}>Auto-refresh: 30s</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* KPI stat cards (5) */}
|
{/* KPI stat cards (5) */}
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ export function RouteDetail() {
|
|||||||
{ label: id ?? 'Unknown' },
|
{ label: id ?? 'Unknown' },
|
||||||
]}
|
]}
|
||||||
environment="PRODUCTION"
|
environment="PRODUCTION"
|
||||||
shift="Day (06:00-18:00)"
|
|
||||||
user={{ name: 'hendrik' }}
|
user={{ name: 'hendrik' }}
|
||||||
/>
|
/>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
@@ -236,7 +236,7 @@ export function RouteDetail() {
|
|||||||
{ label: route.name },
|
{ label: route.name },
|
||||||
]}
|
]}
|
||||||
environment="PRODUCTION"
|
environment="PRODUCTION"
|
||||||
shift="Day (06:00-18:00)"
|
|
||||||
user={{ name: 'hendrik' }}
|
user={{ name: 'hendrik' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user