ruby_llm-agents 0.2.3 → 0.3.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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +273 -0
  3. data/app/channels/ruby_llm/agents/executions_channel.rb +24 -1
  4. data/app/controllers/concerns/ruby_llm/agents/filterable.rb +81 -0
  5. data/app/controllers/concerns/ruby_llm/agents/paginatable.rb +51 -0
  6. data/app/controllers/ruby_llm/agents/agents_controller.rb +228 -59
  7. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +167 -12
  8. data/app/controllers/ruby_llm/agents/executions_controller.rb +189 -31
  9. data/app/controllers/ruby_llm/agents/settings_controller.rb +20 -0
  10. data/app/helpers/ruby_llm/agents/application_helper.rb +307 -7
  11. data/app/models/ruby_llm/agents/execution/analytics.rb +224 -20
  12. data/app/models/ruby_llm/agents/execution/metrics.rb +41 -25
  13. data/app/models/ruby_llm/agents/execution/scopes.rb +234 -14
  14. data/app/models/ruby_llm/agents/execution.rb +259 -16
  15. data/app/services/ruby_llm/agents/agent_registry.rb +49 -12
  16. data/app/views/layouts/rubyllm/agents/application.html.erb +351 -85
  17. data/app/views/rubyllm/agents/agents/_version_comparison.html.erb +186 -0
  18. data/app/views/rubyllm/agents/agents/show.html.erb +233 -10
  19. data/app/views/rubyllm/agents/dashboard/_action_center.html.erb +62 -0
  20. data/app/views/rubyllm/agents/dashboard/_alerts_feed.html.erb +62 -0
  21. data/app/views/rubyllm/agents/dashboard/_breaker_strip.html.erb +47 -0
  22. data/app/views/rubyllm/agents/dashboard/_budgets_bar.html.erb +165 -0
  23. data/app/views/rubyllm/agents/dashboard/_now_strip.html.erb +10 -0
  24. data/app/views/rubyllm/agents/dashboard/_now_strip_values.html.erb +71 -0
  25. data/app/views/rubyllm/agents/dashboard/index.html.erb +215 -109
  26. data/app/views/rubyllm/agents/executions/_filters.html.erb +152 -155
  27. data/app/views/rubyllm/agents/executions/_list.html.erb +103 -12
  28. data/app/views/rubyllm/agents/executions/dry_run.html.erb +149 -0
  29. data/app/views/rubyllm/agents/executions/index.html.erb +17 -72
  30. data/app/views/rubyllm/agents/executions/index.turbo_stream.erb +16 -2
  31. data/app/views/rubyllm/agents/executions/show.html.erb +693 -14
  32. data/app/views/rubyllm/agents/settings/show.html.erb +369 -0
  33. data/app/views/rubyllm/agents/shared/_filter_dropdown.html.erb +121 -0
  34. data/app/views/rubyllm/agents/shared/_select_dropdown.html.erb +85 -0
  35. data/config/routes.rb +7 -0
  36. data/lib/generators/ruby_llm_agents/templates/add_attempts_migration.rb.tt +27 -0
  37. data/lib/generators/ruby_llm_agents/templates/add_caching_migration.rb.tt +23 -0
  38. data/lib/generators/ruby_llm_agents/templates/add_finish_reason_migration.rb.tt +19 -0
  39. data/lib/generators/ruby_llm_agents/templates/add_routing_migration.rb.tt +19 -0
  40. data/lib/generators/ruby_llm_agents/templates/add_streaming_migration.rb.tt +8 -0
  41. data/lib/generators/ruby_llm_agents/templates/add_tracing_migration.rb.tt +34 -0
  42. data/lib/generators/ruby_llm_agents/templates/agent.rb.tt +66 -4
  43. data/lib/generators/ruby_llm_agents/templates/application_agent.rb.tt +53 -6
  44. data/lib/generators/ruby_llm_agents/templates/initializer.rb.tt +139 -8
  45. data/lib/generators/ruby_llm_agents/templates/migration.rb.tt +38 -1
  46. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +78 -0
  47. data/lib/ruby_llm/agents/alert_manager.rb +207 -0
  48. data/lib/ruby_llm/agents/attempt_tracker.rb +295 -0
  49. data/lib/ruby_llm/agents/base.rb +580 -112
  50. data/lib/ruby_llm/agents/budget_tracker.rb +360 -0
  51. data/lib/ruby_llm/agents/circuit_breaker.rb +197 -0
  52. data/lib/ruby_llm/agents/configuration.rb +279 -1
  53. data/lib/ruby_llm/agents/engine.rb +59 -6
  54. data/lib/ruby_llm/agents/execution_logger_job.rb +17 -6
  55. data/lib/ruby_llm/agents/inflections.rb +13 -2
  56. data/lib/ruby_llm/agents/instrumentation.rb +538 -87
  57. data/lib/ruby_llm/agents/redactor.rb +130 -0
  58. data/lib/ruby_llm/agents/reliability.rb +185 -0
  59. data/lib/ruby_llm/agents/version.rb +3 -1
  60. data/lib/ruby_llm/agents.rb +52 -0
  61. metadata +41 -2
  62. data/app/controllers/ruby_llm/agents/application_controller.rb +0 -37
@@ -1,171 +1,168 @@
1
1
  <%
2
- has_filters = params[:agent_types].present? || params[:statuses].present? || params[:days].present?
2
+ # Parse filter params
3
3
  selected_agents = params[:agent_types].present? ? (params[:agent_types].is_a?(Array) ? params[:agent_types] : params[:agent_types].split(",")) : []
4
4
  selected_statuses = params[:statuses].present? ? (params[:statuses].is_a?(Array) ? params[:statuses] : params[:statuses].split(",")) : []
5
+ selected_models = params[:model_ids].present? ? (params[:model_ids].is_a?(Array) ? params[:model_ids] : params[:model_ids].split(",")) : []
6
+
7
+ has_filters = selected_agents.any? || selected_statuses.any? || params[:days].present? || selected_models.any?
8
+ active_filter_count = [
9
+ selected_agents.any? ? 1 : 0,
10
+ selected_statuses.any? ? 1 : 0,
11
+ params[:days].present? ? 1 : 0,
12
+ selected_models.any? ? 1 : 0
13
+ ].sum
14
+
15
+ status_options = [
16
+ { value: "success", label: "Success", color: "bg-green-500" },
17
+ { value: "error", label: "Error", color: "bg-red-500" },
18
+ { value: "running", label: "Running", color: "bg-blue-500" },
19
+ { value: "timeout", label: "Timeout", color: "bg-yellow-500" }
20
+ ]
21
+
22
+ time_options = [
23
+ { value: "", label: "All Time" },
24
+ { value: "1", label: "Today" },
25
+ { value: "7", label: "Last 7 Days" },
26
+ { value: "30", label: "Last 30 Days" }
27
+ ]
28
+
29
+ agent_options = agent_types.map { |t| { value: t, label: t.gsub(/Agent$/, '') } }
30
+
31
+ # Build model options - extract short name from model_id
32
+ model_options = local_assigns[:model_ids]&.map do |m|
33
+ short_name = m.split("/").last.split(":").first # Handle "provider/model:version" format
34
+ { value: m, label: short_name }
35
+ end || []
5
36
  %>
6
- <div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 mb-6" data-controller="filter-dropdown">
7
- <%= form_with url: ruby_llm_agents.executions_path, method: :get, data: { turbo_frame: "executions_content", turbo_action: "advance" }, id: "filters-form" do |f| %>
8
- <div class="flex flex-wrap items-center gap-3">
9
- <!-- Agent Type Filter (Multi-select) -->
10
- <div class="relative filter-dropdown" data-filter="agent_types">
11
- <button type="button" onclick="toggleDropdown(this)" class="flex items-center gap-2 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors <%= selected_agents.any? ? 'ring-2 ring-blue-500 ring-offset-1' : '' %>">
12
- <svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
13
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
14
- </svg>
15
- <span class="dropdown-label text-gray-700 dark:text-gray-200">
16
- <% if selected_agents.empty? %>
17
- All Agents
18
- <% elsif selected_agents.length == 1 %>
19
- <%= selected_agents.first.gsub(/Agent$/, '') %>
20
- <% else %>
21
- <%= selected_agents.length %> Agents
22
- <% end %>
37
+ <div id="filters-container" class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 mb-6"
38
+ x-data="{ mobileOpen: false }">
39
+ <%= form_with url: ruby_llm_agents.executions_path, method: :get,
40
+ data: { turbo_stream: true, turbo_action: "advance" },
41
+ id: "filters-form" do |f| %>
42
+
43
+ <%# Mobile: Toggle button (only shows on small screens) %>
44
+ <button type="button" @click="mobileOpen = !mobileOpen"
45
+ class="md:hidden w-full flex items-center justify-between gap-2 px-3 py-2 text-sm
46
+ bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg
47
+ <%= has_filters ? 'ring-2 ring-blue-500 dark:ring-offset-gray-900' : '' %>">
48
+ <div class="flex items-center gap-2">
49
+ <svg class="w-4 h-4 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
50
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"/>
51
+ </svg>
52
+ <span class="text-gray-700 dark:text-gray-200">Filters</span>
53
+ <% if active_filter_count > 0 %>
54
+ <span id="mobile-filter-badge" class="inline-flex items-center justify-center w-5 h-5 text-xs font-medium text-white bg-blue-600 rounded-full">
55
+ <%= active_filter_count %>
23
56
  </span>
24
- <svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
25
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
26
- </svg>
27
- </button>
28
- <div class="dropdown-menu hidden absolute z-10 mt-1 w-56 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1">
29
- <div class="px-3 py-2 border-b border-gray-100 dark:border-gray-700">
30
- <label class="flex items-center gap-2 cursor-pointer">
31
- <input type="checkbox" class="select-all-checkbox rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 dark:bg-gray-700" onchange="toggleAllOptions(this, 'agent_types')" <%= selected_agents.empty? ? 'checked' : '' %>>
32
- <span class="text-sm font-medium text-gray-700 dark:text-gray-200">All Agents</span>
33
- </label>
34
- </div>
35
- <% agent_types.each do |agent_type| %>
36
- <label class="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer">
37
- <input type="checkbox" name="agent_types[]" value="<%= agent_type %>" class="filter-checkbox rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 dark:bg-gray-700" onchange="updateMultiSelect('agent_types')" <%= selected_agents.include?(agent_type) ? 'checked' : '' %>>
38
- <svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
39
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
40
- </svg>
41
- <%= agent_type.gsub(/Agent$/, '') %>
42
- </label>
43
- <% end %>
44
- </div>
57
+ <% end %>
45
58
  </div>
59
+ <svg class="w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200" :class="{ 'rotate-180': mobileOpen }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
60
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
61
+ </svg>
62
+ </button>
46
63
 
47
- <!-- Status Filter (Multi-select) -->
48
- <div class="relative filter-dropdown" data-filter="statuses">
49
- <button type="button" onclick="toggleDropdown(this)" class="flex items-center gap-2 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors <%= selected_statuses.any? ? 'ring-2 ring-blue-500 ring-offset-1' : '' %>">
50
- <% if selected_statuses.length == 1
51
- status_color = case selected_statuses.first
52
- when 'success' then 'bg-green-500'
53
- when 'error' then 'bg-red-500'
54
- when 'running' then 'bg-blue-500'
55
- when 'timeout' then 'bg-yellow-500'
56
- else 'bg-gray-400'
57
- end
58
- else
59
- status_color = 'bg-gray-400'
60
- end %>
61
- <span class="w-2 h-2 rounded-full <%= status_color %>"></span>
62
- <span class="dropdown-label text-gray-700 dark:text-gray-200">
63
- <% if selected_statuses.empty? %>
64
- All Statuses
65
- <% elsif selected_statuses.length == 1 %>
66
- <%= selected_statuses.first.capitalize %>
67
- <% else %>
68
- <%= selected_statuses.length %> Statuses
69
- <% end %>
70
- </span>
71
- <svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
72
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
73
- </svg>
74
- </button>
75
- <div class="dropdown-menu hidden absolute z-10 mt-1 w-44 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1">
76
- <div class="px-3 py-2 border-b border-gray-100 dark:border-gray-700">
77
- <label class="flex items-center gap-2 cursor-pointer">
78
- <input type="checkbox" class="select-all-checkbox rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 dark:bg-gray-700" onchange="toggleAllOptions(this, 'statuses')" <%= selected_statuses.empty? ? 'checked' : '' %>>
79
- <span class="text-sm font-medium text-gray-700 dark:text-gray-200">All Statuses</span>
80
- </label>
81
- </div>
82
- <label class="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer">
83
- <input type="checkbox" name="statuses[]" value="success" class="filter-checkbox rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 dark:bg-gray-700" onchange="updateMultiSelect('statuses')" <%= selected_statuses.include?('success') ? 'checked' : '' %>>
84
- <span class="w-2 h-2 rounded-full bg-green-500"></span>
85
- Success
86
- </label>
87
- <label class="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer">
88
- <input type="checkbox" name="statuses[]" value="error" class="filter-checkbox rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 dark:bg-gray-700" onchange="updateMultiSelect('statuses')" <%= selected_statuses.include?('error') ? 'checked' : '' %>>
89
- <span class="w-2 h-2 rounded-full bg-red-500"></span>
90
- Error
91
- </label>
92
- <label class="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer">
93
- <input type="checkbox" name="statuses[]" value="running" class="filter-checkbox rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 dark:bg-gray-700" onchange="updateMultiSelect('statuses')" <%= selected_statuses.include?('running') ? 'checked' : '' %>>
94
- <span class="w-2 h-2 rounded-full bg-blue-500 animate-pulse"></span>
95
- Running
96
- </label>
97
- <label class="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer">
98
- <input type="checkbox" name="statuses[]" value="timeout" class="filter-checkbox rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 dark:bg-gray-700" onchange="updateMultiSelect('statuses')" <%= selected_statuses.include?('timeout') ? 'checked' : '' %>>
99
- <span class="w-2 h-2 rounded-full bg-yellow-500"></span>
100
- Timeout
101
- </label>
64
+ <%# Filters container: responsive layout %>
65
+ <%# Mobile: hidden by default, toggle with Alpine %>
66
+ <%# Desktop: always visible %>
67
+ <div x-cloak class="mt-3 md:mt-0"
68
+ :class="{ 'hidden md:block': !mobileOpen, 'block': mobileOpen }">
69
+
70
+ <%# Responsive flex: column on mobile, row on desktop %>
71
+ <div class="flex flex-col md:flex-row md:items-center gap-3">
72
+ <%# Status Filter %>
73
+ <div class="md:w-auto">
74
+ <%= render "rubyllm/agents/shared/filter_dropdown",
75
+ name: "statuses[]",
76
+ filter_id: "statuses",
77
+ label: "Status",
78
+ all_label: "All Statuses",
79
+ options: status_options,
80
+ selected: selected_statuses,
81
+ width: "w-44",
82
+ full_width: true %>
102
83
  </div>
103
- </div>
104
84
 
105
- <!-- Time Range Filter -->
106
- <div class="relative filter-dropdown" data-filter="days">
107
- <button type="button" onclick="toggleDropdown(this)" class="flex items-center gap-2 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors <%= params[:days].present? ? 'ring-2 ring-blue-500 ring-offset-1' : '' %>">
108
- <svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
109
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
110
- </svg>
111
- <span class="dropdown-label text-gray-700 dark:text-gray-200">
112
- <% case params[:days]
113
- when '1' then %>Today<%
114
- when '7' then %>Last 7 Days<%
115
- when '30' then %>Last 30 Days<%
116
- else %>All Time<%
117
- end %>
118
- </span>
119
- <svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
120
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
121
- </svg>
122
- </button>
123
- <div class="dropdown-menu hidden absolute z-10 mt-1 w-40 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1">
124
- <a href="#" onclick="selectSingleFilter('days', '', 'All Time'); return false;" class="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 <%= params[:days].blank? ? 'bg-blue-50 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300' : '' %>">
125
- <svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
126
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
127
- </svg>
128
- All Time
129
- </a>
130
- <a href="#" onclick="selectSingleFilter('days', '1', 'Today'); return false;" class="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 <%= params[:days] == '1' ? 'bg-blue-50 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300' : '' %>">
131
- <svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
132
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
133
- </svg>
134
- Today
135
- </a>
136
- <a href="#" onclick="selectSingleFilter('days', '7', 'Last 7 Days'); return false;" class="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 <%= params[:days] == '7' ? 'bg-blue-50 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300' : '' %>">
137
- <svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
138
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
139
- </svg>
140
- Last 7 Days
141
- </a>
142
- <a href="#" onclick="selectSingleFilter('days', '30', 'Last 30 Days'); return false;" class="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 <%= params[:days] == '30' ? 'bg-blue-50 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300' : '' %>">
143
- <svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
144
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
145
- </svg>
146
- Last 30 Days
147
- </a>
85
+ <%# Time Range Filter %>
86
+ <div class="md:w-auto">
87
+ <%= render "rubyllm/agents/shared/select_dropdown",
88
+ name: "days",
89
+ filter_id: "days",
90
+ options: time_options,
91
+ selected: params[:days],
92
+ icon: "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z",
93
+ width: "w-40",
94
+ full_width: true %>
148
95
  </div>
149
- <%= f.hidden_field :days, value: params[:days], id: "filter_days" %>
150
- </div>
151
96
 
152
- <!-- Clear Filters -->
153
- <% if has_filters %>
154
- <%= link_to ruby_llm_agents.executions_path, data: { turbo_frame: "executions_content", turbo_action: "advance" }, class: "flex items-center gap-1 px-3 py-2 text-sm text-red-500 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" do %>
155
- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
156
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
157
- </svg>
158
- Clear
97
+ <%# Model Filter %>
98
+ <% if model_options.any? %>
99
+ <div class="md:w-auto">
100
+ <%= render "rubyllm/agents/shared/filter_dropdown",
101
+ name: "model_ids[]",
102
+ filter_id: "model_ids",
103
+ label: "Model",
104
+ all_label: "All Models",
105
+ options: model_options,
106
+ selected: selected_models,
107
+ icon: "M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z",
108
+ full_width: true %>
109
+ </div>
159
110
  <% end %>
160
- <% end %>
161
111
 
162
- <!-- Stats Summary (right aligned) -->
163
- <div class="ml-auto flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
164
- <span><%= number_to_human_short(filter_stats[:total_count]) %> executions</span>
165
- <span class="text-gray-300 dark:text-gray-600">|</span>
166
- <span><%= number_to_human_short(filter_stats[:total_cost], prefix: "$", precision: 2) %></span>
167
- <span class="text-gray-300 dark:text-gray-600">|</span>
168
- <span><%= number_to_human_short(filter_stats[:total_tokens]) %> tokens</span>
112
+ <%# Agent Types Filter %>
113
+ <% if agent_types.any? %>
114
+ <div class="md:w-auto">
115
+ <%= render "rubyllm/agents/shared/filter_dropdown",
116
+ name: "agent_types[]",
117
+ filter_id: "agent_types",
118
+ label: "Agents",
119
+ all_label: "All Agents",
120
+ options: agent_options,
121
+ selected: selected_agents,
122
+ icon: "M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z",
123
+ width: "w-52",
124
+ full_width: true %>
125
+ </div>
126
+ <% end %>
127
+
128
+ <%# Spacer (desktop only) %>
129
+ <div class="hidden md:block flex-1"></div>
130
+
131
+ <%# Stats %>
132
+ <div id="filter-stats" class="flex items-center justify-between md:justify-end gap-3 py-2 px-3 md:p-0 bg-gray-50 md:bg-transparent dark:bg-gray-700/50 md:dark:bg-transparent rounded-lg md:rounded-none">
133
+ <span class="text-xs text-gray-500 dark:text-gray-400 md:hidden">Results</span>
134
+ <div class="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400">
135
+ <span class="tabular-nums"><%= number_to_human_short(filter_stats[:total_count]) %><span class="md:hidden"> executions</span></span>
136
+ <span class="text-gray-300 dark:text-gray-600">|</span>
137
+ <span class="tabular-nums"><%= number_to_human_short(filter_stats[:total_cost], prefix: "$", precision: 2) %></span>
138
+ <span class="hidden md:inline text-gray-300 dark:text-gray-600">|</span>
139
+ <span class="hidden md:inline tabular-nums"><%= number_to_human_short(filter_stats[:total_tokens]) %>t</span>
140
+ </div>
141
+ </div>
142
+
143
+ <%# Actions %>
144
+ <div class="flex items-center gap-2 md:gap-1">
145
+ <% if has_filters %>
146
+ <%= link_to ruby_llm_agents.executions_path,
147
+ data: { turbo_stream: true, turbo_action: "advance" },
148
+ class: "flex-1 md:flex-initial flex items-center justify-center gap-2 px-3 py-2 md:p-2 text-sm md:text-base text-red-600 md:text-gray-400 dark:text-red-400 md:dark:text-gray-400 bg-red-50 md:bg-transparent dark:bg-red-900/20 md:dark:bg-transparent hover:text-red-500 hover:bg-red-100 md:hover:bg-red-50 dark:hover:bg-red-900/30 md:dark:hover:bg-red-900/20 rounded-lg transition-colors",
149
+ title: "Clear filters" do %>
150
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
151
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
152
+ </svg>
153
+ <span class="md:hidden">Clear</span>
154
+ <% end %>
155
+ <% end %>
156
+ <%= link_to ruby_llm_agents.export_executions_path(agent_types: selected_agents.presence, statuses: selected_statuses.presence, days: params[:days].presence, model_ids: selected_models.presence),
157
+ class: "flex-1 md:flex-initial flex items-center justify-center gap-2 px-3 py-2 md:p-2 text-sm md:text-base text-gray-600 md:text-gray-400 dark:text-gray-300 md:dark:text-gray-400 bg-gray-50 md:bg-transparent dark:bg-gray-700 md:dark:bg-transparent hover:text-gray-600 md:hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600 md:dark:hover:bg-gray-700 rounded-lg transition-colors",
158
+ title: "Export CSV",
159
+ data: { turbo: false } do %>
160
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
161
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
162
+ </svg>
163
+ <span class="md:hidden">Export</span>
164
+ <% end %>
165
+ </div>
169
166
  </div>
170
167
  </div>
171
168
  <% end %>
@@ -1,4 +1,4 @@
1
- <div class="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
1
+ <div id="executions-list" class="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
2
2
  <% if executions.empty? %>
3
3
  <div class="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
4
4
  <p class="text-lg">No executions found</p>
@@ -9,6 +9,7 @@
9
9
  <table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
10
10
  <thead class="bg-gray-50 dark:bg-gray-900">
11
11
  <tr>
12
+ <th scope="col" class="px-2 py-3 w-8"></th>
12
13
  <th
13
14
  scope="col"
14
15
  class="
@@ -39,6 +40,16 @@
39
40
  Version
40
41
  </th>
41
42
 
43
+ <th
44
+ scope="col"
45
+ class="
46
+ px-4 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400
47
+ uppercase tracking-wider
48
+ "
49
+ >
50
+ Attempts
51
+ </th>
52
+
42
53
  <th
43
54
  scope="col"
44
55
  class="
@@ -83,29 +94,61 @@
83
94
 
84
95
  <tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-100 dark:divide-gray-700">
85
96
  <% executions.each do |execution| %>
97
+ <% has_attempts = execution.attempts.present? && execution.attempts.size > 0 %>
86
98
  <tr
87
- class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer"
88
- onclick="window.location='<%= ruby_llm_agents.execution_path(execution) %>'"
99
+ class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
100
+ id="execution-row-<%= execution.id %>"
89
101
  >
90
- <td class="px-4 py-3 whitespace-nowrap">
102
+ <!-- Expand/Collapse Button -->
103
+ <td class="px-2 py-3 whitespace-nowrap">
104
+ <% if has_attempts %>
105
+ <button
106
+ type="button"
107
+ onclick="toggleAttempts(<%= execution.id %>); event.stopPropagation();"
108
+ class="expand-btn p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
109
+ data-execution-id="<%= execution.id %>"
110
+ aria-expanded="false"
111
+ aria-label="Toggle attempts"
112
+ >
113
+ <svg class="w-4 h-4 text-gray-500 dark:text-gray-400 transform transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
114
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
115
+ </svg>
116
+ </button>
117
+ <% end %>
118
+ </td>
119
+
120
+ <td class="px-4 py-3 whitespace-nowrap cursor-pointer" onclick="window.location='<%= ruby_llm_agents.execution_path(execution) %>'">
91
121
  <span class="text-sm font-medium text-gray-900 dark:text-gray-100">
92
122
  <%= execution.agent_type.gsub(/Agent$/, '') %>
93
123
  </span>
94
124
  </td>
95
125
 
96
- <td class="px-4 py-3 whitespace-nowrap">
126
+ <td class="px-4 py-3 whitespace-nowrap cursor-pointer" onclick="window.location='<%= ruby_llm_agents.execution_path(execution) %>'">
97
127
  <%= render "rubyllm/agents/shared/status_badge", status: execution.status %>
98
128
  </td>
99
129
 
100
- <td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
130
+ <td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 cursor-pointer" onclick="window.location='<%= ruby_llm_agents.execution_path(execution) %>'">
101
131
  v<%= execution.agent_version %>
102
132
  </td>
103
133
 
134
+ <!-- Attempts Count -->
135
+ <td class="px-4 py-3 whitespace-nowrap text-sm text-center cursor-pointer" onclick="window.location='<%= ruby_llm_agents.execution_path(execution) %>'">
136
+ <% attempts_count = execution.attempts_count || (execution.attempts&.size || 1) %>
137
+ <% if attempts_count > 1 %>
138
+ <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
139
+ <%= attempts_count %> attempts
140
+ </span>
141
+ <% else %>
142
+ <span class="text-gray-400 dark:text-gray-500">1</span>
143
+ <% end %>
144
+ </td>
145
+
104
146
  <td
105
147
  class="
106
148
  px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100
107
- text-right font-medium
149
+ text-right font-medium cursor-pointer
108
150
  "
151
+ onclick="window.location='<%= ruby_llm_agents.execution_path(execution) %>'"
109
152
  >
110
153
  <%= number_to_human_short(execution.total_tokens || 0) %>
111
154
  </td>
@@ -113,8 +156,9 @@
113
156
  <td
114
157
  class="
115
158
  px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100
116
- text-right font-medium
159
+ text-right font-medium cursor-pointer
117
160
  "
161
+ onclick="window.location='<%= ruby_llm_agents.execution_path(execution) %>'"
118
162
  >
119
163
  <%= number_to_human_short(execution.total_cost || 0, prefix: "$", precision: 2) %>
120
164
  </td>
@@ -122,21 +166,68 @@
122
166
  <td
123
167
  class="
124
168
  px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100
125
- text-right font-medium
169
+ text-right font-medium cursor-pointer
126
170
  "
171
+ onclick="window.location='<%= ruby_llm_agents.execution_path(execution) %>'"
127
172
  >
128
173
  <%= number_with_delimiter(execution.duration_ms || 0) %>ms
129
174
  </td>
130
175
 
131
- <td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 text-right">
176
+ <td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 text-right cursor-pointer" onclick="window.location='<%= ruby_llm_agents.execution_path(execution) %>'">
132
177
  <%= time_ago_in_words(execution.created_at) %> ago
133
178
  </td>
134
179
  </tr>
135
180
 
181
+ <!-- Inline Attempts Expansion Row (hidden by default) -->
182
+ <% if has_attempts %>
183
+ <tr id="attempts-row-<%= execution.id %>" class="hidden bg-gray-50 dark:bg-gray-900">
184
+ <td colspan="9" class="px-4 py-3">
185
+ <div class="ml-6">
186
+ <h4 class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-2">Attempts Timeline</h4>
187
+ <div class="space-y-2">
188
+ <% execution.attempts.each_with_index do |attempt, idx| %>
189
+ <% is_successful = attempt["error_class"].nil? && !attempt["short_circuited"] %>
190
+ <% is_short_circuited = attempt["short_circuited"] %>
191
+ <div class="flex items-center gap-3 text-sm <%= is_successful ? 'bg-green-50 dark:bg-green-900/20 border-l-2 border-green-500' : is_short_circuited ? 'bg-gray-100 dark:bg-gray-800 border-l-2 border-gray-400' : 'bg-red-50 dark:bg-red-900/20 border-l-2 border-red-500' %> px-3 py-2 rounded-r">
192
+ <span class="font-medium text-gray-700 dark:text-gray-300 w-6">#<%= idx + 1 %></span>
193
+ <span class="text-gray-600 dark:text-gray-400 w-32 truncate" title="<%= attempt["model_id"] %>">
194
+ <%= attempt["model_id"]&.split("/")&.last || "unknown" %>
195
+ </span>
196
+ <% if is_successful %>
197
+ <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-200">
198
+ Success
199
+ </span>
200
+ <% elsif is_short_circuited %>
201
+ <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
202
+ Skipped (breaker)
203
+ </span>
204
+ <% else %>
205
+ <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-200" title="<%= attempt["error_message"] %>">
206
+ <%= attempt["error_class"]&.split("::")&.last || "Error" %>
207
+ </span>
208
+ <% end %>
209
+ <% if attempt["duration_ms"] %>
210
+ <span class="text-gray-500 dark:text-gray-400 text-xs">
211
+ <%= number_with_delimiter(attempt["duration_ms"].to_i) %>ms
212
+ </span>
213
+ <% end %>
214
+ <% if attempt["input_tokens"] || attempt["output_tokens"] %>
215
+ <span class="text-gray-500 dark:text-gray-400 text-xs">
216
+ <%= number_to_human_short((attempt["input_tokens"] || 0) + (attempt["output_tokens"] || 0)) %> tokens
217
+ </span>
218
+ <% end %>
219
+ </div>
220
+ <% end %>
221
+ </div>
222
+ </div>
223
+ </td>
224
+ </tr>
225
+ <% end %>
226
+
136
227
  <% if execution.status_error? && execution.error_message.present? %>
137
228
  <tr class="bg-red-50 dark:bg-red-900/30">
138
- <td colspan="7" class="px-4 py-2">
139
- <p class="text-xs text-red-600 dark:text-red-400">
229
+ <td colspan="9" class="px-4 py-2">
230
+ <p class="text-xs text-red-600 dark:text-red-400 ml-8">
140
231
  <span class="font-medium">
141
232
  <%= execution.error_class %>:
142
233
  </span>