Integration & Wiring: - useStore/useAppState wired into App.tsx (replaces 8 useState calls) - React Router wired at app root (URL-based navigation) - SparklineChart/MetricCard/BarChart integrated into Admin + Ethics pages - useNotifications.handleWSEvent wired into WebSocket handler - Notification center dropdown in header with unread badge - Locale selector added to Settings page (6 languages) - Dashboard data fetching with 10s polling into MetricCards - File drag-and-drop support on chat area Production Hardening: - PostgresStateBackend with connection pooling (psycopg2) - App lifespan wires backend from FUSIONAGI_DB_BACKEND env (memory|sqlite|postgres) - Redis cache wired from FUSIONAGI_REDIS_URL env at startup - Multi-process uvicorn config for horizontal scaling Testing: - Playwright visual regression tests (12 stories x 2 viewports) - k6 load test script with ramp/spike/ramp-down stages - 7 new Python tests (postgres fallback, app wiring) 575 Python tests + 45 frontend tests = 620 total, 0 ruff errors. Co-Authored-By: Nakamoto, S <defi@defi-oracle.io>
235 lines
9.7 KiB
TypeScript
235 lines
9.7 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react'
|
|
import { MetricCard, Sparkline, BarChart } from '../components/SparklineChart'
|
|
import { t } from '../i18n'
|
|
import type { SystemStatus, VoiceProfile } from '../types'
|
|
|
|
function StatusCard({ label, value, unit, statusClass }: {
|
|
label: string; value: string | number | null; unit?: string; statusClass?: string
|
|
}) {
|
|
return (
|
|
<div className="status-card" role="status" aria-label={`${label}: ${value ?? 'N/A'}${unit && value != null ? unit : ''}`}>
|
|
<span className="status-label">{label}</span>
|
|
<span className={`status-value ${statusClass || ''}`}>
|
|
{statusClass && <span className={`status-dot ${statusClass}`} aria-hidden="true" />}
|
|
{value ?? 'N/A'}{unit && value != null ? unit : ''}
|
|
</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
interface StatusHistory {
|
|
cpu: number[]
|
|
memory: number[]
|
|
tasks: number[]
|
|
sessions: number[]
|
|
}
|
|
|
|
export function AdminPage({ authHeaders }: { authHeaders: () => Record<string, string> }) {
|
|
const [status, setStatus] = useState<SystemStatus | null>(null)
|
|
const [voices, setVoices] = useState<VoiceProfile[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [newVoiceName, setNewVoiceName] = useState('')
|
|
const [newVoiceLang, setNewVoiceLang] = useState('en-US')
|
|
const [tab, setTab] = useState<'overview' | 'voices' | 'agents' | 'governance'>('overview')
|
|
const [history, setHistory] = useState<StatusHistory>({ cpu: [], memory: [], tasks: [], sessions: [] })
|
|
|
|
const fetchStatus = useCallback(async () => {
|
|
try {
|
|
const r = await fetch('/v1/admin/status', { headers: authHeaders() })
|
|
if (r.ok) {
|
|
const data = await r.json()
|
|
setStatus(data)
|
|
setHistory((h) => ({
|
|
cpu: [...h.cpu, data.cpu_usage_percent ?? 0].slice(-20),
|
|
memory: [...h.memory, data.memory_usage_mb ?? 0].slice(-20),
|
|
tasks: [...h.tasks, data.active_tasks ?? 0].slice(-20),
|
|
sessions: [...h.sessions, data.active_sessions ?? 0].slice(-20),
|
|
}))
|
|
}
|
|
} catch { /* offline */ }
|
|
}, [authHeaders])
|
|
|
|
const fetchVoices = useCallback(async () => {
|
|
try {
|
|
const r = await fetch('/v1/admin/voices', { headers: authHeaders() })
|
|
if (r.ok) setVoices(await r.json())
|
|
} catch { /* offline */ }
|
|
}, [authHeaders])
|
|
|
|
useEffect(() => {
|
|
setLoading(true)
|
|
Promise.all([fetchStatus(), fetchVoices()]).finally(() => setLoading(false))
|
|
const interval = setInterval(fetchStatus, 10000)
|
|
return () => clearInterval(interval)
|
|
}, [fetchStatus, fetchVoices])
|
|
|
|
const addVoice = async () => {
|
|
if (!newVoiceName.trim()) return
|
|
try {
|
|
const r = await fetch('/v1/admin/voices', {
|
|
method: 'POST',
|
|
headers: authHeaders(),
|
|
body: JSON.stringify({ name: newVoiceName, language: newVoiceLang }),
|
|
})
|
|
if (r.ok) {
|
|
setNewVoiceName('')
|
|
fetchVoices()
|
|
} else {
|
|
setError('Failed to add voice')
|
|
}
|
|
} catch { setError('Network error') }
|
|
}
|
|
|
|
const formatUptime = (s: number) => {
|
|
const h = Math.floor(s / 3600)
|
|
const m = Math.floor((s % 3600) / 60)
|
|
return `${h}h ${m}m`
|
|
}
|
|
|
|
const statusClass = status?.status === 'healthy' ? 'healthy' : status?.status === 'degraded' ? 'degraded' : status?.status === 'offline' ? 'offline' : ''
|
|
|
|
const cpuTrend = history.cpu.length >= 2 ? (history.cpu[history.cpu.length - 1] > history.cpu[history.cpu.length - 2] ? 'up' : history.cpu[history.cpu.length - 1] < history.cpu[history.cpu.length - 2] ? 'down' : 'flat') as 'up' | 'down' | 'flat' : undefined
|
|
const memTrend = history.memory.length >= 2 ? (history.memory[history.memory.length - 1] > history.memory[history.memory.length - 2] ? 'up' : 'down') as 'up' | 'down' : undefined
|
|
|
|
if (loading) return <div className="page-loading" role="status" aria-live="polite">{t('common.loading')}</div>
|
|
|
|
return (
|
|
<div className="admin-page" role="main" aria-label={t('admin.title')}>
|
|
<div className="admin-tabs" role="tablist" aria-label="Admin sections">
|
|
{(['overview', 'voices', 'agents', 'governance'] as const).map((tb) => (
|
|
<button
|
|
key={tb}
|
|
className={tab === tb ? 'active' : ''}
|
|
onClick={() => setTab(tb)}
|
|
role="tab"
|
|
aria-selected={tab === tb}
|
|
aria-controls={`panel-${tb}`}
|
|
>
|
|
{tb.charAt(0).toUpperCase() + tb.slice(1)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{error && <div className="error-banner" role="alert" onClick={() => setError(null)}>{error}</div>}
|
|
|
|
{tab === 'overview' && (
|
|
<div className="admin-section" role="tabpanel" id="panel-overview" aria-label="System Overview">
|
|
<h2>{t('admin.status')}</h2>
|
|
<div className="metrics-grid">
|
|
<MetricCard
|
|
title="CPU Usage"
|
|
value={status?.cpu_usage_percent ?? 0}
|
|
unit="%"
|
|
data={history.cpu}
|
|
trend={cpuTrend}
|
|
color="var(--color-warning, #ff9800)"
|
|
/>
|
|
<MetricCard
|
|
title="Memory"
|
|
value={status?.memory_usage_mb ?? 0}
|
|
unit=" MB"
|
|
data={history.memory}
|
|
trend={memTrend}
|
|
color="var(--accent)"
|
|
/>
|
|
<MetricCard
|
|
title="Active Tasks"
|
|
value={status?.active_tasks ?? 0}
|
|
data={history.tasks}
|
|
color="var(--color-success, #4caf50)"
|
|
/>
|
|
<MetricCard
|
|
title="Sessions"
|
|
value={status?.active_sessions ?? 0}
|
|
data={history.sessions}
|
|
color="var(--color-info, #2196f3)"
|
|
/>
|
|
</div>
|
|
<div className="status-grid" role="group" aria-label="System metrics" style={{ marginTop: '1rem' }}>
|
|
<StatusCard label="Status" value={status?.status ?? 'unknown'} statusClass={statusClass} />
|
|
<StatusCard label="Uptime" value={status ? formatUptime(status.uptime_seconds) : 'N/A'} />
|
|
<StatusCard label="Active Agents" value={status?.active_agents ?? 0} />
|
|
</div>
|
|
{status && (
|
|
<div style={{ marginTop: '1rem' }}>
|
|
<h3>Agent Distribution</h3>
|
|
<BarChart
|
|
data={[
|
|
{ label: 'Tasks', value: status.active_tasks ?? 0, color: 'var(--color-success, #4caf50)' },
|
|
{ label: 'Agents', value: status.active_agents ?? 0, color: 'var(--accent)' },
|
|
{ label: 'Sessions', value: status.active_sessions ?? 0, color: 'var(--color-info, #2196f3)' },
|
|
]}
|
|
width={300}
|
|
height={80}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{tab === 'voices' && (
|
|
<div className="admin-section" role="tabpanel" id="panel-voices" aria-label="Voice Library">
|
|
<h2>{t('admin.voices')}</h2>
|
|
<div className="add-form" role="form" aria-label="Add voice">
|
|
<label htmlFor="voice-name" className="sr-only">Voice name</label>
|
|
<input id="voice-name" placeholder="Voice name" value={newVoiceName} onChange={(e) => setNewVoiceName(e.target.value)} />
|
|
<label htmlFor="voice-lang" className="sr-only">Language</label>
|
|
<select id="voice-lang" value={newVoiceLang} onChange={(e) => setNewVoiceLang(e.target.value)}>
|
|
<option value="en-US">English (US)</option>
|
|
<option value="en-GB">English (UK)</option>
|
|
<option value="es-ES">Spanish</option>
|
|
<option value="fr-FR">French</option>
|
|
<option value="de-DE">German</option>
|
|
<option value="ja-JP">Japanese</option>
|
|
</select>
|
|
<button onClick={addVoice}>Add Voice</button>
|
|
</div>
|
|
<div className="voice-list" role="list" aria-label="Voice profiles">
|
|
{voices.length === 0 && <p className="muted">No voice profiles configured</p>}
|
|
{voices.map((v) => (
|
|
<div key={v.id} className="voice-card" role="listitem">
|
|
<strong>{v.name}</strong>
|
|
<span className="muted">{v.language} | {v.provider}</span>
|
|
<span className="muted">Pitch: {v.pitch}x | Speed: {v.speed}x</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{tab === 'agents' && (
|
|
<div className="admin-section" role="tabpanel" id="panel-agents" aria-label="Agent Configuration">
|
|
<h2>{t('admin.agents')}</h2>
|
|
<div className="agent-grid" role="list" aria-label="Active agents">
|
|
{['Planner', 'Reasoner', 'Executor', 'Critic', '12 Heads', 'Witness'].map((a) => (
|
|
<div key={a} className="agent-card" role="listitem">
|
|
<strong>{a}</strong>
|
|
<span className="status-badge active" role="status">Active</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{tab === 'governance' && (
|
|
<div className="admin-section" role="tabpanel" id="panel-governance" aria-label="Governance Mode">
|
|
<h2>{t('admin.governance')}</h2>
|
|
<div className="governance-info">
|
|
<div className="governance-mode" role="status" aria-label="Current governance mode: Advisory">
|
|
<span className="mode-label">Current Mode:</span>
|
|
<span className="mode-value advisory">ADVISORY</span>
|
|
</div>
|
|
<p className="muted">
|
|
All governance checks are advisory — violations are logged but actions proceed.
|
|
The system learns from outcomes through the Consequence Engine and Adaptive Ethics.
|
|
</p>
|
|
</div>
|
|
<h3>Audit Trail</h3>
|
|
<p className="muted">Full audit trail available via /v1/admin/telemetry endpoint</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|