🛡️ Neural Monitor Dashboard v1.0

- Fullscreen 3D visualization with Three.js/React Three Fiber
- Real-time WebSocket connection to NATS event stream
- Agent nodes (Claudia, Mona, Vera, Stella, Viola) with activity tracking
- Event type nodes (Messages, Tools, Knowledge, Lifecycle)
- Glowing energy beams for agent communication
- Activity beams showing agent → event type flows
- Floating glassmorphism UI overlays
- Event log panel with filtering
- Responsive fullscreen layout

Tech: React, Vite, Tailwind CSS, Three.js, NATS JetStream
This commit is contained in:
Claudia 2026-02-02 23:28:43 +01:00
commit 6adf0757d3
20 changed files with 6270 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
node_modules/
dist/
.DS_Store
*.log

13
index.html Normal file
View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Claudia Monitor 🛡️</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🛡️</text></svg>">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

3825
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

26
package.json Normal file
View file

@ -0,0 +1,26 @@
{
"name": "claudia-monitor",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"three": "^0.160.0",
"@react-three/fiber": "^8.15.0",
"@react-three/drei": "^9.92.0",
"recharts": "^2.10.0",
"date-fns": "^3.0.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.0",
"vite": "^5.0.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0"
}
}

6
postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}

308
server/bridge.mjs Normal file
View file

@ -0,0 +1,308 @@
#!/usr/bin/env node
/**
* Claudia Monitor - WebSocket Bridge
* Streams NATS events to the frontend + provides stats API
*/
import { connect, StringCodec } from 'nats'
import { WebSocketServer } from 'ws'
import http from 'http'
const NATS_URL = process.env.NATS_URL || 'nats://localhost:4222'
const WS_PORT = parseInt(process.env.WS_PORT || '8765')
const HTTP_PORT = parseInt(process.env.HTTP_PORT || '8766')
const sc = StringCodec()
let nc = null
/**
* Parse NATS URL with optional credentials
* Supports: nats://user:pass@host:port or nats://host:port
*/
function parseNatsUrl(urlString) {
try {
const httpUrl = urlString.replace(/^nats:\/\//, 'http://')
const url = new URL(httpUrl)
const servers = `${url.hostname}:${url.port || 4222}`
if (url.username && url.password) {
return {
servers,
user: decodeURIComponent(url.username),
pass: decodeURIComponent(url.password),
}
}
return { servers }
} catch {
return { servers: urlString.replace(/^nats:\/\//, '') }
}
}
let stats = { total: 0, byType: {}, agents: {} }
const bridgeStartTime = Date.now()
// Agent configuration
const AGENTS = {
main: { name: 'Claudia', emoji: '🛡️', stream: 'openclaw-events' },
'mondo-assistant': { name: 'Mona', emoji: '🌙', stream: 'events-mondo-assistant' },
vera: { name: 'Vera', emoji: '🔒', stream: 'events-vera' },
stella: { name: 'Stella', emoji: '💰', stream: 'events-stella' },
viola: { name: 'Viola', emoji: '⚙️', stream: 'events-viola' }
}
// Connect to NATS
async function connectNats() {
try {
const { servers, user, pass } = parseNatsUrl(NATS_URL)
const connectOpts = { servers, ...(user && pass ? { user, pass } : {}) }
nc = await connect(connectOpts)
console.log(`📡 Connected to NATS at ${servers}${user ? ' (authenticated)' : ''}`)
// Get initial stats from stream
const js = nc.jetstream()
const stream = await js.streams.get('openclaw-events')
const info = await stream.info({ subjects_filter: '>' })
stats.total = info.state.messages
// Get subject breakdown from stream subjects
try {
const { execSync } = await import('child_process')
// Use monitor credentials for CLI
const natsCliUrl = NATS_URL.includes('@') ? `-s "${NATS_URL}"` : ''
const output = execSync(`/home/keller/bin/nats ${natsCliUrl} stream subjects openclaw-events --json 2>/dev/null`).toString()
const subjects = JSON.parse(output)
stats.subCategories = {}
if (subjects && typeof subjects === 'object') {
for (const [subject, count] of Object.entries(subjects)) {
// Main categories
if (subject.includes('message')) stats.byType.message = (stats.byType.message || 0) + count
else if (subject.includes('tool')) stats.byType.tool = (stats.byType.tool || 0) + count
else if (subject.includes('knowledge')) stats.byType.knowledge = (stats.byType.knowledge || 0) + count
else if (subject.includes('lifecycle')) stats.byType.lifecycle = (stats.byType.lifecycle || 0) + count
// Sub-categories (last part of subject)
const subCat = subject.split('.').pop()
stats.subCategories[subCat] = count
}
}
} catch (e) {
console.log('Could not get subject breakdown:', e.message)
}
console.log(`📊 Stream has ${stats.total} events`, stats.byType)
return true
} catch (e) {
console.error('❌ NATS connection failed:', e.message)
return false
}
}
// WebSocket server for real-time events
function startWebSocket() {
const wss = new WebSocketServer({ port: WS_PORT })
wss.on('connection', async (ws) => {
console.log('🔌 Client connected')
ws.on('message', async (msg) => {
try {
const data = JSON.parse(msg.toString())
if (data.action === 'subscribe' && data.pattern) {
// Subscribe to NATS pattern
const sub = nc.subscribe(data.pattern)
console.log(`📥 Subscribed to: ${data.pattern}`)
;(async () => {
for await (const m of sub) {
if (ws.readyState !== 1) break
const subject = m.subject
let payload = {}
try {
payload = JSON.parse(sc.decode(m.data))
} catch {
payload = { raw: sc.decode(m.data) }
}
// Update stats
stats.total++
const type = subject.split('.')[3] || 'unknown'
stats.byType[type] = (stats.byType[type] || 0) + 1
// Extract agent from payload.session: "agent:main:main" or "agent:viola:xxx"
const sessionKey = payload?.session || payload?.sessionKey || ''
const sessionParts = sessionKey.split(':')
// Format: agent:<agentId>:<sessionId> → we want index 1
const agentFromSession = sessionParts[1] || 'main'
// Fallback: try subject (openclaw.events.<agent>.<type>)
const subjectParts = subject.split('.')
const agentFromSubject = subjectParts[2]
// Use session first (more reliable), skip generic "agent"
const agent = (agentFromSession && agentFromSession !== 'agent')
? agentFromSession
: (agentFromSubject !== 'agent' ? agentFromSubject : 'main')
// Send to client with explicit agent
ws.send(JSON.stringify({
type: 'event',
subject,
agent,
data: payload,
timestamp: Date.now()
}))
}
})()
}
} catch (e) {
console.error('Message parse error:', e)
}
})
ws.on('close', () => {
console.log('❌ Client disconnected')
})
})
console.log(`🌐 WebSocket server on ws://0.0.0.0:${WS_PORT}`)
}
// HTTP server for stats API
function startHttp() {
const server = http.createServer(async (req, res) => {
// CORS
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Content-Type', 'application/json')
if (req.url === '/stats') {
// Fetch stats from ALL agent streams
try {
const { execSync } = await import('child_process')
stats.total = 0
stats.byType = {}
stats.subCategories = {}
stats.agents = {}
for (const [agentId, agentConfig] of Object.entries(AGENTS)) {
try {
// Get stream info
const infoOutput = execSync(`/home/keller/bin/nats stream info ${agentConfig.stream} --json 2>/dev/null`).toString()
const info = JSON.parse(infoOutput)
const messages = info?.state?.messages || 0
const bytes = info?.state?.bytes || 0
const lastTs = info?.state?.last_ts || null
// Get subject breakdown
let subjects = {}
try {
const subjectsOutput = execSync(`/home/keller/bin/nats stream subjects ${agentConfig.stream} --json 2>/dev/null`).toString()
subjects = JSON.parse(subjectsOutput) || {}
} catch (e) {}
// Calculate event types for this agent
let msgIn = 0, msgOut = 0, toolCalls = 0, lifecycle = 0
for (const [subject, count] of Object.entries(subjects)) {
if (subject.includes('message_in')) msgIn += count
else if (subject.includes('message_out')) msgOut += count
else if (subject.includes('tool')) toolCalls += count
else if (subject.includes('lifecycle')) lifecycle += count
// Global stats
if (subject.includes('message')) stats.byType.message = (stats.byType.message || 0) + count
else if (subject.includes('tool')) stats.byType.tool = (stats.byType.tool || 0) + count
else if (subject.includes('knowledge')) stats.byType.knowledge = (stats.byType.knowledge || 0) + count
else if (subject.includes('lifecycle')) stats.byType.lifecycle = (stats.byType.lifecycle || 0) + count
const subCat = subject.split('.').pop()
stats.subCategories[subCat] = (stats.subCategories[subCat] || 0) + count
}
stats.agents[agentId] = {
...agentConfig,
messages,
bytes,
lastTs,
types: { msgIn, msgOut, toolCalls, lifecycle }
}
stats.total += messages
} catch (e) {
// Stream doesn't exist or error
stats.agents[agentId] = {
...agentConfig,
messages: 0,
bytes: 0,
lastTs: null,
types: { msgIn: 0, msgOut: 0, toolCalls: 0, lifecycle: 0 }
}
}
}
} catch (e) {
console.log('Stats refresh error:', e.message)
}
// Get GATEWAY uptime
let gatewayUptime = null
try {
const { execSync } = await import('child_process')
const etime = execSync('ps -o etimes= -p $(pgrep -f "openclaw-gateway" | head -1) 2>/dev/null').toString().trim()
if (etime) {
const seconds = parseInt(etime)
gatewayUptime = {
seconds,
formatted: formatUptime(seconds)
}
}
} catch (e) {
const uptimeSeconds = Math.floor((Date.now() - bridgeStartTime) / 1000)
gatewayUptime = {
seconds: uptimeSeconds,
formatted: formatUptime(uptimeSeconds) + ' (bridge)'
}
}
res.writeHead(200)
res.end(JSON.stringify({
...stats,
uptime: gatewayUptime
}))
} else if (req.url === '/health') {
res.writeHead(200)
res.end(JSON.stringify({ status: 'ok', nats: !!nc }))
} else {
res.writeHead(404)
res.end(JSON.stringify({ error: 'Not found' }))
}
})
function formatUptime(seconds) {
const d = Math.floor(seconds / 86400)
const h = Math.floor((seconds % 86400) / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = seconds % 60
if (d > 0) return `${d}d ${h}h ${m}m`
if (h > 0) return `${h}h ${m}m ${s}s`
return `${m}m ${s}s`
}
server.listen(HTTP_PORT, '0.0.0.0', () => {
console.log(`📊 Stats API on http://0.0.0.0:${HTTP_PORT}`)
})
}
// Main
async function main() {
console.log('🛡️ Claudia Monitor Bridge starting...')
if (await connectNats()) {
startWebSocket()
startHttp()
console.log('✅ Bridge ready!')
} else {
process.exit(1)
}
}
main()

64
server/package-lock.json generated Normal file
View file

@ -0,0 +1,64 @@
{
"name": "server",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "server",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"nats": "^2.29.3",
"ws": "^8.19.0"
}
},
"node_modules/nats": {
"version": "2.29.3",
"resolved": "https://registry.npmjs.org/nats/-/nats-2.29.3.tgz",
"integrity": "sha512-tOQCRCwC74DgBTk4pWZ9V45sk4d7peoE2njVprMRCBXrhJ5q5cYM7i6W+Uvw2qUrcfOSnuisrX7bEx3b3Wx4QA==",
"dependencies": {
"nkeys.js": "1.1.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/nkeys.js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/nkeys.js/-/nkeys.js-1.1.0.tgz",
"integrity": "sha512-tB/a0shZL5UZWSwsoeyqfTszONTt4k2YS0tuQioMOD180+MbombYVgzDUYHlx+gejYK6rgf08n/2Df99WY0Sxg==",
"dependencies": {
"tweetnacl": "1.0.3"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/tweetnacl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

16
server/package.json Normal file
View file

@ -0,0 +1,16 @@
{
"name": "server",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"nats": "^2.29.3",
"ws": "^8.19.0"
}
}

361
src/App.jsx Normal file
View file

@ -0,0 +1,361 @@
import React, { useState, useEffect, useRef, Suspense } from 'react'
import EventDetails from './components/EventDetails'
import EventLog from './components/EventLog'
// Lazy load Three.js component
const NeuralViz = React.lazy(() => import('./components/NeuralViz'))
const WS_URL = 'ws://192.168.0.20:8765'
export default function App() {
const [events, setEvents] = useState([])
const [selectedEvent, setSelectedEvent] = useState(null)
const [stats, setStats] = useState({
total: 0,
messages: 0,
tools: 0,
knowledge: 0,
connected: false,
lastEvent: null
})
const [activityData, setActivityData] = useState([])
const [nodeStats, setNodeStats] = useState({
message: { count: 0, lastActive: null },
tool: { count: 0, lastActive: null },
knowledge: { count: 0, lastActive: null },
lifecycle: { count: 0, lastActive: null }
})
const [historicCounts, setHistoricCounts] = useState({})
const [subCategories, setSubCategories] = useState({})
const [uptime, setUptime] = useState('--')
const [agentStats, setAgentStats] = useState({})
const [visibleAgents, setVisibleAgents] = useState([]) // empty = all visible
const [visibleTypes, setVisibleTypes] = useState([]) // empty = all visible
const [eventFilters, setEventFilters] = useState([]) // for EventLog
// Fetch historic counts and uptime
useEffect(() => {
const fetchStats = async () => {
try {
const res = await fetch('http://192.168.0.20:8766/stats')
if (res.ok) {
const data = await res.json()
setHistoricCounts(data.byType || {})
setSubCategories(data.subCategories || {})
setAgentStats(data.agents || {})
if (data.uptime?.formatted) {
setUptime(data.uptime.formatted)
}
}
} catch (e) {}
}
fetchStats()
// Refresh every 30 seconds
const interval = setInterval(fetchStats, 30000)
return () => clearInterval(interval)
}, [])
const wsRef = useRef(null)
const reconnectRef = useRef(null)
const connect = () => {
if (wsRef.current?.readyState === WebSocket.OPEN) return
const ws = new WebSocket(WS_URL)
wsRef.current = ws
ws.onopen = () => {
console.log('🔌 Connected to Claudia Event Stream')
setStats(s => ({ ...s, connected: true }))
ws.send(JSON.stringify({ action: 'subscribe', pattern: 'openclaw.events.>' }))
}
ws.onmessage = (msg) => {
try {
const data = JSON.parse(msg.data)
if (data.type === 'event') {
const subject = data.subject || ''
let eventType = 'other'
if (subject.includes('message')) eventType = 'message'
else if (subject.includes('tool')) eventType = 'tool'
else if (subject.includes('knowledge')) eventType = 'knowledge'
else if (subject.includes('lifecycle')) eventType = 'lifecycle'
const event = {
id: Date.now() + Math.random(),
subject: data.subject,
agent: data.agent, // THIS WAS MISSING!
type: eventType,
timestamp: new Date(),
data: data.data,
preview: extractPreview(data.data, eventType)
}
setEvents(prev => [event, ...prev].slice(0, 200))
// Update stats
setStats(s => ({
...s,
total: s.total + 1,
messages: s.messages + (eventType === 'message' ? 1 : 0),
tools: s.tools + (eventType === 'tool' ? 1 : 0),
knowledge: s.knowledge + (eventType === 'knowledge' ? 1 : 0),
lastEvent: event
}))
// Update node stats for 3D viz
setNodeStats(prev => ({
...prev,
[eventType]: {
count: (prev[eventType]?.count || 0) + 1,
lastActive: Date.now()
}
}))
// Update activity chart
const now = new Date()
const minute = now.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
setActivityData(prev => {
const existing = prev.find(d => d.time === minute)
if (existing) {
return prev.map(d => d.time === minute
? { ...d, count: d.count + 1, [eventType]: (d[eventType] || 0) + 1 }
: d)
}
return [...prev, { time: minute, count: 1, [eventType]: 1 }].slice(-30)
})
}
} catch (e) {
console.error('Parse error:', e)
}
}
ws.onclose = () => {
console.log('❌ Disconnected')
setStats(s => ({ ...s, connected: false }))
reconnectRef.current = setTimeout(connect, 3000)
}
ws.onerror = () => ws.close()
}
useEffect(() => {
connect()
return () => {
wsRef.current?.close()
clearTimeout(reconnectRef.current)
}
}, [])
return (
<div className="h-screen w-screen overflow-hidden bg-[#030014] text-white">
{/* FULLSCREEN 3D Canvas - Base Layer */}
<div className="fixed inset-0 z-0">
<Suspense fallback={<LoadingViz />}>
<NeuralViz
events={events}
nodeStats={nodeStats}
historicCounts={historicCounts}
subCategories={subCategories}
agentStats={agentStats}
onNodeClick={(type) => setSelectedEvent(events.find(e => e.type === type))}
/>
</Suspense>
</div>
{/* UI Overlay Layer - pointer-events-none container */}
<div className="fixed inset-0 z-10 pointer-events-none">
{/* Floating Header - Top */}
<header className="absolute top-4 left-4 right-4 pointer-events-auto">
<div className="flex items-center justify-between gap-4">
{/* Logo + Title */}
<div className="flex items-center gap-3 px-4 py-2 rounded-2xl bg-black/40 backdrop-blur-xl border border-white/10 shadow-2xl">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-cyan-500 to-violet-600 flex items-center justify-center text-lg shadow-lg shadow-cyan-500/30">
🛡
</div>
<h1 className="text-lg font-bold bg-gradient-to-r from-cyan-400 via-violet-400 to-fuchsia-400 bg-clip-text text-transparent">
Neural Monitor
</h1>
</div>
{/* Stats + Connection */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-4 px-4 py-2 rounded-2xl bg-black/40 backdrop-blur-xl border border-white/10">
<div className="flex items-center gap-2 text-sm">
<span className="text-gray-400"></span>
<span className="text-cyan-400 font-mono">{uptime}</span>
</div>
<div className="w-px h-4 bg-white/10" />
<div className="flex items-center gap-2 text-sm">
<span className="text-gray-400">🧠</span>
<span className="text-emerald-400 font-mono">{(Object.values(agentStats).reduce((sum, a) => sum + (a.messages || 0), 0) || stats.total).toLocaleString()}</span>
</div>
</div>
<div className={`flex items-center gap-2 px-3 py-2 rounded-2xl backdrop-blur-xl border shadow-lg ${
stats.connected
? 'bg-emerald-500/20 border-emerald-500/40 text-emerald-400 shadow-emerald-500/20'
: 'bg-red-500/20 border-red-500/40 text-red-400'
}`}>
<span className={`w-2 h-2 rounded-full ${stats.connected ? 'bg-emerald-400 animate-pulse' : 'bg-red-400'}`} />
<span className="text-xs font-medium">{stats.connected ? 'Live' : 'Offline'}</span>
</div>
</div>
</div>
</header>
{/* Floating Agent Cards - Bottom */}
<div className="absolute bottom-4 left-4 right-4 pointer-events-auto">
<div className="flex gap-3 justify-center flex-wrap">
{Object.entries(agentStats).map(([id, agent]) => (
<div
key={id}
className={`px-4 py-3 rounded-2xl backdrop-blur-xl border transition-all duration-300 hover:scale-105 hover:shadow-xl ${
agent.messages > 0
? 'bg-black/50 border-violet-500/40 hover:border-violet-400/60 shadow-lg shadow-violet-500/10'
: 'bg-black/30 border-gray-700/40 opacity-60'
}`}
>
<div className="flex items-center gap-3">
<span className="text-2xl">{agent.emoji}</span>
<div>
<p className="font-semibold text-sm">{agent.name}</p>
<div className="flex items-center gap-2 text-xs">
<span className={agent.messages > 0 ? 'text-cyan-400 font-bold' : 'text-gray-500'}>
{agent.messages?.toLocaleString() || 0}
</span>
<span className="text-gray-600"></span>
<span className={agent.messages > 0 ? 'text-emerald-400' : 'text-gray-600'}>
{formatAge(agent.lastTs)}
</span>
</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
{/* Event Log Panel */}
<EventLog
events={events}
filters={eventFilters}
onFilterToggle={(type) => {
setEventFilters(prev => {
if (prev.includes(type)) return prev.filter(t => t !== type)
return [...prev, type]
})
}}
/>
{/* Event Details Modal */}
{selectedEvent && (
<EventDetails event={selectedEvent} onClose={() => setSelectedEvent(null)} />
)}
</div>
)
}
function formatBytes(bytes) {
if (!bytes) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
let i = 0
while (bytes >= 1024 && i < units.length - 1) {
bytes /= 1024
i++
}
return `${bytes.toFixed(1)} ${units[i]}`
}
function formatAge(ts) {
if (!ts || ts === '0001-01-01T00:00:00Z') return 'never'
try {
const date = new Date(ts)
const now = new Date()
const seconds = Math.floor((now - date) / 1000)
if (seconds < 0 || seconds > 365 * 24 * 3600 * 100) return 'never'
if (seconds < 60) return `${seconds}s ago`
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`
return `${Math.floor(seconds / 86400)}d ago`
} catch {
return 'unknown'
}
}
function LoadingViz() {
return (
<div className="h-full flex items-center justify-center bg-black/50">
<div className="text-center">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-gradient-to-r from-cyan-500 to-violet-500 animate-pulse" />
<p className="text-gray-400">Initializing Neural Network...</p>
</div>
</div>
)
}
function extractPreview(data, type) {
if (!data) return null
// OpenClaw event structure: payload.data.text or payload.data.args
const payload = data?.payload
const innerData = payload?.data
// Messages - payload.data.text
if (type === 'message') {
if (innerData?.text) return innerData.text
if (innerData?.content) return innerData.content
if (payload?.text) return payload.text
if (data?.text) return data.text
}
// Tools - payload.data.name + payload.data.args.command
if (type === 'tool') {
const name = innerData?.name
const args = innerData?.args
const phase = innerData?.phase
const result = innerData?.partialResult?.content?.[0]?.text
// Show result if available (tool output)
if (result && result.length > 10) return `📤 ${result.slice(0, 400)}`
// Show command being executed
if (name === 'exec' && args?.command) return `▶️ ${args.command}`
if (name && args) return `${name}: ${JSON.stringify(args).slice(0, 200)}`
if (name) return `${name} (${phase || 'running'})`
}
// Knowledge - fact field
if (type === 'knowledge') {
if (data?.fact) return data.fact
if (innerData?.fact) return innerData.fact
if (data?.content) return data.content
if (data?.summary) return data.summary
}
// Lifecycle - session events
if (type === 'lifecycle') {
if (innerData?.event) return innerData.event
if (data?.event) return data.event
if (data?.type) return data.type
}
// Deep search for text
const searchText = (obj, depth = 0) => {
if (!obj || depth > 3) return null
if (typeof obj === 'string' && obj.length > 5) return obj
if (typeof obj !== 'object') return null
for (const key of ['text', 'content', 'message', 'fact', 'command', 'result']) {
if (obj[key] && typeof obj[key] === 'string') return obj[key]
}
for (const val of Object.values(obj)) {
const found = searchText(val, depth + 1)
if (found) return found
}
return null
}
return searchText(data) || data?.type || '—'
}

View file

@ -0,0 +1,108 @@
import React from 'react'
import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend } from 'recharts'
const typeColors = {
message: '#22d3ee',
tool: '#a78bfa',
knowledge: '#34d399',
lifecycle: '#f472b6'
}
const CustomTooltip = ({ active, payload, label }) => {
if (active && payload && payload.length) {
const total = payload.reduce((sum, p) => sum + (p.value || 0), 0)
return (
<div className="glass-card rounded-xl p-3 border border-white/10">
<p className="text-cyan-400 font-medium text-sm mb-2">{label}</p>
<div className="space-y-1">
{payload.map((p, i) => (
<div key={i} className="flex items-center justify-between gap-4 text-xs">
<span className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full" style={{ background: p.color }} />
<span className="text-gray-400 capitalize">{p.dataKey}</span>
</span>
<span style={{ color: p.color }}>{p.value || 0}</span>
</div>
))}
<div className="pt-1 mt-1 border-t border-white/10 flex justify-between text-xs">
<span className="text-gray-500">Total</span>
<span className="text-white font-medium">{total}</span>
</div>
</div>
</div>
)
}
return null
}
export default function ActivityChart({ data, compact }) {
if (data.length === 0) {
return (
<div className={`${compact ? 'h-full' : 'h-48'} flex items-center justify-center`}>
<div className="text-center">
<span className="text-2xl">📈</span>
<p className="text-gray-500 text-xs mt-1">Waiting for activity...</p>
</div>
</div>
)
}
return (
<div className={compact ? 'h-full' : 'h-56'}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 5, right: 5, left: -20, bottom: 5 }}>
<defs>
{Object.entries(typeColors).map(([key, color]) => (
<linearGradient key={key} id={`gradient-${key}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.4} />
<stop offset="100%" stopColor={color} stopOpacity={0} />
</linearGradient>
))}
</defs>
<XAxis
dataKey="time"
stroke="transparent"
tick={{ fill: '#666', fontSize: 10 }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
/>
<YAxis
stroke="transparent"
tick={{ fill: '#666', fontSize: 10 }}
tickLine={false}
axisLine={false}
width={30}
/>
<Tooltip content={<CustomTooltip />} />
{Object.entries(typeColors).map(([key, color]) => (
<Area
key={key}
type="monotone"
dataKey={key}
stackId="1"
stroke={color}
strokeWidth={2}
fill={`url(#gradient-${key})`}
animationDuration={300}
/>
))}
</AreaChart>
</ResponsiveContainer>
{/* Legend - hide in compact mode */}
{!compact && (
<div className="flex justify-center gap-4 mt-2">
{Object.entries(typeColors).map(([key, color]) => (
<div key={key} className="flex items-center gap-1.5 text-xs">
<span className="w-2 h-2 rounded-full" style={{ background: color }} />
<span className="text-gray-500 capitalize">{key}</span>
</div>
))}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,101 @@
import React from 'react'
const typeConfig = {
message: { icon: '💬', color: '#22d3ee', label: 'Message' },
tool: { icon: '🔧', color: '#a78bfa', label: 'Tool' },
knowledge: { icon: '🧠', color: '#34d399', label: 'Knowledge' },
lifecycle: { icon: '⚡', color: '#f472b6', label: 'Lifecycle' },
other: { icon: '📦', color: '#94a3b8', label: 'Event' }
}
export default function EventDetails({ event, onClose }) {
if (!event) return null
const config = typeConfig[event.type] || typeConfig.other
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
onClick={onClose}
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
{/* Modal */}
<div
className="relative w-full max-w-lg glass-card rounded-3xl p-6 animate-scale-in"
onClick={e => e.stopPropagation()}
style={{
'--accent-color': config.color
}}
>
{/* Header */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div
className="w-12 h-12 rounded-2xl flex items-center justify-center text-2xl"
style={{ background: `${config.color}20` }}
>
{config.icon}
</div>
<div>
<h3 className="font-semibold" style={{ color: config.color }}>
{config.label} Event
</h3>
<p className="text-xs text-gray-500">
{event.timestamp.toLocaleString('de-DE')}
</p>
</div>
</div>
<button
onClick={onClose}
className="w-8 h-8 rounded-full bg-white/5 hover:bg-white/10 flex items-center justify-center transition-colors"
>
</button>
</div>
{/* Subject */}
<div className="mb-4">
<label className="text-xs text-gray-500 uppercase tracking-wide">Subject</label>
<p className="font-mono text-sm text-gray-300 mt-1 break-all">
{event.subject}
</p>
</div>
{/* Preview */}
{event.preview && (
<div className="mb-4">
<label className="text-xs text-gray-500 uppercase tracking-wide">Preview</label>
<p className="text-sm text-gray-300 mt-1">
{event.preview}
</p>
</div>
)}
{/* Raw Data */}
<div>
<label className="text-xs text-gray-500 uppercase tracking-wide">Raw Data</label>
<pre className="mt-2 p-4 rounded-xl bg-black/30 text-xs text-gray-400 overflow-auto max-h-48 font-mono">
{JSON.stringify(event.data, null, 2)}
</pre>
</div>
{/* Accent line */}
<div
className="absolute top-0 left-6 right-6 h-0.5 rounded-full"
style={{ background: `linear-gradient(90deg, ${config.color}, transparent)` }}
/>
</div>
<style>{`
@keyframes scale-in {
from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.animate-scale-in { animation: scale-in 0.2s ease-out; }
`}</style>
</div>
)
}

133
src/components/EventLog.jsx Normal file
View file

@ -0,0 +1,133 @@
import React, { useState, useRef, useEffect } from 'react'
const TYPE_CONFIG = {
message: { icon: '💬', color: 'text-cyan-400', bg: 'bg-cyan-500/20' },
tool: { icon: '🔧', color: 'text-violet-400', bg: 'bg-violet-500/20' },
knowledge: { icon: '🧠', color: 'text-emerald-400', bg: 'bg-emerald-500/20' },
lifecycle: { icon: '⚡', color: 'text-pink-400', bg: 'bg-pink-500/20' },
other: { icon: '📋', color: 'text-gray-400', bg: 'bg-gray-500/20' }
}
export default function EventLog({ events, filters, onFilterToggle }) {
const listRef = useRef(null)
const [expanded, setExpanded] = useState(true)
// Filter events based on active filters
const filteredEvents = events.filter(e => {
if (filters.length === 0) return true
return filters.includes(e.type)
})
// Auto-scroll to top on new events
useEffect(() => {
if (listRef.current && filteredEvents.length > 0) {
listRef.current.scrollTop = 0
}
}, [filteredEvents.length])
const formatTime = (timestamp) => {
if (!timestamp) return '--:--'
const d = new Date(timestamp)
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
const getPreview = (event) => {
if (event.preview) return event.preview
const data = event.data
if (!data) return event.subject || 'Event'
// Try to extract meaningful text
const payload = data?.payload
const innerData = payload?.data
if (innerData?.text) return innerData.text
if (innerData?.name) return `${innerData.name}${innerData.args?.command ? `: ${innerData.args.command}` : ''}`
if (payload?.text) return payload.text
return event.subject?.split('.').pop() || 'Event'
}
if (!expanded) {
return (
<button
onClick={() => setExpanded(true)}
className="fixed right-4 bottom-24 px-4 py-2 rounded-xl bg-black/60 backdrop-blur-xl border border-white/10 text-white text-sm hover:bg-black/80 transition-all z-50"
>
📜 Show Event Log ({events.length})
</button>
)
}
return (
<div className="fixed right-4 bottom-24 w-80 max-h-[50vh] bg-black/60 backdrop-blur-xl rounded-2xl border border-white/10 shadow-2xl z-50 flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<span className="text-cyan-400">📜</span> Event Flow
<span className="text-xs text-gray-500 font-mono">({filteredEvents.length})</span>
</h3>
<button
onClick={() => setExpanded(false)}
className="text-gray-400 hover:text-white text-xs"
>
</button>
</div>
{/* Filter Pills */}
<div className="flex gap-1 px-3 py-2 border-b border-white/5 flex-wrap">
{Object.entries(TYPE_CONFIG).filter(([k]) => k !== 'other').map(([type, config]) => {
const isActive = filters.length === 0 || filters.includes(type)
return (
<button
key={type}
onClick={() => onFilterToggle?.(type)}
className={`px-2 py-1 rounded-lg text-xs transition-all ${
isActive
? `${config.bg} ${config.color} border border-current/30`
: 'bg-gray-800/50 text-gray-500 border border-transparent'
}`}
>
{config.icon}
</button>
)
})}
</div>
{/* Event List */}
<div ref={listRef} className="flex-1 overflow-y-auto min-h-0">
{filteredEvents.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 text-gray-500">
<span className="text-2xl mb-2">👀</span>
<span className="text-xs">Waiting for events...</span>
</div>
) : (
<div className="divide-y divide-white/5">
{filteredEvents.slice(0, 50).map((event, i) => {
const config = TYPE_CONFIG[event.type] || TYPE_CONFIG.other
const isNew = i === 0
return (
<div
key={event.id || i}
className={`px-3 py-2 hover:bg-white/5 transition-colors ${isNew ? 'animate-pulse-once' : ''}`}
>
<div className="flex items-start gap-2">
<span className={`text-sm ${config.color}`}>{config.icon}</span>
<div className="flex-1 min-w-0">
<p className="text-xs text-white truncate">
{getPreview(event)}
</p>
<p className="text-[10px] text-gray-500 font-mono mt-0.5">
{formatTime(event.timestamp)}
</p>
</div>
</div>
</div>
)
})}
</div>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,87 @@
import React from 'react'
const typeConfig = {
message: { icon: '💬', color: '#22d3ee', bg: 'from-cyan-500/20 to-cyan-500/5' },
tool: { icon: '🔧', color: '#a78bfa', bg: 'from-violet-500/20 to-violet-500/5' },
knowledge: { icon: '🧠', color: '#34d399', bg: 'from-emerald-500/20 to-emerald-500/5' },
lifecycle: { icon: '⚡', color: '#f472b6', bg: 'from-pink-500/20 to-pink-500/5' },
other: { icon: '📦', color: '#94a3b8', bg: 'from-gray-500/20 to-gray-500/5' }
}
function formatSubject(subject) {
const parts = subject.split('.')
return parts[parts.length - 1]
}
function EventCard({ event, onClick }) {
const config = typeConfig[event.type] || typeConfig.other
const time = event.timestamp.toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
const isRecent = (Date.now() - event.timestamp.getTime()) < 5000
return (
<button
onClick={() => onClick?.(event)}
className={`w-full text-left p-3 rounded-xl transition-all duration-300 hover:scale-[1.02] ${
isRecent ? 'animate-slide-in' : ''
}`}
style={{
background: `linear-gradient(135deg, ${config.color}15, ${config.color}05)`,
borderLeft: `3px solid ${config.color}`
}}
>
<div className="flex items-start gap-3">
<span className="text-lg mt-0.5">{config.icon}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-mono text-xs" style={{ color: config.color }}>
{formatSubject(event.subject)}
</span>
<span className="text-[10px] text-gray-600">{time}</span>
{isRecent && (
<span className="px-1.5 py-0.5 rounded text-[10px] bg-cyan-500/20 text-cyan-400">
NEW
</span>
)}
</div>
{event.preview && (
<p className="text-xs text-gray-500 truncate">
{event.preview}
</p>
)}
</div>
</div>
</button>
)
}
export default function EventStream({ events, onSelect }) {
return (
<div className="flex-1 overflow-y-auto space-y-2 pr-1">
{events.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center py-12">
<div className="w-16 h-16 rounded-full bg-violet-500/10 flex items-center justify-center mb-4">
<span className="text-3xl animate-pulse">👀</span>
</div>
<p className="text-gray-500 text-sm">Waiting for events...</p>
<p className="text-gray-600 text-xs mt-1">Neural activity will appear here</p>
</div>
) : (
events.slice(0, 50).map(event => (
<EventCard key={event.id} event={event} onClick={onSelect} />
))
)}
<style>{`
@keyframes slide-in {
from { opacity: 0; transform: translateX(-10px); }
to { opacity: 1; transform: translateX(0); }
}
.animate-slide-in { animation: slide-in 0.3s ease-out; }
`}</style>
</div>
)
}

View file

@ -0,0 +1,141 @@
import React, { useRef, useEffect } from 'react'
const typeConfig = {
message: { icon: '💬', color: '#22d3ee', label: 'Message' },
tool: { icon: '🔧', color: '#a78bfa', label: 'Tool' },
knowledge: { icon: '🧠', color: '#34d399', label: 'Knowledge' },
lifecycle: { icon: '⚡', color: '#f472b6', label: 'Lifecycle' },
other: { icon: '📦', color: '#94a3b8', label: 'Other' }
}
function formatTime(date) {
return date.toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3
})
}
function formatSubject(subject) {
// openclaw.events.agent.conversation_message_out -> conversation_message_out
const parts = subject.split('.')
return parts.slice(-1)[0]
}
function formatAgent(subject) {
// openclaw.events.agent.xxx -> agent
// openclaw.events.claudia.xxx -> claudia
const parts = subject.split('.')
return parts[2] || 'unknown'
}
export default function EventTable({ events, onSelect }) {
const tableRef = useRef(null)
const autoScrollRef = useRef(true)
// Auto-scroll to top when new events arrive
useEffect(() => {
if (autoScrollRef.current && tableRef.current) {
tableRef.current.scrollTop = 0
}
}, [events.length])
return (
<div className="glass-card rounded-3xl overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-white/5 flex items-center justify-between">
<h2 className="text-lg font-semibold flex items-center gap-2">
<span className="text-violet-400"></span> Live Event Stream
<span className="text-xs text-gray-500 font-normal ml-2">
{events.length} events
</span>
</h2>
<div className="flex items-center gap-2 text-xs">
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
<span className="text-gray-400">Auto-updating</span>
</div>
</div>
{/* Table Header */}
<div className="grid grid-cols-12 gap-2 px-6 py-3 bg-white/[0.02] border-b border-white/5 text-xs text-gray-500 uppercase tracking-wide">
<div className="col-span-1">Time</div>
<div className="col-span-1">Type</div>
<div className="col-span-2">Subject</div>
<div className="col-span-8">Content</div>
</div>
{/* Table Body - Scrollable */}
<div
ref={tableRef}
className="overflow-y-auto"
style={{ maxHeight: '400px' }}
onScroll={(e) => {
// Disable auto-scroll if user scrolls away from top
autoScrollRef.current = e.target.scrollTop < 50
}}
>
{events.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="w-16 h-16 rounded-full bg-violet-500/10 flex items-center justify-center mb-4">
<span className="text-3xl animate-pulse">👀</span>
</div>
<p className="text-gray-500">Waiting for events...</p>
<p className="text-gray-600 text-xs mt-1">Neural activity will stream here in real-time</p>
</div>
) : (
events.map((event, index) => {
const config = typeConfig[event.type] || typeConfig.other
const isNew = index < 3 && (Date.now() - event.timestamp.getTime()) < 5000
return (
<button
key={event.id}
onClick={() => onSelect?.(event)}
className={`w-full grid grid-cols-12 gap-2 px-6 py-2 text-left border-b border-white/[0.02] hover:bg-white/[0.02] transition-all ${
isNew ? 'bg-cyan-500/5 animate-flash' : ''
}`}
>
{/* Time */}
<div className="col-span-1 font-mono text-xs text-gray-500">
{formatTime(event.timestamp).slice(0, 8)}
</div>
{/* Type */}
<div className="col-span-1">
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs"
style={{
background: `${config.color}20`,
color: config.color
}}
>
{config.icon}
</span>
</div>
{/* Subject */}
<div className="col-span-2 font-mono text-xs truncate" style={{ color: config.color }}>
{formatSubject(event.subject)}
</div>
{/* Content - larger, readable */}
<div className="col-span-8 text-sm text-gray-300 line-clamp-2">
{event.preview || '—'}
</div>
</button>
)
})
)}
</div>
<style>{`
@keyframes flash {
0% { background: rgba(34, 211, 238, 0.15); }
100% { background: rgba(34, 211, 238, 0.05); }
}
.animate-flash { animation: flash 1s ease-out; }
`}</style>
</div>
)
}

View file

@ -0,0 +1,769 @@
import React, { useRef, useMemo, useState, useEffect } from 'react'
import { Canvas, useFrame } from '@react-three/fiber'
import { OrbitControls, Html } from '@react-three/drei'
import * as THREE from 'three'
// Agent configuration - INNER RING
const AGENT_CONFIG = {
main: {
name: 'Claudia',
emoji: '🛡️',
color: '#6366f1',
description: 'Chief of Staff'
},
'mondo-assistant': {
name: 'Mona',
emoji: '🌙',
color: '#8b5cf6',
description: 'Mondo Gate Business'
},
vera: {
name: 'Vera',
emoji: '🔒',
color: '#ef4444',
description: 'Security & Compliance'
},
stella: {
name: 'Stella',
emoji: '💰',
color: '#f59e0b',
description: 'Business Development'
},
viola: {
name: 'Viola',
emoji: '⚙️',
color: '#10b981',
description: 'Operations & Infra'
}
}
// Event category configuration - OUTER RING
const CATEGORY_CONFIG = {
message: {
label: '💬 Messages',
color: '#22d3ee',
description: 'Conversation messages'
},
tool: {
label: '🔧 Tools',
color: '#a78bfa',
description: 'Tool calls & results'
},
knowledge: {
label: '🧠 Knowledge',
color: '#34d399',
description: 'Facts & memories'
},
lifecycle: {
label: '⚡ Lifecycle',
color: '#f472b6',
description: 'Session events'
}
}
// Calculate positions in a ring
function ringPosition(index, total, radius, yOffset = 0) {
const angle = (index / total) * Math.PI * 2 - Math.PI / 2
return [
Math.cos(angle) * radius,
yOffset,
Math.sin(angle) * radius
]
}
// Glowing energy beam with multiple particles
function GlowBeam({ from, to, color, intensity = 1, particleCount = 3 }) {
const groupRef = useRef()
const particlesRef = useRef([])
const glowRef = useRef()
// Create curved path points
const curve = useMemo(() => {
const mid = [
(from[0] + to[0]) / 2,
(from[1] + to[1]) / 2 + 0.4, // Arc upward
(from[2] + to[2]) / 2
]
return new THREE.QuadraticBezierCurve3(
new THREE.Vector3(...from),
new THREE.Vector3(...mid),
new THREE.Vector3(...to)
)
}, [from, to])
// Get points along curve for the beam
const tubePoints = useMemo(() => curve.getPoints(20), [curve])
useFrame((state) => {
const time = state.clock.elapsedTime
// Animate particles along the beam
particlesRef.current.forEach((particle, i) => {
if (particle) {
const offset = i / particleCount
const t = ((time * 0.8 + offset) % 1)
const pos = curve.getPoint(t)
particle.position.copy(pos)
// Pulse size
const pulse = 0.8 + Math.sin(time * 8 + i * 2) * 0.3
particle.scale.setScalar(pulse)
}
})
// Pulse the glow
if (glowRef.current) {
glowRef.current.material.opacity = 0.15 + Math.sin(time * 4) * 0.1
}
})
if (intensity <= 0) return null
return (
<group ref={groupRef}>
{/* Outer glow tube */}
<mesh ref={glowRef}>
<tubeGeometry args={[curve, 20, 0.08, 8, false]} />
<meshBasicMaterial
color={color}
transparent
opacity={0.2}
side={THREE.DoubleSide}
/>
</mesh>
{/* Inner bright core */}
<mesh>
<tubeGeometry args={[curve, 20, 0.02, 8, false]} />
<meshBasicMaterial
color="#ffffff"
transparent
opacity={0.8}
/>
</mesh>
{/* Traveling particles with glow */}
{Array.from({ length: particleCount }).map((_, i) => (
<group key={i} ref={el => particlesRef.current[i] = el}>
{/* Outer glow */}
<mesh>
<sphereGeometry args={[0.12, 16, 16]} />
<meshBasicMaterial
color={color}
transparent
opacity={0.4}
/>
</mesh>
{/* Inner bright core */}
<mesh>
<sphereGeometry args={[0.05, 12, 12]} />
<meshBasicMaterial color="#ffffff" />
</mesh>
</group>
))}
</group>
)
}
// Beam from Agent to Event Type (when agent uses that type)
function ActivityBeam({ from, to, color, active }) {
const beamRef = useRef()
const particleRef = useRef()
const curve = useMemo(() => {
const mid = [
(from[0] + to[0]) / 2,
(from[1] + to[1]) / 2 + 0.2,
(from[2] + to[2]) / 2
]
return new THREE.QuadraticBezierCurve3(
new THREE.Vector3(...from),
new THREE.Vector3(...mid),
new THREE.Vector3(...to)
)
}, [from, to])
useFrame((state) => {
if (!active || !particleRef.current) return
const t = (state.clock.elapsedTime * 1.5) % 1
const pos = curve.getPoint(t)
particleRef.current.position.copy(pos)
particleRef.current.scale.setScalar(0.6 + Math.sin(state.clock.elapsedTime * 10) * 0.2)
})
if (!active) return null
return (
<group>
{/* Faint beam line */}
<mesh>
<tubeGeometry args={[curve, 12, 0.015, 6, false]} />
<meshBasicMaterial color={color} transparent opacity={0.3} />
</mesh>
{/* Single fast particle */}
<group ref={particleRef}>
<mesh>
<sphereGeometry args={[0.06, 12, 12]} />
<meshBasicMaterial color={color} transparent opacity={0.8} />
</mesh>
<mesh>
<sphereGeometry args={[0.03, 8, 8]} />
<meshBasicMaterial color="#ffffff" />
</mesh>
</group>
</group>
)
}
// Legacy wrapper for compatibility
function AgentBeam({ from, to, color, intensity, label }) {
return <GlowBeam from={from} to={to} color={color} intensity={intensity} particleCount={3} />
}
// Agent node (inner ring)
function AgentNode({ id, config, position, stats, isActive, onClick, activeConnections }) {
const groupRef = useRef()
const glowRef = useRef()
const [hovered, setHovered] = useState(false)
const eventCount = stats?.messages || 0
const scale = 0.6 + Math.min(eventCount / 5000, 0.4)
useFrame((state) => {
if (groupRef.current) {
// Gentle floating
groupRef.current.position.y = position[1] + Math.sin(state.clock.elapsedTime * 0.5 + id.length) * 0.08
// Pulse when active
if (glowRef.current && isActive) {
glowRef.current.scale.setScalar(1.3 + Math.sin(state.clock.elapsedTime * 8) * 0.2)
}
}
})
const hasConnections = activeConnections?.length > 0
return (
<group
ref={groupRef}
position={position}
onClick={(e) => { e.stopPropagation(); onClick?.(id) }}
onPointerOver={() => setHovered(true)}
onPointerOut={() => setHovered(false)}
>
{/* Outer glow */}
<mesh ref={glowRef} scale={isActive ? 1.5 : 1.1}>
<sphereGeometry args={[0.35, 32, 32]} />
<meshBasicMaterial
color={config.color}
transparent
opacity={isActive ? 0.4 : (hasConnections ? 0.25 : 0.1)}
/>
</mesh>
{/* Main sphere */}
<mesh scale={scale}>
<sphereGeometry args={[0.28, 32, 32]} />
<meshStandardMaterial
color={config.color}
emissive={config.color}
emissiveIntensity={hovered ? 0.9 : (isActive ? 0.7 : 0.4)}
metalness={0.4}
roughness={0.3}
/>
</mesh>
{/* Inner core */}
<mesh scale={scale * 0.4}>
<sphereGeometry args={[0.28, 16, 16]} />
<meshBasicMaterial color="white" transparent opacity={0.85} />
</mesh>
{/* Emoji + Name label */}
<Html position={[0, 0.6, 0]} center distanceFactor={8}>
<div
className={`whitespace-nowrap px-2 py-1 rounded-full text-xs font-medium transition-all cursor-pointer ${
hovered ? 'scale-110' : ''
}`}
style={{
background: `linear-gradient(135deg, ${config.color}40, ${config.color}20)`,
border: `1px solid ${config.color}60`,
color: config.color,
backdropFilter: 'blur(8px)'
}}
>
{config.emoji} {config.name}
</div>
</Html>
{/* Event count badge */}
<Html position={[0, -0.5, 0]} center distanceFactor={8}>
<div
className="px-2 py-0.5 rounded-full text-xs font-bold"
style={{
background: eventCount > 0 ? config.color : '#333',
color: eventCount > 0 ? '#000' : '#666'
}}
>
{eventCount.toLocaleString()}
</div>
</Html>
{/* Hover description */}
{hovered && (
<Html position={[0, -0.85, 0]} center distanceFactor={8}>
<div className="text-xs text-gray-400 whitespace-nowrap">
{config.description}
</div>
</Html>
)}
</group>
)
}
// Category node (outer ring)
function CategoryNode({ type, config, position, count, isActive }) {
const groupRef = useRef()
const [hovered, setHovered] = useState(false)
useFrame((state) => {
if (groupRef.current) {
groupRef.current.position.y = position[1] + Math.sin(state.clock.elapsedTime * 0.3 + type.length * 2) * 0.05
}
})
return (
<group ref={groupRef} position={position}>
{/* Glow */}
<mesh scale={isActive ? 1.4 : 1}>
<sphereGeometry args={[0.22, 24, 24]} />
<meshBasicMaterial
color={config.color}
transparent
opacity={isActive ? 0.35 : 0.15}
/>
</mesh>
{/* Main sphere */}
<mesh
onPointerOver={() => setHovered(true)}
onPointerOut={() => setHovered(false)}
>
<sphereGeometry args={[0.18, 24, 24]} />
<meshStandardMaterial
color={config.color}
emissive={config.color}
emissiveIntensity={hovered ? 0.8 : 0.5}
metalness={0.3}
roughness={0.5}
/>
</mesh>
{/* Label */}
<Html position={[0, 0.4, 0]} center distanceFactor={10}>
<div
className={`whitespace-nowrap px-2 py-0.5 rounded text-xs transition-all ${hovered ? 'scale-105' : ''}`}
style={{
background: `${config.color}25`,
color: config.color,
border: `1px solid ${config.color}40`
}}
>
{config.label}
</div>
</Html>
{/* Count */}
<Html position={[0, -0.35, 0]} center distanceFactor={10}>
<div
className="text-xs font-mono"
style={{ color: config.color, opacity: 0.8 }}
>
{(count || 0).toLocaleString()}
</div>
</Html>
</group>
)
}
// Core hub (center)
function CoreHub({ totalEvents }) {
const meshRef = useRef()
const ringsRef = useRef()
useFrame((state) => {
if (meshRef.current) {
meshRef.current.rotation.y = state.clock.elapsedTime * 0.15
meshRef.current.rotation.x = Math.sin(state.clock.elapsedTime * 0.2) * 0.1
}
if (ringsRef.current) {
ringsRef.current.rotation.z = state.clock.elapsedTime * 0.25
}
})
return (
<group>
{/* Core icosahedron */}
<mesh ref={meshRef}>
<icosahedronGeometry args={[0.2, 1]} />
<meshStandardMaterial
color="#ffffff"
emissive="#8b5cf6"
emissiveIntensity={0.6}
metalness={0.9}
roughness={0.1}
wireframe
/>
</mesh>
{/* Inner glow */}
<mesh>
<sphereGeometry args={[0.15, 32, 32]} />
<meshBasicMaterial color="#8b5cf6" transparent opacity={0.6} />
</mesh>
{/* Orbital rings */}
<group ref={ringsRef}>
<mesh rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[0.4, 0.015, 16, 64]} />
<meshBasicMaterial color="#22d3ee" transparent opacity={0.4} />
</mesh>
<mesh rotation={[Math.PI / 3, Math.PI / 4, 0]}>
<torusGeometry args={[0.5, 0.01, 16, 64]} />
<meshBasicMaterial color="#a78bfa" transparent opacity={0.3} />
</mesh>
</group>
{/* Total label */}
<Html position={[0, -0.5, 0]} center distanceFactor={8}>
<div className="text-center">
<div className="text-[9px] text-violet-300 font-mono opacity-60">
TOTAL EVENTS
</div>
<div
className="px-2 py-0.5 rounded-full text-sm font-bold"
style={{
background: 'linear-gradient(135deg, #8b5cf6, #06b6d4)',
color: '#fff'
}}
>
{totalEvents.toLocaleString()}
</div>
</div>
</Html>
</group>
)
}
// Ambient particles
function AmbientParticles() {
const particlesRef = useRef()
const particles = useMemo(() => {
const positions = new Float32Array(80 * 3)
for (let i = 0; i < 80; i++) {
positions[i * 3] = (Math.random() - 0.5) * 12
positions[i * 3 + 1] = (Math.random() - 0.5) * 12
positions[i * 3 + 2] = (Math.random() - 0.5) * 12
}
return positions
}, [])
useFrame((state) => {
if (particlesRef.current) {
particlesRef.current.rotation.y = state.clock.elapsedTime * 0.015
}
})
return (
<points ref={particlesRef}>
<bufferGeometry>
<bufferAttribute
attach="attributes-position"
count={80}
array={particles}
itemSize={3}
/>
</bufferGeometry>
<pointsMaterial size={0.025} color="#8b5cf6" transparent opacity={0.25} />
</points>
)
}
// Connection lines from agents to core
function AgentConnections({ agentPositions, activeAgents }) {
return (
<group>
{Object.entries(agentPositions).map(([id, pos]) => {
const isActive = activeAgents?.includes(id)
const config = AGENT_CONFIG[id]
return (
<line key={id}>
<bufferGeometry>
<bufferAttribute
attach="attributes-position"
count={2}
array={new Float32Array([0, 0, 0, ...pos])}
itemSize={3}
/>
</bufferGeometry>
<lineBasicMaterial
color={config?.color || '#666'}
transparent
opacity={isActive ? 0.5 : 0.15}
/>
</line>
)
})}
</group>
)
}
// Main component
export default function NeuralViz({ events, nodeStats, historicCounts, subCategories, agentStats, onNodeClick }) {
const [activeBeams, setActiveBeams] = useState([])
const [activeAgents, setActiveAgents] = useState([])
// Calculate total events = sum of all agent events
const totalEvents = useMemo(() => {
const agentTotal = Object.values(agentStats || {}).reduce((sum, agent) => sum + (agent.messages || 0), 0)
return agentTotal || Object.values(historicCounts || {}).reduce((a, b) => a + b, 0)
}, [agentStats, historicCounts])
// Calculate agent positions (inner ring)
const agentPositions = useMemo(() => {
const agents = Object.keys(AGENT_CONFIG)
const positions = {}
agents.forEach((id, i) => {
positions[id] = ringPosition(i, agents.length, 1.8)
})
return positions
}, [])
// Calculate category positions (outer ring)
const categoryPositions = useMemo(() => {
const categories = Object.keys(CATEGORY_CONFIG)
const positions = {}
categories.forEach((type, i) => {
positions[type] = ringPosition(i, categories.length, 3.5, 0.5)
})
return positions
}, [])
// Watch for inter-agent events (sessions_send, etc.)
useEffect(() => {
if (!events || events.length === 0) return
const latest = events[0]
if (!latest) return
// Detect inter-agent communication
const subject = latest.subject || ''
const data = latest.data || {}
// Check for sessions_send tool calls
if (subject.includes('tool') && data?.payload?.data?.name === 'sessions_send') {
const args = data?.payload?.data?.args || {}
const fromAgent = data?.payload?.agent || 'main'
const toAgent = args.sessionKey || args.label || 'unknown'
// Map session keys to agent IDs
const agentMap = {
'mondo-assistant': 'mondo-assistant',
'mona': 'mondo-assistant',
'vera': 'vera',
'stella': 'stella',
'viola': 'viola',
'main': 'main'
}
const toId = Object.keys(agentMap).find(k => toAgent.toLowerCase().includes(k))
? agentMap[Object.keys(agentMap).find(k => toAgent.toLowerCase().includes(k))]
: null
if (toId && agentPositions[fromAgent] && agentPositions[toId]) {
const beam = {
id: Date.now(),
from: fromAgent,
to: toId,
label: `${AGENT_CONFIG[fromAgent]?.emoji || '?'}${AGENT_CONFIG[toId]?.emoji || '?'}`
}
setActiveBeams(prev => [...prev, beam])
setActiveAgents(prev => [...new Set([...prev, fromAgent, toId])])
// Clear beam after 3 seconds
setTimeout(() => {
setActiveBeams(prev => prev.filter(b => b.id !== beam.id))
}, 3000)
// Clear active agents after 5 seconds
setTimeout(() => {
setActiveAgents(prev => prev.filter(a => a !== fromAgent && a !== toId))
}, 5000)
}
}
// Also detect by agent field changes in messages
const agent = data?.payload?.agent
if (agent && AGENT_CONFIG[agent]) {
setActiveAgents(prev => {
if (prev.includes(agent)) return prev
setTimeout(() => {
setActiveAgents(p => p.filter(a => a !== agent))
}, 3000)
return [...prev, agent]
})
}
}, [events, agentPositions])
return (
<div className="w-full h-full relative">
{/* Active communications indicator */}
{activeBeams.length > 0 && (
<div className="absolute bottom-20 left-1/2 -translate-x-1/2 z-20">
<div className="px-4 py-2 rounded-full bg-violet-500/20 border border-violet-500/40 text-violet-300 text-sm animate-pulse">
🔗 {activeBeams.map(b => b.label).join(' • ')}
</div>
</div>
)}
{/* Instructions */}
<div className="absolute bottom-4 left-4 z-10 text-xs text-gray-500">
🖱 Drag to rotate Scroll to zoom Watch for agent communication beams
</div>
<Canvas camera={{ position: [0, 2, 8], fov: 50 }}>
<color attach="background" args={['#030014']} />
{/* Lighting */}
<ambientLight intensity={0.35} />
<pointLight position={[10, 10, 10]} intensity={0.5} color="#22d3ee" />
<pointLight position={[-10, -10, -10]} intensity={0.3} color="#a78bfa" />
<pointLight position={[0, 10, 0]} intensity={0.2} color="#34d399" />
{/* Scene */}
<AmbientParticles />
{/* Core */}
<CoreHub totalEvents={totalEvents} />
{/* Agent connections to core */}
<AgentConnections agentPositions={agentPositions} activeAgents={activeAgents} />
{/* Inter-agent beams */}
{activeBeams.map(beam => (
<AgentBeam
key={beam.id}
from={agentPositions[beam.from]}
to={agentPositions[beam.to]}
color={AGENT_CONFIG[beam.from]?.color || '#fff'}
intensity={1}
label={beam.label}
/>
))}
{/* Activity beams: Agent → Event Type (based on recent events) */}
{(() => {
// Find active agenttype combinations from recent events
const activeFlows = new Map() // key: "agent-type", value: { agent, type, color }
const now = Date.now()
events.slice(0, 20).forEach(event => {
const eventAge = now - new Date(event.timestamp).getTime()
if (eventAge > 5000) return // Only last 5 seconds
// Get agent: prefer explicit agent field, fallback to subject parsing
const explicitAgent = event.agent
const subjectParts = (event.subject || '').split('.')
const agentFromSubject = subjectParts[2] || 'main'
// Use explicit agent first, then subject, skip type-like values
const agent = explicitAgent ||
(agentFromSubject !== 'conversation' &&
agentFromSubject !== 'knowledge' &&
agentFromSubject !== 'lifecycle'
? agentFromSubject
: 'main')
const type = event.type
// Map agent names to our IDs
const agentId = agent === 'mondo-assistant' ? 'mondo-assistant' :
agent === 'mona' ? 'mondo-assistant' :
agent === 'vera' ? 'vera' :
agent === 'stella' ? 'stella' :
agent === 'viola' ? 'viola' :
agent === 'main' ? 'main' :
agent === 'claudia' ? 'main' : // claudia = main
agent === 'agent' ? 'main' : // subagent default
agent === 'unknown' ? 'main' : // unknown = main
'main'
if (type && CATEGORY_CONFIG[type] && agentPositions[agentId]) {
const key = `${agentId}-${type}`
if (!activeFlows.has(key)) {
activeFlows.set(key, { agent: agentId, type, color: CATEGORY_CONFIG[type].color })
}
}
})
return Array.from(activeFlows.values()).map(flow => {
const fromPos = agentPositions[flow.agent]
const toPos = categoryPositions[flow.type]
if (!fromPos || !toPos) return null
return (
<ActivityBeam
key={`activity-${flow.agent}-${flow.type}`}
from={fromPos}
to={toPos}
color={flow.color}
active={true}
/>
)
})
})()}
{/* Agent nodes (inner ring) */}
{Object.entries(AGENT_CONFIG).map(([id, config]) => (
<AgentNode
key={id}
id={id}
config={config}
position={agentPositions[id]}
stats={agentStats?.[id]}
isActive={activeAgents.includes(id)}
onClick={onNodeClick}
activeConnections={activeBeams.filter(b => b.from === id || b.to === id)}
/>
))}
{/* Category nodes (outer ring) */}
{Object.entries(CATEGORY_CONFIG).map(([type, config]) => (
<CategoryNode
key={type}
type={type}
config={config}
position={categoryPositions[type]}
count={historicCounts?.[type] || 0}
isActive={nodeStats?.[type]?.lastActive && (Date.now() - nodeStats[type].lastActive) < 2000}
/>
))}
<OrbitControls
enablePan={false}
minDistance={5}
maxDistance={15}
autoRotate
autoRotateSpeed={0.2}
enableDamping
dampingFactor={0.05}
maxPolarAngle={Math.PI * 0.75}
minPolarAngle={Math.PI * 0.25}
/>
</Canvas>
</div>
)
}

View file

@ -0,0 +1,142 @@
import React, { useState, useEffect } from 'react'
const statConfig = [
{ key: 'messages', icon: '💬', label: 'Messages', color: '#22d3ee' },
{ key: 'tools', icon: '🔧', label: 'Tools', color: '#a78bfa' },
{ key: 'knowledge', icon: '🧠', label: 'Knowledge', color: '#34d399' }
]
function StatCard({ icon, label, value, color, isActive, subtext }) {
return (
<div
className="stat-card transition-all duration-300"
style={{ '--accent-color': color }}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-2xl">{icon}</span>
<div>
<p className="text-xs text-gray-500 uppercase tracking-wide">{label}</p>
<div className="flex items-baseline gap-2">
<p className="text-2xl font-bold" style={{ color }}>{value.toLocaleString()}</p>
{subtext && <span className="text-xs text-emerald-400">{subtext}</span>}
</div>
</div>
</div>
{isActive && (
<span className="w-2 h-2 rounded-full animate-pulse" style={{ background: color }} />
)}
</div>
</div>
)
}
export default function StatsPanel({ stats, nodeStats }) {
const [uptime, setUptime] = useState('--')
const [historicTotal, setHistoricTotal] = useState(4082) // From NATS
const [historicByType, setHistoricByType] = useState({})
// Fetch historic stats
useEffect(() => {
const fetchStats = async () => {
try {
const res = await fetch('http://192.168.0.20:8766/stats')
if (res.ok) {
const data = await res.json()
setHistoricTotal(data.total || 4082)
setHistoricByType(data.byType || {})
}
} catch (e) {}
}
fetchStats()
const interval = setInterval(fetchStats, 10000)
return () => clearInterval(interval)
}, [])
// Calculate uptime
useEffect(() => {
const start = new Date('2026-01-26T00:00:00')
const update = () => {
const diff = Date.now() - start.getTime()
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
const mins = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
setUptime(`${days}d ${hours}h ${mins}m`)
}
update()
const interval = setInterval(update, 60000)
return () => clearInterval(interval)
}, [])
return (
<div className="h-full flex flex-col">
{/* Header */}
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<span className="text-emerald-400">📊</span> Neural Stats
</h2>
{/* Uptime Card */}
<div className="mb-4 p-4 rounded-2xl bg-gradient-to-br from-violet-500/10 to-cyan-500/10 border border-violet-500/20">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">System Uptime</p>
<p className="text-2xl font-bold bg-gradient-to-r from-cyan-400 to-violet-400 bg-clip-text text-transparent">
{uptime}
</p>
<p className="text-xs text-gray-600 mt-1">Since Jan 26, 2026</p>
</div>
{/* Total Events */}
<div className="mb-4 p-4 rounded-2xl bg-gradient-to-br from-emerald-500/10 to-emerald-500/5 border border-emerald-500/20">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-gray-500 uppercase tracking-wide">Total Memories</p>
<p className="text-3xl font-bold text-emerald-400">
{(historicTotal + stats.total).toLocaleString()}
</p>
</div>
<div className="text-4xl">🧠</div>
</div>
<p className="text-xs text-emerald-600 mt-2">
+{stats.total} this session
</p>
</div>
{/* Individual Stats - Historic + Live */}
<div className="space-y-3 flex-1">
{statConfig.map(({ key, icon, label, color }) => {
const typeKey = key === 'messages' ? 'message' : key === 'tools' ? 'tool' : key
const historic = historicByType[typeKey] || 0
const live = stats[key] || 0
return (
<StatCard
key={key}
icon={icon}
label={label}
value={historic + live}
subtext={live > 0 ? `+${live} live` : null}
color={color}
isActive={nodeStats[typeKey]?.lastActive &&
(Date.now() - nodeStats[typeKey]?.lastActive) < 2000}
/>
)
})}
</div>
{/* Last Event */}
{stats.lastEvent && (
<div className="mt-4 pt-4 border-t border-white/5">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-2">Last Event</p>
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-cyan-400 animate-pulse" />
<span className="text-sm text-gray-400 truncate">
{stats.lastEvent.preview || stats.lastEvent.subject.split('.').pop()}
</span>
</div>
<p className="text-xs text-gray-600 mt-1">
{stats.lastEvent.timestamp.toLocaleTimeString('de-DE')}
</p>
</div>
)}
</div>
)
}

128
src/index.css Normal file
View file

@ -0,0 +1,128 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
html, body, #root {
margin: 0;
padding: 0;
height: 100vh;
width: 100vw;
overflow: hidden;
background: #030014;
color: #e0e0e0;
}
/* Glass morphism cards */
.glass-card {
background: linear-gradient(135deg, rgba(255,255,255,0.05) 0%, rgba(255,255,255,0.02) 100%);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255,255,255,0.08);
box-shadow:
0 8px 32px rgba(0,0,0,0.4),
inset 0 1px 0 rgba(255,255,255,0.05);
}
.glass-card:hover {
border-color: rgba(255,255,255,0.12);
}
/* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: rgba(139, 92, 246, 0.3);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(139, 92, 246, 0.5);
}
/* Glow effects */
.glow-cyan {
box-shadow: 0 0 30px rgba(34, 211, 238, 0.3),
0 0 60px rgba(34, 211, 238, 0.1);
}
.glow-violet {
box-shadow: 0 0 30px rgba(139, 92, 246, 0.3),
0 0 60px rgba(139, 92, 246, 0.1);
}
/* Animations */
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
@keyframes pulse-glow {
0%, 100% { opacity: 1; filter: brightness(1); }
50% { opacity: 0.8; filter: brightness(1.2); }
}
.animate-float { animation: float 3s ease-in-out infinite; }
.animate-pulse-glow { animation: pulse-glow 2s ease-in-out infinite; }
@keyframes fade-in-down {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-pulse-once {
animation: fade-in-down 0.3s ease-out;
}
/* Event type colors */
.event-message { --event-color: #22d3ee; }
.event-tool { --event-color: #a78bfa; }
.event-knowledge { --event-color: #34d399; }
.event-lifecycle { --event-color: #f472b6; }
.event-other { --event-color: #94a3b8; }
/* Stat cards */
.stat-card {
@apply relative overflow-hidden rounded-2xl p-4;
background: linear-gradient(135deg, rgba(255,255,255,0.03) 0%, rgba(255,255,255,0.01) 100%);
border: 1px solid rgba(255,255,255,0.05);
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, var(--accent-color), transparent);
}
/* Line clamp for text truncation */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Mobile optimizations */
@media (max-width: 768px) {
.glass-card {
backdrop-filter: blur(10px);
}
}

10
src/main.jsx Normal file
View file

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

18
tailwind.config.js Normal file
View file

@ -0,0 +1,18 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,jsx}'],
theme: {
extend: {
colors: {
'cyber-bg': '#0a0a0f',
'cyber-card': '#12121a',
'cyber-border': '#1e1e2e',
'cyber-blue': '#00d4ff',
'cyber-purple': '#a855f7',
'cyber-pink': '#ec4899',
'cyber-green': '#22c55e'
}
}
},
plugins: []
}

10
vite.config.js Normal file
View file

@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 5173
}
})