ruby_llm-agents 3.7.2 → 3.8.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/ruby_llm/agents/agents_controller.rb +14 -141
  3. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +12 -166
  4. data/app/controllers/ruby_llm/agents/executions_controller.rb +1 -1
  5. data/app/helpers/ruby_llm/agents/application_helper.rb +38 -0
  6. data/app/models/ruby_llm/agents/execution/analytics.rb +302 -103
  7. data/app/models/ruby_llm/agents/execution.rb +76 -54
  8. data/app/models/ruby_llm/agents/execution_detail.rb +2 -0
  9. data/app/models/ruby_llm/agents/tenant.rb +39 -0
  10. data/app/services/ruby_llm/agents/agent_registry.rb +98 -0
  11. data/app/views/ruby_llm/agents/executions/_list.html.erb +3 -17
  12. data/lib/generators/ruby_llm_agents/templates/add_dashboard_performance_indexes_migration.rb.tt +23 -0
  13. data/lib/generators/ruby_llm_agents/templates/migration.rb.tt +3 -0
  14. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +25 -0
  15. data/lib/ruby_llm/agents/base_agent.rb +7 -1
  16. data/lib/ruby_llm/agents/core/configuration.rb +1 -0
  17. data/lib/ruby_llm/agents/core/instrumentation.rb +15 -19
  18. data/lib/ruby_llm/agents/core/version.rb +1 -1
  19. data/lib/ruby_llm/agents/infrastructure/alert_manager.rb +4 -4
  20. data/lib/ruby_llm/agents/infrastructure/budget_tracker.rb +19 -11
  21. data/lib/ruby_llm/agents/pipeline/builder.rb +8 -4
  22. data/lib/ruby_llm/agents/pipeline/context.rb +43 -1
  23. data/lib/ruby_llm/agents/pipeline/middleware/budget.rb +6 -4
  24. data/lib/ruby_llm/agents/pipeline/middleware/cache.rb +6 -4
  25. data/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb +26 -75
  26. data/lib/ruby_llm/agents/pipeline/middleware/reliability.rb +6 -6
  27. data/lib/ruby_llm/agents/pipeline/middleware/tenant.rb +23 -27
  28. data/lib/ruby_llm/agents/providers/inception/capabilities.rb +107 -0
  29. data/lib/ruby_llm/agents/providers/inception/chat.rb +17 -0
  30. data/lib/ruby_llm/agents/providers/inception/configuration.rb +9 -0
  31. data/lib/ruby_llm/agents/providers/inception/models.rb +38 -0
  32. data/lib/ruby_llm/agents/providers/inception/registry.rb +45 -0
  33. data/lib/ruby_llm/agents/providers/inception.rb +50 -0
  34. data/lib/ruby_llm/agents/results/base.rb +4 -2
  35. data/lib/ruby_llm/agents/results/image_analysis_result.rb +4 -2
  36. data/lib/ruby_llm/agents/text/embedder.rb +4 -0
  37. data/lib/ruby_llm/agents.rb +4 -0
  38. metadata +8 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 891034cc430fa52a5b9d1b07bd9cf06558414b91396f4a1521ebd0f8618f2a68
4
- data.tar.gz: fc6775ed017277076bec9c0931cfe4c34d4ef1023815f4898458f40e55b61855
3
+ metadata.gz: e2d24a8046e58bdadf37d70ddb1effb038f69b1fa01c4dd7f9e08bf819527d94
4
+ data.tar.gz: 0f620acc86ebe001c151c6a95ee784463bb1e10eb66532e7f6d2684ca2021f06
5
5
  SHA512:
6
- metadata.gz: 9de47e331e3f0b48465fa12c9f91f79045dfec64d76f242308184a36c41a67be7d324d9b358c96450cde053dbb8f69fe326a1d3a6078a3ea93136b8c327edc30
7
- data.tar.gz: d7ba9f35cf90656e7618189141d7291d704dd7199c54ced508d9da1cb72aa417f1988cd15e884cd42f2ae235e3149c2c31ca0d4d3567db9e04132d890a11e517
6
+ metadata.gz: 9bd06ce2399f86359e00a115d269af22d3f73a0ad8a1bc0d21e315c4a41b0a023bd59b9b9504ac3902d65c76195467f6ae232caed955b5b247439e27aca59b7e
7
+ data.tar.gz: bb2b29a3b6d7c3c4b0312103fe98bd8c3744dc42415280fda8bbb41054c414f489954c1ee869f6b21cb4e98a470eb7f7e9cf8fc63cc05c0810d4f1b635c9bd55
@@ -100,11 +100,12 @@ module RubyLLM
100
100
  #
101
101
  # @return [void]
102
102
  def load_agent_stats
103
- @stats = Execution.stats_for(@agent_type, period: :all_time)
104
- @stats_today = Execution.stats_for(@agent_type, period: :today)
103
+ base = tenant_scoped_executions
104
+ @stats = base.stats_for(@agent_type, period: :all_time)
105
+ @stats_today = base.stats_for(@agent_type, period: :today)
105
106
 
106
107
  # Additional stats for new schema fields
107
- agent_scope = Execution.by_agent(@agent_type)
108
+ agent_scope = base.by_agent(@agent_type)
108
109
  @cache_hit_rate = agent_scope.cache_hit_rate
109
110
  @streaming_rate = agent_scope.streaming_rate
110
111
  @avg_ttft = agent_scope.avg_time_to_first_token
@@ -118,9 +119,10 @@ module RubyLLM
118
119
  # @return [void]
119
120
  def load_filter_options
120
121
  # Single query to get all filter options (fixes N+1)
121
- filter_data = Execution.by_agent(@agent_type)
122
+ base = tenant_scoped_executions.by_agent(@agent_type)
123
+ filter_data = base
122
124
  .where.not(model_id: nil)
123
- .or(Execution.by_agent(@agent_type).where.not(temperature: nil))
125
+ .or(base.where.not(temperature: nil))
124
126
  .pluck(:model_id, :temperature)
125
127
 
126
128
  @models = filter_data.map(&:first).compact.uniq.sort
@@ -152,7 +154,7 @@ module RubyLLM
152
154
  #
153
155
  # @return [ActiveRecord::Relation] Filtered execution scope
154
156
  def build_filtered_scope
155
- scope = Execution.by_agent(@agent_type)
157
+ scope = tenant_scoped_executions.by_agent(@agent_type)
156
158
 
157
159
  # Apply status filter with validation
158
160
  statuses = parse_array_param(:statuses)
@@ -177,9 +179,10 @@ module RubyLLM
177
179
  #
178
180
  # @return [void]
179
181
  def load_chart_data
180
- @trend_data = Execution.trend_analysis(agent_type: @agent_type, days: 30)
181
- @status_distribution = Execution.by_agent(@agent_type).group(:status).count
182
- @finish_reason_distribution = Execution.by_agent(@agent_type).finish_reason_distribution
182
+ base = tenant_scoped_executions
183
+ @trend_data = base.trend_analysis(agent_type: @agent_type, days: 30)
184
+ @status_distribution = base.by_agent(@agent_type).group(:status).count
185
+ @finish_reason_distribution = base.by_agent(@agent_type).finish_reason_distribution
183
186
  end
184
187
 
185
188
  # Loads the current agent class configuration
@@ -189,140 +192,10 @@ module RubyLLM
189
192
  # Detects agent type and loads appropriate config.
190
193
  #
191
194
  # @return [void]
195
+ # Loads agent configuration using AgentRegistry
192
196
  def load_agent_config
193
197
  @agent_type_kind = AgentRegistry.send(:detect_agent_type, @agent_class)
194
-
195
- # Common config for all types
196
- @config = {
197
- model: safe_config_call(:model),
198
- description: safe_config_call(:description)
199
- }
200
-
201
- # Type-specific config
202
- case @agent_type_kind
203
- when "embedder"
204
- load_embedder_config
205
- when "speaker"
206
- load_speaker_config
207
- when "transcriber"
208
- load_transcriber_config
209
- when "image_generator"
210
- load_image_generator_config
211
- when "router"
212
- load_router_config
213
- else
214
- load_base_agent_config
215
- end
216
- end
217
-
218
- # Loads configuration specific to Base agents
219
- #
220
- # @return [void]
221
- def load_base_agent_config
222
- @config.merge!(
223
- temperature: safe_config_call(:temperature),
224
- timeout: safe_config_call(:timeout),
225
- cache_enabled: safe_config_call(:cache_enabled?) || false,
226
- cache_ttl: safe_config_call(:cache_ttl),
227
- params: safe_config_call(:params) || {},
228
- retries: safe_config_call(:retries),
229
- fallback_models: safe_config_call(:fallback_models),
230
- total_timeout: safe_config_call(:total_timeout),
231
- circuit_breaker: safe_config_call(:circuit_breaker_config)
232
- )
233
- end
234
-
235
- # Loads configuration specific to Embedders
236
- #
237
- # @return [void]
238
- def load_embedder_config
239
- @config.merge!(
240
- dimensions: safe_config_call(:dimensions),
241
- batch_size: safe_config_call(:batch_size),
242
- cache_enabled: safe_config_call(:cache_enabled?) || false,
243
- cache_ttl: safe_config_call(:cache_ttl)
244
- )
245
- end
246
-
247
- # Loads configuration specific to Speakers
248
- #
249
- # @return [void]
250
- def load_speaker_config
251
- @config.merge!(
252
- provider: safe_config_call(:provider),
253
- voice: safe_config_call(:voice),
254
- voice_id: safe_config_call(:voice_id),
255
- speed: safe_config_call(:speed),
256
- output_format: safe_config_call(:output_format),
257
- streaming: safe_config_call(:streaming?),
258
- ssml_enabled: safe_config_call(:ssml_enabled?),
259
- cache_enabled: safe_config_call(:cache_enabled?) || false,
260
- cache_ttl: safe_config_call(:cache_ttl)
261
- )
262
- end
263
-
264
- # Loads configuration specific to Transcribers
265
- #
266
- # @return [void]
267
- def load_transcriber_config
268
- @config.merge!(
269
- language: safe_config_call(:language),
270
- output_format: safe_config_call(:output_format),
271
- include_timestamps: safe_config_call(:include_timestamps),
272
- cache_enabled: safe_config_call(:cache_enabled?) || false,
273
- cache_ttl: safe_config_call(:cache_ttl),
274
- fallback_models: safe_config_call(:fallback_models)
275
- )
276
- end
277
-
278
- # Loads configuration specific to ImageGenerators
279
- #
280
- # @return [void]
281
- def load_image_generator_config
282
- @config.merge!(
283
- size: safe_config_call(:size),
284
- quality: safe_config_call(:quality),
285
- style: safe_config_call(:style),
286
- content_policy: safe_config_call(:content_policy),
287
- template: safe_config_call(:template_string),
288
- negative_prompt: safe_config_call(:negative_prompt),
289
- seed: safe_config_call(:seed),
290
- guidance_scale: safe_config_call(:guidance_scale),
291
- steps: safe_config_call(:steps),
292
- cache_enabled: safe_config_call(:cache_enabled?) || false,
293
- cache_ttl: safe_config_call(:cache_ttl)
294
- )
295
- end
296
-
297
- # Loads configuration specific to Router agents
298
- #
299
- # @return [void]
300
- def load_router_config
301
- routes = safe_config_call(:routes) || {}
302
- @config.merge!(
303
- temperature: safe_config_call(:temperature),
304
- timeout: safe_config_call(:timeout),
305
- cache_enabled: safe_config_call(:cache_enabled?) || false,
306
- cache_ttl: safe_config_call(:cache_ttl),
307
- default_route: safe_config_call(:default_route_name),
308
- routes: routes.transform_values { |v| v[:description] },
309
- route_count: routes.size,
310
- retries: safe_config_call(:retries),
311
- fallback_models: safe_config_call(:fallback_models),
312
- total_timeout: safe_config_call(:total_timeout),
313
- circuit_breaker: safe_config_call(:circuit_breaker_config)
314
- )
315
- end
316
-
317
- # Safely calls a method on the agent class, returning nil on error
318
- #
319
- # @param method [Symbol] The method to call
320
- # @return [Object, nil] The result or nil if error
321
- def safe_config_call(method)
322
- return nil unless @agent_class&.respond_to?(method)
323
- @agent_class.public_send(method)
324
- rescue
325
- nil
198
+ @config = AgentRegistry.config_for(@agent_class)
326
199
  end
327
200
 
328
201
  # Loads circuit breaker status for the agent's models
@@ -24,7 +24,7 @@ module RubyLLM
24
24
  base_scope = tenant_scoped_executions
25
25
  @now_strip = build_now_strip(base_scope)
26
26
  @critical_alerts = load_critical_alerts(base_scope)
27
- @recent_executions = base_scope.recent(10)
27
+ @recent_executions = base_scope.includes(:detail).recent(10)
28
28
  @agent_stats = build_agent_comparison(base_scope)
29
29
  @top_errors = build_top_errors(base_scope)
30
30
  @tenant_budget = load_tenant_budget(base_scope)
@@ -194,100 +194,14 @@ module RubyLLM
194
194
  @agent_stats
195
195
  end
196
196
 
197
- # Builds per-model statistics for model comparison and cost breakdown
198
- #
199
- # @param base_scope [ActiveRecord::Relation] Base scope to filter from
200
- # @return [Array<Hash>] Array of model stats sorted by total cost descending
197
+ # Delegates to Execution.model_stats with time scoping
201
198
  def build_model_stats(base_scope = Execution)
202
- scope = time_scoped(base_scope).where.not(model_id: nil)
203
-
204
- # Batch fetch stats grouped by model
205
- counts = scope.group(:model_id).count
206
- costs = scope.group(:model_id).sum(:total_cost)
207
- tokens = scope.group(:model_id).sum(:total_tokens)
208
- durations = scope.group(:model_id).average(:duration_ms)
209
- success_counts = scope.successful.group(:model_id).count
210
-
211
- total_cost = costs.values.sum
212
-
213
- model_ids = counts.keys
214
- model_ids.map do |model_id|
215
- count = counts[model_id] || 0
216
- model_cost = costs[model_id] || 0
217
- model_tokens = tokens[model_id] || 0
218
- successful = success_counts[model_id] || 0
219
-
220
- {
221
- model_id: model_id,
222
- executions: count,
223
- total_cost: model_cost,
224
- total_tokens: model_tokens,
225
- avg_duration_ms: durations[model_id]&.round || 0,
226
- success_rate: (count > 0) ? (successful.to_f / count * 100).round(1) : 0,
227
- cost_per_1k_tokens: (model_tokens > 0) ? (model_cost / model_tokens * 1000).round(4) : 0,
228
- cost_percentage: (total_cost > 0) ? (model_cost / total_cost * 100).round(1) : 0
229
- }
230
- end.sort_by { |m| -(m[:total_cost] || 0) }
199
+ Execution.model_stats(scope: time_scoped(base_scope))
231
200
  end
232
201
 
233
- # Builds top errors list
234
- #
235
- # @param base_scope [ActiveRecord::Relation] Base scope to filter from
236
- # @return [Array<Hash>] Top 5 error classes with counts
202
+ # Delegates to Execution.top_errors with time scoping
237
203
  def build_top_errors(base_scope = Execution)
238
- scope = time_scoped(base_scope).where(status: "error")
239
- total_errors = scope.count
240
-
241
- scope.group(:error_class)
242
- .select("error_class, COUNT(*) as count, MAX(created_at) as last_seen")
243
- .order("count DESC")
244
- .limit(5)
245
- .map do |row|
246
- {
247
- error_class: row.error_class || "Unknown Error",
248
- count: row.count,
249
- percentage: (total_errors > 0) ? (row.count.to_f / total_errors * 100).round(1) : 0,
250
- last_seen: row.last_seen
251
- }
252
- end
253
- end
254
-
255
- # Fetches cached daily statistics for the dashboard
256
- #
257
- # Results are cached for 1 minute to reduce database load while
258
- # keeping the dashboard reasonably up-to-date.
259
- #
260
- # @return [Hash] Daily statistics
261
- # @option return [Integer] :total_executions Total execution count today
262
- # @option return [Integer] :successful Successful execution count
263
- # @option return [Integer] :failed Failed execution count
264
- # @option return [Float] :total_cost Combined cost of all executions
265
- # @option return [Integer] :total_tokens Combined token usage
266
- # @option return [Integer] :avg_duration_ms Average execution duration
267
- # @option return [Float] :success_rate Percentage of successful executions
268
- def daily_stats
269
- Rails.cache.fetch("ruby_llm_agents/daily_stats/#{Date.current}", expires_in: 1.minute) do
270
- scope = Execution.today
271
- {
272
- total_executions: scope.count,
273
- successful: scope.successful.count,
274
- failed: scope.failed.count,
275
- total_cost: scope.total_cost_sum || 0,
276
- total_tokens: scope.total_tokens_sum || 0,
277
- avg_duration_ms: scope.avg_duration&.round || 0,
278
- success_rate: calculate_success_rate(scope)
279
- }
280
- end
281
- end
282
-
283
- # Calculates the success rate percentage for a scope
284
- #
285
- # @param scope [ActiveRecord::Relation] The execution scope to calculate from
286
- # @return [Float] Success rate as a percentage (0.0-100.0)
287
- def calculate_success_rate(scope)
288
- total = scope.count
289
- return 0.0 if total.zero?
290
- (scope.successful.count.to_f / total * 100).round(1)
204
+ Execution.top_errors(scope: time_scoped(base_scope))
291
205
  end
292
206
 
293
207
  # Loads budget status for display on dashboard
@@ -304,7 +218,7 @@ module RubyLLM
304
218
  open_breakers = []
305
219
 
306
220
  # Get all agents from execution history
307
- agent_types = Execution.distinct.pluck(:agent_type)
221
+ agent_types = tenant_scoped_executions.distinct.pluck(:agent_type)
308
222
 
309
223
  agent_types.each do |agent_type|
310
224
  # Get the agent class if available
@@ -435,87 +349,19 @@ module RubyLLM
435
349
  alerts.take(3)
436
350
  end
437
351
 
438
- # Builds cache savings statistics for the dashboard
439
- #
440
- # @param base_scope [ActiveRecord::Relation] Base scope to filter from
441
- # @return [Hash] Cache savings data with count, estimated savings, and hit rate
352
+ # Delegates to Execution.cache_savings with time scoping
442
353
  def build_cache_savings(base_scope)
443
- scope = time_scoped(base_scope)
444
- total_count = scope.count
445
- return {count: 0, estimated_savings: 0, hit_rate: 0, total_executions: 0} if total_count.zero?
446
-
447
- cached_scope = scope.cached
448
- cache_count = cached_scope.count
449
- estimated_savings = cached_scope.sum(:total_cost)
450
-
451
- {
452
- count: cache_count,
453
- estimated_savings: estimated_savings,
454
- hit_rate: (cache_count.to_f / total_count * 100).round(1),
455
- total_executions: total_count
456
- }
354
+ Execution.cache_savings(scope: time_scoped(base_scope))
457
355
  end
458
356
 
459
- # Builds top tenants list for the dashboard overview
460
- #
461
- # @return [Array<Hash>, nil] Top 5 tenants by monthly spend, or nil if none
357
+ # Delegates to Tenant.top_by_spend
462
358
  def build_top_tenants
463
- return nil unless Tenant.table_exists?
464
-
465
- tenants = Tenant.active
466
- .where("monthly_cost_spent > 0 OR monthly_executions_count > 0")
467
- .order(monthly_cost_spent: :desc)
468
- .limit(5)
469
-
470
- return nil if tenants.empty?
471
-
472
- tenants.map do |tenant|
473
- tenant.ensure_daily_reset!
474
- tenant.ensure_monthly_reset!
475
-
476
- monthly_limit = tenant.effective_monthly_limit
477
- daily_limit = tenant.effective_daily_limit
478
-
479
- {
480
- id: tenant.id,
481
- tenant_id: tenant.tenant_id,
482
- name: tenant.display_name,
483
- enforcement: tenant.effective_enforcement,
484
- monthly_spend: tenant.monthly_cost_spent,
485
- monthly_limit: monthly_limit,
486
- monthly_percentage: (monthly_limit.to_f > 0) ? (tenant.monthly_cost_spent / monthly_limit * 100).round(1) : 0,
487
- daily_spend: tenant.daily_cost_spent,
488
- daily_limit: daily_limit,
489
- daily_percentage: (daily_limit.to_f > 0) ? (tenant.daily_cost_spent / daily_limit * 100).round(1) : 0,
490
- monthly_executions: tenant.monthly_executions_count
491
- }
492
- end
359
+ Tenant.top_by_spend(limit: 5)
493
360
  end
494
361
 
495
- # Batch fetches execution stats for all agents in a time period
496
- #
497
- # @param scope [ActiveRecord::Relation] Base scope with time filter
498
- # @return [Hash<String, Hash>] Agent type => stats hash
362
+ # Delegates to Execution.batch_agent_stats with pre-filtered scope
499
363
  def batch_fetch_agent_stats(scope)
500
- counts = scope.group(:agent_type).count
501
- costs = scope.group(:agent_type).sum(:total_cost)
502
- success_counts = scope.successful.group(:agent_type).count
503
- durations = scope.group(:agent_type).average(:duration_ms)
504
-
505
- agent_types = (counts.keys + costs.keys).uniq
506
- agent_types.each_with_object({}) do |agent_type, hash|
507
- count = counts[agent_type] || 0
508
- total_cost = costs[agent_type] || 0
509
- successful = success_counts[agent_type] || 0
510
-
511
- hash[agent_type] = {
512
- count: count,
513
- total_cost: total_cost,
514
- avg_cost: (count > 0) ? (total_cost / count).round(6) : 0,
515
- avg_duration_ms: durations[agent_type]&.round || 0,
516
- success_rate: (count > 0) ? (successful.to_f / count * 100).round(1) : 0
517
- }
518
- end
364
+ Execution.batch_agent_stats(scope: scope)
519
365
  end
520
366
  end
521
367
  end
@@ -39,7 +39,7 @@ module RubyLLM
39
39
  #
40
40
  # @return [void]
41
41
  def show
42
- @execution = Execution.find(params[:id])
42
+ @execution = tenant_scoped_executions.includes(:detail, :child_executions).find(params[:id])
43
43
  end
44
44
 
45
45
  # Handles filter search requests via Turbo Stream
@@ -120,6 +120,44 @@ module RubyLLM
120
120
  end
121
121
  end
122
122
 
123
+ # Renders a sortable column header link with arrow indicator
124
+ #
125
+ # Replaces inline `raw()` calls in views with safe content_tag usage.
126
+ #
127
+ # @param column [String] The sort column name
128
+ # @param label [String] Display label for the header
129
+ # @param current_column [String] Currently active sort column
130
+ # @param current_direction [String] Current sort direction ("asc"/"desc")
131
+ # @param extra_class [String] Additional CSS classes
132
+ # @return [ActiveSupport::SafeBuffer] HTML link element
133
+ def sort_header_link(column, label, current_column:, current_direction:, extra_class: "")
134
+ is_active = column == current_column
135
+ next_dir = (is_active && current_direction == "asc") ? "desc" : "asc"
136
+ url = url_for(request.query_parameters.merge(sort: column, direction: next_dir, page: 1))
137
+
138
+ arrow = if is_active && current_direction == "asc"
139
+ content_tag(:svg, class: "w-2.5 h-2.5 inline", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24") do
140
+ content_tag(:path, nil, "stroke-linecap": "round", "stroke-linejoin": "round", "stroke-width": "2", d: "M5 15l7-7 7 7")
141
+ end
142
+ elsif is_active
143
+ content_tag(:svg, class: "w-2.5 h-2.5 inline", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24") do
144
+ content_tag(:path, nil, "stroke-linecap": "round", "stroke-linejoin": "round", "stroke-width": "2", d: "M19 9l-7 7-7-7")
145
+ end
146
+ else
147
+ "".html_safe
148
+ end
149
+
150
+ active_class = is_active ? "text-gray-700 dark:text-gray-300" : ""
151
+ opacity_class = is_active ? "opacity-100" : "opacity-0 group-hover:opacity-50"
152
+
153
+ link_to url, class: "group inline-flex items-center gap-0.5 hover:text-gray-700 dark:hover:text-gray-300 #{active_class} #{extra_class}" do
154
+ safe_join([
155
+ content_tag(:span, label),
156
+ content_tag(:span, arrow, class: "#{opacity_class} transition-opacity")
157
+ ])
158
+ end
159
+ end
160
+
123
161
  # Syntax-highlights a Ruby object as pretty-printed JSON
124
162
  #
125
163
  # Converts the object to JSON and applies color highlighting