- <%# 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) %>
-
+ <%= 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.