Real-Time Activity Dashboard with Live Conversation Graph and Agent Chat
Objective
Build real-time activity dashboard that visualizes agent mesh communications, displays live conversation graphs, streams agent chat, and provides insights from activity stream service.
Dependencies
- OSSA Issue #21: Activity streaming schema
- agent-mesh Issue #6: Mesh observer for relationship graph
- agent-brain Issue #10: Activity stream service (port 50052)
- Requires: React/Next.js, D3.js/Cytoscape.js, WebSocket, gRPC-Web
Scope
- Live Conversation Graph - Real-time visualization of agent mesh relationships
- Agent Chat Stream - Live view of all agent conversations with semantic insights
- Activity Timeline - Real-time activity feed from all agents
- Metrics Dashboard - Real-time agent performance metrics
- Conversation Replay - Historical conversation playback with analysis
- Search and Filters - Filter by agent, capability, conversation, time range
Architecture
┌────────────────────────────────────────────────────────────┐
│ Agent Studio Dashboard (Web UI) │
├────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Real-Time Data Layer (gRPC-Web) │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌─────────┐ │ │
│ │ │ Activity │ │ Mesh Graph │ │ Metrics │ │ │
│ │ │ Stream │ │ Stream │ │ Stream │ │ │
│ │ └──────┬───────┘ └──────┬───────┘ └────┬────┘ │ │
│ └─────────┼───────────────────┼────────────────┼──────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌────────────┐ │
│ │ Conversation │ │ Live Graph │ │ Metrics │ │
│ │ Chat View │ │ Visualization │ │ Dashboard │ │
│ │ (Real-time) │ │ (D3/Cytoscape) │ │ (Charts) │ │
│ └─────────────────┘ └─────────────────┘ └────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Activity Timeline & Search │ │
│ └─────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
│
▼ gRPC-Web (port 8080)
┌──────────────────────────────┐
│ gRPC-Web Proxy (Envoy) │
└──────────────┬───────────────┘
│
▼ gRPC (port 50052)
┌──────────────────────────────┐
│ Activity Stream Service │
└──────────────────────────────┘
Implementation
1. Real-Time Data Layer
// src/lib/activity-stream-client.ts
import { ActivityStreamServiceClient } from './proto/activity_stream_grpc_web_pb';
import { ActivityEvent } from './proto/activity_stream_pb';
export class ActivityStreamClient {
private client: ActivityStreamServiceClient;
private stream: any;
private listeners: Map<string, Set<(event: ActivityEvent) => void>>;
constructor(endpoint: string = 'http://localhost:8080') {
this.client = new ActivityStreamServiceClient(endpoint);
this.listeners = new Map();
}
/**
* Subscribe to activity stream
*/
subscribe(callback: (event: ActivityEvent) => void): () => void {
const stream = this.client.streamActivities();
stream.on('data', (event: ActivityEvent) => {
callback(event);
});
stream.on('error', (error: any) => {
console.error('Activity stream error:', error);
});
// Return unsubscribe function
return () => {
stream.cancel();
};
}
/**
* Subscribe to specific event types
*/
subscribeToEventType(
eventType: 'capability_invocation' | 'conversation' | 'state_change' | 'error',
callback: (event: ActivityEvent) => void
): () => void {
return this.subscribe((event) => {
if (event.getType() === eventType) {
callback(event);
}
});
}
/**
* Subscribe to specific agent's activities
*/
subscribeToAgent(agentId: string, callback: (event: ActivityEvent) => void): () => void {
return this.subscribe((event) => {
if (event.getMetadata()?.agent_id === agentId) {
callback(event);
}
});
}
/**
* Subscribe to specific conversation
*/
subscribeToConversation(conversationId: string, callback: (event: ActivityEvent) => void): () => void {
return this.subscribe((event) => {
if (event.getMetadata()?.conversation_id === conversationId) {
callback(event);
}
});
}
}
2. Live Conversation Graph Component
// src/components/LiveAgentGraph.tsx
'use client';
import React, { useEffect, useRef, useState } from 'react';
import cytoscape from 'cytoscape';
import { ActivityStreamClient } from '@/lib/activity-stream-client';
export function LiveAgentGraph() {
const containerRef = useRef<HTMLDivElement>(null);
const cyRef = useRef<cytoscape.Core | null>(null);
const [nodes, setNodes] = useState<Map<string, any>>(new Map());
const [edges, setEdges] = useState<Map<string, any>>(new Map());
useEffect(() => {
if (!containerRef.current) return;
// Initialize Cytoscape
cyRef.current = cytoscape({
container: containerRef.current,
style: [
{
selector: 'node',
style: {
'background-color': '#4A90E2',
'label': 'data(label)',
'width': 60,
'height': 60,
'font-size': 12,
'text-valign': 'center',
'text-halign': 'center',
'color': '#fff',
'text-outline-color': '#4A90E2',
'text-outline-width': 2
}
},
{
selector: 'edge',
style: {
'width': 'data(weight)',
'line-color': '#ccc',
'target-arrow-color': '#ccc',
'target-arrow-shape': 'triangle',
'curve-style': 'bezier',
'label': 'data(label)',
'font-size': 10
}
},
{
selector: '.active',
style: {
'line-color': '#E24A4A',
'target-arrow-color': '#E24A4A',
'width': 4,
'z-index': 999
}
}
],
layout: {
name: 'cose',
animate: true,
animationDuration: 500
}
});
// Subscribe to activity stream
const client = new ActivityStreamClient();
const unsubscribe = client.subscribeToEventType('capability_invocation', (event) => {
const source = event.getMetadata()?.source_agent_id;
const target = event.getMetadata()?.target_agent_id;
const capability = event.getMetadata()?.capability;
const latency = event.getDurationMs();
if (source && target) {
updateGraph(source, target, capability, latency);
}
});
return () => {
unsubscribe();
cyRef.current?.destroy();
};
}, []);
const updateGraph = (source: string, target: string, capability: string, latency: number) => {
if (!cyRef.current) return;
// Add/update nodes
if (!cyRef.current.$id(source).length) {
cyRef.current.add({
group: 'nodes',
data: { id: source, label: source }
});
}
if (!cyRef.current.$id(target).length) {
cyRef.current.add({
group: 'nodes',
data: { id: target, label: target }
});
}
// Add/update edge
const edgeId = `${source}-${target}`;
const existingEdge = cyRef.current.$id(edgeId);
if (existingEdge.length) {
const currentWeight = existingEdge.data('weight') || 1;
const currentCalls = existingEdge.data('calls') || 0;
const avgLatency = existingEdge.data('avgLatency') || 0;
existingEdge.data({
weight: Math.min(currentWeight + 0.5, 10),
calls: currentCalls + 1,
avgLatency: (avgLatency * currentCalls + latency) / (currentCalls + 1),
label: `${currentCalls + 1} calls\\n${Math.round((avgLatency * currentCalls + latency) / (currentCalls + 1))}ms`
});
// Animate active edge
existingEdge.addClass('active');
setTimeout(() => {
existingEdge.removeClass('active');
}, 1000);
} else {
cyRef.current.add({
group: 'edges',
data: {
id: edgeId,
source,
target,
weight: 2,
calls: 1,
avgLatency: latency,
label: `1 call\\n${latency}ms`
}
});
}
// Re-layout
cyRef.current.layout({ name: 'cose', animate: true }).run();
};
return (
<div className="relative h-full w-full">
<div ref={containerRef} className="h-full w-full" />
<div className="absolute top-4 right-4 bg-white/90 backdrop-blur p-4 rounded-lg shadow-lg">
<h3 className="font-semibold mb-2">Legend</h3>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full bg-[#4A90E2]" />
<span>Agent Node</span>
</div>
<div className="flex items-center gap-2">
<div className="w-8 h-0.5 bg-gray-300" />
<span>Communication</span>
</div>
<div className="flex items-center gap-2">
<div className="w-8 h-1 bg-[#E24A4A]" />
<span>Active (now)</span>
</div>
</div>
</div>
</div>
);
}
3. Agent Chat Stream Component
// src/components/AgentChatStream.tsx
'use client';
import React, { useEffect, useState } from 'react';
import { ActivityStreamClient } from '@/lib/activity-stream-client';
interface ConversationTurn {
conversation_id: string;
agent_id: string;
message: string;
response: string;
sentiment: string;
intent: string;
timestamp: string;
}
export function AgentChatStream() {
const [conversations, setConversations] = useState<ConversationTurn[]>([]);
const [filter, setFilter] = useState<string>('');
useEffect(() => {
const client = new ActivityStreamClient();
const unsubscribe = client.subscribeToEventType('conversation', (event) => {
const turn: ConversationTurn = {
conversation_id: event.getMetadata()?.conversation_id || '',
agent_id: event.getMetadata()?.agent_id || '',
message: event.getMetadata()?.message || '',
response: event.getMetadata()?.response || '',
sentiment: event.getMetadata()?.sentiment || 'neutral',
intent: event.getMetadata()?.intent || '',
timestamp: event.getTimestamp()
};
setConversations(prev => [turn, ...prev].slice(0, 100)); // Keep last 100
});
return () => unsubscribe();
}, []);
const filteredConversations = filter
? conversations.filter(c =>
c.agent_id.includes(filter) ||
c.conversation_id.includes(filter) ||
c.message.toLowerCase().includes(filter.toLowerCase())
)
: conversations;
return (
<div className="flex flex-col h-full">
<div className="p-4 border-b">
<input
type="text"
placeholder="Filter conversations..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="w-full px-4 py-2 border rounded-lg"
/>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{filteredConversations.map((turn, i) => (
<div key={i} className="bg-white rounded-lg shadow p-4 border-l-4" style={{
borderLeftColor: turn.sentiment === 'positive' ? '#10B981' :
turn.sentiment === 'negative' ? '#EF4444' : '#6B7280'
}}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="font-mono text-sm bg-gray-100 px-2 py-1 rounded">{turn.agent_id}</span>
<span className="text-xs text-gray-500">{turn.conversation_id.slice(0, 8)}</span>
</div>
<span className="text-xs text-gray-400">{new Date(turn.timestamp).toLocaleTimeString()}</span>
</div>
<div className="space-y-2">
<div className="bg-blue-50 p-3 rounded-lg">
<p className="text-sm font-medium text-blue-900">User</p>
<p className="text-sm text-blue-800">{turn.message}</p>
</div>
<div className="bg-green-50 p-3 rounded-lg">
<p className="text-sm font-medium text-green-900">Agent</p>
<p className="text-sm text-green-800">{turn.response}</p>
</div>
</div>
<div className="mt-3 flex items-center gap-4 text-xs">
<span className="px-2 py-1 rounded" style={{
backgroundColor: turn.sentiment === 'positive' ? '#D1FAE5' :
turn.sentiment === 'negative' ? '#FEE2E2' : '#F3F4F6',
color: turn.sentiment === 'positive' ? '#065F46' :
turn.sentiment === 'negative' ? '#991B1B' : '#374151'
}}>
{turn.sentiment}
</span>
<span className="text-gray-600">Intent: {turn.intent}</span>
</div>
</div>
))}
</div>
</div>
);
}
4. Main Dashboard Layout
// src/app/dashboard/page.tsx
import { LiveAgentGraph } from '@/components/LiveAgentGraph';
import { AgentChatStream } from '@/components/AgentChatStream';
import { MetricsDashboard } from '@/components/MetricsDashboard';
import { ActivityTimeline } from '@/components/ActivityTimeline';
export default function DashboardPage() {
return (
<div className="h-screen bg-gray-50 flex flex-col">
{/* Header */}
<header className="bg-white border-b px-6 py-4">
<h1 className="text-2xl font-bold">Agent Studio - Real-Time Activity Dashboard</h1>
</header>
{/* Main Grid */}
<div className="flex-1 grid grid-cols-2 grid-rows-2 gap-4 p-4 overflow-hidden">
{/* Top Left: Live Graph */}
<div className="bg-white rounded-lg shadow p-4">
<h2 className="text-lg font-semibold mb-4">Live Agent Mesh</h2>
<div className="h-[calc(100%-3rem)]">
<LiveAgentGraph />
</div>
</div>
{/* Top Right: Chat Stream */}
<div className="bg-white rounded-lg shadow p-4">
<h2 className="text-lg font-semibold mb-4">Agent Conversations</h2>
<div className="h-[calc(100%-3rem)]">
<AgentChatStream />
</div>
</div>
{/* Bottom Left: Metrics */}
<div className="bg-white rounded-lg shadow p-4">
<h2 className="text-lg font-semibold mb-4">Metrics</h2>
<MetricsDashboard />
</div>
{/* Bottom Right: Activity Timeline */}
<div className="bg-white rounded-lg shadow p-4">
<h2 className="text-lg font-semibold mb-4">Activity Timeline</h2>
<ActivityTimeline />
</div>
</div>
</div>
);
}
Deployment (gRPC-Web Proxy)
# kubernetes/envoy-proxy.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: envoy-config
data:
envoy.yaml: |
static_resources:
listeners:
- address:
socket_address:
address: 0.0.0.0
port_value: 8080
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
codec_type: auto
stat_prefix: ingress_http
route_config:
name: local_route
virtual_hosts:
- name: backend
domains: ["*"]
routes:
- match:
prefix: "/"
route:
cluster: activity_stream_service
http_filters:
- name: envoy.filters.http.grpc_web
- name: envoy.filters.http.cors
- name: envoy.filters.http.router
clusters:
- name: activity_stream_service
type: LOGICAL_DNS
lb_policy: ROUND_ROBIN
dns_lookup_family: V4_ONLY
typed_extension_protocol_options:
envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
"@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
explicit_http_config:
http2_protocol_options: {}
load_assignment:
cluster_name: activity_stream_service
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: activity-stream-service
port_value: 50052
Acceptance Criteria
-
Live agent mesh graph with real-time updates -
Agent chat stream with semantic insights (sentiment, intent) -
Metrics dashboard with real-time charts -
Activity timeline with filtering -
gRPC-Web integration via Envoy proxy -
Responsive UI (works on tablets) -
Search and filter functionality -
Conversation replay feature -
Export graph as PNG/SVG -
Real-time performance: <100ms UI updates
Files to Create
src/lib/activity-stream-client.ts
src/components/LiveAgentGraph.tsx
src/components/AgentChatStream.tsx
src/components/MetricsDashboard.tsx
src/components/ActivityTimeline.tsx
src/app/dashboard/page.tsx
kubernetes/envoy-proxy.yaml
-
proto/activity_stream_grpc_web_pb.js
(generated)
Performance Requirements
- Real-time updates: <100ms latency
- Graph rendering: Handle 100+ nodes smoothly
- Memory usage: <500MB for 1000+ messages
- Concurrent users: Support 50+ simultaneous viewers
Related Issues
- OSSA #21: Activity streaming schema
- agent-mesh #6: Mesh observer
- agent-brain #10: Activity stream service