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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 50ffc9d68f60773bb6d86c42addae6911999d703eadd295bd30b5e4fcc5858f6
4
- data.tar.gz: b6c40aa4a3f1ef6ac52e4d2beeb27871038a48d8c80ba71b37dcbd43f21394de
3
+ metadata.gz: 67962a08cb48a28feb5b1dc27557ddb959fbdd034314e8734410f6626cdd66fb
4
+ data.tar.gz: cc87df7123f4cd180eda63945ae703b3e9dd927e5caef5c2164a0971e9b4fc56
5
5
  SHA512:
6
- metadata.gz: e3f02b1ff28adcb9b102acdcbed8e31470130c1763470af992a501750681ae0c9ebe86b3a2bf5152dcd40216b7f3fe2298c5d1959e54957dba947084d14317a9
7
- data.tar.gz: 8f8c8a9942c6123a92d0e9f0a2f7424fd2d655ca27689cf58faf7358ce2964fca9f4d155527990392f22b89d2127b7c54c90bb43e11c41f695c87baaedb12550
6
+ metadata.gz: 67d4507bf18669118aa35180124d0b4bbeabd512260598f042f608551acb9ba86eed3342e7e93c0a4d252d8ccc212e844e405d5d20b805882f7c1616637adb9d
7
+ data.tar.gz: 79699601f5b3763ecd13f4eb133115fd83823fba5fbed5b492109991ab2ca4f0a49139b540c72dffe53f8bcff4d2c3d83159c0fc93ace0da83c6fa95cc10f657
@@ -7,7 +7,8 @@ module RubyLLM
7
7
  # Provides simple offset-based pagination with consistent return format.
8
8
  #
9
9
  # @example Using in a controller
10
- # result = paginate(Execution.all)
10
+ # stats = scope.totals
11
+ # result = paginate(scope, total_count: stats[:total_count])
11
12
  # @executions = result[:records]
12
13
  # @pagination = result[:pagination]
13
14
  #
@@ -19,17 +20,25 @@ module RubyLLM
19
20
 
20
21
  # Paginates a scope with optional ordering
21
22
  #
23
+ # Counting every row a filter matches is the expensive half of
24
+ # pagination on a large table, so the count is the caller's to supply
25
+ # (usually from an aggregate it needed anyway). Without one the page is
26
+ # still served: one extra row is fetched to learn whether a next page
27
+ # exists, and the view falls back to prev/next links.
28
+ #
22
29
  # @param scope [ActiveRecord::Relation] The scope to paginate
23
30
  # @param ordered [Boolean] Whether to apply default descending order (default: true)
24
31
  # @param sort_params [Hash, nil] Optional custom sort parameters with :column and :direction
32
+ # @param total_count [Integer, nil] Total rows in the scope, when known
25
33
  # @return [Hash] Contains :records and :pagination keys
26
- # @option return [ActiveRecord::Relation] :records Paginated records
34
+ # @option return [Array, ActiveRecord::Relation] :records The loaded page
27
35
  # @option return [Hash] :pagination Pagination metadata
28
36
  # - :current_page [Integer] Current page number
29
37
  # - :per_page [Integer] Records per page
30
- # - :total_count [Integer] Total record count
31
- # - :total_pages [Integer] Total page count
32
- def paginate(scope, ordered: true, sort_params: nil)
38
+ # - :total_count [Integer, nil] Total record count, nil when unknown
39
+ # - :total_pages [Integer, nil] Total page count, nil when unknown
40
+ # - :next_page [Boolean] Whether a later page exists
41
+ def paginate(scope, ordered: true, sort_params: nil, total_count: nil)
33
42
  page = [(params[:page] || 1).to_i, 1].max
34
43
  per_page = RubyLLM::Agents.configuration.per_page
35
44
  offset = (page - 1) * per_page
@@ -41,15 +50,27 @@ module RubyLLM
41
50
  elsif ordered
42
51
  scope = scope.order("#{table_name}.created_at DESC")
43
52
  end
44
- total_count = scope.count
53
+
54
+ # Loaded eagerly: the views call `records.empty?` before iterating,
55
+ # which on an unloaded relation costs a second SELECT.
56
+ if total_count
57
+ records = scope.offset(offset).limit(per_page).load
58
+ total_pages = (total_count.to_f / per_page).ceil
59
+ next_page = page < total_pages
60
+ else
61
+ records = scope.offset(offset).limit(per_page + 1).to_a
62
+ next_page = records.size > per_page
63
+ records = records.first(per_page)
64
+ end
45
65
 
46
66
  {
47
- records: scope.offset(offset).limit(per_page),
67
+ records: records,
48
68
  pagination: {
49
69
  current_page: page,
50
70
  per_page: per_page,
51
71
  total_count: total_count,
52
- total_pages: (total_count.to_f / per_page).ceil
72
+ total_pages: total_pages,
73
+ next_page: next_page
53
74
  }
54
75
  }
55
76
  end
@@ -21,6 +21,13 @@ module RubyLLM
21
21
  DEFAULT_AGENT_SORT_COLUMN = "name"
22
22
  DEFAULT_AGENT_SORT_DIRECTION = "asc"
23
23
 
24
+ # Days of history behind the agent page's headline stats and trend chart
25
+ STATS_WINDOW_DAYS = 30
26
+
27
+ # Seconds the executions table may spend counting before it renders
28
+ # with prev/next links only
29
+ TOTALS_TIMEOUT = 1
30
+
24
31
  # Lists all registered agents with their details
25
32
  #
26
33
  # Uses AgentRegistry to discover agents from both file system
@@ -56,6 +63,10 @@ module RubyLLM
56
63
 
57
64
  @agent_count = @agents.size
58
65
  @deleted_count = @deleted_agents.size
66
+ @stats_window_days = AgentRegistry::STATS_WINDOW.in_days.to_i
67
+ @stats_available = all_agents.empty? || all_agents.any? { |a| a[:execution_count] }
68
+ rescue ::ActiveRecord::QueryCanceled
69
+ raise
59
70
  rescue => e
60
71
  Rails.logger.error("[RubyLLM::Agents] Error loading agents: #{e.message}")
61
72
  @agents = []
@@ -64,6 +75,8 @@ module RubyLLM
64
75
  @agent_count = 0
65
76
  @deleted_count = 0
66
77
  @sort_params = {column: DEFAULT_AGENT_SORT_COLUMN, direction: DEFAULT_AGENT_SORT_DIRECTION}
78
+ @stats_window_days = AgentRegistry::STATS_WINDOW.in_days.to_i
79
+ @stats_available = true
67
80
  flash.now[:alert] = "Error loading agents list"
68
81
  end
69
82
 
@@ -80,7 +93,6 @@ module RubyLLM
80
93
  @agent_active = @agent_class.present?
81
94
 
82
95
  load_agent_stats
83
- load_filter_options
84
96
  load_filtered_executions
85
97
  load_chart_data
86
98
 
@@ -89,6 +101,8 @@ module RubyLLM
89
101
  # Load circuit breaker status for agents that support reliability
90
102
  load_circuit_breaker_status if @agent_type_kind.in?(%w[agent router])
91
103
  end
104
+ rescue ::ActiveRecord::QueryCanceled
105
+ raise
92
106
  rescue => e
93
107
  Rails.logger.error("[RubyLLM::Agents] Error loading agent #{@agent_type}: #{e.message}")
94
108
  redirect_to ruby_llm_agents.agents_path, alert: "Error loading agent details"
@@ -152,55 +166,48 @@ module RubyLLM
152
166
 
153
167
  private
154
168
 
155
- # Loads all-time and today's statistics for the agent
169
+ # Loads the agent's headline statistics
170
+ #
171
+ # @stats covers the last STATS_WINDOW_DAYS rather than all time: an
172
+ # agent's whole history can be millions of rows, and each figure here
173
+ # used to be its own scan of it. Both hashes come from one query each.
156
174
  #
157
175
  # @return [void]
158
176
  def load_agent_stats
159
- base = tenant_scoped_executions
160
- @stats = base.stats_for(@agent_type, period: :all_time)
161
- @stats_today = base.stats_for(@agent_type, period: :today)
162
-
163
- # Additional stats for new schema fields
164
- agent_scope = base.by_agent(@agent_type)
165
- @cache_hit_rate = agent_scope.cache_hit_rate
166
- @streaming_rate = agent_scope.streaming_rate
167
- @avg_ttft = agent_scope.avg_time_to_first_token
168
- end
177
+ agent_scope = tenant_scoped_executions.by_agent(@agent_type)
169
178
 
170
- # Loads available filter options from execution history
171
- #
172
- # Uses a single optimized query to fetch all filter values
173
- # (versions, models, temperatures) avoiding N+1 queries.
174
- #
175
- # @return [void]
176
- def load_filter_options
177
- # Single query to get all filter options (fixes N+1)
178
- base = tenant_scoped_executions.by_agent(@agent_type)
179
- filter_data = base
180
- .where.not(model_id: nil)
181
- .or(base.where.not(temperature: nil))
182
- .pluck(:model_id, :temperature)
183
-
184
- @models = filter_data.map(&:first).compact.uniq.sort
185
- @temperatures = filter_data.map(&:last).compact.uniq.sort
179
+ @stats_window_days = STATS_WINDOW_DAYS
180
+ @stats = cached_stats(:agent_summary, @agent_type, expires_in: 5.minutes) do
181
+ agent_scope.last_n_days(STATS_WINDOW_DAYS).usage_summary
182
+ end
183
+ @stats_today = cached_stats(:agent_today, @agent_type, expires_in: 30.seconds) do
184
+ agent_scope.today.usage_summary
185
+ end
186
+ @cache_hit_rate = @stats[:cache_hit_rate]
187
+ @streaming_rate = @stats[:streaming_rate]
186
188
  end
187
189
 
188
- # Loads paginated and filtered executions with statistics
190
+ # Loads paginated and filtered executions
189
191
  #
190
- # Sets @executions, @pagination, and @filter_stats for the view.
192
+ # Sets @executions, @pagination, and @filter_stats for the view. The
193
+ # count behind the page numbers is best-effort, as on the executions
194
+ # index: unfiltered, it is a count of the agent's entire history.
191
195
  #
192
196
  # @return [void]
193
197
  def load_filtered_executions
194
198
  base_scope = build_filtered_scope
195
- result = paginate(base_scope)
199
+
200
+ filters = request.query_parameters.except("page")
201
+ @filter_stats = cached_stats(:agent_totals, @agent_type, filters.to_query) do
202
+ Execution.best_effort(timeout: TOTALS_TIMEOUT) { base_scope.totals }
203
+ end
204
+
205
+ # error_detail: the table renders error_message per row, and the full
206
+ # detail row carries every prompt and response payload.
207
+ result = paginate(base_scope.preload(:error_detail),
208
+ total_count: @filter_stats&.fetch(:total_count))
196
209
  @executions = result[:records]
197
210
  @pagination = result[:pagination]
198
-
199
- @filter_stats = {
200
- total_count: result[:pagination][:total_count],
201
- total_cost: base_scope.sum(:total_cost),
202
- total_tokens: base_scope.sum(:total_tokens)
203
- }
204
211
  end
205
212
 
206
213
  # Builds a filtered scope for the current agent's executions
@@ -229,16 +236,13 @@ module RubyLLM
229
236
  apply_time_filter(scope, days)
230
237
  end
231
238
 
232
- # Loads chart data for agent performance visualization
233
- #
234
- # Fetches 30-day trend analysis and status/finish_reason distribution for charts.
239
+ # Loads the 30-day trend for the agent's chart
235
240
  #
236
241
  # @return [void]
237
242
  def load_chart_data
238
- base = tenant_scoped_executions
239
- @trend_data = base.trend_analysis(agent_type: @agent_type, days: 30)
240
- @status_distribution = base.by_agent(@agent_type).group(:status).count
241
- @finish_reason_distribution = base.by_agent(@agent_type).finish_reason_distribution
243
+ @trend_data = cached_stats(:agent_trend, @agent_type, expires_in: 5.minutes) do
244
+ tenant_scoped_executions.trend_analysis(agent_type: @agent_type, days: STATS_WINDOW_DAYS)
245
+ end
242
246
  end
243
247
 
244
248
  # Loads the current agent class configuration
@@ -13,6 +13,10 @@ module RubyLLM
13
13
  VALID_RANGES = %w[7d 30d 90d custom].freeze
14
14
  DEFAULT_RANGE = "30d"
15
15
 
16
+ # Every section covers the same filtered range, so they are all derived
17
+ # from one Breakdown of it: the page scans the range once, plus one
18
+ # aggregate each for the prior period and the month to date. Results
19
+ # are cached, since these ranges are wide and move slowly.
16
20
  def index
17
21
  @selected_range = sanitize_range(params[:range])
18
22
  @days = range_to_days(@selected_range)
@@ -21,12 +25,13 @@ module RubyLLM
21
25
  load_filter_options
22
26
  base = apply_filters(time_scoped(tenant_scoped_executions))
23
27
  prior = apply_filters(prior_period_scope(tenant_scoped_executions))
28
+ breakdown = Execution::Breakdown.new(cached_analytics(:breakdown) { base.breakdown.rows })
24
29
 
25
- load_summary(base, prior)
30
+ load_summary(breakdown, cached_analytics(:prior) { aggregate(prior) })
26
31
  load_projection(base)
27
- load_savings_opportunity(base)
28
- load_efficiency(base)
29
- load_error_cost(base)
32
+ load_savings_opportunity(breakdown)
33
+ @efficiency = breakdown.model_stats
34
+ load_error_cost(breakdown)
30
35
  end
31
36
 
32
37
  # Returns chart JSON: current period + prior period overlay
@@ -39,7 +44,7 @@ module RubyLLM
39
44
  current_scope = apply_filters(time_scoped(tenant_scoped_executions))
40
45
  prior_scope = apply_filters(prior_period_scope(tenant_scoped_executions))
41
46
 
42
- render json: build_overlay_chart_json(current_scope, prior_scope)
47
+ render json: cached_analytics(:chart) { build_overlay_chart_json(current_scope, prior_scope) }
43
48
  end
44
49
 
45
50
  private
@@ -106,9 +111,8 @@ module RubyLLM
106
111
  # ── Filters ──────────────────────────
107
112
 
108
113
  def load_filter_options
109
- base = tenant_scoped_executions
110
- @available_agents = base.where.not(agent_type: nil).distinct.pluck(:agent_type).sort
111
- @available_models = base.where.not(model_id: nil).distinct.pluck(:model_id).sort
114
+ @available_agents = available_agent_types
115
+ @available_models = available_model_ids
112
116
  @available_tenants = if tenant_filter_enabled? && Tenant.table_exists?
113
117
  Tenant.pluck(:tenant_id, :name).map { |tid, name| [tid, name.presence || tid] }.sort_by(&:last)
114
118
  else
@@ -137,18 +141,23 @@ module RubyLLM
137
141
 
138
142
  # ── Data loaders ──────────────────────────
139
143
 
140
- def load_summary(base, prior)
141
- current_agg = aggregate(base)
142
- prior_agg = aggregate(prior)
144
+ # Caches one of the page's figures under the selected range and filters
145
+ def cached_analytics(name, &block)
146
+ cached_stats(:analytics, name, @selected_range, @custom_from, @custom_to,
147
+ @filter_agent, @filter_model, @filter_tenant, expires_in: 5.minutes, &block)
148
+ end
149
+
150
+ def load_summary(breakdown, prior_agg)
151
+ totals = breakdown.totals
143
152
 
144
153
  @summary = {
145
- total_cost: current_agg[:cost],
146
- total_runs: current_agg[:count],
147
- total_tokens: current_agg[:tokens],
148
- avg_cost: (current_agg[:count] > 0) ? (current_agg[:cost].to_f / current_agg[:count]) : 0,
149
- avg_tokens: (current_agg[:count] > 0) ? (current_agg[:tokens].to_f / current_agg[:count]).round : 0,
150
- cost_change: pct_change(prior_agg[:cost], current_agg[:cost]),
151
- runs_change: pct_change(prior_agg[:count], current_agg[:count]),
154
+ total_cost: totals[:cost],
155
+ total_runs: totals[:total],
156
+ total_tokens: totals[:tokens],
157
+ avg_cost: (totals[:total] > 0) ? (totals[:cost] / totals[:total]) : 0,
158
+ avg_tokens: (totals[:total] > 0) ? (totals[:tokens].to_f / totals[:total]).round : 0,
159
+ cost_change: pct_change(prior_agg[:cost], totals[:cost]),
160
+ runs_change: pct_change(prior_agg[:count], totals[:total]),
152
161
  prior_cost: prior_agg[:cost],
153
162
  prior_runs: prior_agg[:count]
154
163
  }
@@ -160,8 +169,10 @@ module RubyLLM
160
169
  # Calculate daily burn rate and project to end of month
161
170
  daily_rate = @summary[:total_cost].to_f / @days
162
171
  days_left = (Date.current.end_of_month - Date.current).to_i
163
- month_so_far = base.where("created_at >= ?", Date.current.beginning_of_month.beginning_of_day)
164
- .sum(:total_cost).to_f
172
+ month_so_far = cached_analytics(:month_so_far) do
173
+ base.where("created_at >= ?", Date.current.beginning_of_month.beginning_of_day)
174
+ .sum(:total_cost).to_f
175
+ end
165
176
 
166
177
  @projection = {
167
178
  daily_rate: daily_rate,
@@ -171,31 +182,9 @@ module RubyLLM
171
182
  }
172
183
  end
173
184
 
174
- def load_savings_opportunity(base)
185
+ def load_savings_opportunity(breakdown)
175
186
  # Find the most expensive model and suggest switching to the cheapest
176
- models = base.where.not(model_id: nil)
177
- .select(
178
- :model_id,
179
- Arel.sql("COUNT(*) AS exec_count"),
180
- Arel.sql("COALESCE(SUM(total_cost), 0) AS sum_cost"),
181
- Arel.sql("COALESCE(SUM(total_tokens), 0) AS sum_tokens")
182
- )
183
- .group(:model_id)
184
- .order(Arel.sql("sum_cost DESC"))
185
-
186
- model_data = models.map do |row|
187
- count = row["exec_count"].to_i
188
- cost = row["sum_cost"].to_f
189
- tokens = row["sum_tokens"].to_i
190
- {
191
- model_id: row.model_id,
192
- runs: count,
193
- cost: cost,
194
- tokens: tokens,
195
- cost_per_run: (count > 0) ? (cost / count) : 0,
196
- cost_per_1k_tokens: (tokens > 0) ? (cost / tokens * 1000) : 0
197
- }
198
- end
187
+ model_data = breakdown.configured_model_usage
199
188
 
200
189
  @savings = nil
201
190
  return if model_data.size < 2
@@ -219,35 +208,12 @@ module RubyLLM
219
208
  }
220
209
  end
221
210
 
222
- def load_efficiency(base)
223
- @efficiency = Execution.model_stats(scope: base)
224
- end
211
+ def load_error_cost(breakdown)
212
+ error_cost = breakdown.error_cost(limit: 10)
225
213
 
226
- def load_error_cost(base)
227
- error_scope = base.where(status: "error")
228
- @error_total_cost = error_scope.sum(:total_cost) || 0
229
- @error_total_count = error_scope.count
230
-
231
- @error_breakdown = error_scope
232
- .select(
233
- :error_class,
234
- :agent_type,
235
- Arel.sql("COUNT(*) AS err_count"),
236
- Arel.sql("COALESCE(SUM(total_cost), 0) AS err_cost"),
237
- Arel.sql("MAX(created_at) AS last_seen")
238
- )
239
- .group(:error_class, :agent_type)
240
- .order(Arel.sql("err_cost DESC"))
241
- .limit(10)
242
- .map do |row|
243
- {
244
- error_class: row.error_class || "Unknown",
245
- agent_type: row.agent_type,
246
- count: row["err_count"].to_i,
247
- cost: row["err_cost"].to_f,
248
- last_seen: row["last_seen"]
249
- }
250
- end
214
+ @error_total_cost = error_cost[:total_cost]
215
+ @error_total_count = error_cost[:total_count]
216
+ @error_breakdown = error_cost[:breakdown]
251
217
  end
252
218
 
253
219
  # ── Helpers ──────────────────────────
@@ -13,8 +13,12 @@ module RubyLLM
13
13
  class DashboardController < ApplicationController
14
14
  # Renders the main dashboard view
15
15
  #
16
- # Loads now strip data, critical alerts, hourly activity,
17
- # recent executions, agent comparison, and top errors.
16
+ # Loads now strip data, critical alerts, recent executions, agent and
17
+ # model comparison, top errors and cache savings.
18
+ #
19
+ # Every section that covers the selected range is derived from a single
20
+ # Breakdown of it, so the page scans that window once (twice, counting
21
+ # the previous-period comparison) however many sections it shows.
18
22
  #
19
23
  # @return [void]
20
24
  def index
@@ -22,14 +26,15 @@ module RubyLLM
22
26
  @days = range_to_days(@selected_range)
23
27
  parse_custom_dates if @selected_range == "custom"
24
28
  base_scope = tenant_scoped_executions
25
- @now_strip = build_now_strip(base_scope)
29
+ breakdown = load_breakdown(base_scope)
30
+ @now_strip = build_now_strip(base_scope, breakdown)
26
31
  @critical_alerts = load_critical_alerts(base_scope)
27
- @recent_executions = base_scope.includes(:detail).recent(10)
28
- @agent_stats = build_agent_comparison(base_scope)
29
- @top_errors = build_top_errors(base_scope)
32
+ @recent_executions = base_scope.preload(:error_detail).recent(10)
33
+ @agent_stats = build_agent_comparison(breakdown)
34
+ @top_errors = breakdown.top_errors
30
35
  @tenant_budget = load_tenant_budget(base_scope)
31
- @model_stats = build_model_stats(base_scope)
32
- @cache_savings = build_cache_savings(base_scope)
36
+ @model_stats = breakdown.model_stats
37
+ @cache_savings = breakdown.cache_savings
33
38
  @top_tenants = build_top_tenants
34
39
  end
35
40
 
@@ -40,19 +45,19 @@ module RubyLLM
40
45
  def chart_data
41
46
  range = sanitize_range(params[:range])
42
47
  scope = tenant_scoped_executions
48
+ from = parse_date(params[:from])
49
+ to = parse_date(params[:to])
50
+ custom = range == "custom" && from && to
51
+ from, to = [from, to].sort if custom
43
52
 
44
- data = if range == "custom"
45
- from = parse_date(params[:from])
46
- to = parse_date(params[:to])
47
- if from && to
48
- from, to = [from, to].sort
49
- to = [to, Date.current].min
50
- scope.activity_chart_json_for_dates(from: from, to: to)
51
- else
53
+ data = cached_stats(:dashboard_chart, range, (from if custom), (to if custom), expires_in: stats_ttl(range)) do
54
+ if custom
55
+ scope.activity_chart_json_for_dates(from: from, to: [to, Date.current].min)
56
+ elsif range == "custom"
52
57
  scope.activity_chart_json(range: "today")
58
+ else
59
+ scope.activity_chart_json(range: range)
53
60
  end
54
- else
55
- scope.activity_chart_json(range: range)
56
61
  end
57
62
 
58
63
  render json: data
@@ -131,16 +136,51 @@ module RubyLLM
131
136
  end
132
137
  end
133
138
 
134
- # Routes to the correct now_strip_data method based on range
139
+ # How long the figures for a range may be served from cache
140
+ #
141
+ # "today" is the live view, so it stays close to real time. The wider
142
+ # ranges move slowly and are the expensive ones to recompute.
143
+ #
144
+ # @param range [String] Sanitized range
145
+ # @return [ActiveSupport::Duration]
146
+ def stats_ttl(range)
147
+ (range == "today") ? 30.seconds : 5.minutes
148
+ end
149
+
150
+ # Scans the selected range once, grouped for every section of the page
151
+ #
152
+ # @param base_scope [ActiveRecord::Relation] Base scope
153
+ # @return [Execution::Breakdown]
154
+ def load_breakdown(base_scope)
155
+ rows = cached_stats(:dashboard_breakdown, @selected_range, @custom_from, @custom_to,
156
+ expires_in: stats_ttl(@selected_range)) { time_scoped(base_scope).breakdown.rows }
157
+
158
+ Execution::Breakdown.new(rows)
159
+ end
160
+
161
+ # Builds the now strip, reusing the breakdown for the current period
162
+ #
163
+ # The strip's current period is the breakdown's window for every range
164
+ # except "today" (the strip counts from midnight, the other sections
165
+ # cover the last 24 hours), so only "today" scans it separately. The
166
+ # running count is always read live.
135
167
  #
136
168
  # @param base_scope [ActiveRecord::Relation] Base scope
169
+ # @param breakdown [Execution::Breakdown] Breakdown of the selected range
137
170
  # @return [Hash] Now strip metrics
138
- def build_now_strip(base_scope)
139
- if @selected_range == "custom" && @custom_from && @custom_to
140
- base_scope.now_strip_data_for_dates(from: @custom_from, to: @custom_to)
141
- else
142
- base_scope.now_strip_data(range: @selected_range)
171
+ def build_now_strip(base_scope, breakdown)
172
+ strip = cached_stats(:dashboard_now_strip, @selected_range, @custom_from, @custom_to,
173
+ expires_in: stats_ttl(@selected_range)) do
174
+ if @selected_range == "custom" && @custom_from && @custom_to
175
+ base_scope.now_strip_data_for_dates(from: @custom_from, to: @custom_to, current: breakdown.totals)
176
+ elsif @selected_range == "today"
177
+ base_scope.now_strip_data(range: "today")
178
+ else
179
+ base_scope.now_strip_data(range: @selected_range, current: breakdown.totals)
180
+ end
143
181
  end
182
+
183
+ strip.merge(running: base_scope.running.count)
144
184
  end
145
185
 
146
186
  # Builds per-agent comparison statistics for all agent types
@@ -152,22 +192,18 @@ module RubyLLM
152
192
  # - @speaker_stats: Speakers
153
193
  # - @image_generator_stats: Image generators
154
194
  #
155
- # @param base_scope [ActiveRecord::Relation] Base scope to filter from
195
+ # @param breakdown [Execution::Breakdown] Breakdown of the selected range
156
196
  # @return [Array<Hash>] Array of base agent stats (for backward compatibility)
157
- def build_agent_comparison(base_scope = Execution)
158
- scope = time_scoped(base_scope)
159
-
197
+ def build_agent_comparison(breakdown)
160
198
  # Get ALL agents from registry (file system + execution history)
161
199
  all_agent_types = AgentRegistry.all
162
-
163
- # Batch fetch stats for executed agents (4 queries total)
164
- execution_stats = batch_fetch_agent_stats(scope)
200
+ execution_stats = breakdown.agent_stats
165
201
 
166
202
  all_stats = all_agent_types.map do |agent_type|
167
203
  agent_class = AgentRegistry.find(agent_type)
168
204
  detected_type = AgentRegistry.send(:detect_agent_type, agent_class)
169
205
 
170
- # Get stats from batch or use zeros for never-executed agents
206
+ # Zeros for agents with no executions in the range
171
207
  stats = execution_stats[agent_type] || {
172
208
  count: 0, total_cost: 0, avg_cost: 0, avg_duration_ms: 0, success_rate: 0
173
209
  }
@@ -194,16 +230,6 @@ module RubyLLM
194
230
  @agent_stats
195
231
  end
196
232
 
197
- # Delegates to Execution.model_stats with time scoping
198
- def build_model_stats(base_scope = Execution)
199
- Execution.model_stats(scope: time_scoped(base_scope))
200
- end
201
-
202
- # Delegates to Execution.top_errors with time scoping
203
- def build_top_errors(base_scope = Execution)
204
- Execution.top_errors(scope: time_scoped(base_scope))
205
- end
206
-
207
233
  # Loads budget status for display on dashboard
208
234
  #
209
235
  # @return [Hash] Budget status with global daily and monthly info
@@ -217,10 +243,7 @@ module RubyLLM
217
243
  def load_open_breakers
218
244
  open_breakers = []
219
245
 
220
- # Get all agents from execution history
221
- agent_types = tenant_scoped_executions.distinct.pluck(:agent_type)
222
-
223
- agent_types.each do |agent_type|
246
+ available_agent_types.each do |agent_type|
224
247
  # Get the agent class if available
225
248
  agent_class = AgentRegistry.find(agent_type)
226
249
  next unless agent_class
@@ -349,20 +372,10 @@ module RubyLLM
349
372
  alerts.take(3)
350
373
  end
351
374
 
352
- # Delegates to Execution.cache_savings with time scoping
353
- def build_cache_savings(base_scope)
354
- Execution.cache_savings(scope: time_scoped(base_scope))
355
- end
356
-
357
375
  # Delegates to Tenant.top_by_spend
358
376
  def build_top_tenants
359
377
  Tenant.top_by_spend(limit: 5)
360
378
  end
361
-
362
- # Delegates to Execution.batch_agent_stats with pre-filtered scope
363
- def batch_fetch_agent_stats(scope)
364
- Execution.batch_agent_stats(scope: scope)
365
- end
366
379
  end
367
380
  end
368
381
  end