feat: ExchangeDetail and AgentHealth pages
ExchangeDetail (/exchanges/:id): exchange header card with ID/route/ status/duration, ProcessorTimeline for the specific exchange, step-by- step exchange inspector using Collapsible+CodeBlock for headers/body at each processor step, and error details block for failed exchanges. AgentHealth (/agents): 6-card system overview strip, 2-column grid of agent cards (StatusDot, name, version, tps, uptime, last-seen, CPU/mem usage, active routes), expandable per-agent LineCharts for throughput and error rate trends. Both pages use AppShell + shared Sidebar layout. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
192
src/pages/AgentHealth/AgentHealth.module.css
Normal file
192
src/pages/AgentHealth/AgentHealth.module.css
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
/* Scrollable content area */
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px 24px 40px;
|
||||||
|
min-width: 0;
|
||||||
|
background: var(--bg-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* System overview strip */
|
||||||
|
.overviewStrip {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overviewCard {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
padding: 12px 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overviewLabel {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.6px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overviewValue {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.valueLive { color: var(--success); }
|
||||||
|
.valueStale { color: var(--warning); }
|
||||||
|
.valueDead { color: var(--error); }
|
||||||
|
|
||||||
|
/* Section header */
|
||||||
|
.sectionHeaderRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionMeta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Agent cards grid */
|
||||||
|
.agentGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Agent card */
|
||||||
|
.agentCard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Agent card header */
|
||||||
|
.agentCardHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 16px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agentCardHeader:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agentCardLeft {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agentCardName {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agentCardService {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agentCardRight {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expandIcon {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Agent metrics row */
|
||||||
|
.agentMetrics {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0;
|
||||||
|
padding: 6px 12px 12px;
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agentMetric {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 6px 12px;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricLabel {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.6px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricValue {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricValueWarn {
|
||||||
|
color: var(--warning);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricValueError {
|
||||||
|
color: var(--error);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expanded charts area */
|
||||||
|
.agentCharts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agentChart {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartTitle {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
262
src/pages/AgentHealth/AgentHealth.tsx
Normal file
262
src/pages/AgentHealth/AgentHealth.tsx
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import styles from './AgentHealth.module.css'
|
||||||
|
|
||||||
|
// Layout
|
||||||
|
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
|
||||||
|
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
|
||||||
|
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||||
|
|
||||||
|
// Composites
|
||||||
|
import { LineChart } from '../../design-system/composites/LineChart/LineChart'
|
||||||
|
|
||||||
|
// Primitives
|
||||||
|
import { StatusDot } from '../../design-system/primitives/StatusDot/StatusDot'
|
||||||
|
import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
|
||||||
|
import { Badge } from '../../design-system/primitives/Badge/Badge'
|
||||||
|
import { Card } from '../../design-system/primitives/Card/Card'
|
||||||
|
|
||||||
|
// Mock data
|
||||||
|
import { agents } from '../../mocks/agents'
|
||||||
|
import { routes } from '../../mocks/routes'
|
||||||
|
|
||||||
|
// ─── Sidebar data (shared) ────────────────────────────────────────────────────
|
||||||
|
const APPS = [
|
||||||
|
{ id: 'order-service', name: 'order-service', agentCount: 2, health: 'live' as const, execCount: 1433 },
|
||||||
|
{ id: 'payment-svc', name: 'payment-svc', agentCount: 1, health: 'live' as const, execCount: 912 },
|
||||||
|
{ id: 'shipment-tracker', name: 'shipment-tracker', agentCount: 2, health: 'live' as const, execCount: 471 },
|
||||||
|
{ id: 'notification-hub', name: 'notification-hub', agentCount: 1, health: 'stale' as const, execCount: 128 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const SIDEBAR_ROUTES = routes.slice(0, 3).map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
execCount: r.execCount,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ─── Build trend data for each agent ─────────────────────────────────────────
|
||||||
|
function buildAgentTrendSeries(agentId: string) {
|
||||||
|
const baseValues: Record<string, { throughput: number; errorRate: number }> = {
|
||||||
|
'prod-1': { throughput: 14.2, errorRate: 0.2 },
|
||||||
|
'prod-2': { throughput: 11.8, errorRate: 3.1 },
|
||||||
|
'prod-3': { throughput: 12.1, errorRate: 0.5 },
|
||||||
|
'prod-4': { throughput: 9.1, errorRate: 0.3 },
|
||||||
|
}
|
||||||
|
const base = baseValues[agentId] ?? { throughput: 10, errorRate: 1 }
|
||||||
|
|
||||||
|
const now = new Date('2026-03-18T09:15:00')
|
||||||
|
const points = 20
|
||||||
|
const intervalMs = (3 * 60 * 60 * 1000) / points // 3 hours
|
||||||
|
|
||||||
|
const throughputData = Array.from({ length: points }, (_, i) => ({
|
||||||
|
x: new Date(now.getTime() - (points - i) * intervalMs),
|
||||||
|
y: Math.max(0, base.throughput + (Math.random() - 0.5) * 4),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const errorRateData = Array.from({ length: points }, (_, i) => ({
|
||||||
|
x: new Date(now.getTime() - (points - i) * intervalMs),
|
||||||
|
y: Math.max(0, base.errorRate + (Math.random() - 0.5) * 2),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return { throughputData, errorRateData }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Summary stats ────────────────────────────────────────────────────────────
|
||||||
|
const liveCount = agents.filter((a) => a.status === 'live').length
|
||||||
|
const totalTps = agents.reduce((sum, a) => sum + parseFloat(a.tps), 0)
|
||||||
|
const totalActiveRoutes = agents.reduce((sum, a) => sum + a.activeRoutes, 0)
|
||||||
|
|
||||||
|
// ─── AgentHealth page ─────────────────────────────────────────────────────────
|
||||||
|
export function AgentHealth() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [activeItem, setActiveItem] = useState('agents')
|
||||||
|
const [expandedAgent, setExpandedAgent] = useState<string | null>(null)
|
||||||
|
|
||||||
|
function handleItemClick(id: string) {
|
||||||
|
setActiveItem(id)
|
||||||
|
const route = routes.find((r) => r.id === id)
|
||||||
|
if (route) navigate(`/routes/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAgent(id: string) {
|
||||||
|
setExpandedAgent((prev) => (prev === id ? null : id))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
sidebar={
|
||||||
|
<Sidebar
|
||||||
|
apps={APPS}
|
||||||
|
routes={SIDEBAR_ROUTES}
|
||||||
|
agents={agents}
|
||||||
|
activeItem={activeItem}
|
||||||
|
onItemClick={handleItemClick}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Top bar */}
|
||||||
|
<TopBar
|
||||||
|
breadcrumb={[
|
||||||
|
{ label: 'Dashboard', href: '/' },
|
||||||
|
{ label: 'Agents' },
|
||||||
|
]}
|
||||||
|
environment="PRODUCTION"
|
||||||
|
shift="Day (06:00-18:00)"
|
||||||
|
user={{ name: 'hendrik' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Scrollable content */}
|
||||||
|
<div className={styles.content}>
|
||||||
|
|
||||||
|
{/* System overview strip */}
|
||||||
|
<div className={styles.overviewStrip}>
|
||||||
|
<div className={styles.overviewCard}>
|
||||||
|
<div className={styles.overviewLabel}>Total Agents</div>
|
||||||
|
<div className={styles.overviewValue}>{agents.length}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.overviewCard}>
|
||||||
|
<div className={styles.overviewLabel}>Live</div>
|
||||||
|
<div className={`${styles.overviewValue} ${styles.valueLive}`}>{liveCount}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.overviewCard}>
|
||||||
|
<div className={styles.overviewLabel}>Stale</div>
|
||||||
|
<div className={`${styles.overviewValue} ${agents.some(a => a.status === 'stale') ? styles.valueStale : ''}`}>
|
||||||
|
{agents.filter((a) => a.status === 'stale').length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.overviewCard}>
|
||||||
|
<div className={styles.overviewLabel}>Dead</div>
|
||||||
|
<div className={`${styles.overviewValue} ${agents.some(a => a.status === 'dead') ? styles.valueDead : ''}`}>
|
||||||
|
{agents.filter((a) => a.status === 'dead').length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.overviewCard}>
|
||||||
|
<div className={styles.overviewLabel}>Total TPS</div>
|
||||||
|
<div className={styles.overviewValue}>{totalTps.toFixed(1)}/s</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.overviewCard}>
|
||||||
|
<div className={styles.overviewLabel}>Active Routes</div>
|
||||||
|
<div className={styles.overviewValue}>{totalActiveRoutes}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section header */}
|
||||||
|
<div className={styles.sectionHeaderRow}>
|
||||||
|
<span className={styles.sectionTitle}>Agent Details</span>
|
||||||
|
<span className={styles.sectionMeta}>{liveCount}/{agents.length} live · Click to expand charts</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Agent cards grid */}
|
||||||
|
<div className={styles.agentGrid}>
|
||||||
|
{agents.map((agent) => {
|
||||||
|
const isExpanded = expandedAgent === agent.id
|
||||||
|
const trendData = isExpanded ? buildAgentTrendSeries(agent.id) : null
|
||||||
|
const statusVariant = agent.status === 'live' ? 'live' : agent.status === 'stale' ? 'stale' : 'dead'
|
||||||
|
const cardAccent = agent.status === 'live' ? 'success' : agent.status === 'stale' ? 'warning' : 'error'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={agent.id}
|
||||||
|
accent={cardAccent as 'success' | 'warning' | 'error'}
|
||||||
|
className={styles.agentCard}
|
||||||
|
>
|
||||||
|
{/* Agent card header */}
|
||||||
|
<div
|
||||||
|
className={styles.agentCardHeader}
|
||||||
|
onClick={() => toggleAgent(agent.id)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') toggleAgent(agent.id)
|
||||||
|
}}
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
>
|
||||||
|
<div className={styles.agentCardLeft}>
|
||||||
|
<StatusDot variant={statusVariant} />
|
||||||
|
<div>
|
||||||
|
<div className={styles.agentCardName}>{agent.name}</div>
|
||||||
|
<div className={styles.agentCardService}>{agent.service} {agent.version}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.agentCardRight}>
|
||||||
|
<Badge
|
||||||
|
label={agent.status.toUpperCase()}
|
||||||
|
color={agent.status === 'live' ? 'success' : agent.status === 'stale' ? 'warning' : 'error'}
|
||||||
|
variant="filled"
|
||||||
|
/>
|
||||||
|
<span className={styles.expandIcon}>{isExpanded ? '▲' : '▼'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Agent metrics row */}
|
||||||
|
<div className={styles.agentMetrics}>
|
||||||
|
<div className={styles.agentMetric}>
|
||||||
|
<span className={styles.metricLabel}>TPS</span>
|
||||||
|
<MonoText size="sm" className={styles.metricValue}>{agent.tps}</MonoText>
|
||||||
|
</div>
|
||||||
|
<div className={styles.agentMetric}>
|
||||||
|
<span className={styles.metricLabel}>Uptime</span>
|
||||||
|
<MonoText size="sm" className={styles.metricValue}>{agent.uptime}</MonoText>
|
||||||
|
</div>
|
||||||
|
<div className={styles.agentMetric}>
|
||||||
|
<span className={styles.metricLabel}>Last Seen</span>
|
||||||
|
<MonoText size="sm" className={styles.metricValue}>{agent.lastSeen}</MonoText>
|
||||||
|
</div>
|
||||||
|
{agent.errorRate && (
|
||||||
|
<div className={styles.agentMetric}>
|
||||||
|
<span className={styles.metricLabel}>Error Rate</span>
|
||||||
|
<MonoText size="sm" className={styles.metricValueError}>{agent.errorRate}</MonoText>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.agentMetric}>
|
||||||
|
<span className={styles.metricLabel}>CPU</span>
|
||||||
|
<MonoText size="sm" className={agent.cpuUsagePct > 70 ? styles.metricValueWarn : styles.metricValue}>
|
||||||
|
{agent.cpuUsagePct}%
|
||||||
|
</MonoText>
|
||||||
|
</div>
|
||||||
|
<div className={styles.agentMetric}>
|
||||||
|
<span className={styles.metricLabel}>Memory</span>
|
||||||
|
<MonoText size="sm" className={agent.memoryUsagePct > 80 ? styles.metricValueError : agent.memoryUsagePct > 70 ? styles.metricValueWarn : styles.metricValue}>
|
||||||
|
{agent.memoryUsagePct}%
|
||||||
|
</MonoText>
|
||||||
|
</div>
|
||||||
|
<div className={styles.agentMetric}>
|
||||||
|
<span className={styles.metricLabel}>Routes</span>
|
||||||
|
<MonoText size="sm" className={styles.metricValue}>
|
||||||
|
{agent.activeRoutes}/{agent.totalRoutes}
|
||||||
|
</MonoText>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded detail: trend charts */}
|
||||||
|
{isExpanded && trendData && (
|
||||||
|
<div className={styles.agentCharts}>
|
||||||
|
<div className={styles.agentChart}>
|
||||||
|
<div className={styles.chartTitle}>Throughput (msg/s)</div>
|
||||||
|
<LineChart
|
||||||
|
series={[{ label: 'tps', data: trendData.throughputData }]}
|
||||||
|
height={140}
|
||||||
|
width={380}
|
||||||
|
yLabel="msg/s"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.agentChart}>
|
||||||
|
<div className={styles.chartTitle}>Error Rate (err/h)</div>
|
||||||
|
<LineChart
|
||||||
|
series={[{ label: 'errors', data: trendData.errorRateData, color: 'var(--error)' }]}
|
||||||
|
height={140}
|
||||||
|
width={380}
|
||||||
|
yLabel="err/h"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
264
src/pages/ExchangeDetail/ExchangeDetail.module.css
Normal file
264
src/pages/ExchangeDetail/ExchangeDetail.module.css
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
/* Scrollable content area */
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px 24px 40px;
|
||||||
|
min-width: 0;
|
||||||
|
background: var(--bg-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Exchange header card */
|
||||||
|
.exchangeHeader {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
padding: 16px 20px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerLeft {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exchangeId {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exchangeRoute {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.routeLink {
|
||||||
|
color: var(--amber);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.routeLink:hover {
|
||||||
|
color: var(--amber-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerDivider {
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerRight {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerStat {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerStatLabel {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.6px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerStatValue {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section layout */
|
||||||
|
.section {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionMeta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Timeline wrapper */
|
||||||
|
.timelineWrap {
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inspector steps */
|
||||||
|
.inspectorSteps {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepCollapsible {
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepCollapsible:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepTitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepIndex {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepOk {
|
||||||
|
background: var(--success-bg);
|
||||||
|
color: var(--success);
|
||||||
|
border: 1px solid var(--success-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepSlow {
|
||||||
|
background: var(--warning-bg);
|
||||||
|
color: var(--warning);
|
||||||
|
border: 1px solid var(--warning-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepFail {
|
||||||
|
background: var(--error-bg);
|
||||||
|
color: var(--error);
|
||||||
|
border: 1px solid var(--error-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepName {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-primary);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepDuration {
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Step body (two-column layout) */
|
||||||
|
.stepBody {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 2fr;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepPanel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepPanelLabel {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.6px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeBlock {
|
||||||
|
flex: 1;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error section */
|
||||||
|
.errorSection {
|
||||||
|
background: var(--error-bg);
|
||||||
|
border: 1px solid var(--error-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorBody {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorClass {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--error);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorMessage {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--error-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 10px 12px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorHint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
331
src/pages/ExchangeDetail/ExchangeDetail.tsx
Normal file
331
src/pages/ExchangeDetail/ExchangeDetail.tsx
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import styles from './ExchangeDetail.module.css'
|
||||||
|
|
||||||
|
// Layout
|
||||||
|
import { AppShell } from '../../design-system/layout/AppShell/AppShell'
|
||||||
|
import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
|
||||||
|
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||||
|
|
||||||
|
// Composites
|
||||||
|
import { ProcessorTimeline } from '../../design-system/composites/ProcessorTimeline/ProcessorTimeline'
|
||||||
|
import type { ProcessorStep } from '../../design-system/composites/ProcessorTimeline/ProcessorTimeline'
|
||||||
|
|
||||||
|
// Primitives
|
||||||
|
import { Badge } from '../../design-system/primitives/Badge/Badge'
|
||||||
|
import { StatusDot } from '../../design-system/primitives/StatusDot/StatusDot'
|
||||||
|
import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
|
||||||
|
import { Collapsible } from '../../design-system/primitives/Collapsible/Collapsible'
|
||||||
|
import { CodeBlock } from '../../design-system/primitives/CodeBlock/CodeBlock'
|
||||||
|
import { InfoCallout } from '../../design-system/primitives/InfoCallout/InfoCallout'
|
||||||
|
|
||||||
|
// Mock data
|
||||||
|
import { executions } from '../../mocks/executions'
|
||||||
|
import { routes } from '../../mocks/routes'
|
||||||
|
import { agents } from '../../mocks/agents'
|
||||||
|
|
||||||
|
// ─── Sidebar data (shared) ────────────────────────────────────────────────────
|
||||||
|
const APPS = [
|
||||||
|
{ id: 'order-service', name: 'order-service', agentCount: 2, health: 'live' as const, execCount: 1433 },
|
||||||
|
{ id: 'payment-svc', name: 'payment-svc', agentCount: 1, health: 'live' as const, execCount: 912 },
|
||||||
|
{ id: 'shipment-tracker', name: 'shipment-tracker', agentCount: 2, health: 'live' as const, execCount: 471 },
|
||||||
|
{ id: 'notification-hub', name: 'notification-hub', agentCount: 1, health: 'stale' as const, execCount: 128 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const SIDEBAR_ROUTES = routes.slice(0, 3).map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
execCount: r.execCount,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
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 statusToVariant(status: 'completed' | 'failed' | 'running' | 'warning'): 'success' | 'error' | 'running' | 'warning' {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed': return 'success'
|
||||||
|
case 'failed': return 'error'
|
||||||
|
case 'running': return 'running'
|
||||||
|
case 'warning': return 'warning'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusToLabel(status: 'completed' | 'failed' | 'running' | 'warning'): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed': return 'COMPLETED'
|
||||||
|
case 'failed': return 'FAILED'
|
||||||
|
case 'running': return 'RUNNING'
|
||||||
|
case 'warning': return 'WARNING'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Exchange body mock generator ────────────────────────────────────────────
|
||||||
|
// For each processor step, generate a plausible exchange body snapshot
|
||||||
|
function generateExchangeSnapshot(
|
||||||
|
step: ProcessorStep,
|
||||||
|
orderId: string,
|
||||||
|
customer: string,
|
||||||
|
stepIndex: number,
|
||||||
|
) {
|
||||||
|
const baseBody = {
|
||||||
|
orderId,
|
||||||
|
customer,
|
||||||
|
status: step.status === 'fail' ? 'ERROR' : 'PROCESSING',
|
||||||
|
processorStep: step.name,
|
||||||
|
stepIndex,
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'CamelCorrelationId': `cmr-${Math.random().toString(36).slice(2, 10)}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'CamelTimerName': step.name,
|
||||||
|
'CamelBreadcrumbId': `${orderId}-${stepIndex}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stepIndex === 0) {
|
||||||
|
return {
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
...baseBody,
|
||||||
|
raw: { orderId, customer, items: ['ITEM-001', 'ITEM-002'], total: 142.50 },
|
||||||
|
}, null, 2),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step.type === 'enrich') {
|
||||||
|
return {
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'enrichedBy': step.name.replace('enrich(', '').replace(')', ''),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
...baseBody,
|
||||||
|
enrichment: { source: step.name, addedFields: ['customerId', 'address', 'tier'] },
|
||||||
|
}, null, 2),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(baseBody, null, 2),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── ExchangeDetail component ─────────────────────────────────────────────────
|
||||||
|
export function ExchangeDetail() {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [activeItem, setActiveItem] = useState('')
|
||||||
|
|
||||||
|
const execution = useMemo(() => executions.find((e) => e.id === id), [id])
|
||||||
|
|
||||||
|
function handleItemClick(itemId: string) {
|
||||||
|
setActiveItem(itemId)
|
||||||
|
const route = routes.find((r) => r.id === itemId)
|
||||||
|
if (route) navigate(`/routes/${itemId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not found state
|
||||||
|
if (!execution) {
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
sidebar={
|
||||||
|
<Sidebar
|
||||||
|
apps={APPS}
|
||||||
|
routes={SIDEBAR_ROUTES}
|
||||||
|
agents={agents}
|
||||||
|
activeItem={activeItem}
|
||||||
|
onItemClick={handleItemClick}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TopBar
|
||||||
|
breadcrumb={[
|
||||||
|
{ label: 'Dashboard', href: '/' },
|
||||||
|
{ label: 'Exchanges' },
|
||||||
|
{ label: id ?? 'Unknown' },
|
||||||
|
]}
|
||||||
|
environment="PRODUCTION"
|
||||||
|
shift="Day (06:00-18:00)"
|
||||||
|
user={{ name: 'hendrik' }}
|
||||||
|
/>
|
||||||
|
<div className={styles.content}>
|
||||||
|
<InfoCallout variant="warning">Exchange "{id}" not found in mock data.</InfoCallout>
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusVariant = statusToVariant(execution.status)
|
||||||
|
const statusLabel = statusToLabel(execution.status)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
sidebar={
|
||||||
|
<Sidebar
|
||||||
|
apps={APPS}
|
||||||
|
routes={SIDEBAR_ROUTES}
|
||||||
|
agents={agents}
|
||||||
|
activeItem={activeItem}
|
||||||
|
onItemClick={handleItemClick}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Top bar */}
|
||||||
|
<TopBar
|
||||||
|
breadcrumb={[
|
||||||
|
{ label: 'Dashboard', href: '/' },
|
||||||
|
{ label: execution.route, href: `/routes/${execution.route}` },
|
||||||
|
{ label: execution.id },
|
||||||
|
]}
|
||||||
|
environment="PRODUCTION"
|
||||||
|
shift="Day (06:00-18:00)"
|
||||||
|
user={{ name: 'hendrik' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Scrollable content */}
|
||||||
|
<div className={styles.content}>
|
||||||
|
|
||||||
|
{/* Exchange header */}
|
||||||
|
<div className={styles.exchangeHeader}>
|
||||||
|
<div className={styles.headerRow}>
|
||||||
|
<div className={styles.headerLeft}>
|
||||||
|
<StatusDot variant={statusVariant} />
|
||||||
|
<div>
|
||||||
|
<div className={styles.exchangeId}>
|
||||||
|
<MonoText size="md">{execution.id}</MonoText>
|
||||||
|
<Badge label={statusLabel} color={statusVariant} variant="filled" />
|
||||||
|
</div>
|
||||||
|
<div className={styles.exchangeRoute}>
|
||||||
|
Route: <span className={styles.routeLink} onClick={() => navigate(`/routes/${execution.route}`)}>{execution.route}</span>
|
||||||
|
<span className={styles.headerDivider}>·</span>
|
||||||
|
Order: <MonoText size="xs">{execution.orderId}</MonoText>
|
||||||
|
<span className={styles.headerDivider}>·</span>
|
||||||
|
Customer: <MonoText size="xs">{execution.customer}</MonoText>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.headerRight}>
|
||||||
|
<div className={styles.headerStat}>
|
||||||
|
<div className={styles.headerStatLabel}>Duration</div>
|
||||||
|
<div className={styles.headerStatValue}>{formatDuration(execution.durationMs)}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.headerStat}>
|
||||||
|
<div className={styles.headerStatLabel}>Agent</div>
|
||||||
|
<div className={styles.headerStatValue}>{execution.agent}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.headerStat}>
|
||||||
|
<div className={styles.headerStatLabel}>Started</div>
|
||||||
|
<div className={styles.headerStatValue}>
|
||||||
|
{execution.timestamp.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.headerStat}>
|
||||||
|
<div className={styles.headerStatLabel}>Processors</div>
|
||||||
|
<div className={styles.headerStatValue}>{execution.processors.length}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Processor timeline */}
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<span className={styles.sectionTitle}>Processor Timeline</span>
|
||||||
|
<span className={styles.sectionMeta}>Total: {formatDuration(execution.durationMs)}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.timelineWrap}>
|
||||||
|
<ProcessorTimeline
|
||||||
|
processors={execution.processors}
|
||||||
|
totalMs={execution.durationMs}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step-by-step inspector */}
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<span className={styles.sectionTitle}>Exchange Inspector</span>
|
||||||
|
<span className={styles.sectionMeta}>{execution.processors.length} processor steps</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.inspectorSteps}>
|
||||||
|
{execution.processors.map((proc, index) => {
|
||||||
|
const snapshot = generateExchangeSnapshot(proc, execution.orderId, execution.customer, index)
|
||||||
|
const stepStatusClass =
|
||||||
|
proc.status === 'fail'
|
||||||
|
? styles.stepFail
|
||||||
|
: proc.status === 'slow'
|
||||||
|
? styles.stepSlow
|
||||||
|
: styles.stepOk
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible
|
||||||
|
key={index}
|
||||||
|
title={
|
||||||
|
<div className={styles.stepTitle}>
|
||||||
|
<span className={`${styles.stepIndex} ${stepStatusClass}`}>{index + 1}</span>
|
||||||
|
<span className={styles.stepName}>{proc.name}</span>
|
||||||
|
<Badge
|
||||||
|
label={proc.status.toUpperCase()}
|
||||||
|
color={proc.status === 'fail' ? 'error' : proc.status === 'slow' ? 'warning' : 'success'}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
<span className={styles.stepDuration}>{formatDuration(proc.durationMs)}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
defaultOpen={proc.status === 'fail'}
|
||||||
|
className={styles.stepCollapsible}
|
||||||
|
>
|
||||||
|
<div className={styles.stepBody}>
|
||||||
|
<div className={styles.stepPanel}>
|
||||||
|
<div className={styles.stepPanelLabel}>Exchange Headers</div>
|
||||||
|
<CodeBlock
|
||||||
|
content={JSON.stringify(snapshot.headers, null, 2)}
|
||||||
|
language="json"
|
||||||
|
copyable
|
||||||
|
className={styles.codeBlock}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.stepPanel}>
|
||||||
|
<div className={styles.stepPanelLabel}>Exchange Body</div>
|
||||||
|
<CodeBlock
|
||||||
|
content={snapshot.body}
|
||||||
|
language="json"
|
||||||
|
copyable
|
||||||
|
className={styles.codeBlock}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Collapsible>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error block (if failed) */}
|
||||||
|
{execution.status === 'failed' && execution.errorMessage && (
|
||||||
|
<div className={styles.errorSection}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<span className={styles.sectionTitle}>Error Details</span>
|
||||||
|
<Badge label="FAILED" color="error" />
|
||||||
|
</div>
|
||||||
|
<div className={styles.errorBody}>
|
||||||
|
<div className={styles.errorClass}>{execution.errorClass}</div>
|
||||||
|
<pre className={styles.errorMessage}>{execution.errorMessage}</pre>
|
||||||
|
<div className={styles.errorHint}>
|
||||||
|
Failed at processor: <MonoText size="xs">
|
||||||
|
{execution.processors.find((p) => p.status === 'fail')?.name ?? 'unknown'}
|
||||||
|
</MonoText>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user