Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **Bound trace console list limits** (`actionagent`). The console defaults
to 50 recent matching traces, clamps integer limits to 1–1000, and uses
the default for malformed values. Its documentation describes the bounded
recent list rather than claiming pagination.

- **Select trace time windows in dark mode** (`actionagent`). The dark
Traces header includes the shared time-window selector and zoom controls,
including when embedded on an agent page or when the window is empty.

- **Show tool usage in both dashboard themes** (`actionagent`). The Traces
page renders the same tool counts, durations, failures and empty state
in dark and light mode, using the shared telemetry colors.

- **The agent interactions page follows the dark theme** (`actionagent`). It
was written in Tailwind's grey scale (white panes, grey text) and stayed
light under the dark theme at every width, with its run stream rendered
Expand Down
2 changes: 1 addition & 1 deletion actionagent/app/assets/builds/action_agent.css

Large diffs are not rendered by default.

98 changes: 49 additions & 49 deletions actionagent/app/assets/builds/action_agent.js

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions actionagent/app/controllers/action_agent/traces_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ module ActionAgent
# Controller for viewing telemetry traces.
#
# Provides:
# - List view with filtering and pagination
# - Bounded list of recent traces with filtering (no pagination)
# - Detail view with span timeline
# - Metrics overview
# - Live updates via Turbo Streams
Expand Down Expand Up @@ -76,7 +76,8 @@ def fetch_traces
)
end

traces.limit(params[:limit] || 50)
limit = Integer(params[:limit], 10, exception: false) || 50
traces.limit(limit.clamp(1, 1000))
end

def calculate_metrics
Expand Down
144 changes: 78 additions & 66 deletions actionagent/frontend/components/dashboard/TracesView.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useTimeWindow } from '../../contexts/TimeWindowContext';
import AgentStatCard from './AgentStatCard';
import TimeWindowSelector from './TimeWindowSelector';
import TraceCard from './TraceCard';
import { telemetryColors } from './TelemetryObject';
import { formatDuration, getSpanIcon } from './SpanWaterfall';
import { dashboardPath } from '../../utils/dashboardPath';
import { tracesQuery } from '../../utils/tracesQuery.mjs';
Expand Down Expand Up @@ -480,6 +481,79 @@ export default function TracesView({ agentId = null, embedded = false }) {
);
}

// Tool usage — which tools get called and what they cost in aggregate.
// A 20ms tool called 40 times can dominate a 3s one called once, and
// neither is visible from a single trace's waterfall.
const renderToolsBreakdown = (dark) => {
const colors = telemetryColors(dark);
return (
<div className={dark ? 'px-6 pb-6 max-md:px-4' : undefined}>
{toolStats.length === 0 ? (
<div className="text-center py-8 text-sm" style={{ color: colors.textSecondary }}>
No tool calls in this window
</div>
) : (
<div className="rounded-xl border overflow-x-auto" style={{ background: colors.cardBg, borderColor: colors.cardBorder }}>
{/* Flexbox rather than a 12-column grid: Tailwind compiles only
the class names it finds in the source, and the grid column
utilities appear nowhere else. Naming one, even in a comment,
would add it to the build. The min width scrolls the table on
narrow screens instead of crushing its columns. */}
<div style={{ minWidth: 560 }}>
<div className="flex items-center gap-4 px-4 py-2 text-xs font-medium border-b" style={{ color: colors.textSecondary, borderColor: colors.cardBorder }}>
<div className="flex-1">Tool</div>
<div className="w-20 text-right">Calls</div>
<div className="w-24 text-right">Total time</div>
<div className="w-20 text-right">Avg</div>
<div className="w-20 text-right">Slowest</div>
</div>
{toolStats.map((stat) => {
const share = toolStats[0].totalDuration
? (stat.totalDuration / toolStats[0].totalDuration) * 100
: 0;
return (
<div key={stat.name} className="px-4 py-2 border-b last:border-0" style={{ borderColor: colors.cardBorder }}>
<div className="flex items-center gap-4 text-sm">
<div className="flex-1 min-w-0">
<div className="font-medium truncate" style={{ color: colors.textPrimary }} title={stat.name}>{stat.name}</div>
{stat.agents.length > 0 && (
<div className="text-xs truncate" style={{ color: colors.textMuted }} title={stat.agents.join(', ')}>
{stat.agents.join(', ')}
</div>
)}
</div>
<div className="w-20 text-right" style={{ fontFamily: TYPOGRAPHY.mono, color: colors.textBody }}>
{stat.count.toLocaleString()}
{stat.errors > 0 && (
<span className="ml-1" style={{ color: colors.bad }} title={`${stat.errors} failed`}>
({stat.errors})
</span>
)}
</div>
<div className="w-24 text-right font-medium" style={{ fontFamily: TYPOGRAPHY.mono, color: colors.textPrimary }}>
{formatDuration(stat.totalDuration)}
</div>
<div className="w-20 text-right" style={{ fontFamily: TYPOGRAPHY.mono, color: colors.textSecondary }}>
{formatDuration(stat.avgDuration)}
</div>
<div className="w-20 text-right" style={{ fontFamily: TYPOGRAPHY.mono, color: colors.textSecondary }}>
{formatDuration(stat.maxDuration)}
</div>
</div>
{/* Share of the heaviest tool's total time */}
<div className="h-1 rounded mt-1.5" style={{ background: colors.trackBg }}>
<div className="h-1 rounded" style={{ width: `${Math.max(share, 1)}%`, background: colors.good }} />
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
};

const summaryStats = windowData.summary;
const detailNotice = windowData.truncated && (
<div role="status" data-testid="trace-window-notice" style={{ padding: '12px 24px', fontSize: 13, color: darkMode ? '#fbbf24' : '#92400e' }}>
Expand Down Expand Up @@ -507,6 +581,7 @@ export default function TracesView({ agentId = null, embedded = false }) {
</p>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap', minWidth: 0 }}>
<TimeWindowSelector />
{/* View Mode Toggle */}
<div style={{ display: 'flex', background: 'rgba(255,255,255,0.1)', borderRadius: '8px', padding: '2px', maxWidth: '100%', overflowX: 'auto' }}>
{['timeline', 'agents', 'actions', 'tools', 'spans'].map((mode) => (
Expand Down Expand Up @@ -864,6 +939,8 @@ export default function TracesView({ agentId = null, embedded = false }) {
</div>
)}

{viewMode === 'tools' && renderToolsBreakdown(true)}

{/* Span Duration Breakdown View */}
{viewMode === 'spans' && renderSpansBreakdown(true)}

Expand Down Expand Up @@ -1174,72 +1251,7 @@ export default function TracesView({ agentId = null, embedded = false }) {
</div>
)}

{/* Tool usage — which tools get called and what they cost in aggregate.
A 20ms tool called 40 times can dominate a 3s one called once, and
neither is visible from a single trace's waterfall. */}
{viewMode === 'tools' && (
toolStats.length === 0 ? (
<div className="text-center py-8 text-gray-500 text-sm">
No tool calls in this window
</div>
) : (
<div className="bg-white rounded-xl border border-gray-200 overflow-x-auto">
{/* A table meant to scroll: its four figure columns keep their
widths and the box scrolls sideways on a phone. */}
<div className="min-w-[560px]">
{/* Flexbox rather than a 12-col grid: grid-cols-12 / col-span-*
aren't in the compiled Tailwind build. */}
<div className="flex items-center gap-4 px-4 py-2 text-xs font-medium text-gray-500 border-b border-gray-100">
<div className="flex-1">Tool</div>
<div className="w-20 text-right">Calls</div>
<div className="w-24 text-right">Total time</div>
<div className="w-20 text-right">Avg</div>
<div className="w-20 text-right">Slowest</div>
</div>
{toolStats.map((stat) => {
const share = toolStats[0].totalDuration
? (stat.totalDuration / toolStats[0].totalDuration) * 100
: 0;
return (
<div key={stat.name} className="px-4 py-2 border-b border-gray-50 last:border-0">
<div className="flex items-center gap-4 text-sm">
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900 truncate" title={stat.name}>{stat.name}</div>
{stat.agents.length > 0 && (
<div className="text-xs text-gray-400 truncate" title={stat.agents.join(', ')}>
{stat.agents.join(', ')}
</div>
)}
</div>
<div className="w-20 text-right text-gray-700" style={{ fontFamily: TYPOGRAPHY.mono }}>
{stat.count.toLocaleString()}
{stat.errors > 0 && (
<span className="text-red-600 ml-1" title={`${stat.errors} failed`}>
({stat.errors})
</span>
)}
</div>
<div className="w-24 text-right text-gray-900 font-medium" style={{ fontFamily: TYPOGRAPHY.mono }}>
{formatDuration(stat.totalDuration)}
</div>
<div className="w-20 text-right text-gray-500" style={{ fontFamily: TYPOGRAPHY.mono }}>
{formatDuration(stat.avgDuration)}
</div>
<div className="w-20 text-right text-gray-500" style={{ fontFamily: TYPOGRAPHY.mono }}>
{formatDuration(stat.maxDuration)}
</div>
</div>
{/* Share of the heaviest tool's total time */}
<div className="h-1 bg-gray-100 rounded mt-1.5">
<div className="h-1 bg-green-500 rounded" style={{ width: `${Math.max(share, 1)}%` }} />
</div>
</div>
);
})}
</div>
</div>
)
)}
{viewMode === 'tools' && renderToolsBreakdown(false)}
{/* Span Duration Breakdown View */}
{viewMode === 'spans' && renderSpansBreakdown(false)}

Expand Down
141 changes: 141 additions & 0 deletions actionagent/frontend/test/traceControlsViews.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import assert from 'node:assert/strict';
import test, { before, after, afterEach } from 'node:test';
import { build } from 'esbuild';
import { JSDOM } from 'jsdom';
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';

const output = fileURLToPath(new URL(`../node_modules/.cache/trace-controls-${process.pid}.mjs`, import.meta.url));
let view, window, root, container, payload;
const requests = [];
const trace = (spans = []) => ({
id: 'synthetic-trace', short_id: 'synthetic', display_name: 'LookupAgent.search',
agent: 'LookupAgent', action: 'search', status: 'OK', duration_ms: 500,
timestamp_ms: Date.now(), spans,
});

before(async () => {
({ window } = new JSDOM('<!doctype html><body><div id="root"></div></body>', { url: 'http://localhost/dashboard' }));
for (const key of ['window', 'document', 'navigator', 'localStorage']) {
Object.defineProperty(globalThis, key, { value: key === 'window' ? window : window[key], configurable: true });
}
window.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {} });
globalThis.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} };
window.ACTIVE_AGENT_DASHBOARD = { mountPath: '/dashboard' };
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
globalThis.fetch = async (url) => {
requests.push(String(url));
return { ok: true, json: async () => payload };
};
const result = await build({
stdin: { contents: `
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import TracesView from './components/dashboard/TracesView.jsx';
import { ThemeProvider } from './contexts/ThemeContext.jsx';
import { TimeWindowProvider } from './contexts/TimeWindowContext.jsx';
export { act };
export function mount(node, props) { const root = createRoot(node); root.render(
<ThemeProvider><TimeWindowProvider><TracesView {...props} /></TimeWindowProvider></ThemeProvider>); return root; }
`, resolveDir: fileURLToPath(new URL('..', import.meta.url)), loader: 'jsx' },
bundle: true, write: false, platform: 'node', format: 'esm',
external: ['react', 'react-dom/client', 'recharts'], logLevel: 'error',
});
mkdirSync(dirname(output), { recursive: true });
writeFileSync(output, result.outputFiles[0].text);
view = await import(pathToFileURL(output).href);
container = window.document.getElementById('root');
});

afterEach(async () => {
if (root) await view.act(async () => root.unmount());
root = null;
});
after(() => { rmSync(output, { force: true }); window.close(); });

async function mount(theme, traces, props = {}) {
localStorage.clear();
localStorage.setItem('dashboard-theme', theme);
requests.length = 0;
payload = { traces, agents: ['LookupAgent'] };
await view.act(async () => { root = view.mount(container, props); });
}

async function clickButton(label) {
const button = [...container.querySelectorAll('button')].find((node) => node.textContent.trim().toLowerCase() === label);
assert.ok(button, `missing ${label} button`);
await view.act(async () => button.click());
}

for (const theme of ['light', 'dark']) {
test(`${theme} Tools view shows aggregated calls, durations and failures`, async () => {
await mount(theme, [trace([
{ type: 'tool', name: 'tool.search_docs', duration: 20 },
{ type: 'tool', name: 'ignored', attributes: { 'tool.name': 'search_docs' }, duration: 40, error: 'Synthetic failure' },
{ type: 'tool', name: 'tool.lookup_order', duration: 300 },
{ type: 'llm', name: 'provider.generate', duration: 500 },
])]);
await clickButton('tools');
const name = [...container.querySelectorAll('[title]')].find((node) => node.title === 'search_docs');
assert.ok(name, 'tool results must render after selecting Tools');
assert.equal(window.getComputedStyle(name).color, theme === 'dark' ? 'rgb(255, 255, 255)' : 'rgb(17, 24, 39)');
const row = name.parentElement.parentElement;
assert.match(row.textContent, /LookupAgent#search/);
assert.match(row.textContent, /2\s*\(1\)/);
assert.deepEqual([...row.children].slice(2).map((cell) => cell.textContent), ['60ms', '30ms', '40ms']);
assert.equal(row.querySelector('[title="1 failed"]').textContent, '(1)');
const names = [...container.querySelectorAll('[title]')].map((node) => node.title).filter((title) => ['search_docs', 'lookup_order'].includes(title));
assert.deepEqual(names, ['lookup_order', 'search_docs']);
});

test(`${theme} Tools view explains when there are no tool calls`, async () => {
await mount(theme, [trace()]);
await clickButton('tools');
assert.match(container.textContent, /No tool calls in this window/);
});
}

for (const theme of ['light', 'dark']) {
test(`${theme} time window refreshes tool results and preserves the embedded agent`, async () => {
await mount(theme, [trace([{ type: 'tool', name: 'tool.search_docs', duration: 20 }])], { embedded: true, agentId: 42 });
await clickButton('tools');
const selector = container.querySelector('select[aria-label="Time window"]');
assert.ok(selector, 'the time window must be selectable in either theme');
assert.equal(selector.value, '30m');
const initialQuery = new URL(requests.at(-1), window.location.origin).searchParams;
assert.equal(initialQuery.get('minutes'), '30');
assert.equal(initialQuery.get('agent_id'), '42');

payload = { traces: [trace([{ type: 'tool', name: 'tool.lookup_order', duration: 300 }])], agents: ['LookupAgent'] };
await view.act(async () => {
selector.value = '1h';
selector.dispatchEvent(new window.Event('change', { bubbles: true }));
});
const query = new URL(requests.at(-1), window.location.origin).searchParams;
assert.equal(query.get('minutes'), '60');
assert.equal(query.get('bucket_seconds'), '120');
assert.equal(query.get('agent_id'), '42');
assert.equal(localStorage.getItem('activeagents.timeWindow'), '1h');
assert.ok(container.querySelector('[title="lookup_order"]'));
assert.equal(container.querySelector('[title="search_docs"]'), null);
});

test(`${theme} time-window zoom respects the shortest and longest windows`, async () => {
await mount(theme, []);
const selector = container.querySelector('select[aria-label="Time window"]');
assert.ok(selector, 'the time window must remain available for empty results');
const choose = async (id) => view.act(async () => {
selector.value = id;
selector.dispatchEvent(new window.Event('change', { bubbles: true }));
});
await choose('5m');
assert.equal(container.querySelector('button[aria-label="Zoom in"]').disabled, true);
await view.act(async () => container.querySelector('button[aria-label="Zoom out"]').click());
assert.equal(selector.value, '10m');
await choose('90d');
assert.equal(container.querySelector('button[aria-label="Zoom out"]').disabled, true);
await view.act(async () => container.querySelector('button[aria-label="Zoom in"]').click());
assert.equal(selector.value, '30d');
});
}
Loading
Loading