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.
- checksums.yaml +4 -4
- data/app/controllers/concerns/ruby_llm/agents/paginatable.rb +29 -8
- data/app/controllers/ruby_llm/agents/agents_controller.rb +48 -44
- data/app/controllers/ruby_llm/agents/analytics_controller.rb +38 -72
- data/app/controllers/ruby_llm/agents/dashboard_controller.rb +69 -56
- data/app/controllers/ruby_llm/agents/executions_controller.rb +25 -41
- data/app/controllers/ruby_llm/agents/requests_controller.rb +117 -45
- data/app/controllers/ruby_llm/agents/tenants_controller.rb +67 -47
- data/app/models/ruby_llm/agents/execution/analytics.rb +174 -133
- data/app/models/ruby_llm/agents/execution/breakdown.rb +243 -0
- data/app/models/ruby_llm/agents/execution/scopes.rb +39 -3
- data/app/models/ruby_llm/agents/execution.rb +23 -5
- data/app/services/ruby_llm/agents/agent_registry.rb +75 -12
- data/app/views/ruby_llm/agents/agents/index.html.erb +24 -7
- data/app/views/ruby_llm/agents/agents/show.html.erb +3 -2
- data/app/views/ruby_llm/agents/executions/_filters.html.erb +12 -7
- data/app/views/ruby_llm/agents/executions/_list.html.erb +1 -52
- data/app/views/ruby_llm/agents/requests/index.html.erb +8 -57
- data/app/views/ruby_llm/agents/shared/_executions_table.html.erb +1 -52
- data/app/views/ruby_llm/agents/shared/_pagination.html.erb +66 -0
- data/app/views/ruby_llm/agents/shared/query_timeout.html.erb +18 -0
- data/app/views/ruby_llm/agents/system_config/show.html.erb +4 -0
- data/app/views/ruby_llm/agents/tenants/index.html.erb +1 -1
- data/app/views/ruby_llm/agents/tenants/show.html.erb +6 -5
- data/config/routes.rb +2 -2
- data/lib/generators/ruby_llm_agents/templates/add_executions_analytics_index_migration.rb.tt +50 -0
- data/lib/generators/ruby_llm_agents/templates/add_root_execution_list_index_migration.rb.tt +16 -0
- data/lib/generators/ruby_llm_agents/templates/initializer.rb.tt +5 -0
- data/lib/generators/ruby_llm_agents/templates/migration.rb.tt +26 -0
- data/lib/generators/ruby_llm_agents/upgrade_generator.rb +44 -0
- data/lib/ruby_llm/agents/core/configuration.rb +20 -0
- data/lib/ruby_llm/agents/core/version.rb +1 -1
- data/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb +39 -1
- data/lib/ruby_llm/agents/rails/engine.rb +87 -7
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 67962a08cb48a28feb5b1dc27557ddb959fbdd034314e8734410f6626cdd66fb
|
|
4
|
+
data.tar.gz: cc87df7123f4cd180eda63945ae703b3e9dd927e5caef5c2164a0971e9b4fc56
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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(
|
|
30
|
+
load_summary(breakdown, cached_analytics(:prior) { aggregate(prior) })
|
|
26
31
|
load_projection(base)
|
|
27
|
-
load_savings_opportunity(
|
|
28
|
-
|
|
29
|
-
load_error_cost(
|
|
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
|
-
|
|
110
|
-
@
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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:
|
|
146
|
-
total_runs:
|
|
147
|
-
total_tokens:
|
|
148
|
-
avg_cost: (
|
|
149
|
-
avg_tokens: (
|
|
150
|
-
cost_change: pct_change(prior_agg[:cost],
|
|
151
|
-
runs_change: pct_change(prior_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 =
|
|
164
|
-
.
|
|
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(
|
|
185
|
+
def load_savings_opportunity(breakdown)
|
|
175
186
|
# Find the most expensive model and suggest switching to the cheapest
|
|
176
|
-
|
|
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
|
|
223
|
-
|
|
224
|
-
end
|
|
211
|
+
def load_error_cost(breakdown)
|
|
212
|
+
error_cost = breakdown.error_cost(limit: 10)
|
|
225
213
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
@
|
|
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,
|
|
17
|
-
#
|
|
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
|
-
|
|
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.
|
|
28
|
-
@agent_stats = build_agent_comparison(
|
|
29
|
-
@top_errors =
|
|
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 =
|
|
32
|
-
@cache_savings =
|
|
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 =
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
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(
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|