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
@@ -22,6 +22,9 @@ module RubyLLM
22
22
  CSV_COLUMNS = %w[id agent_type status model_id total_tokens total_cost
23
23
  duration_ms created_at error_class error_message].freeze
24
24
 
25
+ # Seconds the totals strip may spend before the list renders without it
26
+ TOTALS_TIMEOUT = 1
27
+
25
28
  # Lists all executions with filtering and pagination
26
29
  #
27
30
  # @return [void]
@@ -39,7 +42,7 @@ module RubyLLM
39
42
  #
40
43
  # @return [void]
41
44
  def show
42
- @execution = tenant_scoped_executions.includes(:detail, :child_executions).find(params[:id])
45
+ @execution = tenant_scoped_executions.includes(:detail).find(params[:id])
43
46
  end
44
47
 
45
48
  # Handles filter search requests via Turbo Stream
@@ -83,7 +86,7 @@ module RubyLLM
83
86
  self.response_body = Enumerator.new do |yielder|
84
87
  yielder << CSV.generate_line(CSV_COLUMNS)
85
88
 
86
- filtered_executions.find_each(batch_size: 1000) do |execution|
89
+ filtered_executions.preload(:error_detail).find_each(batch_size: 1000) do |execution|
87
90
  yielder << generate_csv_row(execution)
88
91
  end
89
92
  end
@@ -124,50 +127,35 @@ module RubyLLM
124
127
  @statuses = Execution.statuses.keys
125
128
  end
126
129
 
127
- # Returns distinct agent types from execution history
128
- #
129
- # Memoized to avoid duplicate queries within a request.
130
- # Uses tenant_scoped_executions to respect multi-tenancy filtering.
131
- #
132
- # @return [Array<String>] Agent type names
133
- def available_agent_types
134
- @available_agent_types ||= tenant_scoped_executions.distinct.pluck(:agent_type)
135
- end
136
-
137
- # Returns distinct model IDs from execution history
138
- #
139
- # Memoized to avoid duplicate queries within a request.
140
- # Uses tenant_scoped_executions to respect multi-tenancy filtering.
141
- #
142
- # @return [Array<String>] Model IDs
143
- def available_model_ids
144
- @available_model_ids ||= tenant_scoped_executions.where.not(model_id: nil).distinct.pluck(:model_id).sort
145
- end
146
-
147
130
  # Loads paginated executions and associated statistics
148
131
  #
149
132
  # Sets @executions, @pagination, @sort_params, and @filter_stats instance variables
150
133
  # for use in views.
151
134
  #
135
+ # The list itself is an index walk and stays fast at any table size. The
136
+ # totals strip is not: it sums every execution the filters match, which
137
+ # with no time range is the whole table. So the totals are best-effort
138
+ # and cached — when the database cannot produce them in TOTALS_TIMEOUT
139
+ # the strip shows a placeholder and pagination falls back to prev/next,
140
+ # instead of the page waiting on (or timing out behind) an aggregate.
141
+ #
152
142
  # @return [void]
153
143
  def load_executions_with_stats
154
144
  @sort_params = parse_sort_params
155
- result = paginate(filtered_executions, sort_params: @sort_params)
145
+ scope = filtered_executions
146
+
147
+ filters = request.query_parameters.except("page", "sort", "direction")
148
+ @filter_stats = cached_stats(:executions_totals, filters.to_query) do
149
+ Execution.best_effort(timeout: TOTALS_TIMEOUT) { scope.totals }
150
+ end
151
+
152
+ # The count from the same aggregate drives pagination, so a page
153
+ # never runs a separate COUNT.
154
+ result = paginate(scope.preload(:error_detail),
155
+ sort_params: @sort_params,
156
+ total_count: @filter_stats&.fetch(:total_count))
156
157
  @executions = result[:records]
157
158
  @pagination = result[:pagination]
158
- load_filter_stats
159
- end
160
-
161
- # Calculates aggregate statistics for the current filter
162
- #
163
- # @return [void]
164
- def load_filter_stats
165
- scope = filtered_executions
166
- @filter_stats = {
167
- total_count: scope.count,
168
- total_cost: scope.sum(:total_cost) || 0,
169
- total_tokens: scope.sum(:total_tokens) || 0
170
- }
171
159
  end
172
160
 
173
161
  # Builds a filtered execution scope based on request params
@@ -212,11 +200,7 @@ module RubyLLM
212
200
  scope = scope.where("attempts_count > 1") if params[:has_retries].present?
213
201
 
214
202
  # Only show root executions - children are nested under parents
215
- scope = scope.where(parent_execution_id: nil)
216
-
217
- # Eager load children for grouping and detail for error_message, which
218
- # the list renders per row (otherwise an N+1 on error rows).
219
- scope.includes(:child_executions, :detail)
203
+ scope.where(parent_execution_id: nil)
220
204
  end
221
205
 
222
206
  # Checks whether turbo-rails is available in the host application
@@ -11,43 +11,43 @@ module RubyLLM
11
11
  class RequestsController < ApplicationController
12
12
  include Paginatable
13
13
 
14
+ # Seconds the header totals may spend before the list renders without them
15
+ TOTALS_TIMEOUT = 1
16
+
17
+ # Progressively wider windows searched for the most recent requests
18
+ RECENT_WINDOWS = [1.day, 7.days, 30.days].freeze
19
+
14
20
  # Lists all tracked requests with aggregated stats
15
21
  #
22
+ # A request is a GROUP BY over its executions, so listing "the latest
23
+ # 25" naively aggregates every tracked execution ever recorded before
24
+ # it can sort. Two things keep the default view off that path: the
25
+ # page of request ids is found in a recent window first (see
26
+ # #recent_request_ids) and only those requests are aggregated, and the
27
+ # header totals are best-effort.
28
+ #
16
29
  # @return [void]
17
30
  def index
18
31
  @sort_column = sanitize_sort_column(params[:sort])
19
32
  @sort_direction = (params[:direction] == "asc") ? "asc" : "desc"
20
33
 
21
- scope = Execution
22
- .where.not(request_id: [nil, ""])
23
- .select(
24
- "request_id",
25
- "COUNT(*) AS call_count",
26
- "SUM(total_cost) AS total_cost",
27
- "SUM(total_tokens) AS total_tokens",
28
- "MIN(started_at) AS started_at",
29
- "MAX(completed_at) AS completed_at",
30
- "SUM(duration_ms) AS total_duration_ms",
31
- "GROUP_CONCAT(DISTINCT agent_type) AS agent_types_list",
32
- "GROUP_CONCAT(DISTINCT status) AS statuses_list",
33
- "MAX(created_at) AS latest_created_at"
34
- )
35
- .group(:request_id)
36
-
37
- # Apply time filter
34
+ tracked = Execution.where.not(request_id: [nil, ""])
38
35
  days = params[:days].to_i
39
- scope = scope.where("created_at >= ?", days.days.ago) if days > 0
36
+ tracked = tracked.where("created_at >= ?", days.days.ago) if days > 0
37
+
38
+ # One query for the distinct request count and total cost; the count
39
+ # is shared with pagination instead of being run a second time.
40
+ @stats = cached_stats(:requests_totals, days) do
41
+ Execution.best_effort(timeout: TOTALS_TIMEOUT) do
42
+ total_requests, total_cost = tracked
43
+ .pick(Arel.sql("COUNT(DISTINCT request_id)"), Arel.sql("COALESCE(SUM(total_cost), 0)"))
44
+ {total_requests: total_requests.to_i, total_cost: (total_cost || 0).to_d.round(6)}
45
+ end
46
+ end
40
47
 
41
- result = paginate_requests(scope)
48
+ result = paginate_requests(tracked, total_count: @stats&.fetch(:total_requests))
42
49
  @requests = result[:records]
43
50
  @pagination = result[:pagination]
44
-
45
- # Stats
46
- total_scope = Execution.where.not(request_id: [nil, ""])
47
- @stats = {
48
- total_requests: total_scope.distinct.count(:request_id),
49
- total_cost: total_scope.sum(:total_cost) || 0
50
- }
51
51
  end
52
52
 
53
53
  # Shows a single tracked request with all its executions
@@ -55,9 +55,16 @@ module RubyLLM
55
55
  # @return [void]
56
56
  def show
57
57
  @request_id = params[:id]
58
+
59
+ # Loaded once: the summary is derived from the same handful of rows
60
+ # the page lists, instead of nine more queries against them. (Two of
61
+ # those were DISTINCT plucks on an ordered relation, which PostgreSQL
62
+ # rejects, so this page raised there.)
58
63
  @executions = Execution
59
64
  .where(request_id: @request_id)
65
+ .preload(:error_detail)
60
66
  .order(started_at: :asc)
67
+ .to_a
61
68
 
62
69
  if @executions.empty?
63
70
  redirect_to ruby_llm_agents.requests_path,
@@ -66,15 +73,15 @@ module RubyLLM
66
73
  end
67
74
 
68
75
  @summary = {
69
- call_count: @executions.count,
70
- total_cost: @executions.sum(:total_cost) || 0,
71
- total_tokens: @executions.sum(:total_tokens) || 0,
72
- started_at: @executions.minimum(:started_at),
73
- completed_at: @executions.maximum(:completed_at),
74
- agent_types: @executions.distinct.pluck(:agent_type),
75
- models_used: @executions.distinct.pluck(:model_id),
76
- all_successful: @executions.where.not(status: "success").count.zero?,
77
- error_count: @executions.where(status: "error").count
76
+ call_count: @executions.size,
77
+ total_cost: @executions.sum { |e| e.total_cost || 0 },
78
+ total_tokens: @executions.sum { |e| e.total_tokens || 0 },
79
+ started_at: @executions.filter_map(&:started_at).min,
80
+ completed_at: @executions.filter_map(&:completed_at).max,
81
+ agent_types: @executions.map(&:agent_type).uniq,
82
+ models_used: @executions.map(&:model_id).uniq,
83
+ all_successful: @executions.all?(&:status_success?),
84
+ error_count: @executions.count(&:status_error?)
78
85
  }
79
86
 
80
87
  if @summary[:started_at] && @summary[:completed_at]
@@ -90,25 +97,90 @@ module RubyLLM
90
97
  ALLOWED_SORT_COLUMNS.include?(column) ? column : "latest_created_at"
91
98
  end
92
99
 
93
- def paginate_requests(scope)
94
- page = [(params[:page] || 1).to_i, 1].max
95
- per_page = RubyLLM::Agents.configuration.per_page
100
+ # Comma-separated DISTINCT values of a column per group.
101
+ # GROUP_CONCAT exists on SQLite and MySQL; PostgreSQL spells it STRING_AGG.
102
+ def distinct_list_sql(column)
103
+ if Execution.connection.adapter_name.downcase.include?("postg")
104
+ "STRING_AGG(DISTINCT #{column}, ',')"
105
+ else
106
+ "GROUP_CONCAT(DISTINCT #{column})"
107
+ end
108
+ end
96
109
 
97
- total_count = Execution
98
- .where.not(request_id: [nil, ""])
99
- .distinct
100
- .count(:request_id)
110
+ # Aggregates executions into one row per request
111
+ def request_rows(scope)
112
+ scope
113
+ .select(
114
+ "request_id",
115
+ "COUNT(*) AS call_count",
116
+ "SUM(total_cost) AS total_cost",
117
+ "SUM(total_tokens) AS total_tokens",
118
+ "MIN(started_at) AS started_at",
119
+ "MAX(completed_at) AS completed_at",
120
+ "SUM(duration_ms) AS total_duration_ms",
121
+ "#{distinct_list_sql("agent_type")} AS agent_types_list",
122
+ "#{distinct_list_sql("status")} AS statuses_list",
123
+ "MAX(created_at) AS latest_created_at"
124
+ )
125
+ .group(:request_id)
126
+ end
101
127
 
102
- sorted = scope.order("#{@sort_column} #{@sort_direction.upcase}")
128
+ # Request ids for one page of the newest-first list
129
+ #
130
+ # The requests active most recently are, by definition, the ones with
131
+ # an execution in the most recent stretch of time. So instead of
132
+ # ranking every request, rank the ones seen in the last day; if that
133
+ # does not fill the page, the last week; and so on, ending with no
134
+ # bound at all. Each attempt is a bounded read of the created_at index,
135
+ # and the first one that fills the page is exact.
136
+ #
137
+ # @param tracked [ActiveRecord::Relation] Executions that carry a request_id
138
+ # @param offset [Integer] Requests to skip
139
+ # @param limit [Integer] Requests to return
140
+ # @return [Array<String>] Request ids, newest activity first
141
+ def recent_request_ids(tracked, offset:, limit:)
142
+ [*RECENT_WINDOWS, nil].each do |window|
143
+ scope = window ? tracked.where("created_at >= ?", window.ago) : tracked
144
+ ids = scope.group(:request_id)
145
+ .order(Arel.sql("MAX(created_at) DESC"))
146
+ .offset(offset).limit(limit)
147
+ .pluck(:request_id)
148
+
149
+ return ids if ids.size >= limit || window.nil?
150
+ end
151
+ end
152
+
153
+ # Loads one page of requests
154
+ #
155
+ # Fetches one row more than the page holds, so a next page can be
156
+ # detected when the total count is unavailable.
157
+ #
158
+ # @param tracked [ActiveRecord::Relation] Executions that carry a request_id
159
+ # @param total_count [Integer, nil] Distinct requests, when known
160
+ # @return [Hash] :records and :pagination, as Paginatable#paginate
161
+ def paginate_requests(tracked, total_count:)
162
+ page = [(params[:page] || 1).to_i, 1].max
163
+ per_page = RubyLLM::Agents.configuration.per_page
103
164
  offset = (page - 1) * per_page
104
165
 
166
+ records = if @sort_column == "latest_created_at" && @sort_direction == "desc"
167
+ ids = recent_request_ids(tracked, offset: offset, limit: per_page + 1)
168
+ request_rows(tracked.where(request_id: ids)).sort_by { |row| ids.index(row.request_id) }
169
+ else
170
+ # Sorting on an aggregate has to aggregate every request first.
171
+ request_rows(tracked)
172
+ .order("#{@sort_column} #{@sort_direction.upcase}")
173
+ .offset(offset).limit(per_page + 1).to_a
174
+ end
175
+
105
176
  {
106
- records: sorted.offset(offset).limit(per_page),
177
+ records: records.first(per_page),
107
178
  pagination: {
108
179
  current_page: page,
109
180
  per_page: per_page,
110
181
  total_count: total_count,
111
- total_pages: (total_count.to_f / per_page).ceil
182
+ total_pages: total_count && (total_count.to_f / per_page).ceil,
183
+ next_page: records.size > per_page
112
184
  }
113
185
  }
114
186
  end
@@ -38,14 +38,27 @@ module RubyLLM
38
38
 
39
39
  # Shows a single tenant's budget details
40
40
  #
41
+ # Usage figures cover the current month, from one grouped scan of it
42
+ # (see Execution.breakdown). A tenant's executions are scattered across
43
+ # the whole table, so the all-time versions of these figures were the
44
+ # most expensive queries in the dashboard.
45
+ #
41
46
  # @return [void]
42
47
  def show
43
48
  @tenant = TenantBudget.find(params[:id])
44
- @executions = tenant_executions(@tenant.tenant_id).recent.limit(10)
45
- @usage_stats = calculate_usage_stats(@tenant)
46
- @usage_by_agent = @tenant.usage_by_agent(period: nil)
47
- @usage_by_model = @tenant.usage_by_model(period: nil)
48
- load_tenant_analytics
49
+ @executions = tenant_executions(@tenant.tenant_id).preload(:error_detail).recent(10)
50
+
51
+ month = Execution::Breakdown.new(cached_tenant_stats(:month) do
52
+ @tenant.executions.where(created_at: Time.current.all_month).breakdown.rows
53
+ end)
54
+ @usage_stats = calculate_usage_stats(@tenant, month)
55
+ @usage_by_agent = month.agent_stats.transform_values do |stats|
56
+ {cost: stats[:total_cost], tokens: stats[:total_tokens], count: stats[:count]}
57
+ end
58
+ @usage_by_model = month.configured_model_usage.to_h do |usage|
59
+ [usage[:model_id], {cost: usage[:cost], tokens: usage[:tokens], count: usage[:runs]}]
60
+ end
61
+ load_tenant_analytics(month)
49
62
  end
50
63
 
51
64
  # Renders the edit form for a tenant budget
@@ -101,45 +114,56 @@ module RubyLLM
101
114
  Execution.by_tenant(tenant_id)
102
115
  end
103
116
 
117
+ # Caches one of the tenant page's figures
118
+ def cached_tenant_stats(name, expires_in: 5.minutes, &block)
119
+ cached_stats(:tenant, @tenant.tenant_id, name, expires_in: expires_in, &block)
120
+ end
121
+
104
122
  # Calculates usage statistics for a tenant
105
123
  #
106
124
  # @param tenant [TenantBudget] The tenant budget record
107
- # @return [Hash] Usage statistics
108
- def calculate_usage_stats(tenant)
109
- scope = tenant_executions(tenant.tenant_id)
110
- today_scope = scope.where("created_at >= ?", Time.current.beginning_of_day)
111
- month_scope = scope.where("created_at >= ?", Time.current.beginning_of_month)
112
-
113
- daily_spend = today_scope.sum(:total_cost) || 0
114
- monthly_spend = month_scope.sum(:total_cost) || 0
115
- daily_tokens = today_scope.sum(:total_tokens) || 0
116
- monthly_tokens = month_scope.sum(:total_tokens) || 0
125
+ # @param month [Execution::Breakdown] The tenant's executions this month
126
+ # @return [Hash] Usage statistics; the total_* keys cover this month
127
+ def calculate_usage_stats(tenant, month)
128
+ totals = month.totals
129
+ daily_spend, daily_tokens = tenant_executions(tenant.tenant_id)
130
+ .where("created_at >= ?", Time.current.beginning_of_day)
131
+ .pick(Arel.sql("COALESCE(SUM(total_cost), 0)"), Arel.sql("COALESCE(SUM(total_tokens), 0)"))
117
132
 
118
133
  {
119
134
  daily_spend: daily_spend,
120
- monthly_spend: monthly_spend,
135
+ monthly_spend: totals[:cost],
121
136
  daily_tokens: daily_tokens,
122
- monthly_tokens: monthly_tokens,
137
+ monthly_tokens: totals[:tokens],
123
138
  daily_spend_percentage: percentage_used(daily_spend, tenant.effective_daily_limit),
124
- monthly_spend_percentage: percentage_used(monthly_spend, tenant.effective_monthly_limit),
139
+ monthly_spend_percentage: percentage_used(totals[:cost], tenant.effective_monthly_limit),
125
140
  daily_token_percentage: percentage_used(daily_tokens, tenant.effective_daily_token_limit),
126
- monthly_token_percentage: percentage_used(monthly_tokens, tenant.effective_monthly_token_limit),
127
- total_executions: scope.count,
128
- total_cost: scope.sum(:total_cost) || 0,
129
- total_tokens: scope.sum(:total_tokens) || 0
141
+ monthly_token_percentage: percentage_used(totals[:tokens], tenant.effective_monthly_token_limit),
142
+ total_executions: totals[:total],
143
+ total_cost: totals[:cost],
144
+ total_tokens: totals[:tokens],
145
+ success_count: totals[:success]
130
146
  }
131
147
  end
132
148
 
133
149
  # Loads trend data and period comparison for the tenant analytics section.
134
150
  #
151
+ # @param month [Execution::Breakdown] The tenant's executions this month
135
152
  # @return [void]
136
- def load_tenant_analytics
153
+ def load_tenant_analytics(month)
137
154
  # 30-day daily cost/tokens trend
138
- @daily_trend = @tenant.usage_by_day(period: 30.days.ago..Time.current)
155
+ @daily_trend = cached_tenant_stats(:daily_trend) do
156
+ @tenant.usage_by_day(period: 30.days.ago..Time.current)
157
+ end
139
158
 
140
- # Period comparison: this month vs last month
141
- this_month = @tenant.usage_summary(period: :this_month)
142
- last_month = @tenant.usage_summary(period: :last_month)
159
+ # Period comparison: this month vs last month. Last month no longer
160
+ # changes, so one aggregate of it is kept for an hour.
161
+ totals = month.totals
162
+ last = cached_tenant_stats(:last_month, expires_in: 1.hour) do
163
+ @tenant.executions.where(created_at: 1.month.ago.all_month).totals
164
+ end
165
+ this_month = {cost: totals[:cost], tokens: totals[:tokens], executions: totals[:total]}
166
+ last_month = {cost: last[:total_cost], tokens: last[:total_tokens], executions: last[:total_count]}
143
167
  @period_comparison = {
144
168
  this_month: this_month,
145
169
  last_month: last_month,
@@ -153,10 +177,10 @@ module RubyLLM
153
177
  @period_comparison[:avg_cost_last], @period_comparison[:avg_cost_this]
154
178
  )
155
179
 
156
- # Error cost: money spent on failed executions
157
- error_scope = @tenant.executions.where(status: "error")
158
- @error_cost = error_scope.sum(:total_cost) || 0
159
- @error_count = error_scope.count
180
+ # Error cost: money spent on failed executions this month
181
+ error_cost = month.error_cost
182
+ @error_cost = error_cost[:total_cost]
183
+ @error_count = error_cost[:total_count]
160
184
  end
161
185
 
162
186
  # Calculates percentage change between two values
@@ -167,25 +191,21 @@ module RubyLLM
167
191
  ((new_val.to_f - old_val.to_f) / old_val.to_f * 100).round(1)
168
192
  end
169
193
 
170
- # Preloads cost and last-execution data for all tenants in one query each,
171
- # avoiding N+1 queries in the index view.
194
+ # Builds the cost and last-execution lookups for the index view
195
+ #
196
+ # Read from the counter columns each tenant row already carries (they
197
+ # are updated on every execution), rather than by grouping the whole
198
+ # executions table by tenant. Cost is month-to-date; a counter that has
199
+ # not been reset yet this month belongs to an earlier one.
172
200
  #
173
201
  # @return [void]
174
202
  def preload_tenant_index_data
175
- @tenant_costs = {}
176
- @tenant_last_executions = {}
177
- tenant_ids = @tenants.map(&:tenant_id)
178
- return if tenant_ids.empty?
179
-
180
- # Batch-load total cost per tenant
181
- @tenant_costs = Execution.where(tenant_id: tenant_ids)
182
- .group(:tenant_id)
183
- .sum(:total_cost)
184
-
185
- # Batch-load last execution time per tenant
186
- @tenant_last_executions = Execution.where(tenant_id: tenant_ids)
187
- .group(:tenant_id)
188
- .maximum(:created_at)
203
+ month_start = Date.current.beginning_of_month
204
+
205
+ @tenant_costs = @tenants.to_h do |tenant|
206
+ [tenant.tenant_id, (tenant.monthly_reset_date == month_start) ? tenant.monthly_cost_spent : 0]
207
+ end
208
+ @tenant_last_executions = @tenants.to_h { |tenant| [tenant.tenant_id, tenant.last_execution_at] }
189
209
  end
190
210
 
191
211
  # Parses and validates sort parameters for tenants list