ruby_llm-agents 3.15.1 → 3.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/ruby_llm/agents/paginatable.rb +29 -8
  3. data/app/controllers/ruby_llm/agents/agents_controller.rb +48 -44
  4. data/app/controllers/ruby_llm/agents/analytics_controller.rb +38 -72
  5. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +69 -56
  6. data/app/controllers/ruby_llm/agents/executions_controller.rb +25 -41
  7. data/app/controllers/ruby_llm/agents/requests_controller.rb +117 -45
  8. data/app/controllers/ruby_llm/agents/tenants_controller.rb +67 -47
  9. data/app/models/ruby_llm/agents/execution/analytics.rb +174 -133
  10. data/app/models/ruby_llm/agents/execution/breakdown.rb +243 -0
  11. data/app/models/ruby_llm/agents/execution/scopes.rb +39 -3
  12. data/app/models/ruby_llm/agents/execution.rb +23 -5
  13. data/app/services/ruby_llm/agents/agent_registry.rb +75 -12
  14. data/app/views/ruby_llm/agents/agents/index.html.erb +24 -7
  15. data/app/views/ruby_llm/agents/agents/show.html.erb +3 -2
  16. data/app/views/ruby_llm/agents/executions/_filters.html.erb +12 -7
  17. data/app/views/ruby_llm/agents/executions/_list.html.erb +1 -52
  18. data/app/views/ruby_llm/agents/requests/index.html.erb +8 -57
  19. data/app/views/ruby_llm/agents/shared/_executions_table.html.erb +1 -52
  20. data/app/views/ruby_llm/agents/shared/_pagination.html.erb +66 -0
  21. data/app/views/ruby_llm/agents/shared/query_timeout.html.erb +18 -0
  22. data/app/views/ruby_llm/agents/system_config/show.html.erb +4 -0
  23. data/app/views/ruby_llm/agents/tenants/index.html.erb +1 -1
  24. data/app/views/ruby_llm/agents/tenants/show.html.erb +6 -5
  25. data/config/routes.rb +2 -2
  26. data/lib/generators/ruby_llm_agents/templates/add_executions_analytics_index_migration.rb.tt +50 -0
  27. data/lib/generators/ruby_llm_agents/templates/add_root_execution_list_index_migration.rb.tt +16 -0
  28. data/lib/generators/ruby_llm_agents/templates/initializer.rb.tt +5 -0
  29. data/lib/generators/ruby_llm_agents/templates/migration.rb.tt +26 -0
  30. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +44 -0
  31. data/lib/ruby_llm/agents/core/configuration.rb +20 -0
  32. data/lib/ruby_llm/agents/core/version.rb +1 -1
  33. data/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb +39 -1
  34. data/lib/ruby_llm/agents/rails/engine.rb +87 -7
  35. metadata +7 -2
@@ -153,9 +153,9 @@ module RubyLLM
153
153
  joined.where("json_extract(#{detail_table}.parameters, ?) IS NOT NULL", "$.#{key}")
154
154
  end
155
155
  elsif value
156
- joined.where("#{detail_table}.parameters @> ?", {key => value}.to_json)
156
+ joined.where("#{detail_table}.parameters::jsonb @> ?", {key => value}.to_json)
157
157
  else
158
- joined.where("#{detail_table}.parameters ? :key", key: key.to_s)
158
+ joined.where("#{detail_table}.parameters::jsonb ? :key", key: key.to_s)
159
159
  end
160
160
  end
161
161
 
@@ -338,7 +338,7 @@ module RubyLLM
338
338
  if connection.adapter_name.downcase.include?("sqlite")
339
339
  where("json_extract(metadata, ?) = 1", "$.#{key}")
340
340
  else
341
- where("metadata @> ?", {key.to_s => true}.to_json)
341
+ where("metadata::jsonb @> ?", {key.to_s => true}.to_json)
342
342
  end
343
343
  end
344
344
 
@@ -355,6 +355,42 @@ module RubyLLM
355
355
  end
356
356
  end
357
357
 
358
+ # Distinct non-null values of a column, sorted
359
+ #
360
+ # `SELECT DISTINCT col` reads every row (or every index entry) to
361
+ # return a handful of values, which takes seconds on a table with
362
+ # millions of executions, and PostgreSQL before 18 has no skip scan
363
+ # to avoid it. When an index leads with the column this walks the
364
+ # index instead: take the smallest value, then repeatedly the
365
+ # smallest value greater than the last one. The cost is one index
366
+ # probe per distinct value, whatever the row count.
367
+ #
368
+ # Falls back to a plain DISTINCT when called on anything narrower
369
+ # than the whole table (the walk would ignore the filter) or when no
370
+ # index leads with the column (each probe would be a full scan).
371
+ #
372
+ # @param column [Symbol, String] A column of the executions table
373
+ # @return [Array] Sorted distinct values, nil excluded
374
+ def distinct_values(column)
375
+ column = column.to_s
376
+ raise ArgumentError, "Unknown column: #{column}" unless column_names.include?(column)
377
+
378
+ walkable = all.to_sql == unscoped.to_sql &&
379
+ connection.schema_cache.indexes(table_name).any? { |index| Array(index.columns).first == column }
380
+ return where.not(column => nil).distinct.pluck(column).sort unless walkable
381
+
382
+ col = connection.quote_column_name(column)
383
+ connection.select_values(<<~SQL.squish, "#{name} Distinct Values").sort
384
+ WITH RECURSIVE walk(v) AS (
385
+ SELECT MIN(#{col}) FROM #{quoted_table_name}
386
+ UNION ALL
387
+ SELECT (SELECT MIN(#{col}) FROM #{quoted_table_name} WHERE #{col} > walk.v)
388
+ FROM walk WHERE walk.v IS NOT NULL
389
+ )
390
+ SELECT v FROM walk WHERE v IS NOT NULL
391
+ SQL
392
+ end
393
+
358
394
  # Returns sum of total_cost for the current scope
359
395
  #
360
396
  # @return [Float, nil] Total cost in USD
@@ -74,6 +74,18 @@ module RubyLLM
74
74
  has_one :detail, class_name: "RubyLLM::Agents::ExecutionDetail",
75
75
  foreign_key: :execution_id, dependent: :destroy
76
76
 
77
+ # Same row as :detail, but selecting only error_message.
78
+ #
79
+ # List and CSV views read nothing but the error message, while the full
80
+ # detail row carries prompts and response JSON that routinely run to
81
+ # hundreds of kilobytes each. Preloading :detail for a page of rows drags
82
+ # all of that into memory; preloading :error_detail costs a few bytes per
83
+ # row. Never use this association for anything but error_message — the
84
+ # other attributes are not selected and will raise.
85
+ has_one :error_detail, -> { select(:id, :execution_id, :error_message) },
86
+ class_name: "RubyLLM::Agents::ExecutionDetail",
87
+ foreign_key: :execution_id, inverse_of: false
88
+
77
89
  # Individual tool call records (real-time tracking)
78
90
  has_many :tool_executions, class_name: "RubyLLM::Agents::ToolExecution",
79
91
  foreign_key: :execution_id, dependent: :destroy
@@ -94,7 +106,8 @@ module RubyLLM
94
106
  #
95
107
  # @return [String, nil]
96
108
  def error_message
97
- detail&.error_message || metadata&.dig("error_message")
109
+ row = association(:detail).loaded? ? detail : error_detail
110
+ row&.error_message || metadata&.dig("error_message")
98
111
  end
99
112
 
100
113
  # Validations
@@ -348,8 +361,11 @@ module RubyLLM
348
361
  # instead of ~15 individual count/sum/average queries.
349
362
  #
350
363
  # @param range [String] Time range: "today", "7d", "30d", or "90d"
364
+ # @param current [Hash, nil] Stats for the current period, when the caller
365
+ # already has them (a {Breakdown#totals} of the same window) — saves
366
+ # scanning that window a second time
351
367
  # @return [Hash] Now strip metrics with period-over-period comparisons
352
- def self.now_strip_data(range: "today")
368
+ def self.now_strip_data(range: "today", current: nil)
353
369
  current_scope = case range
354
370
  when "7d" then last_n_days(7)
355
371
  when "30d" then last_n_days(30)
@@ -364,7 +380,7 @@ module RubyLLM
364
380
  else yesterday
365
381
  end
366
382
 
367
- curr = aggregate_period_stats(current_scope)
383
+ curr = current || aggregate_period_stats(current_scope)
368
384
  prev = aggregate_period_stats(previous_scope)
369
385
 
370
386
  {
@@ -395,15 +411,17 @@ module RubyLLM
395
411
  #
396
412
  # @param from [Date] Start date (inclusive)
397
413
  # @param to [Date] End date (inclusive)
414
+ # @param current [Hash, nil] Precomputed stats for the selected range (see
415
+ # {.now_strip_data})
398
416
  # @return [Hash] Now strip metrics with period-over-period comparisons
399
- def self.now_strip_data_for_dates(from:, to:)
417
+ def self.now_strip_data_for_dates(from:, to:, current: nil)
400
418
  span_days = (to - from).to_i + 1
401
419
  current_scope = where(created_at: from.beginning_of_day..to.end_of_day)
402
420
  previous_from = from - span_days.days
403
421
  previous_to = from - 1.day
404
422
  previous_scope = where(created_at: previous_from.beginning_of_day..previous_to.end_of_day)
405
423
 
406
- curr = aggregate_period_stats(current_scope)
424
+ curr = current || aggregate_period_stats(current_scope)
407
425
  prev = aggregate_period_stats(previous_scope)
408
426
 
409
427
  {
@@ -21,6 +21,14 @@ module RubyLLM
21
21
  #
22
22
  # @api public
23
23
  class AgentRegistry
24
+ # Window the per-agent stats in {.all_with_details} cover
25
+ #
26
+ # All-time stats mean reading the whole executions table, which stops
27
+ # being viable in the millions of rows. A recent window keeps the list
28
+ # proportional to current traffic, and is the more useful health signal
29
+ # anyway.
30
+ STATS_WINDOW = 30.days
31
+
24
32
  class << self
25
33
  # Returns all unique agent type names
26
34
  #
@@ -47,10 +55,18 @@ module RubyLLM
47
55
 
48
56
  # Returns detailed info about all agents
49
57
  #
58
+ # Usage figures (:execution_count, :total_cost, :total_tokens,
59
+ # :avg_duration_ms, :success_rate, :error_rate) cover the last
60
+ # STATS_WINDOW and come from one grouped query shared by every agent.
61
+ # They are nil when that query could not finish within
62
+ # config.dashboard_query_timeout. :last_executed is all-time.
63
+ #
50
64
  # @return [Array<Hash>] Agent info hashes with configuration and stats
51
65
  def all_with_details
66
+ recent = recent_usage
67
+
52
68
  all.map do |agent_type|
53
- build_agent_info(agent_type)
69
+ build_agent_info(agent_type, recent)
54
70
  end
55
71
  end
56
72
 
@@ -183,9 +199,16 @@ module RubyLLM
183
199
 
184
200
  # Finds agent types from execution history
185
201
  #
202
+ # Cached briefly: it only exists to keep deleted agents visible, so a
203
+ # few minutes of staleness is invisible.
204
+ #
186
205
  # @return [Array<String>] Agent class names with execution records
187
206
  def execution_agents
188
- Execution.distinct.pluck(:agent_type).compact
207
+ Rails.cache.fetch(["ruby_llm_agents", "agent_registry", "execution_agents"], expires_in: 5.minutes) do
208
+ Execution.distinct_values(:agent_type)
209
+ end
210
+ rescue ::ActiveRecord::QueryCanceled
211
+ raise
189
212
  rescue => e
190
213
  Rails.logger.error("[RubyLLM::Agents] Error loading agents from executions: #{e.message}")
191
214
  []
@@ -213,10 +236,11 @@ module RubyLLM
213
236
  # Builds detailed info hash for an agent
214
237
  #
215
238
  # @param agent_type [String] The agent class name
239
+ # @param recent [Execution::Breakdown, nil] Usage over STATS_WINDOW
216
240
  # @return [Hash] Agent info including config and stats
217
- def build_agent_info(agent_type)
241
+ def build_agent_info(agent_type, recent)
218
242
  agent_class = find(agent_type)
219
- stats = fetch_stats(agent_type)
243
+ stats = fetch_stats(agent_type, recent)
220
244
 
221
245
  # Detect the agent type (agent, embedder, speaker, transcriber, image_generator)
222
246
  detected_type = detect_agent_type(agent_class)
@@ -240,7 +264,7 @@ module RubyLLM
240
264
  avg_duration_ms: stats[:avg_duration_ms],
241
265
  success_rate: stats[:success_rate],
242
266
  error_rate: stats[:error_rate],
243
- last_executed: last_execution_time(agent_type)
267
+ last_executed: stats[:last_seen] || last_execution_time(agent_type)
244
268
  }
245
269
  end
246
270
 
@@ -258,22 +282,61 @@ module RubyLLM
258
282
  nil
259
283
  end
260
284
 
261
- # Fetches statistics for an agent
285
+ # Usage of every agent over STATS_WINDOW, from one grouped query
286
+ #
287
+ # Cached, since the agents list is the same for everyone looking at
288
+ # it, and best-effort: nil (also cached) when the database cannot
289
+ # answer within config.dashboard_query_timeout.
290
+ #
291
+ # @return [Execution::Breakdown, nil]
292
+ def recent_usage
293
+ rows = Rails.cache.fetch(["ruby_llm_agents", "agent_registry", "recent_usage"], expires_in: 5.minutes) do
294
+ Execution.best_effort(timeout: RubyLLM::Agents.configuration.dashboard_query_timeout) do
295
+ Execution.where("created_at >= ?", STATS_WINDOW.ago).breakdown.rows
296
+ end
297
+ end
298
+
299
+ rows && Execution::Breakdown.new(rows)
300
+ rescue => e
301
+ Rails.logger.error("[RubyLLM::Agents] Error loading agent stats: #{e.message}")
302
+ nil
303
+ end
304
+
305
+ # Picks one agent's statistics out of the shared usage breakdown
306
+ #
307
+ # Executions recorded under the agent's previous names (aliases) count
308
+ # towards it.
262
309
  #
263
310
  # @param agent_type [String] The agent class name
264
- # @return [Hash] Statistics hash
265
- def fetch_stats(agent_type)
266
- Execution.stats_for(agent_type, period: :all_time)
267
- rescue
268
- {count: 0, total_cost: 0, total_tokens: 0, avg_duration_ms: 0, success_rate: 0, error_rate: 0}
311
+ # @param recent [Execution::Breakdown, nil] Usage over STATS_WINDOW
312
+ # @return [Hash] Statistics hash; values are nil when usage is unavailable
313
+ def fetch_stats(agent_type, recent)
314
+ return {} unless recent
315
+
316
+ totals = recent.for_agents(Execution.resolve_agent_names(agent_type)).totals
317
+
318
+ {
319
+ count: totals[:total],
320
+ total_cost: totals[:cost],
321
+ total_tokens: totals[:tokens],
322
+ avg_duration_ms: totals[:avg_duration_ms],
323
+ success_rate: totals[:success_rate],
324
+ error_rate: totals[:error_rate],
325
+ last_seen: totals[:last_seen]
326
+ }
269
327
  end
270
328
 
271
329
  # Gets the timestamp of the last execution for an agent
272
330
  #
331
+ # Only needed for agents with no runs inside STATS_WINDOW. One index
332
+ # probe per name the agent has gone by.
333
+ #
273
334
  # @param agent_type [String] The agent class name
274
335
  # @return [Time, nil] Last execution time or nil
275
336
  def last_execution_time(agent_type)
276
- Execution.by_agent(agent_type).order(created_at: :desc).first&.created_at
337
+ Execution.resolve_agent_names(agent_type)
338
+ .filter_map { |name| Execution.where(agent_type: name).maximum(:created_at) }
339
+ .max
277
340
  rescue
278
341
  nil
279
342
  end
@@ -42,6 +42,11 @@
42
42
  <span x-text="currentCount"></span>
43
43
  <%= render "ruby_llm/agents/shared/doc_link" %>
44
44
  </div>
45
+ <% if @stats_available %>
46
+ <span class="font-mono text-[10px] text-gray-400 dark:text-gray-600">runs, cost and ok% cover the last <%= @stats_window_days %> days</span>
47
+ <% else %>
48
+ <span class="font-mono text-[10px] text-yellow-600 dark:text-yellow-500" role="status">usage stats took too long to load and were skipped</span>
49
+ <% end %>
45
50
  <div class="flex-1 border-t border-gray-200 dark:border-gray-800"></div>
46
51
  <div class="flex font-mono text-xs gap-0.5">
47
52
  <button type="button" @click="activeSubTab = null; updateUrl()"
@@ -108,7 +113,7 @@
108
113
  "activeSubTab !== 'deleted' && (activeSubTab === null || activeSubTab === '#{agent[:agent_type]}')"
109
114
  end
110
115
  success_rate = agent[:success_rate] || 0
111
- dot_color = agent[:active] ? (success_rate >= 95 ? 'bg-green-500' : success_rate >= 80 ? 'bg-yellow-500' : 'bg-red-500') : 'bg-gray-400'
116
+ dot_color = !agent[:active] || !@stats_available ? 'bg-gray-400' : (success_rate >= 95 ? 'bg-green-500' : success_rate >= 80 ? 'bg-yellow-500' : 'bg-red-500')
112
117
  %>
113
118
  <div x-show="<%= show_condition %>"
114
119
  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"
@@ -120,9 +125,15 @@
120
125
  <span class="flex-1 min-w-0 truncate text-gray-500 dark:text-gray-400 hidden sm:inline"><%= agent[:agent_type]&.to_s || 'agent' %></span>
121
126
  <span class="w-1.5 h-1.5 rounded-full flex-shrink-0 <%= dot_color %>"></span>
122
127
  <span class="flex-[2] min-w-0 truncate text-gray-400 dark:text-gray-600 hidden lg:inline"><%= agent[:model]&.split("/")&.last || "—" %></span>
123
- <span class="w-14 flex-shrink-0 text-right text-gray-500 dark:text-gray-400"><%= number_with_delimiter(agent[:execution_count] || 0) %><span class="text-gray-400 dark:text-gray-600">r</span></span>
124
- <span class="w-16 flex-shrink-0 text-right text-gray-500 dark:text-gray-400 hidden md:inline">$<%= number_with_precision(agent[:total_cost] || 0, precision: 2) %></span>
125
- <span class="w-12 flex-shrink-0 text-right <%= success_rate >= 95 ? 'text-green-500' : success_rate >= 80 ? 'text-yellow-500' : 'text-red-500' %>"><%= success_rate.round %>%</span>
128
+ <% if @stats_available %>
129
+ <span class="w-14 flex-shrink-0 text-right text-gray-500 dark:text-gray-400"><%= number_with_delimiter(agent[:execution_count] || 0) %><span class="text-gray-400 dark:text-gray-600">r</span></span>
130
+ <span class="w-16 flex-shrink-0 text-right text-gray-500 dark:text-gray-400 hidden md:inline">$<%= number_with_precision(agent[:total_cost] || 0, precision: 2) %></span>
131
+ <span class="w-12 flex-shrink-0 text-right <%= success_rate >= 95 ? 'text-green-500' : success_rate >= 80 ? 'text-yellow-500' : 'text-red-500' %>"><%= success_rate.round %>%</span>
132
+ <% else %>
133
+ <span class="w-14 flex-shrink-0 text-right text-gray-400 dark:text-gray-600">—</span>
134
+ <span class="w-16 flex-shrink-0 text-right text-gray-400 dark:text-gray-600 hidden md:inline">—</span>
135
+ <span class="w-12 flex-shrink-0 text-right text-gray-400 dark:text-gray-600">—</span>
136
+ <% end %>
126
137
  <span class="w-24 flex-shrink-0 text-gray-400 dark:text-gray-600 text-right whitespace-nowrap">
127
138
  <% if agent[:last_executed] %>
128
139
  <%= time_ago_in_words(agent[:last_executed]) %>
@@ -144,9 +155,15 @@
144
155
  <span class="flex-1 min-w-0 truncate text-gray-400 dark:text-gray-600 hidden sm:inline"><%= agent[:agent_type]&.to_s || 'agent' %></span>
145
156
  <span class="w-1.5 h-1.5 rounded-full flex-shrink-0 bg-gray-400 dark:bg-gray-600"></span>
146
157
  <span class="flex-[2] min-w-0 truncate text-gray-400 dark:text-gray-600 hidden lg:inline"><%= agent[:model]&.split("/")&.last || "—" %></span>
147
- <span class="w-14 flex-shrink-0 text-right text-gray-400 dark:text-gray-600"><%= number_with_delimiter(agent[:execution_count] || 0) %><span class="text-gray-400 dark:text-gray-600">r</span></span>
148
- <span class="w-16 flex-shrink-0 text-right text-gray-400 dark:text-gray-600 hidden md:inline">$<%= number_with_precision(agent[:total_cost] || 0, precision: 2) %></span>
149
- <span class="w-12 flex-shrink-0 text-right text-gray-400 dark:text-gray-600"><%= success_rate.round %>%</span>
158
+ <% if @stats_available %>
159
+ <span class="w-14 flex-shrink-0 text-right text-gray-400 dark:text-gray-600"><%= number_with_delimiter(agent[:execution_count] || 0) %><span class="text-gray-400 dark:text-gray-600">r</span></span>
160
+ <span class="w-16 flex-shrink-0 text-right text-gray-400 dark:text-gray-600 hidden md:inline">$<%= number_with_precision(agent[:total_cost] || 0, precision: 2) %></span>
161
+ <span class="w-12 flex-shrink-0 text-right text-gray-400 dark:text-gray-600"><%= success_rate.round %>%</span>
162
+ <% else %>
163
+ <span class="w-14 flex-shrink-0 text-right text-gray-400 dark:text-gray-600">—</span>
164
+ <span class="w-16 flex-shrink-0 text-right text-gray-400 dark:text-gray-600 hidden md:inline">—</span>
165
+ <span class="w-12 flex-shrink-0 text-right text-gray-400 dark:text-gray-600">—</span>
166
+ <% end %>
150
167
  <span class="w-24 flex-shrink-0 text-gray-400 dark:text-gray-600 text-right whitespace-nowrap">
151
168
  <% if agent[:last_executed] %>
152
169
  <%= time_ago_in_words(agent[:last_executed]) %>
@@ -75,6 +75,7 @@
75
75
  <div class="flex-shrink-0 text-right font-mono text-xs text-gray-400 dark:text-gray-500 space-y-1">
76
76
  <% success_rate = @stats[:success_rate] || 0 %>
77
77
  <div class="flex items-center justify-end gap-x-4">
78
+ <span class="text-gray-300 dark:text-gray-700"><%= @stats_window_days %>d</span>
78
79
  <span><span class="text-gray-800 dark:text-gray-200"><%= number_with_delimiter(@stats[:count]) %></span> runs</span>
79
80
  <span class="<%= success_rate >= 95 ? 'text-green-500' : success_rate >= 80 ? 'text-yellow-500' : 'text-red-500' %>"><%= success_rate %>% ok</span>
80
81
  <span><span class="text-gray-800 dark:text-gray-200">$<%= number_with_precision(@stats[:total_cost] || 0, precision: 4) %></span> cost</span>
@@ -311,11 +312,11 @@
311
312
 
312
313
  <%= render "ruby_llm/agents/shared/executions_table", executions: @executions, pagination: @pagination, hide_agent_column: true %>
313
314
 
314
- <% if @stats[:count] > 0 %>
315
+ <% if @executions.any? %>
315
316
  <div class="mt-3 font-mono text-xs">
316
317
  <%= link_to ruby_llm_agents.executions_path("agent_types[]": @agent_type),
317
318
  class: "text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" do %>
318
- show all <%= number_with_delimiter(@stats[:count]) %> executions →
319
+ show all executions →
319
320
  <% end %>
320
321
  </div>
321
322
  <% end %>
@@ -156,13 +156,18 @@
156
156
  <%# Stats %>
157
157
  <div id="filter-stats" class="flex items-center justify-between md:justify-end gap-2 py-2 px-3 md:p-0 bg-gray-50 md:bg-transparent dark:bg-gray-700/50 md:dark:bg-transparent rounded-lg md:rounded-none">
158
158
  <span class="text-xs text-gray-400 dark:text-gray-500 md:hidden">Results</span>
159
- <div class="flex items-center gap-2 text-xs text-gray-400 dark:text-gray-500">
160
- <span class="tabular-nums"><%= number_to_human_short(filter_stats[:total_count]) %><span class="md:hidden"> executions</span></span>
161
- <span class="text-gray-300 dark:text-gray-600">/</span>
162
- <span class="tabular-nums"><%= number_to_human_short(filter_stats[:total_cost], prefix: "$", precision: 2) %></span>
163
- <span class="hidden md:inline text-gray-300 dark:text-gray-600">/</span>
164
- <span class="hidden md:inline tabular-nums"><%= number_to_human_short(filter_stats[:total_tokens]) %>t</span>
165
- </div>
159
+ <% if filter_stats %>
160
+ <div class="flex items-center gap-2 text-xs text-gray-400 dark:text-gray-500">
161
+ <span class="tabular-nums"><%= number_to_human_short(filter_stats[:total_count]) %><span class="md:hidden"> executions</span></span>
162
+ <span class="text-gray-300 dark:text-gray-600">/</span>
163
+ <span class="tabular-nums"><%= number_to_human_short(filter_stats[:total_cost], prefix: "$", precision: 2) %></span>
164
+ <span class="hidden md:inline text-gray-300 dark:text-gray-600">/</span>
165
+ <span class="hidden md:inline tabular-nums"><%= number_to_human_short(filter_stats[:total_tokens]) %>t</span>
166
+ </div>
167
+ <% else %>
168
+ <span class="text-xs text-gray-400 dark:text-gray-500 cursor-help"
169
+ title="Too many executions match to total them quickly. Pick a time range to see totals.">totals: pick a time range</span>
170
+ <% end %>
166
171
  </div>
167
172
 
168
173
  <%# Actions %>
@@ -77,57 +77,6 @@
77
77
  <% end %>
78
78
  </div>
79
79
 
80
- <%# Pagination %>
81
- <% if pagination && pagination[:total_pages] > 1 %>
82
- <%
83
- current_page = pagination[:current_page]
84
- total_pages = pagination[:total_pages]
85
- total_count = pagination[:total_count]
86
- per_page = pagination[:per_page]
87
- from_record = ((current_page - 1) * per_page) + 1
88
- to_record = [current_page * per_page, total_count].min
89
- %>
90
- <div class="flex items-center justify-between font-mono text-xs mt-4 pt-4 border-t border-gray-200 dark:border-gray-800">
91
- <span class="text-gray-400 dark:text-gray-600"><%= from_record %>-<%= to_record %> of <%= number_with_delimiter(total_count) %></span>
92
- <nav class="flex items-center gap-1">
93
- <% if current_page > 1 %>
94
- <%= link_to "prev", url_for(request.query_parameters.merge(page: current_page - 1)),
95
- class: "px-2 py-0.5 text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" %>
96
- <% else %>
97
- <span class="px-2 py-0.5 text-gray-300 dark:text-gray-700">prev</span>
98
- <% end %>
99
-
100
- <%
101
- window = 2
102
- pages_to_show = []
103
- (1..total_pages).each do |page|
104
- if page <= 1 || page >= total_pages || (page >= current_page - window && page <= current_page + window)
105
- pages_to_show << page
106
- elsif pages_to_show.last != :gap
107
- pages_to_show << :gap
108
- end
109
- end
110
- %>
111
-
112
- <% pages_to_show.each do |page| %>
113
- <% if page == :gap %>
114
- <span class="px-1 text-gray-400 dark:text-gray-600">...</span>
115
- <% elsif page == current_page %>
116
- <span class="px-2 py-0.5 text-gray-900 dark:text-gray-100"><%= page %></span>
117
- <% else %>
118
- <%= link_to page, url_for(request.query_parameters.merge(page: page)),
119
- class: "px-2 py-0.5 text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" %>
120
- <% end %>
121
- <% end %>
122
-
123
- <% if current_page < total_pages %>
124
- <%= link_to "next", url_for(request.query_parameters.merge(page: current_page + 1)),
125
- class: "px-2 py-0.5 text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" %>
126
- <% else %>
127
- <span class="px-2 py-0.5 text-gray-300 dark:text-gray-700">next</span>
128
- <% end %>
129
- </nav>
130
- </div>
131
- <% end %>
80
+ <%= render "ruby_llm/agents/shared/pagination", pagination: pagination, shown: executions.size if pagination %>
132
81
  <% end %>
133
82
  </div>
@@ -1,11 +1,13 @@
1
1
  <div class="flex items-center gap-3 mb-6">
2
2
  <span class="text-[10px] font-medium text-gray-400 dark:text-gray-600 uppercase tracking-widest font-mono">requests</span>
3
3
  <div class="flex-1 border-t border-gray-200 dark:border-gray-800"></div>
4
- <span class="font-mono text-[10px] text-gray-400 dark:text-gray-600">
5
- <%= number_with_delimiter(@stats[:total_requests]) %> tracked
6
- &middot;
7
- $<%= number_with_precision(@stats[:total_cost] || 0, precision: 4) %> total
8
- </span>
4
+ <% if @stats %>
5
+ <span class="font-mono text-[10px] text-gray-400 dark:text-gray-600">
6
+ <%= number_with_delimiter(@stats[:total_requests]) %> tracked
7
+ &middot;
8
+ $<%= number_with_precision(@stats[:total_cost] || 0, precision: 4) %> total
9
+ </span>
10
+ <% end %>
9
11
  </div>
10
12
 
11
13
  <%
@@ -98,56 +100,5 @@
98
100
  <% end %>
99
101
  </div>
100
102
 
101
- <%# Pagination %>
102
- <% if @pagination && @pagination[:total_pages] > 1 %>
103
- <%
104
- current_page = @pagination[:current_page]
105
- total_pages = @pagination[:total_pages]
106
- total_count = @pagination[:total_count]
107
- per_page = @pagination[:per_page]
108
- from_record = ((current_page - 1) * per_page) + 1
109
- to_record = [current_page * per_page, total_count].min
110
- %>
111
- <div class="flex items-center justify-between font-mono text-xs mt-4 pt-4 border-t border-gray-200 dark:border-gray-800">
112
- <span class="text-gray-400 dark:text-gray-600"><%= from_record %>-<%= to_record %> of <%= number_with_delimiter(total_count) %></span>
113
- <nav class="flex items-center gap-1">
114
- <% if current_page > 1 %>
115
- <%= link_to "prev", url_for(request.query_parameters.merge(page: current_page - 1)),
116
- class: "px-2 py-0.5 text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" %>
117
- <% else %>
118
- <span class="px-2 py-0.5 text-gray-300 dark:text-gray-700">prev</span>
119
- <% end %>
120
-
121
- <%
122
- window = 2
123
- pages_to_show = []
124
- (1..total_pages).each do |page|
125
- if page <= 1 || page >= total_pages || (page >= current_page - window && page <= current_page + window)
126
- pages_to_show << page
127
- elsif pages_to_show.last != :gap
128
- pages_to_show << :gap
129
- end
130
- end
131
- %>
132
-
133
- <% pages_to_show.each do |page| %>
134
- <% if page == :gap %>
135
- <span class="px-1 text-gray-400 dark:text-gray-600">...</span>
136
- <% elsif page == current_page %>
137
- <span class="px-2 py-0.5 text-gray-900 dark:text-gray-100"><%= page %></span>
138
- <% else %>
139
- <%= link_to page, url_for(request.query_parameters.merge(page: page)),
140
- class: "px-2 py-0.5 text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" %>
141
- <% end %>
142
- <% end %>
143
-
144
- <% if current_page < total_pages %>
145
- <%= link_to "next", url_for(request.query_parameters.merge(page: current_page + 1)),
146
- class: "px-2 py-0.5 text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" %>
147
- <% else %>
148
- <span class="px-2 py-0.5 text-gray-300 dark:text-gray-700">next</span>
149
- <% end %>
150
- </nav>
151
- </div>
152
- <% end %>
103
+ <%= render "ruby_llm/agents/shared/pagination", pagination: @pagination, shown: @requests.size if @pagination %>
153
104
  <% end %>
@@ -63,56 +63,5 @@
63
63
  <% end %>
64
64
  </div>
65
65
 
66
- <%# Pagination %>
67
- <% if pagination[:total_pages] > 1 %>
68
- <%
69
- current_page = pagination[:current_page]
70
- total_pages = pagination[:total_pages]
71
- total_count = pagination[:total_count]
72
- per_page = pagination[:per_page]
73
- from_record = ((current_page - 1) * per_page) + 1
74
- to_record = [current_page * per_page, total_count].min
75
- %>
76
- <div class="flex items-center justify-between font-mono text-xs mt-4 pt-4 border-t border-gray-200 dark:border-gray-800">
77
- <span class="text-gray-400 dark:text-gray-600"><%= from_record %>-<%= to_record %> of <%= number_with_delimiter(total_count) %></span>
78
- <nav class="flex items-center gap-1">
79
- <% if current_page > 1 %>
80
- <%= link_to "prev", url_for(request.query_parameters.merge(page: current_page - 1)),
81
- class: "px-2 py-0.5 text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" %>
82
- <% else %>
83
- <span class="px-2 py-0.5 text-gray-300 dark:text-gray-700">prev</span>
84
- <% end %>
85
-
86
- <%
87
- window = 2
88
- pages_to_show = []
89
- (1..total_pages).each do |page|
90
- if page <= 1 || page >= total_pages || (page >= current_page - window && page <= current_page + window)
91
- pages_to_show << page
92
- elsif pages_to_show.last != :gap
93
- pages_to_show << :gap
94
- end
95
- end
96
- %>
97
-
98
- <% pages_to_show.each do |page| %>
99
- <% if page == :gap %>
100
- <span class="px-1 text-gray-400 dark:text-gray-600">...</span>
101
- <% elsif page == current_page %>
102
- <span class="px-2 py-0.5 text-gray-900 dark:text-gray-100"><%= page %></span>
103
- <% else %>
104
- <%= link_to page, url_for(request.query_parameters.merge(page: page)),
105
- class: "px-2 py-0.5 text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" %>
106
- <% end %>
107
- <% end %>
108
-
109
- <% if current_page < total_pages %>
110
- <%= link_to "next", url_for(request.query_parameters.merge(page: current_page + 1)),
111
- class: "px-2 py-0.5 text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" %>
112
- <% else %>
113
- <span class="px-2 py-0.5 text-gray-300 dark:text-gray-700">next</span>
114
- <% end %>
115
- </nav>
116
- </div>
117
- <% end %>
66
+ <%= render "ruby_llm/agents/shared/pagination", pagination: pagination, shown: executions.size %>
118
67
  <% end %>
@@ -0,0 +1,66 @@
1
+ <%#
2
+ Pagination links for a list page.
3
+
4
+ pagination: the hash built by Paginatable#paginate. When :total_count is
5
+ known this renders "1-25 of 1,234" with numbered pages; when it is not
6
+ (counting was skipped or timed out on a very large table) it renders the
7
+ current range with prev/next only.
8
+ %>
9
+ <%
10
+ current_page = pagination[:current_page]
11
+ per_page = pagination[:per_page]
12
+ total_count = pagination[:total_count]
13
+ total_pages = pagination[:total_pages]
14
+ has_next = total_pages ? current_page < total_pages : pagination[:next_page]
15
+ from_record = ((current_page - 1) * per_page) + 1
16
+ link_class = "px-2 py-0.5 text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
17
+ %>
18
+ <% if current_page > 1 || has_next %>
19
+ <div class="flex items-center justify-between font-mono text-xs mt-4 pt-4 border-t border-gray-200 dark:border-gray-800">
20
+ <span class="text-gray-400 dark:text-gray-600">
21
+ <% if total_count %>
22
+ <%= from_record %>-<%= [current_page * per_page, total_count].min %> of <%= number_with_delimiter(total_count) %>
23
+ <% else %>
24
+ <%= number_with_delimiter(from_record) %>-<%= number_with_delimiter(from_record + local_assigns.fetch(:shown, per_page) - 1) %>
25
+ <% end %>
26
+ </span>
27
+ <nav class="flex items-center gap-1" aria-label="Pagination">
28
+ <% if current_page > 1 %>
29
+ <%= link_to "prev", url_for(request.query_parameters.merge(page: current_page - 1)), class: link_class %>
30
+ <% else %>
31
+ <span class="px-2 py-0.5 text-gray-300 dark:text-gray-700">prev</span>
32
+ <% end %>
33
+
34
+ <% if total_pages %>
35
+ <%
36
+ window = 2
37
+ pages_to_show = []
38
+ (1..total_pages).each do |page|
39
+ if page <= 1 || page >= total_pages || (page >= current_page - window && page <= current_page + window)
40
+ pages_to_show << page
41
+ elsif pages_to_show.last != :gap
42
+ pages_to_show << :gap
43
+ end
44
+ end
45
+ %>
46
+ <% pages_to_show.each do |page| %>
47
+ <% if page == :gap %>
48
+ <span class="px-1 text-gray-400 dark:text-gray-600">...</span>
49
+ <% elsif page == current_page %>
50
+ <span class="px-2 py-0.5 text-gray-900 dark:text-gray-100" aria-current="page"><%= page %></span>
51
+ <% else %>
52
+ <%= link_to page, url_for(request.query_parameters.merge(page: page)), class: link_class %>
53
+ <% end %>
54
+ <% end %>
55
+ <% else %>
56
+ <span class="px-2 py-0.5 text-gray-900 dark:text-gray-100" aria-current="page"><%= current_page %></span>
57
+ <% end %>
58
+
59
+ <% if has_next %>
60
+ <%= link_to "next", url_for(request.query_parameters.merge(page: current_page + 1)), class: link_class %>
61
+ <% else %>
62
+ <span class="px-2 py-0.5 text-gray-300 dark:text-gray-700">next</span>
63
+ <% end %>
64
+ </nav>
65
+ </div>
66
+ <% end %>