Backend: - SQLiteStateBackend: persistent task/trace storage with SQLite - InMemoryStateBackend: in-memory impl of StateBackend interface - Redis cache backend (CacheBackend ABC + MemoryCacheBackend + RedisCacheBackend) - OpenAI adapter: async acomplete() with retry logic - Per-tenant + per-IP rate limiting in middleware Frontend: - State management: useStore + useAppState (zero-dep, context + reducer) - React Router integration: URL-based navigation (usePageNavigation) - WebSocket streaming: sendPrompt + StreamCallbacks for token-by-token updates - File preview: inline image/text/binary preview with expand/collapse - Sparkline charts + MetricCard + BarChart for dashboard visualization - Push notifications hook (useNotifications) with browser Notification API - i18n system: 6 locales (en, es, fr, de, ja, zh) with interpolation - 6 new Storybook stories (ChatMessage, Skeleton, Markdown, SearchFilter, Toast, FilePreview) Testing: - Playwright E2E config + 6 browser specs (desktop + mobile) - 18 new Python tests (SQLiteStateBackend, InMemoryStateBackend, cache backends) 570 Python tests + 45 frontend tests = 615 total, 0 ruff errors. Co-Authored-By: Nakamoto, S <defi@defi-oracle.io>
142 lines
4.0 KiB
TypeScript
142 lines
4.0 KiB
TypeScript
/**
|
|
* Lightweight SVG sparkline chart component.
|
|
*
|
|
* Zero-dependency mini chart for rendering inline metrics
|
|
* in the Admin and Ethics dashboards.
|
|
*/
|
|
|
|
interface SparklineProps {
|
|
data: number[]
|
|
width?: number
|
|
height?: number
|
|
color?: string
|
|
fillColor?: string
|
|
strokeWidth?: number
|
|
showDots?: boolean
|
|
label?: string
|
|
}
|
|
|
|
export function Sparkline({
|
|
data,
|
|
width = 120,
|
|
height = 32,
|
|
color = 'var(--accent)',
|
|
fillColor,
|
|
strokeWidth = 1.5,
|
|
showDots = false,
|
|
label,
|
|
}: SparklineProps) {
|
|
if (data.length < 2) {
|
|
return <svg width={width} height={height} aria-label={label || 'No data'} />
|
|
}
|
|
|
|
const min = Math.min(...data)
|
|
const max = Math.max(...data)
|
|
const range = max - min || 1
|
|
const padding = 2
|
|
|
|
const points = data.map((val, i) => {
|
|
const x = padding + (i / (data.length - 1)) * (width - 2 * padding)
|
|
const y = height - padding - ((val - min) / range) * (height - 2 * padding)
|
|
return { x, y }
|
|
})
|
|
|
|
const pathD = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ')
|
|
const fillD = fillColor
|
|
? `${pathD} L ${points[points.length - 1].x} ${height} L ${points[0].x} ${height} Z`
|
|
: undefined
|
|
|
|
return (
|
|
<svg
|
|
width={width}
|
|
height={height}
|
|
viewBox={`0 0 ${width} ${height}`}
|
|
role="img"
|
|
aria-label={label || `Sparkline chart with ${data.length} data points`}
|
|
>
|
|
{fillD && (
|
|
<path d={fillD} fill={fillColor} opacity={0.15} />
|
|
)}
|
|
<path d={pathD} fill="none" stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" />
|
|
{showDots && points.map((p, i) => (
|
|
<circle key={i} cx={p.x} cy={p.y} r={2} fill={color} />
|
|
))}
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
interface MetricCardProps {
|
|
title: string
|
|
value: string | number
|
|
unit?: string
|
|
data?: number[]
|
|
trend?: 'up' | 'down' | 'flat'
|
|
color?: string
|
|
}
|
|
|
|
export function MetricCard({ title, value, unit, data, trend, color = 'var(--accent)' }: MetricCardProps) {
|
|
const trendSymbol = trend === 'up' ? '\u2191' : trend === 'down' ? '\u2193' : '\u2192'
|
|
const trendColor = trend === 'up' ? 'var(--color-success, #4caf50)' : trend === 'down' ? 'var(--color-error, #f44336)' : 'var(--text-muted)'
|
|
|
|
return (
|
|
<div className="metric-card" role="group" aria-label={title}>
|
|
<div className="metric-header">
|
|
<span className="metric-title">{title}</span>
|
|
{trend && <span className="metric-trend" style={{ color: trendColor }}>{trendSymbol}</span>}
|
|
</div>
|
|
<div className="metric-value">
|
|
<span className="metric-number">{value}</span>
|
|
{unit && <span className="metric-unit">{unit}</span>}
|
|
</div>
|
|
{data && data.length > 1 && (
|
|
<Sparkline data={data} color={color} fillColor={color} width={120} height={28} label={`${title} trend`} />
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
interface BarChartProps {
|
|
data: { label: string; value: number; color?: string }[]
|
|
width?: number
|
|
height?: number
|
|
barColor?: string
|
|
}
|
|
|
|
export function BarChart({ data, width = 200, height = 60, barColor = 'var(--accent)' }: BarChartProps) {
|
|
if (data.length === 0) return null
|
|
const max = Math.max(...data.map((d) => d.value)) || 1
|
|
const barWidth = Math.max(8, (width - data.length * 4) / data.length)
|
|
|
|
return (
|
|
<svg width={width} height={height + 16} viewBox={`0 0 ${width} ${height + 16}`} role="img" aria-label="Bar chart">
|
|
{data.map((d, i) => {
|
|
const barHeight = (d.value / max) * (height - 4)
|
|
const x = i * (barWidth + 4) + 2
|
|
const y = height - barHeight
|
|
return (
|
|
<g key={i}>
|
|
<rect
|
|
x={x}
|
|
y={y}
|
|
width={barWidth}
|
|
height={barHeight}
|
|
fill={d.color || barColor}
|
|
rx={2}
|
|
opacity={0.85}
|
|
/>
|
|
<text
|
|
x={x + barWidth / 2}
|
|
y={height + 12}
|
|
textAnchor="middle"
|
|
fontSize={8}
|
|
fill="var(--text-muted)"
|
|
>
|
|
{d.label}
|
|
</text>
|
|
</g>
|
|
)
|
|
})}
|
|
</svg>
|
|
)
|
|
}
|