diff --git a/CHANGELOG.md b/CHANGELOG.md index e5de61652..c515c21f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Keep the trace console usable offline** (`actionagent`). The console + ships its own inline styles and uses native details and ordinary links + instead of CDN-hosted Tailwind, Turbo and Stimulus. Automatic refresh is + opt-in (30 or 60 seconds) and uses the browser's refresh mechanism; + expanding a trace no longer depends on JavaScript or a host asset pipeline. + - **Generate runnable agent tests** (`activeagent`). TestUnit agent tests inherit from `ActiveSupport::TestCase` instead of the nonexistent `ActiveAgent::TestCase`. Plain and namespaced agents, including agents diff --git a/actionagent/app/controllers/action_agent/traces_controller.rb b/actionagent/app/controllers/action_agent/traces_controller.rb index 30dcb9342..58eb08278 100644 --- a/actionagent/app/controllers/action_agent/traces_controller.rb +++ b/actionagent/app/controllers/action_agent/traces_controller.rb @@ -9,6 +9,8 @@ module ActionAgent # - Metrics overview # - Live updates via Turbo Streams class TracesController < ApplicationController + before_action :set_refresh_interval + def index @traces = fetch_traces @metrics = calculate_metrics @@ -38,6 +40,10 @@ def metrics private + def set_refresh_interval + @refresh_interval = params[:refresh].to_i if %w[30 60].include?(params[:refresh]) + end + def turbo_stream_available? Mime::Type.lookup_by_extension(:turbo_stream).present? end diff --git a/actionagent/app/views/action_agent/traces/_styles.html.erb b/actionagent/app/views/action_agent/traces/_styles.html.erb new file mode 100644 index 000000000..794369839 --- /dev/null +++ b/actionagent/app/views/action_agent/traces/_styles.html.erb @@ -0,0 +1,45 @@ + diff --git a/actionagent/app/views/action_agent/traces/_summary.html.erb b/actionagent/app/views/action_agent/traces/_summary.html.erb new file mode 100644 index 000000000..8bccdaf21 --- /dev/null +++ b/actionagent/app/views/action_agent/traces/_summary.html.erb @@ -0,0 +1,8 @@ +
+
Total Traces
<%= number_with_delimiter(@metrics[:total_traces]) %>
+
Total Tokens
<%= number_to_human(@metrics[:total_tokens], precision: 2) %>
+
Avg Duration
<%= @metrics[:avg_duration_ms] >= 1000 ? "#{(@metrics[:avg_duration_ms] / 1000.0).round(2)}s" : "#{@metrics[:avg_duration_ms].round(0)}ms" %>
+
Error Rate
<%= @metrics[:error_rate] %>%
+
Active Agents
<%= @metrics[:unique_agents] %>
+
+

Last 24h

diff --git a/actionagent/app/views/action_agent/traces/_trace_detail.html.erb b/actionagent/app/views/action_agent/traces/_trace_detail.html.erb index bb84a2111..400d6d41a 100644 --- a/actionagent/app/views/action_agent/traces/_trace_detail.html.erb +++ b/actionagent/app/views/action_agent/traces/_trace_detail.html.erb @@ -1,64 +1,22 @@ -
- -
-
- Trace ID -
<%= trace.trace_id %>
-
-
- Service -
<%= trace.service_name || "—" %>
-
-
- Provider -
<%= trace.provider || "—" %>
-
-
- Model -
<%= trace.model || "—" %>
-
-
- - <% if trace.error? && trace.error_message.present? %> -
-
- - - -
-

Error

-

<%= trace.error_message %>

-
-
-
+
+
Trace ID
<%= trace.trace_id %>
+
Service
<%= trace.service_name || "Unknown" %>
+
Provider
<%= trace.provider || "Unknown" %>
+
Model
<%= trace.model || "Unknown" %>
+
+<% if trace.error? && trace.error_message.present? %> +

Error: <%= trace.error_message %>

+<% end %> +
+ Input: <%= number_with_delimiter(trace.total_input_tokens || 0) %> + Output: <%= number_with_delimiter(trace.total_output_tokens || 0) %> + <% if trace.total_thinking_tokens.to_i > 0 %> + Thinking: <%= number_with_delimiter(trace.total_thinking_tokens) %> <% end %> - - -
-
-
- Input: - <%= number_with_delimiter(trace.total_input_tokens || 0) %> -
-
-
- Output: - <%= number_with_delimiter(trace.total_output_tokens || 0) %> -
- <% if trace.total_thinking_tokens.to_i > 0 %> -
-
- Thinking: - <%= number_with_delimiter(trace.total_thinking_tokens) %> -
- <% end %> -
- - - <% if trace.spans.present? %> -
-

Span Timeline

-
+
+<% if trace.spans.present? %> +
+

Span Timeline

<% total_duration = (trace.total_duration_ms || 1).to_f %> <% total_duration = 1.0 if total_duration.zero? %> <% @@ -77,41 +35,20 @@ start_pct = ((relative_ms || 0).to_f / total_duration * 100).clamp(0, 100) width_pct = ((span["duration_ms"] || 0).to_f / total_duration * 100).clamp(0.5, [ 100 - start_pct, 0.5 ].max) span_type = span["type"] || "unknown" - colors = { - "root" => "bg-gray-400", - "prompt" => "bg-blue-400", - "llm" => "bg-indigo-500", - "generate" => "bg-indigo-400", - "tool" => "bg-amber-500", - "thinking" => "bg-purple-500", - "response" => "bg-green-400" - } - bg_color = colors[span_type] || "bg-gray-400" %> -
-
"> - <%= span["name"]&.split(".")&.last || span_type %> -
-
-
" - style="left: <%= start_pct %>%; width: <%= width_pct %>%;" - title="<%= span["name"] %>: <%= span["duration_ms"]&.round(1) %>ms"> -
-
-
- <%= span["duration_ms"] ? "#{span["duration_ms"].round(1)}ms" : "—" %> -
-
- <% end %> +
+ "><%= span["name"]&.split(".")&.last || span_type %> +
+
" + style="left: <%= start_pct %>%; width: <%= width_pct %>%;" + title="<%= span["name"] %>: <%= span["duration_ms"]&.round(1) %>ms">
+
+ <%= span["duration_ms"] ? "#{span["duration_ms"].round(1)}ms" : "Unknown" %>
-
- <% end %> - - -
- - View raw trace data - -
<%= JSON.pretty_generate(trace.as_json(except: [:id, :created_at, :updated_at])) %>
-
-
+ <% end %> + +<% end %> +
+ View raw trace data +
<%= JSON.pretty_generate(trace.as_json(except: [ :id, :created_at, :updated_at ])) %>
+
diff --git a/actionagent/app/views/action_agent/traces/index.html.erb b/actionagent/app/views/action_agent/traces/index.html.erb index 1b68ba4c0..37cdea219 100644 --- a/actionagent/app/views/action_agent/traces/index.html.erb +++ b/actionagent/app/views/action_agent/traces/index.html.erb @@ -1,135 +1,26 @@ -
- -
-
-
Total Traces
-
- <%= number_with_delimiter(@metrics[:total_traces]) %> -
-
Last 24h
-
- -
-
Total Tokens
-
- <%= number_to_human(@metrics[:total_tokens], precision: 2) %> -
-
Last 24h
-
- -
-
Avg Duration
-
- <%= @metrics[:avg_duration_ms] >= 1000 ? "#{(@metrics[:avg_duration_ms] / 1000.0).round(2)}s" : "#{@metrics[:avg_duration_ms].round(0)}ms" %> -
-
Last 24h
-
- -
-
Error Rate
-
- <%= @metrics[:error_rate] %>% -
-
Last 24h
-
- -
-
Active Agents
-
- <%= @metrics[:unique_agents] %> -
-
Last 24h
-
-
- - -
-
-

Recent Traces

-
- <%= link_to "All", traces_path, class: "px-3 py-1 text-sm rounded #{params[:status].blank? ? 'bg-indigo-100 text-indigo-700' : 'text-gray-600 hover:bg-gray-100'}" %> - <%= link_to "Errors", traces_path(status: "error"), class: "px-3 py-1 text-sm rounded #{params[:status] == 'error' ? 'bg-red-100 text-red-700' : 'text-gray-600 hover:bg-gray-100'}" %> -
-
- - <% if @traces.any? %> - - - - - - - - - - - - <% @traces.each do |trace| %> - - - - - - - - - - - - - - <% end %> - -
AgentStatusDurationTokensTime
-
- - <%= trace.agent_class || "Unknown" %> - - <% if trace.agent_action %> - - .<%= trace.agent_action %> - - <% end %> -
-
<%= trace.trace_id&.first(8) %>
-
- <% if trace.error? %> - - Error - - <% else %> - - OK - - <% end %> - - <%= trace.formatted_duration %> - -
- <%= trace.formatted_tokens %> - <% if trace.total_thinking_tokens.to_i > 0 %> - - +<%= number_to_human(trace.total_thinking_tokens, precision: 1) %> thinking - - <% end %> -
-
- <%= time_ago_in_words(trace.timestamp || trace.created_at) %> ago -
- <% else %> -
- - - -

No traces yet

-

- Traces will appear here when your agents generate responses. -

-
+

Traces

+<%= render "summary" %> +
+

Recent Traces

+

<%= link_to "All", traces_path %> · <%= link_to "Errors", traces_path(status: "error") %>

+ <% if @traces.any? %> + <% @traces.each do |trace| %> +
+ + + <%= trace.display_name %> + <%= trace.error? ? "Error" : "OK" %> + <%= trace.formatted_duration %> + <%= trace.formatted_tokens %> tokens + <%= time_ago_in_words(trace.timestamp || trace.created_at) %> ago + + + <%= link_to "Open trace", trace_path(trace) %> + <%= render "trace_detail", trace: trace %> +
<% end %> -
-
+ <% else %> +

No traces yet

+

Traces will appear here when your agents generate responses.

+ <% end %> + diff --git a/actionagent/app/views/action_agent/traces/metrics.html.erb b/actionagent/app/views/action_agent/traces/metrics.html.erb index 072b55038..f09c95348 100644 --- a/actionagent/app/views/action_agent/traces/metrics.html.erb +++ b/actionagent/app/views/action_agent/traces/metrics.html.erb @@ -1,145 +1,27 @@ -
-

Metrics Overview

- - -
-
-
-
- - - -
-
-

Total Traces

-

- <%= number_with_delimiter(@metrics[:total_traces]) %> -

-
-
-
- -
-
-
- - - -
-
-

Total Tokens

-

- <%= number_to_human(@metrics[:total_tokens], precision: 2) %> -

-
-
-
- -
-
-
- - - -
-
-

Avg Duration

-

- <%= @metrics[:avg_duration_ms] >= 1000 ? "#{(@metrics[:avg_duration_ms] / 1000.0).round(2)}s" : "#{@metrics[:avg_duration_ms].round(0)}ms" %> -

-
-
-
- -
-
-
- - - -
-
-

Error Rate

-

- <%= @metrics[:error_rate] %>% -

-
-
-
- -
-
-
- - - -
-
-

Active Agents

-

- <%= @metrics[:unique_agents] %> -

-
-
-
-
- - -
-
-

Agent Statistics

-

Performance breakdown by agent class (last 24h)

-
- - <% if @agent_stats.any? %> - - - - - - - - - - - +

Metrics Overview

+<%= render "summary" %> +
+

Agent Statistics

+

Performance breakdown by agent class (last 24h)

+ <% if @agent_stats.any? %> +
+
AgentTracesTokensAvg DurationErrors
+ + <% @agent_stats.each do |stat| %> - - - - - - + + + + + <% avg = stat.avg_duration.to_f %> + + <% end %>
AgentTracesTokensAvg DurationErrors
- - <%= stat.agent_class || "Unknown" %> - - - <%= number_with_delimiter(stat.trace_count) %> - - <%# stat is a grouped row: read the SQL alias, not the model - method (which sums per-trace token columns absent here) %> - <%= number_to_human(stat[:total_tokens] || 0, precision: 1) %> - - <% avg = stat.avg_duration.to_f %> - <%= avg >= 1000 ? "#{(avg / 1000.0).round(2)}s" : "#{avg.round(0)}ms" %> - - <% error_count = stat.error_count.to_i %> - <% if error_count > 0 %> - - <%= error_count %> - - <% else %> - 0 - <% end %> -
<%= stat.agent_class || "Unknown" %><%= number_with_delimiter(stat.trace_count) %><%= number_to_human(stat[:total_tokens] || 0, precision: 1) %><%= avg >= 1000 ? "#{(avg / 1000.0).round(2)}s" : "#{avg.round(0)}ms" %><%= stat.error_count.to_i %>
- <% else %> -
- No agent data available yet. -
- <% end %> -
-
+
+ <% else %> +

No agent data available yet.

+ <% end %> + diff --git a/actionagent/app/views/action_agent/traces/show.html.erb b/actionagent/app/views/action_agent/traces/show.html.erb index 434730a75..071551ddd 100644 --- a/actionagent/app/views/action_agent/traces/show.html.erb +++ b/actionagent/app/views/action_agent/traces/show.html.erb @@ -1,36 +1,7 @@ -
- <%= link_to "← Back to traces", traces_path, class: "text-indigo-600 hover:text-indigo-800 text-sm" %> -
- -
-
-
-
-

- <%= @trace.display_name %> -

-

- <%= @trace.trace_id %> -

-
-
- <% if @trace.error? %> - - Error - - <% else %> - - Success - - <% end %> - - <%= @trace.timestamp&.strftime("%Y-%m-%d %H:%M:%S") %> - -
-
-
- -
- <%= render partial: "trace_detail", locals: { trace: @trace } %> -
-
+

<%= link_to "Back to traces", traces_path %>

+
+

<%= @trace.display_name %>

+

<%= @trace.error? ? "Error" : "Success" %>

+

+ <%= render "trace_detail", trace: @trace %> +
diff --git a/actionagent/app/views/layouts/action_agent/application.html.erb b/actionagent/app/views/layouts/action_agent/application.html.erb index 1388fddc7..5593625a6 100644 --- a/actionagent/app/views/layouts/action_agent/application.html.erb +++ b/actionagent/app/views/layouts/action_agent/application.html.erb @@ -1,94 +1,34 @@ - + - ActiveAgent Dashboard - - - - - + ActiveAgent Trace Console + <% if @refresh_interval %> + + <% end %> + <%= render "action_agent/traces/styles" %> - -
- -
+ +
+
+ <% if @refresh_interval %> + Refreshes every <%= @refresh_interval %> seconds. + <%= link_to "Stop refreshing", "#{request.path}?#{request.query_parameters.except('refresh').to_query}" %> + <% else %> + <%= link_to "Auto-refresh", "#{request.path}?#{request.query_parameters.merge('refresh' => '30').to_query}" %> + <% end %> + <%= link_to "Refresh now", request.path + (request.query_string.present? ? "?#{request.query_string}" : "") %> +
+ <%= yield %> +
diff --git a/actionagent/lib/generators/action_agent/templates/action_agent.rb.erb b/actionagent/lib/generators/action_agent/templates/action_agent.rb.erb index da3156d39..f99d01ee7 100644 --- a/actionagent/lib/generators/action_agent/templates/action_agent.rb.erb +++ b/actionagent/lib/generators/action_agent/templates/action_agent.rb.erb @@ -315,9 +315,10 @@ ActionAgent.configure do |config| # UI # ========================================================================== # - # Custom layout for dashboard views (the default layout loads Tailwind, - # Turbo and Stimulus from CDNs — override for CSP-strict or air-gapped - # environments). + # Custom layout for dashboard views. The default layouts load nothing + # from external hosts, so this is only needed to wrap the dashboard in + # your application's own chrome. A custom layout replaces the trace + # console's built-in styles and refresh controls. # # config.layout = "application" end diff --git a/actionagent/test/trace_console_test.rb b/actionagent/test/trace_console_test.rb new file mode 100644 index 000000000..25b8e15ef --- /dev/null +++ b/actionagent/test/trace_console_test.rb @@ -0,0 +1,60 @@ +require "test_helper" + +class TraceConsoleTest < ActionDispatch::IntegrationTest + setup do + ActionAgent::TelemetryTrace.delete_all + @trace = ActionAgent::TelemetryTrace.create!( + trace_id: SecureRandom.hex(16), service_name: "console-test", timestamp: Time.current, + agent_class: "ConsoleAgent", agent_action: "respond", status: "ERROR", + error_message: "Synthetic failure ", total_duration_ms: 1200, + total_input_tokens: 42, total_output_tokens: 7, + spans: [ { "name" => "ConsoleAgent.respond", "type" => "root", "duration_ms" => 1200 } ] + ) + end + + test "all console pages render without external resources or scripts" do + [ "/activeagents/console/traces", "/activeagents/console/traces/#{@trace.id}", "/activeagents/console/traces/metrics" ].each do |path| + get path + assert_response :success + assert_select "script, link[rel=stylesheet], [data-controller]", count: 0 + assert_select "style", minimum: 1 + assert_select "a[href='/activeagents/console/traces']", minimum: 1 + assert_select "a[href='/activeagents/console/traces/metrics']", minimum: 1 + assert_select "a[download], iframe, img[src^='http']", count: 0 + end + end + + test "trace details expand natively and link to a standalone page" do + get "/activeagents/console/traces" + assert_select "details summary", text: /ConsoleAgent/ + assert_select "details a[href='/activeagents/console/traces/#{@trace.id}']", text: "Open trace" + assert_select "details pre", text: /console-test/ + assert_select "script", count: 0 + assert_includes response.body, "<script>alert(1)</script>" + end + + test "automatic refresh is opt in and bounded to known intervals" do + get "/activeagents/console/traces" + assert_select "meta[http-equiv=refresh]", count: 0 + get "/activeagents/console/traces", params: { refresh: "30", status: "error" } + assert_select "meta[http-equiv=refresh][content='30']", count: 1 + assert_select "a", text: "Stop refreshing" + assert_select "a[href='/activeagents/console/traces?status=error']", text: "Stop refreshing" + get "/activeagents/console/traces", params: { host: "example.invalid", script_name: "//example.invalid" } + assert_select "a[href^='/activeagents/console/traces?']", text: "Auto-refresh" + [ "0", "-1", "1;url=https://example.com", "abc" ].each do |refresh| + get "/activeagents/console/traces", params: { refresh: refresh } + assert_select "meta[http-equiv=refresh]", count: 0 + end + get "/activeagents/console/traces/metrics", params: { refresh: "60" } + assert_select "meta[http-equiv=refresh][content='60']", count: 1 + end + + test "empty pages still render useful navigation" do + @trace.destroy! + get "/activeagents/console/traces" + assert_includes response.body, "No traces yet" + get "/activeagents/console/traces/metrics" + assert_includes response.body, "No agent data available yet" + end +end diff --git a/docs/framework/self-hosted-observability.md b/docs/framework/self-hosted-observability.md index b722cbd19..82e3bee0a 100644 --- a/docs/framework/self-hosted-observability.md +++ b/docs/framework/self-hosted-observability.md @@ -363,9 +363,8 @@ call the concern through a method the engine does not define, such as - **CDN assets.** The React dashboard's CSS and JS are served from the `actionagent` gem, which adds its own `app/assets/builds` to your asset paths, so it works on CSP-strict and air-gapped networks. The server-rendered - console at `/console/traces` still loads Tailwind, Turbo and - Stimulus from public CDNs and renders unstyled without them — set - `config.layout` to a layout of your own that bundles them locally. + console at `/console/traces` loads nothing external — see + [Trace console without JavaScript](#trace-console-without-javascript). - **Time-series charts** on the console's metrics page light up when the optional [groupdate](https://github.com/ankane/groupdate) gem is installed. The React metrics page buckets by hour with the same portable @@ -461,3 +460,17 @@ ActionAgent.sandbox_service = "cloud_run" Whichever backend runs, schedule `bin/rails action_agent:sandbox:reap` so expired sandboxes are stopped. + +## Trace console without JavaScript + +Open `/console/traces` for the server-rendered trace console, or +`/console/traces/metrics` for the last 24 hours of agent statistics. +The console includes its styles in the HTML. It needs no CDN, JavaScript, +asset pipeline, or frontend build. Expand a trace with its summary or use +**Open trace** for a standalone detail page. + +Refresh is manual by default so an open trace stays open. **Auto-refresh** +reloads the current page every 30 seconds; `?refresh=60` uses 60 seconds. +**Stop refreshing** removes that setting. A refresh reloads the whole page +and closes expanded details. The host's dashboard authentication and tenant +scoping apply to these pages as well.