🛡️ 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:
commit
6adf0757d3
20 changed files with 6270 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
dist/
|
||||
.DS_Store
|
||||
*.log
|
||||
13
index.html
Normal file
13
index.html
Normal 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
3825
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
26
package.json
Normal file
26
package.json
Normal 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
6
postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
}
|
||||
308
server/bridge.mjs
Normal file
308
server/bridge.mjs
Normal 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
64
server/package-lock.json
generated
Normal 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
16
server/package.json
Normal 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
361
src/App.jsx
Normal 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 || '—'
|
||||
}
|
||||
108
src/components/ActivityChart.jsx
Normal file
108
src/components/ActivityChart.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
101
src/components/EventDetails.jsx
Normal file
101
src/components/EventDetails.jsx
Normal 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
133
src/components/EventLog.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
87
src/components/EventStream.jsx
Normal file
87
src/components/EventStream.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
141
src/components/EventTable.jsx
Normal file
141
src/components/EventTable.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
769
src/components/NeuralViz.jsx
Normal file
769
src/components/NeuralViz.jsx
Normal 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 agent→type 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>
|
||||
)
|
||||
}
|
||||
142
src/components/StatsPanel.jsx
Normal file
142
src/components/StatsPanel.jsx
Normal 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
128
src/index.css
Normal 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
10
src/main.jsx
Normal 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
18
tailwind.config.js
Normal 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
10
vite.config.js
Normal 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
|
||||
}
|
||||
})
|
||||
Loading…
Reference in a new issue