feat(ui): Add structured observability events
Signed-off-by: Richard Palethorpe <io@richiejp.com>
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
"": {
|
||||
"name": "react-ui",
|
||||
"dependencies": {
|
||||
"highlight.js": "^11.11.1",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
},
|
||||
@@ -300,6 +301,8 @@
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
|
||||
|
||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
"react-dom": "^19.1.0",
|
||||
"highlight.js": "^11.11.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.24.0",
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
/* Base styles */
|
||||
pre.hljs {
|
||||
background-color: var(--medium-bg);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
code.json {
|
||||
display: block;
|
||||
}
|
||||
|
||||
:root {
|
||||
--primary: #00ff95;
|
||||
--secondary: #ff00b1;
|
||||
@@ -1994,16 +2007,62 @@ select.form-control {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.file-button:hover {
|
||||
background: rgba(0, 255, 149, 0.8);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.file-button i {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--medium-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-bottom: 15px;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
|
||||
background: var(--light-bg);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--primary);
|
||||
border-radius: 50%;
|
||||
border-top-color: transparent;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.expand-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
font-size: 1.2em;
|
||||
padding: 5px;
|
||||
margin-left: 10px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.expand-button:hover {
|
||||
color: var(--success);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.expand-button:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--primary);
|
||||
}
|
||||
|
||||
.selected-file-info {
|
||||
margin-top: 20px;
|
||||
padding: 20px;
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import hljs from 'highlight.js/lib/core';
|
||||
import json from 'highlight.js/lib/languages/json';
|
||||
import 'highlight.js/styles/monokai.css';
|
||||
|
||||
hljs.registerLanguage('json', json);
|
||||
|
||||
function AgentStatus() {
|
||||
const [showStatus, setShowStatus] = useState(true);
|
||||
const { name } = useParams();
|
||||
const [statusData, setStatusData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [_eventSource, setEventSource] = useState(null);
|
||||
const [liveUpdates, setLiveUpdates] = useState([]);
|
||||
// Store all observables by id
|
||||
const [observableMap, setObservableMap] = useState({});
|
||||
const [observableTree, setObservableTree] = useState([]);
|
||||
const [expandedCards, setExpandedCards] = useState(new Map());
|
||||
|
||||
// Update document title
|
||||
useEffect(() => {
|
||||
@@ -39,17 +48,80 @@ function AgentStatus() {
|
||||
|
||||
fetchStatusData();
|
||||
|
||||
// Helper to build observable tree from map
|
||||
function buildObservableTree(map) {
|
||||
const nodes = Object.values(map);
|
||||
const nodeMap = {};
|
||||
nodes.forEach(node => { nodeMap[node.id] = { ...node, children: [] }; });
|
||||
const roots = [];
|
||||
nodes.forEach(node => {
|
||||
if (!node.parent_id) {
|
||||
roots.push(nodeMap[node.id]);
|
||||
} else if (nodeMap[node.parent_id]) {
|
||||
nodeMap[node.parent_id].children.push(nodeMap[node.id]);
|
||||
}
|
||||
});
|
||||
return roots;
|
||||
}
|
||||
|
||||
// Fetch initial observable history
|
||||
const fetchObservables = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/agent/${name}/observables`);
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
if (Array.isArray(data.History)) {
|
||||
const map = {};
|
||||
data.History.forEach(obs => {
|
||||
map[obs.id] = obs;
|
||||
});
|
||||
setObservableMap(map);
|
||||
setObservableTree(buildObservableTree(map));
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore errors for now
|
||||
}
|
||||
};
|
||||
fetchObservables();
|
||||
|
||||
// Setup SSE connection for live updates
|
||||
const sse = new EventSource(`/sse/${name}`);
|
||||
setEventSource(sse);
|
||||
|
||||
sse.addEventListener('observable_update', (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log(data);
|
||||
setObservableMap(prevMap => {
|
||||
const prev = prevMap[data.id] || {};
|
||||
const updated = {
|
||||
...prev,
|
||||
...data,
|
||||
creation: data.creation,
|
||||
progress: data.progress,
|
||||
completion: data.completion,
|
||||
// children are always built client-side
|
||||
};
|
||||
const newMap = { ...prevMap, [data.id]: updated };
|
||||
setObservableTree(buildObservableTree(newMap));
|
||||
return newMap;
|
||||
});
|
||||
});
|
||||
|
||||
// Listen for status events and append to statusData.History
|
||||
sse.addEventListener('status', (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
setLiveUpdates(prev => [data, ...prev.slice(0, 19)]); // Keep last 20 updates
|
||||
} catch (err) {
|
||||
setLiveUpdates(prev => [event.data, ...prev.slice(0, 19)]);
|
||||
}
|
||||
const status = event.data;
|
||||
setStatusData(prev => {
|
||||
// If prev is null, start a new object
|
||||
if (!prev || typeof prev !== 'object') {
|
||||
return { History: [status] };
|
||||
}
|
||||
// If History not present, add it
|
||||
if (!Array.isArray(prev.History)) {
|
||||
return { ...prev, History: [status] };
|
||||
}
|
||||
// Otherwise, append
|
||||
return { ...prev, History: [...prev.History, status] };
|
||||
});
|
||||
});
|
||||
|
||||
sse.onerror = (err) => {
|
||||
@@ -69,7 +141,7 @@ function AgentStatus() {
|
||||
if (value === null || value === undefined) {
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
|
||||
if (typeof value === 'object') {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
@@ -77,14 +149,14 @@ function AgentStatus() {
|
||||
return '[Complex Object]';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return String(value);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading-container">
|
||||
<div className="loader"></div>
|
||||
<div>
|
||||
<div></div>
|
||||
<p>Loading agent status...</p>
|
||||
</div>
|
||||
);
|
||||
@@ -92,56 +164,199 @@ function AgentStatus() {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="error-container">
|
||||
<div>
|
||||
<h2>Error</h2>
|
||||
<p>{error}</p>
|
||||
<Link to="/agents" className="back-btn">
|
||||
<Link to="/agents">
|
||||
<i className="fas fa-arrow-left"></i> Back to Agents
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Combine live updates with history
|
||||
const allUpdates = [...liveUpdates, ...(statusData?.History || [])];
|
||||
|
||||
return (
|
||||
<div className="agent-status-container">
|
||||
<header className="page-header">
|
||||
<div className="header-content">
|
||||
<h1>
|
||||
<Link to="/agents" className="back-link">
|
||||
<i className="fas fa-arrow-left"></i>
|
||||
</Link>
|
||||
Agent Status: {name}
|
||||
</h1>
|
||||
<div>
|
||||
<h1>Agent Status: {name}</h1>
|
||||
<div style={{ color: '#aaa', fontSize: 16, marginBottom: 18 }}>
|
||||
See what the agent is doing and thinking
|
||||
</div>
|
||||
{error && (
|
||||
<div>
|
||||
{error}
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
{loading && <div>Loading...</div>}
|
||||
{statusData && (
|
||||
<div>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', userSelect: 'none' }}
|
||||
onClick={() => setShowStatus(prev => !prev)}>
|
||||
<h2 style={{ margin: 0 }}>Current Status</h2>
|
||||
<i
|
||||
className={`fas fa-chevron-${showStatus ? 'up' : 'down'}`}
|
||||
style={{ color: 'var(--primary)', marginLeft: 12 }}
|
||||
title={showStatus ? 'Collapse' : 'Expand'}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ color: '#aaa', fontSize: 14, margin: '5px 0 10px 2px' }}>
|
||||
Summary of the agent's thoughts and actions
|
||||
</div>
|
||||
{showStatus && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
{(Array.isArray(statusData?.History) && statusData.History.length === 0) && (
|
||||
<div style={{ color: '#aaa' }}>No status history available.</div>
|
||||
)}
|
||||
{Array.isArray(statusData?.History) && statusData.History.map((item, idx) => (
|
||||
<div key={idx} style={{
|
||||
background: '#222',
|
||||
border: '1px solid #444',
|
||||
borderRadius: 8,
|
||||
padding: '12px 16px',
|
||||
marginBottom: 10,
|
||||
whiteSpace: 'pre-line',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 15,
|
||||
color: '#eee',
|
||||
}}>
|
||||
{/* Replace <br> tags with newlines, then render as pre-line */}
|
||||
{typeof item === 'string'
|
||||
? item.replace(/<br\s*\/?>/gi, '\n')
|
||||
: JSON.stringify(item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{observableTree.length > 0 && (
|
||||
<div>
|
||||
<h2>Observable Updates</h2>
|
||||
<div style={{ color: '#aaa', fontSize: 14, margin: '5px 0 10px 2px' }}>
|
||||
Drill down into what the agent is doing and thinking when activated by a connector
|
||||
</div>
|
||||
<div>
|
||||
{observableTree.map((container, idx) => (
|
||||
<div key={container.id || idx} className='card' style={{ marginBottom: '1em' }}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
const newExpanded = !expandedCards.get(container.id);
|
||||
setExpandedCards(new Map(expandedCards).set(container.id, newExpanded));
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
|
||||
<i className={`fas fa-${container.icon || 'robot'}`} style={{ verticalAlign: '-0.125em' }}></i>
|
||||
<span>
|
||||
<span className='stat-label'>{container.name}</span>#<span className='stat-label'>{container.id}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<i
|
||||
className={`fas fa-chevron-${expandedCards.get(container.id) ? 'up' : 'down'}`}
|
||||
style={{ color: 'var(--primary)' }}
|
||||
title='Toggle details'
|
||||
/>
|
||||
{!container.completion && (
|
||||
<div className='spinner' />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: expandedCards.get(container.id) ? 'block' : 'none' }}>
|
||||
{container.children && container.children.length > 0 && (
|
||||
|
||||
<div className="chat-container bg-gray-800 shadow-lg rounded-lg">
|
||||
{/* Chat Messages */}
|
||||
<div className="chat-messages p-4">
|
||||
{allUpdates.length > 0 ? (
|
||||
allUpdates.map((item, index) => (
|
||||
<div key={index} className="status-item mb-4">
|
||||
<div className="bg-gray-700 p-4 rounded-lg">
|
||||
<h2 className="text-sm font-semibold mb-2">Agent Action:</h2>
|
||||
<div className="status-details">
|
||||
<div className="status-row">
|
||||
<span className="status-label">{index}</span>
|
||||
<span className="status-value">{formatValue(item)}</span>
|
||||
<div style={{ marginLeft: '2em', marginTop: '1em' }}>
|
||||
<h4>Nested Observables</h4>
|
||||
{container.children.map(child => {
|
||||
const childKey = `child-${child.id}`;
|
||||
const isExpanded = expandedCards.get(childKey);
|
||||
return (
|
||||
<div key={`${container.id}-child-${child.id}`} className='card' style={{ background: '#222', marginBottom: '0.5em' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
const newExpanded = !expandedCards.get(childKey);
|
||||
setExpandedCards(new Map(expandedCards).set(childKey, newExpanded));
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
|
||||
<i className={`fas fa-${child.icon || 'robot'}`} style={{ verticalAlign: '-0.125em' }}></i>
|
||||
<span>
|
||||
<span className='stat-label'>{child.name}</span>#<span className='stat-label'>{child.id}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<i
|
||||
className={`fas fa-chevron-${isExpanded ? 'up' : 'down'}`}
|
||||
style={{ color: 'var(--primary)' }}
|
||||
title='Toggle details'
|
||||
/>
|
||||
{!child.completion && (
|
||||
<div className='spinner' />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: isExpanded ? 'block' : 'none' }}>
|
||||
{child.creation && (
|
||||
<div>
|
||||
<h5>Creation:</h5>
|
||||
<pre className="hljs"><code>
|
||||
<div dangerouslySetInnerHTML={{ __html: hljs.highlight(JSON.stringify(child.creation || {}, null, 2), { language: 'json' }).value }}></div>
|
||||
</code></pre>
|
||||
</div>
|
||||
)}
|
||||
{child.progress && child.progress.length > 0 && (
|
||||
<div>
|
||||
<h5>Progress:</h5>
|
||||
<pre className="hljs"><code>
|
||||
<div dangerouslySetInnerHTML={{ __html: hljs.highlight(JSON.stringify(child.progress || {}, null, 2), { language: 'json' }).value }}></div>
|
||||
</code></pre>
|
||||
</div>
|
||||
)}
|
||||
{child.completion && (
|
||||
<div>
|
||||
<h5>Completion:</h5>
|
||||
<pre className="hljs"><code>
|
||||
<div dangerouslySetInnerHTML={{ __html: hljs.highlight(JSON.stringify(child.completion || {}, null, 2), { language: 'json' }).value }}></div>
|
||||
</code></pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{container.creation && (
|
||||
<div>
|
||||
<h4>Creation:</h4>
|
||||
<pre className="hljs"><code>
|
||||
<div dangerouslySetInnerHTML={{ __html: hljs.highlight(JSON.stringify(container.creation || {}, null, 2), { language: 'json' }).value }}></div>
|
||||
</code></pre>
|
||||
</div>
|
||||
)}
|
||||
{container.progress && container.progress.length > 0 && (
|
||||
<div>
|
||||
<h4>Progress:</h4>
|
||||
<pre className="hljs"><code>
|
||||
<div dangerouslySetInnerHTML={{ __html: hljs.highlight(JSON.stringify(container.progress || {}, null, 2), { language: 'json' }).value }}></div>
|
||||
</code></pre>
|
||||
</div>
|
||||
)}
|
||||
{container.completion && (
|
||||
<div>
|
||||
<h4>Completion:</h4>
|
||||
<pre className="hljs"><code>
|
||||
<div dangerouslySetInnerHTML={{ __html: hljs.highlight(JSON.stringify(container.completion || {}, null, 2), { language: 'json' }).value }}></div>
|
||||
</code></pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="no-status-data">
|
||||
<p>No status data available for this agent.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -241,13 +241,14 @@ func (app *App) registerRoutes(pool *state.AgentPool, webapp *fiber.App) {
|
||||
|
||||
entries := []string{}
|
||||
for _, h := range Reverse(history.Results()) {
|
||||
entries = append(entries, fmt.Sprintf(
|
||||
"Result: %v Action: %v Params: %v Reasoning: %v",
|
||||
h.Result,
|
||||
h.Action.Definition().Name,
|
||||
h.Params,
|
||||
entries = append(entries, fmt.Sprintf(`Reasoning: %s
|
||||
Action taken: %+v
|
||||
Parameters: %+v
|
||||
Result: %s`,
|
||||
h.Reasoning,
|
||||
))
|
||||
h.ActionCurrentState.Action.Definition().Name,
|
||||
h.ActionCurrentState.Params,
|
||||
h.Result))
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
@@ -256,6 +257,21 @@ func (app *App) registerRoutes(pool *state.AgentPool, webapp *fiber.App) {
|
||||
})
|
||||
})
|
||||
|
||||
webapp.Get("/api/agent/:name/observables", func(c *fiber.Ctx) error {
|
||||
name := c.Params("name")
|
||||
agent := pool.GetAgent(name)
|
||||
if agent == nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
|
||||
"error": "Agent not found",
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"Name": name,
|
||||
"History": agent.Observer().History(),
|
||||
})
|
||||
})
|
||||
|
||||
webapp.Post("/settings/import", app.ImportAgent(pool))
|
||||
webapp.Get("/settings/export/:name", app.ExportAgent(pool))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user