Skip to content
Merged
117 changes: 117 additions & 0 deletions app/controllers/ruby_llm/agents/requests_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# frozen_string_literal: true

module RubyLLM
module Agents
# Controller for browsing tracked request groups
#
# Provides listing and detail views for executions grouped by
# request_id, as set by RubyLLM::Agents.track blocks.
#
# @api private
class RequestsController < ApplicationController
include Paginatable

# Lists all tracked requests with aggregated stats
#
# @return [void]
def index
@sort_column = sanitize_sort_column(params[:sort])
@sort_direction = (params[:direction] == "asc") ? "asc" : "desc"

scope = Execution
.where.not(request_id: [nil, ""])
.select(
"request_id",
"COUNT(*) AS call_count",
"SUM(total_cost) AS total_cost",
"SUM(total_tokens) AS total_tokens",
"MIN(started_at) AS started_at",
"MAX(completed_at) AS completed_at",
"SUM(duration_ms) AS total_duration_ms",
"GROUP_CONCAT(DISTINCT agent_type) AS agent_types_list",
"GROUP_CONCAT(DISTINCT status) AS statuses_list",
"MAX(created_at) AS latest_created_at"
)
.group(:request_id)

# Apply time filter
days = params[:days].to_i
scope = scope.where("created_at >= ?", days.days.ago) if days > 0

result = paginate_requests(scope)
@requests = result[:records]
@pagination = result[:pagination]

# Stats
total_scope = Execution.where.not(request_id: [nil, ""])
@stats = {
total_requests: total_scope.distinct.count(:request_id),
total_cost: total_scope.sum(:total_cost) || 0
}
end

# Shows a single tracked request with all its executions
#
# @return [void]
def show
@request_id = params[:id]
@executions = Execution
.where(request_id: @request_id)
.order(started_at: :asc)

if @executions.empty?
redirect_to ruby_llm_agents.requests_path,
alert: "Request not found: #{@request_id}"
return
end

@summary = {
call_count: @executions.count,
total_cost: @executions.sum(:total_cost) || 0,
total_tokens: @executions.sum(:total_tokens) || 0,
started_at: @executions.minimum(:started_at),
completed_at: @executions.maximum(:completed_at),
agent_types: @executions.distinct.pluck(:agent_type),
models_used: @executions.distinct.pluck(:model_id),
all_successful: @executions.where.not(status: "success").count.zero?,
error_count: @executions.where(status: "error").count
}

if @summary[:started_at] && @summary[:completed_at]
@summary[:duration_ms] = ((@summary[:completed_at] - @summary[:started_at]) * 1000).to_i
end
end

private

ALLOWED_SORT_COLUMNS = %w[latest_created_at call_count total_cost total_tokens total_duration_ms].freeze

def sanitize_sort_column(column)
ALLOWED_SORT_COLUMNS.include?(column) ? column : "latest_created_at"
end

def paginate_requests(scope)
page = [(params[:page] || 1).to_i, 1].max
per_page = RubyLLM::Agents.configuration.per_page

total_count = Execution
.where.not(request_id: [nil, ""])
.distinct
.count(:request_id)

sorted = scope.order("#{@sort_column} #{@sort_direction.upcase}")
offset = (page - 1) * per_page

{
records: sorted.offset(offset).limit(per_page),
pagination: {
current_page: page,
per_page: per_page,
total_count: total_count,
total_pages: (total_count.to_f / per_page).ceil
}
}
end
end
end
end
6 changes: 4 additions & 2 deletions app/views/layouts/ruby_llm/agents/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,8 @@
<% nav_items = [
[ruby_llm_agents.root_path, "dashboard"],
[ruby_llm_agents.agents_path, "agents"],
[ruby_llm_agents.executions_path, "executions"]
[ruby_llm_agents.executions_path, "executions"],
[ruby_llm_agents.requests_path, "requests"]
]
nav_items << [ruby_llm_agents.tenants_path, "tenants"] if tenant_filter_enabled?
nav_items.each do |path, label| %>
Expand Down Expand Up @@ -345,7 +346,8 @@
<% mobile_nav_items = [
[ruby_llm_agents.root_path, "dashboard"],
[ruby_llm_agents.agents_path, "agents"],
[ruby_llm_agents.executions_path, "executions"]
[ruby_llm_agents.executions_path, "executions"],
[ruby_llm_agents.requests_path, "requests"]
]
mobile_nav_items << [ruby_llm_agents.tenants_path, "tenants"] if tenant_filter_enabled?
mobile_nav_items.each do |path, label| %>
Expand Down
153 changes: 153 additions & 0 deletions app/views/ruby_llm/agents/requests/index.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
<div class="flex items-center gap-3 mb-6">
<span class="text-[10px] font-medium text-gray-400 dark:text-gray-600 uppercase tracking-widest font-mono">requests</span>
<div class="flex-1 border-t border-gray-200 dark:border-gray-800"></div>
<span class="font-mono text-[10px] text-gray-400 dark:text-gray-600">
<%= number_with_delimiter(@stats[:total_requests]) %> tracked
&middot;
$<%= number_with_precision(@stats[:total_cost] || 0, precision: 4) %> total
</span>
</div>

<%
sort_column = @sort_column
sort_direction = @sort_direction

sort_link = ->(column, label, extra_class: "") {
is_active = column == sort_column
next_dir = is_active && sort_direction == "asc" ? "desc" : "asc"
url = url_for(request.query_parameters.merge(sort: column, direction: next_dir, page: 1))

arrow = if is_active && sort_direction == "asc"
raw('<svg class="w-2.5 h-2.5 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/></svg>')
elsif is_active
raw('<svg class="w-2.5 h-2.5 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>')
else
""
end

active_class = is_active ? "text-gray-700 dark:text-gray-300" : ""
raw(%(<a href="#{url}" class="group inline-flex items-center gap-0.5 hover:text-gray-700 dark:hover:text-gray-300 #{active_class} #{extra_class}"><span>#{label}</span><span class="#{is_active ? 'opacity-100' : 'opacity-0 group-hover:opacity-50'} transition-opacity">#{arrow}</span></a>))
}
%>

<% if @requests.empty? %>
<div class="font-mono text-xs text-gray-400 dark:text-gray-600 py-8 text-center">
No tracked requests found. Use <code class="bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded">RubyLLM::Agents.track { ... }</code> to start tracking.
</div>
<% else %>
<!-- Column headers -->
<div class="flex items-center gap-3 px-2 -mx-2 font-mono text-[10px] text-gray-400 dark:text-gray-600 uppercase tracking-wider mb-1">
<span class="w-36 flex-shrink-0">request_id</span>
<span class="flex-1 min-w-0">agents</span>
<span class="w-12 flex-shrink-0 text-right"><%= sort_link.call("call_count", "calls", extra_class: "justify-end w-full") %></span>
<span class="w-14 flex-shrink-0 text-right hidden sm:block">status</span>
<span class="w-16 flex-shrink-0 text-right hidden md:block"><%= sort_link.call("total_duration_ms", "duration", extra_class: "justify-end w-full") %></span>
<span class="w-16 flex-shrink-0 text-right hidden md:block"><%= sort_link.call("total_tokens", "tokens", extra_class: "justify-end w-full") %></span>
<span class="w-16 flex-shrink-0 text-right"><%= sort_link.call("total_cost", "cost", extra_class: "justify-end w-full") %></span>
<span class="w-24 flex-shrink-0 text-right"><%= sort_link.call("latest_created_at", "time", extra_class: "justify-end w-full") %></span>
</div>

<!-- Rows -->
<div class="font-mono text-xs space-y-px">
<% @requests.each do |req| %>
<%
statuses = (req.statuses_list || "").split(",")
has_errors = statuses.include?("error") || statuses.include?("timeout")
all_success = statuses == ["success"]
status_class = if has_errors
"badge-error"
elsif all_success
"badge-success"
else
"badge-running"
end
status_label = if has_errors
"errors"
elsif all_success
"ok"
else
"mixed"
end
agent_names = (req.agent_types_list || "").split(",").map { |a| a.gsub(/Agent$/, "") }
%>
<div class="group flex items-center gap-3 py-1.5 px-2 -mx-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800/50 cursor-pointer"
onclick="window.location='<%= ruby_llm_agents.request_path(req.request_id) %>'">
<span class="w-36 flex-shrink-0 truncate text-gray-900 dark:text-gray-200" title="<%= req.request_id %>">
<%= truncate(req.request_id, length: 20) %>
</span>
<span class="flex-1 min-w-0 truncate text-gray-400 dark:text-gray-600">
<%= agent_names.first(3).join(", ") %><%= agent_names.size > 3 ? " +#{agent_names.size - 3}" : "" %>
</span>
<span class="w-12 flex-shrink-0 text-right text-gray-500 dark:text-gray-400"><%= req.call_count %></span>
<span class="w-14 flex-shrink-0 text-right hidden sm:block">
<span class="badge badge-sm <%= status_class %>"><%= status_label %></span>
</span>
<span class="w-16 flex-shrink-0 text-right text-gray-500 dark:text-gray-400 hidden md:inline">
<%= req.total_duration_ms ? format_duration_ms(req.total_duration_ms.to_i) : "—" %>
</span>
<span class="w-16 flex-shrink-0 text-right text-gray-500 dark:text-gray-400 hidden md:inline"><%= number_with_delimiter(req.total_tokens || 0) %></span>
<span class="w-16 flex-shrink-0 text-right text-gray-500 dark:text-gray-400">$<%= number_with_precision(req.total_cost || 0, precision: 4) %></span>
<span class="w-24 flex-shrink-0 text-gray-400 dark:text-gray-600 text-right whitespace-nowrap">
<% if req.respond_to?(:latest_created_at) && req.latest_created_at %>
<%= time_ago_in_words(req.latest_created_at) %>
<% else %>
<% end %>
</span>
</div>
<% end %>
</div>

<%# Pagination %>
<% if @pagination && @pagination[:total_pages] > 1 %>
<%
current_page = @pagination[:current_page]
total_pages = @pagination[:total_pages]
total_count = @pagination[:total_count]
per_page = @pagination[:per_page]
from_record = ((current_page - 1) * per_page) + 1
to_record = [current_page * per_page, total_count].min
%>
<div class="flex items-center justify-between font-mono text-xs mt-4 pt-4 border-t border-gray-200 dark:border-gray-800">
<span class="text-gray-400 dark:text-gray-600"><%= from_record %>-<%= to_record %> of <%= number_with_delimiter(total_count) %></span>
<nav class="flex items-center gap-1">
<% if current_page > 1 %>
<%= link_to "prev", url_for(request.query_parameters.merge(page: current_page - 1)),
class: "px-2 py-0.5 text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" %>
<% else %>
<span class="px-2 py-0.5 text-gray-300 dark:text-gray-700">prev</span>
<% end %>

<%
window = 2
pages_to_show = []
(1..total_pages).each do |page|
if page <= 1 || page >= total_pages || (page >= current_page - window && page <= current_page + window)
pages_to_show << page
elsif pages_to_show.last != :gap
pages_to_show << :gap
end
end
%>

<% pages_to_show.each do |page| %>
<% if page == :gap %>
<span class="px-1 text-gray-400 dark:text-gray-600">...</span>
<% elsif page == current_page %>
<span class="px-2 py-0.5 text-gray-900 dark:text-gray-100"><%= page %></span>
<% else %>
<%= link_to page, url_for(request.query_parameters.merge(page: page)),
class: "px-2 py-0.5 text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" %>
<% end %>
<% end %>

<% if current_page < total_pages %>
<%= link_to "next", url_for(request.query_parameters.merge(page: current_page + 1)),
class: "px-2 py-0.5 text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" %>
<% else %>
<span class="px-2 py-0.5 text-gray-300 dark:text-gray-700">next</span>
<% end %>
</nav>
</div>
<% end %>
<% end %>
Loading