ruby_llm-agents 0.3.3 → 0.3.5

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 (97) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +132 -1263
  3. data/app/controllers/concerns/ruby_llm/agents/filterable.rb +5 -1
  4. data/app/controllers/concerns/ruby_llm/agents/paginatable.rb +2 -1
  5. data/app/controllers/ruby_llm/agents/agents_controller.rb +21 -2
  6. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +137 -9
  7. data/app/controllers/ruby_llm/agents/executions_controller.rb +83 -5
  8. data/app/models/ruby_llm/agents/execution/analytics.rb +103 -12
  9. data/app/models/ruby_llm/agents/execution/scopes.rb +25 -0
  10. data/app/models/ruby_llm/agents/execution/workflow.rb +299 -0
  11. data/app/models/ruby_llm/agents/execution.rb +28 -59
  12. data/app/models/ruby_llm/agents/tenant_budget.rb +165 -0
  13. data/app/services/ruby_llm/agents/agent_registry.rb +118 -7
  14. data/app/views/layouts/ruby_llm/agents/application.html.erb +430 -0
  15. data/app/views/ruby_llm/agents/agents/_empty_state.html.erb +23 -0
  16. data/app/views/ruby_llm/agents/agents/_workflow.html.erb +125 -0
  17. data/app/views/ruby_llm/agents/agents/index.html.erb +93 -0
  18. data/app/views/ruby_llm/agents/agents/show.html.erb +775 -0
  19. data/app/views/ruby_llm/agents/dashboard/_agent_comparison.html.erb +112 -0
  20. data/app/views/ruby_llm/agents/dashboard/_budgets_bar.html.erb +75 -0
  21. data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_execution_item.html.erb +7 -4
  22. data/app/views/ruby_llm/agents/dashboard/_now_strip.html.erb +84 -0
  23. data/app/views/ruby_llm/agents/dashboard/_tenant_budget.html.erb +115 -0
  24. data/app/views/ruby_llm/agents/dashboard/_top_errors.html.erb +49 -0
  25. data/app/views/ruby_llm/agents/dashboard/index.html.erb +155 -0
  26. data/app/views/{rubyllm → ruby_llm}/agents/executions/_execution.html.erb +1 -1
  27. data/app/views/{rubyllm → ruby_llm}/agents/executions/_filters.html.erb +39 -11
  28. data/app/views/{rubyllm → ruby_llm}/agents/executions/_list.html.erb +19 -9
  29. data/app/views/ruby_llm/agents/executions/_workflow_summary.html.erb +101 -0
  30. data/app/views/ruby_llm/agents/executions/index.html.erb +88 -0
  31. data/app/views/{rubyllm → ruby_llm}/agents/executions/show.html.erb +260 -200
  32. data/app/views/{rubyllm → ruby_llm}/agents/settings/show.html.erb +1 -1
  33. data/app/views/ruby_llm/agents/shared/_breadcrumbs.html.erb +48 -0
  34. data/app/views/ruby_llm/agents/shared/_executions_table.html.erb +251 -0
  35. data/app/views/{rubyllm → ruby_llm}/agents/shared/_filter_dropdown.html.erb +1 -1
  36. data/app/views/ruby_llm/agents/shared/_nav_link.html.erb +27 -0
  37. data/app/views/{rubyllm → ruby_llm}/agents/shared/_select_dropdown.html.erb +1 -1
  38. data/app/views/{rubyllm → ruby_llm}/agents/shared/_status_badge.html.erb +1 -1
  39. data/app/views/{rubyllm → ruby_llm}/agents/shared/_status_dot.html.erb +1 -1
  40. data/app/views/ruby_llm/agents/shared/_tenant_filter.html.erb +26 -0
  41. data/app/views/ruby_llm/agents/shared/_workflow_type_badge.html.erb +61 -0
  42. data/config/routes.rb +2 -0
  43. data/lib/generators/ruby_llm_agents/multi_tenancy_generator.rb +97 -0
  44. data/lib/generators/ruby_llm_agents/templates/add_attempts_migration.rb.tt +3 -3
  45. data/lib/generators/ruby_llm_agents/templates/add_tenant_to_executions_migration.rb.tt +23 -0
  46. data/lib/generators/ruby_llm_agents/templates/add_tool_calls_migration.rb.tt +2 -2
  47. data/lib/generators/ruby_llm_agents/templates/add_workflow_migration.rb.tt +38 -0
  48. data/lib/generators/ruby_llm_agents/templates/create_tenant_budgets_migration.rb.tt +45 -0
  49. data/lib/generators/ruby_llm_agents/templates/migration.rb.tt +17 -5
  50. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +13 -0
  51. data/lib/ruby_llm/agents/alert_manager.rb +20 -16
  52. data/lib/ruby_llm/agents/base/caching.rb +40 -0
  53. data/lib/ruby_llm/agents/base/cost_calculation.rb +105 -0
  54. data/lib/ruby_llm/agents/base/dsl.rb +261 -0
  55. data/lib/ruby_llm/agents/base/execution.rb +258 -0
  56. data/lib/ruby_llm/agents/base/reliability_execution.rb +136 -0
  57. data/lib/ruby_llm/agents/base/response_building.rb +86 -0
  58. data/lib/ruby_llm/agents/base/tool_tracking.rb +57 -0
  59. data/lib/ruby_llm/agents/base.rb +37 -801
  60. data/lib/ruby_llm/agents/budget_tracker.rb +250 -139
  61. data/lib/ruby_llm/agents/cache_helper.rb +98 -0
  62. data/lib/ruby_llm/agents/circuit_breaker.rb +48 -30
  63. data/lib/ruby_llm/agents/configuration.rb +40 -1
  64. data/lib/ruby_llm/agents/engine.rb +65 -1
  65. data/lib/ruby_llm/agents/inflections.rb +14 -0
  66. data/lib/ruby_llm/agents/instrumentation.rb +66 -0
  67. data/lib/ruby_llm/agents/reliability.rb +8 -2
  68. data/lib/ruby_llm/agents/version.rb +1 -1
  69. data/lib/ruby_llm/agents/workflow/instrumentation.rb +254 -0
  70. data/lib/ruby_llm/agents/workflow/parallel.rb +282 -0
  71. data/lib/ruby_llm/agents/workflow/pipeline.rb +306 -0
  72. data/lib/ruby_llm/agents/workflow/result.rb +390 -0
  73. data/lib/ruby_llm/agents/workflow/router.rb +429 -0
  74. data/lib/ruby_llm/agents/workflow.rb +232 -0
  75. data/lib/ruby_llm/agents.rb +1 -0
  76. metadata +57 -75
  77. data/app/channels/ruby_llm/agents/executions_channel.rb +0 -46
  78. data/app/javascript/ruby_llm/agents/controllers/filter_controller.js +0 -56
  79. data/app/javascript/ruby_llm/agents/controllers/index.js +0 -12
  80. data/app/javascript/ruby_llm/agents/controllers/refresh_controller.js +0 -83
  81. data/app/views/layouts/rubyllm/agents/application.html.erb +0 -626
  82. data/app/views/rubyllm/agents/agents/index.html.erb +0 -20
  83. data/app/views/rubyllm/agents/agents/show.html.erb +0 -772
  84. data/app/views/rubyllm/agents/dashboard/_budgets_bar.html.erb +0 -165
  85. data/app/views/rubyllm/agents/dashboard/_now_strip.html.erb +0 -10
  86. data/app/views/rubyllm/agents/dashboard/_now_strip_values.html.erb +0 -71
  87. data/app/views/rubyllm/agents/dashboard/index.html.erb +0 -197
  88. data/app/views/rubyllm/agents/executions/index.html.erb +0 -28
  89. data/app/views/rubyllm/agents/executions/index.turbo_stream.erb +0 -18
  90. data/app/views/rubyllm/agents/shared/_executions_table.html.erb +0 -193
  91. /data/app/views/{rubyllm → ruby_llm}/agents/agents/_agent.html.erb +0 -0
  92. /data/app/views/{rubyllm → ruby_llm}/agents/agents/_version_comparison.html.erb +0 -0
  93. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_action_center.html.erb +0 -0
  94. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_alerts_feed.html.erb +0 -0
  95. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_breaker_strip.html.erb +0 -0
  96. /data/app/views/{rubyllm → ruby_llm}/agents/executions/dry_run.html.erb +0 -0
  97. /data/app/views/{rubyllm → ruby_llm}/agents/shared/_stat_card.html.erb +0 -0
@@ -74,7 +74,11 @@ module RubyLLM
74
74
  # @param days [Integer, nil] Number of days to filter by
75
75
  # @return [ActiveRecord::Relation] Filtered scope
76
76
  def apply_time_filter(scope, days)
77
- days.present? && days.positive? ? scope.where("created_at >= ?", days.days.ago) : scope
77
+ return scope unless days.present? && days.positive?
78
+
79
+ # Qualify column name to avoid ambiguity when joins are present
80
+ table_name = scope.model.table_name
81
+ scope.where("#{table_name}.created_at >= ?", days.days.ago)
78
82
  end
79
83
  end
80
84
  end
@@ -33,7 +33,8 @@ module RubyLLM
33
33
  per_page = RubyLLM::Agents.configuration.per_page
34
34
  offset = (page - 1) * per_page
35
35
 
36
- scope = scope.order(created_at: :desc) if ordered
36
+ # Qualify column name to avoid ambiguity when joins are present
37
+ scope = scope.order("#{scope.model.table_name}.created_at DESC") if ordered
37
38
  total_count = scope.count
38
39
 
39
40
  {
@@ -20,14 +20,33 @@ module RubyLLM
20
20
  #
21
21
  # Uses AgentRegistry to discover agents from both file system
22
22
  # and execution history, ensuring deleted agents with history
23
- # are still visible.
23
+ # are still visible. Separates agents and workflows for tabbed display.
24
24
  #
25
25
  # @return [void]
26
26
  def index
27
- @agents = AgentRegistry.all_with_details
27
+ all_items = AgentRegistry.all_with_details
28
+
29
+ # Separate agents and workflows
30
+ @agents = all_items.reject { |a| a[:is_workflow] }
31
+ @workflows = all_items.select { |a| a[:is_workflow] }
32
+
33
+ # Group workflows by type for sub-tabs
34
+ @workflows_by_type = {
35
+ pipeline: @workflows.select { |w| w[:workflow_type] == "pipeline" },
36
+ parallel: @workflows.select { |w| w[:workflow_type] == "parallel" },
37
+ router: @workflows.select { |w| w[:workflow_type] == "router" }
38
+ }
39
+
40
+ # Counts for tab badges
41
+ @agent_count = @agents.size
42
+ @workflow_count = @workflows.size
28
43
  rescue StandardError => e
29
44
  Rails.logger.error("[RubyLLM::Agents] Error loading agents: #{e.message}")
30
45
  @agents = []
46
+ @workflows = []
47
+ @workflows_by_type = { pipeline: [], parallel: [], router: [] }
48
+ @agent_count = 0
49
+ @workflow_count = 0
31
50
  flash.now[:alert] = "Error loading agents list"
32
51
  end
33
52
 
@@ -14,25 +14,121 @@ module RubyLLM
14
14
  # Renders the main dashboard view
15
15
  #
16
16
  # Loads now strip data, critical alerts, hourly activity,
17
- # and recent executions for real-time monitoring.
17
+ # recent executions, agent comparison, and top errors.
18
18
  #
19
19
  # @return [void]
20
20
  def index
21
- @now_strip = Execution.now_strip_data
22
- @critical_alerts = load_critical_alerts
23
- @hourly_activity = Execution.hourly_activity_chart
24
- @recent_executions = Execution.recent(10)
21
+ @selected_range = params[:range].presence || "today"
22
+ @days = range_to_days(@selected_range)
23
+ base_scope = tenant_scoped_executions
24
+ @now_strip = base_scope.now_strip_data(range: @selected_range)
25
+ @critical_alerts = load_critical_alerts(base_scope)
26
+ @recent_executions = base_scope.recent(10)
27
+ @agent_stats = build_agent_comparison(base_scope)
28
+ @top_errors = build_top_errors(base_scope)
29
+ @tenant_budget = load_tenant_budget(base_scope)
25
30
  end
26
31
 
27
32
  # Returns chart data as JSON for live updates
28
33
  #
29
- # @return [JSON] Chart data with categories and series
34
+ # @param range [String] Time range: "today", "7d", or "30d"
35
+ # @return [JSON] Chart data with series
30
36
  def chart_data
31
- render json: Execution.hourly_activity_chart_json
37
+ range = params[:range].presence || "today"
38
+ render json: tenant_scoped_executions.activity_chart_json(range: range)
32
39
  end
33
40
 
34
41
  private
35
42
 
43
+ # Converts range parameter to number of days
44
+ #
45
+ # @param range [String] Range parameter (today, 7d, 30d)
46
+ # @return [Integer] Number of days
47
+ def range_to_days(range)
48
+ case range
49
+ when "today" then 1
50
+ when "7d" then 7
51
+ when "30d" then 30
52
+ else 1
53
+ end
54
+ end
55
+
56
+ # Builds per-agent comparison statistics
57
+ #
58
+ # @param base_scope [ActiveRecord::Relation] Base scope to filter from
59
+ # @return [Array<Hash>] Array of all stats sorted by cost descending
60
+ def build_agent_comparison(base_scope = Execution)
61
+ scope = base_scope.last_n_days(@days)
62
+ agent_types = scope.distinct.pluck(:agent_type)
63
+
64
+ all_stats = agent_types.map do |agent_type|
65
+ agent_scope = scope.where(agent_type: agent_type)
66
+ count = agent_scope.count
67
+ total_cost = agent_scope.sum(:total_cost) || 0
68
+ successful = agent_scope.successful.count
69
+
70
+ # Detect if this is a workflow
71
+ agent_class = AgentRegistry.find(agent_type)
72
+ is_workflow = agent_class&.ancestors&.any? { |a| a.name&.include?("Workflow") }
73
+ workflow_type = is_workflow ? detect_workflow_type(agent_class) : nil
74
+
75
+ {
76
+ agent_type: agent_type,
77
+ executions: count,
78
+ total_cost: total_cost,
79
+ avg_cost: count > 0 ? (total_cost / count).round(6) : 0,
80
+ avg_duration_ms: agent_scope.average(:duration_ms)&.round || 0,
81
+ success_rate: count > 0 ? (successful.to_f / count * 100).round(1) : 0,
82
+ is_workflow: is_workflow,
83
+ workflow_type: workflow_type
84
+ }
85
+ end.sort_by { |a| -(a[:total_cost] || 0) }
86
+
87
+ # Split into agents and workflows for tabbed display
88
+ @workflow_stats = all_stats.select { |a| a[:is_workflow] }
89
+ all_stats.reject { |a| a[:is_workflow] }
90
+ end
91
+
92
+ # Detects workflow type from class hierarchy
93
+ #
94
+ # @param agent_class [Class] The agent class
95
+ # @return [String, nil] "pipeline", "parallel", "router", or nil
96
+ def detect_workflow_type(agent_class)
97
+ return nil unless agent_class
98
+
99
+ ancestors = agent_class.ancestors.map { |a| a.name.to_s }
100
+
101
+ if ancestors.include?("RubyLLM::Agents::Workflow::Pipeline")
102
+ "pipeline"
103
+ elsif ancestors.include?("RubyLLM::Agents::Workflow::Parallel")
104
+ "parallel"
105
+ elsif ancestors.include?("RubyLLM::Agents::Workflow::Router")
106
+ "router"
107
+ end
108
+ end
109
+
110
+ # Builds top errors list
111
+ #
112
+ # @param base_scope [ActiveRecord::Relation] Base scope to filter from
113
+ # @return [Array<Hash>] Top 5 error classes with counts
114
+ def build_top_errors(base_scope = Execution)
115
+ scope = base_scope.last_n_days(@days).where(status: "error")
116
+ total_errors = scope.count
117
+
118
+ scope.group(:error_class)
119
+ .select("error_class, COUNT(*) as count, MAX(created_at) as last_seen")
120
+ .order("count DESC")
121
+ .limit(5)
122
+ .map do |row|
123
+ {
124
+ error_class: row.error_class || "Unknown Error",
125
+ count: row.count,
126
+ percentage: total_errors > 0 ? (row.count.to_f / total_errors * 100).round(1) : 0,
127
+ last_seen: row.last_seen
128
+ }
129
+ end
130
+ end
131
+
36
132
  # Fetches cached daily statistics for the dashboard
37
133
  #
38
134
  # Results are cached for 1 minute to reduce database load while
@@ -123,6 +219,37 @@ module RubyLLM
123
219
  []
124
220
  end
125
221
 
222
+ # Loads tenant budget info for the current tenant
223
+ #
224
+ # @param base_scope [ActiveRecord::Relation] Base scope for usage calculation
225
+ # @return [Hash, nil] Tenant budget data with usage info, or nil if not applicable
226
+ def load_tenant_budget(base_scope)
227
+ return nil unless tenant_filter_enabled? && current_tenant_id.present?
228
+ return nil unless TenantBudget.table_exists?
229
+
230
+ budget = TenantBudget.for_tenant(current_tenant_id)
231
+ return nil unless budget
232
+
233
+ # Calculate current usage
234
+ today_scope = base_scope.where("created_at >= ?", Time.current.beginning_of_day)
235
+ month_scope = base_scope.where("created_at >= ?", Time.current.beginning_of_month)
236
+
237
+ daily_spend = today_scope.sum(:total_cost) || 0
238
+ monthly_spend = month_scope.sum(:total_cost) || 0
239
+
240
+ {
241
+ tenant_id: current_tenant_id,
242
+ daily_limit: budget.effective_daily_limit,
243
+ monthly_limit: budget.effective_monthly_limit,
244
+ daily_spend: daily_spend,
245
+ monthly_spend: monthly_spend,
246
+ daily_percentage: budget.effective_daily_limit.to_f > 0 ? (daily_spend / budget.effective_daily_limit * 100).round(1) : 0,
247
+ monthly_percentage: budget.effective_monthly_limit.to_f > 0 ? (monthly_spend / budget.effective_monthly_limit * 100).round(1) : 0,
248
+ enforcement: budget.effective_enforcement,
249
+ per_agent_daily: budget.per_agent_daily || {}
250
+ }
251
+ end
252
+
126
253
  # Loads recent alerts from cache
127
254
  #
128
255
  # @return [Array<Hash>] Array of recent alert events
@@ -140,8 +267,9 @@ module RubyLLM
140
267
  # Combines open circuit breakers, budget breaches, and error spikes
141
268
  # into a single prioritized list (max 3 items).
142
269
  #
270
+ # @param base_scope [ActiveRecord::Relation] Base scope to filter from
143
271
  # @return [Array<Hash>] Critical alerts with type and data
144
- def load_critical_alerts
272
+ def load_critical_alerts(base_scope = Execution)
145
273
  alerts = []
146
274
 
147
275
  # Open circuit breakers
@@ -177,7 +305,7 @@ module RubyLLM
177
305
  end
178
306
 
179
307
  # Error spike detection (>5 errors in last 15 minutes)
180
- error_count_15m = Execution.status_error.where("created_at > ?", 15.minutes.ago).count
308
+ error_count_15m = base_scope.status_error.where("created_at > ?", 15.minutes.ago).count
181
309
  if error_count_15m >= 5
182
310
  alerts << { type: :error_spike, data: { count: error_count_15m } }
183
311
  end
@@ -155,32 +155,53 @@ module RubyLLM
155
155
  # Loads available options for filter dropdowns
156
156
  #
157
157
  # Populates @agent_types with all agent types that have executions,
158
- # @model_ids with all distinct models used, and @statuses with all
159
- # possible status values.
158
+ # @model_ids with all distinct models used, @workflow_types with
159
+ # workflow patterns used, and @statuses with all possible status values.
160
160
  #
161
161
  # @return [void]
162
162
  def load_filter_options
163
163
  @agent_types = available_agent_types
164
164
  @model_ids = available_model_ids
165
+ @workflow_types = available_workflow_types
165
166
  @statuses = Execution.statuses.keys
166
167
  end
167
168
 
168
169
  # Returns distinct agent types from execution history
169
170
  #
170
171
  # Memoized to avoid duplicate queries within a request.
172
+ # Uses tenant_scoped_executions to respect multi-tenancy filtering.
171
173
  #
172
174
  # @return [Array<String>] Agent type names
173
175
  def available_agent_types
174
- @available_agent_types ||= Execution.distinct.pluck(:agent_type)
176
+ @available_agent_types ||= tenant_scoped_executions.distinct.pluck(:agent_type)
175
177
  end
176
178
 
177
179
  # Returns distinct model IDs from execution history
178
180
  #
179
181
  # Memoized to avoid duplicate queries within a request.
182
+ # Uses tenant_scoped_executions to respect multi-tenancy filtering.
180
183
  #
181
184
  # @return [Array<String>] Model IDs
182
185
  def available_model_ids
183
- @available_model_ids ||= Execution.where.not(model_id: nil).distinct.pluck(:model_id).sort
186
+ @available_model_ids ||= tenant_scoped_executions.where.not(model_id: nil).distinct.pluck(:model_id).sort
187
+ end
188
+
189
+ # Returns distinct workflow types from execution history
190
+ #
191
+ # Memoized to avoid duplicate queries within a request.
192
+ # Returns empty array if workflow_type column doesn't exist yet.
193
+ # Uses tenant_scoped_executions to respect multi-tenancy filtering.
194
+ #
195
+ # @return [Array<String>] Workflow types (pipeline, parallel, router)
196
+ def available_workflow_types
197
+ return @available_workflow_types if defined?(@available_workflow_types)
198
+
199
+ @available_workflow_types = if Execution.column_names.include?("workflow_type")
200
+ tenant_scoped_executions.where.not(workflow_type: [nil, ""])
201
+ .distinct.pluck(:workflow_type).sort
202
+ else
203
+ []
204
+ end
184
205
  end
185
206
 
186
207
  # Loads paginated executions and associated statistics
@@ -215,7 +236,7 @@ module RubyLLM
215
236
  #
216
237
  # @return [ActiveRecord::Relation] Filtered execution scope
217
238
  def filtered_executions
218
- scope = Execution.all
239
+ scope = tenant_scoped_executions
219
240
 
220
241
  # Apply search filter
221
242
  scope = scope.search(params[:q]) if params[:q].present?
@@ -244,8 +265,65 @@ module RubyLLM
244
265
  model_ids = parse_array_param(:model_ids)
245
266
  scope = scope.where(model_id: model_ids) if model_ids.any?
246
267
 
268
+ # Apply workflow type filter (only if column exists)
269
+ if Execution.column_names.include?("workflow_type")
270
+ workflow_types = parse_array_param(:workflow_types)
271
+ if workflow_types.any?
272
+ includes_single = workflow_types.include?("single")
273
+ other_types = workflow_types - ["single"]
274
+
275
+ if includes_single && other_types.any?
276
+ # Include both single (null workflow_type) and specific workflow types
277
+ scope = scope.where(workflow_type: [nil, ""] + other_types)
278
+ elsif includes_single
279
+ # Only single executions (non-workflow)
280
+ scope = scope.where(workflow_type: [nil, ""])
281
+ else
282
+ # Only specific workflow types
283
+ scope = scope.where(workflow_type: workflow_types)
284
+ end
285
+ end
286
+ end
287
+
288
+ # Apply execution type tab filter (agents vs workflows)
289
+ scope = apply_execution_type_filter(scope)
290
+
291
+ # Only show root executions (not workflow children) - children are nested under parents
292
+ scope = scope.where(parent_execution_id: nil)
293
+
294
+ # Eager load children for workflow grouping
295
+ scope = scope.includes(:child_executions)
296
+
247
297
  scope
248
298
  end
299
+
300
+ # Applies execution type tab filter (all, agents, workflows)
301
+ #
302
+ # @param scope [ActiveRecord::Relation] The current scope
303
+ # @return [ActiveRecord::Relation] Filtered scope
304
+ def apply_execution_type_filter(scope)
305
+ return scope unless Execution.column_names.include?("workflow_type")
306
+
307
+ execution_type = params[:execution_type]
308
+ case execution_type
309
+ when "agents"
310
+ # Only show executions where workflow_type is null/empty (regular agents)
311
+ scope.where(workflow_type: [nil, ""])
312
+ when "workflows"
313
+ # Only show executions with a workflow_type
314
+ workflow_scope = scope.where.not(workflow_type: [nil, ""])
315
+
316
+ # Apply workflow type sub-filter if specified
317
+ workflow_type_tab = params[:workflow_type_tab]
318
+ if workflow_type_tab.present? && %w[pipeline parallel router].include?(workflow_type_tab)
319
+ workflow_scope = workflow_scope.where(workflow_type: workflow_type_tab)
320
+ end
321
+
322
+ workflow_scope
323
+ else
324
+ scope
325
+ end
326
+ end
249
327
  end
250
328
  end
251
329
  end
@@ -167,35 +167,126 @@ module RubyLLM
167
167
  end
168
168
 
169
169
  # Returns chart data as arrays for Highcharts live updates
170
- # Format: { categories: [...], series: [...] }
170
+ # Format: { categories: [...], series: [...], range: ... }
171
+ #
172
+ # @param range [String] Time range: "today" (hourly), "7d" or "30d" (daily)
173
+ def activity_chart_json(range: "today")
174
+ case range
175
+ when "7d"
176
+ build_daily_chart_data(7)
177
+ when "30d"
178
+ build_daily_chart_data(30)
179
+ else
180
+ build_hourly_chart_data
181
+ end
182
+ end
183
+
184
+ # Alias for backwards compatibility
171
185
  def hourly_activity_chart_json
172
- # Always use current time as reference so chart shows "now" on the right
186
+ activity_chart_json(range: "today")
187
+ end
188
+
189
+ private
190
+
191
+ # Builds hourly chart data for last 24 hours
192
+ # Optimized: Single GROUP BY query instead of 72 individual queries
193
+ # Database-agnostic: works with both PostgreSQL and SQLite
194
+ def build_hourly_chart_data
173
195
  reference_time = Time.current.beginning_of_hour
196
+ start_time = reference_time - 23.hours
174
197
 
175
- categories = []
198
+ # Use database-agnostic aggregation with Ruby post-processing
199
+ results = where(created_at: start_time..(reference_time + 1.hour))
200
+ .select(:status, :total_cost, :created_at)
201
+ .group_by { |r| r.created_at.beginning_of_hour }
202
+
203
+ # Build arrays for all 24 hours (fill missing with zeros)
176
204
  success_data = []
177
205
  failed_data = []
206
+ cost_data = []
207
+ total_success = 0
208
+ total_failed = 0
209
+ total_cost = 0.0
178
210
 
179
- # Create entries for the last 24 hours ending at current hour
180
211
  (23.downto(0)).each do |hours_ago|
181
- start_time = reference_time - hours_ago.hours
182
- end_time = start_time + 1.hour
183
- categories << start_time.in_time_zone.strftime("%H:%M")
212
+ bucket_time = (reference_time - hours_ago.hours).beginning_of_hour
213
+ rows = results[bucket_time] || []
184
214
 
185
- hour_scope = where(created_at: start_time...end_time)
186
- success_data << hour_scope.successful.count
187
- failed_data << hour_scope.failed.count
215
+ s = rows.count { |r| r.status == "success" }
216
+ f = rows.count { |r| r.status.in?(%w[error timeout]) }
217
+ c = rows.sum { |r| r.total_cost.to_f }
218
+
219
+ success_data << s
220
+ failed_data << f
221
+ cost_data << c.round(4)
222
+
223
+ total_success += s
224
+ total_failed += f
225
+ total_cost += c
188
226
  end
189
227
 
190
228
  {
191
- categories: categories,
229
+ range: "today",
230
+ totals: { success: total_success, failed: total_failed, cost: total_cost.round(4) },
192
231
  series: [
193
232
  { name: "Success", data: success_data },
194
- { name: "Failed", data: failed_data }
233
+ { name: "Failed", data: failed_data },
234
+ { name: "Cost", data: cost_data }
195
235
  ]
196
236
  }
197
237
  end
198
238
 
239
+ # Builds daily chart data for specified number of days
240
+ # Optimized: Single query instead of 3*days individual queries
241
+ # Database-agnostic: works with both PostgreSQL and SQLite
242
+ def build_daily_chart_data(days)
243
+ end_date = Date.current
244
+ start_date = (days - 1).days.ago.to_date
245
+
246
+ # Use database-agnostic aggregation with Ruby post-processing
247
+ results = where(created_at: start_date.beginning_of_day..end_date.end_of_day)
248
+ .select(:status, :total_cost, :created_at)
249
+ .group_by { |r| r.created_at.to_date }
250
+
251
+ # Build arrays for all days (fill missing with zeros)
252
+ success_data = []
253
+ failed_data = []
254
+ cost_data = []
255
+ total_success = 0
256
+ total_failed = 0
257
+ total_cost = 0.0
258
+
259
+ (days - 1).downto(0).each do |days_ago|
260
+ date = days_ago.days.ago.to_date
261
+ rows = results[date] || []
262
+
263
+ s = rows.count { |r| r.status == "success" }
264
+ f = rows.count { |r| r.status.in?(%w[error timeout]) }
265
+ c = rows.sum { |r| r.total_cost.to_f }
266
+
267
+ success_data << s
268
+ failed_data << f
269
+ cost_data << c.round(4)
270
+
271
+ total_success += s
272
+ total_failed += f
273
+ total_cost += c
274
+ end
275
+
276
+ {
277
+ range: "#{days}d",
278
+ days: days,
279
+ totals: { success: total_success, failed: total_failed, cost: total_cost.round(4) },
280
+ series: [
281
+ { name: "Success", data: success_data },
282
+ { name: "Failed", data: failed_data },
283
+ { name: "Cost", data: cost_data }
284
+ ]
285
+ }
286
+ end
287
+
288
+ public
289
+
199
290
  # Builds the hourly activity data structure
200
291
  # Shows the last 24 hours with current hour on the right
201
292
  #
@@ -47,6 +47,31 @@ module RubyLLM
47
47
 
48
48
  # @!endgroup
49
49
 
50
+ # @!group Tenant Scopes
51
+
52
+ # @!method by_tenant(tenant_id)
53
+ # Filters to a specific tenant
54
+ # @param tenant_id [String] The tenant identifier
55
+ # @return [ActiveRecord::Relation]
56
+
57
+ # @!method for_current_tenant
58
+ # Filters to the current tenant from the resolver
59
+ # @return [ActiveRecord::Relation]
60
+ scope :by_tenant, ->(tenant_id) { where(tenant_id: tenant_id) }
61
+ scope :without_tenant, -> { where(tenant_id: nil) }
62
+ scope :with_tenant, -> { where.not(tenant_id: nil) }
63
+ scope :for_current_tenant, -> {
64
+ config = RubyLLM::Agents.configuration
65
+ if config.multi_tenancy_enabled?
66
+ tenant_id = config.tenant_resolver&.call
67
+ tenant_id ? where(tenant_id: tenant_id) : all
68
+ else
69
+ all
70
+ end
71
+ }
72
+
73
+ # @!endgroup
74
+
50
75
  # @!group Agent-based Scopes
51
76
 
52
77
  # @!method by_agent(agent_type)