ruby_llm-agents 3.11.0 → 3.13.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/ruby_llm/agents/agents_controller.rb +74 -0
- data/app/controllers/ruby_llm/agents/analytics_controller.rb +304 -0
- data/app/controllers/ruby_llm/agents/executions_controller.rb +5 -0
- data/app/controllers/ruby_llm/agents/tenants_controller.rb +74 -2
- data/app/models/ruby_llm/agents/agent_override.rb +47 -0
- data/app/models/ruby_llm/agents/execution/analytics.rb +37 -16
- data/app/models/ruby_llm/agents/execution.rb +51 -1
- data/app/services/ruby_llm/agents/agent_registry.rb +8 -1
- data/app/views/layouts/ruby_llm/agents/application.html.erb +4 -2
- data/app/views/ruby_llm/agents/agents/_config_agent.html.erb +93 -4
- data/app/views/ruby_llm/agents/agents/show.html.erb +17 -2
- data/app/views/ruby_llm/agents/analytics/index.html.erb +398 -0
- data/app/views/ruby_llm/agents/executions/_audio_player.html.erb +1 -1
- data/app/views/ruby_llm/agents/executions/_filters.html.erb +12 -8
- data/app/views/ruby_llm/agents/executions/show.html.erb +26 -12
- data/app/views/ruby_llm/agents/shared/_filter_dropdown.html.erb +46 -7
- data/app/views/ruby_llm/agents/shared/_tenant_filter.html.erb +2 -2
- data/app/views/ruby_llm/agents/system_config/show.html.erb +6 -2
- data/app/views/ruby_llm/agents/tenants/index.html.erb +3 -2
- data/app/views/ruby_llm/agents/tenants/show.html.erb +225 -0
- data/config/routes.rb +12 -4
- data/lib/generators/ruby_llm_agents/templates/create_overrides_migration.rb.tt +28 -0
- data/lib/generators/ruby_llm_agents/templates/initializer.rb.tt +27 -1
- data/lib/generators/ruby_llm_agents/templates/skills/AGENTS.md.tt +1 -1
- data/lib/generators/ruby_llm_agents/templates/skills/TOOLS.md.tt +1 -1
- data/lib/generators/ruby_llm_agents/upgrade_generator.rb +14 -0
- data/lib/ruby_llm/agents/base_agent.rb +90 -133
- data/lib/ruby_llm/agents/core/base.rb +9 -0
- data/lib/ruby_llm/agents/core/configuration.rb +93 -7
- data/lib/ruby_llm/agents/core/version.rb +1 -1
- data/lib/ruby_llm/agents/dsl/base.rb +131 -4
- data/lib/ruby_llm/agents/dsl/knowledge.rb +157 -0
- data/lib/ruby_llm/agents/dsl.rb +1 -1
- data/lib/ruby_llm/agents/image/concerns/image_operation_execution.rb +9 -5
- data/lib/ruby_llm/agents/infrastructure/retention_job.rb +118 -0
- data/lib/ruby_llm/agents/pipeline/middleware/budget.rb +32 -20
- data/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb +22 -1
- data/lib/ruby_llm/agents/pipeline/middleware/reliability.rb +1 -1
- data/lib/ruby_llm/agents/rails/engine.rb +20 -4
- data/lib/ruby_llm/agents/routing.rb +28 -5
- data/lib/ruby_llm/agents/stream_event.rb +2 -10
- data/lib/ruby_llm/agents/tool.rb +1 -1
- data/lib/ruby_llm/agents.rb +1 -3
- data/lib/tasks/ruby_llm_agents.rake +7 -0
- metadata +9 -5
- data/lib/ruby_llm/agents/agent_tool.rb +0 -143
- data/lib/ruby_llm/agents/dsl/agents.rb +0 -141
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
<%
|
|
2
|
+
s = @summary || {}
|
|
3
|
+
total_cost = s[:total_cost] || 0
|
|
4
|
+
analytics_range_presets = [
|
|
5
|
+
{value: "7d", label: "7 Days"},
|
|
6
|
+
{value: "30d", label: "30 Days"},
|
|
7
|
+
{value: "90d", label: "90 Days"}
|
|
8
|
+
]
|
|
9
|
+
# Preserve filters across range/form changes
|
|
10
|
+
filter_params = {}
|
|
11
|
+
filter_params[:agent] = @filter_agent if @filter_agent
|
|
12
|
+
filter_params[:model] = @filter_model if @filter_model
|
|
13
|
+
filter_params[:filter_tenant] = @filter_tenant if @filter_tenant
|
|
14
|
+
%>
|
|
15
|
+
|
|
16
|
+
<!-- ── header ──────────────── -->
|
|
17
|
+
<div class="flex items-center gap-4 min-w-0 mb-3">
|
|
18
|
+
<h1 class="text-[10px] font-medium text-gray-400 dark:text-gray-500 uppercase tracking-widest font-mono shrink-0">analytics</h1>
|
|
19
|
+
<%= render "ruby_llm/agents/shared/doc_link" %>
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
<!-- ── filter bar ──────────────── -->
|
|
23
|
+
<!-- Filter data (consumed by Alpine below) -->
|
|
24
|
+
<script>
|
|
25
|
+
window.__analyticsFilters = {
|
|
26
|
+
agents: <%= @available_agents.map { |a| { value: a, label: a.to_s.split("::").last } }.to_json.html_safe %>,
|
|
27
|
+
models: <%= @available_models.map { |m| { value: m, label: m.to_s.split("/").last } }.to_json.html_safe %>,
|
|
28
|
+
tenants: <%= @available_tenants.map { |tid, label| { value: tid, label: label } }.to_json.html_safe %>,
|
|
29
|
+
selected: {
|
|
30
|
+
agent: <%= @filter_agent.to_s.to_json.html_safe %>,
|
|
31
|
+
model: <%= @filter_model.to_s.to_json.html_safe %>,
|
|
32
|
+
tenant: <%= @filter_tenant.to_s.to_json.html_safe %>
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
window.__searchSelect = function(dataKey, paramName, placeholder) {
|
|
36
|
+
var cfg = window.__analyticsFilters;
|
|
37
|
+
return {
|
|
38
|
+
items: cfg[dataKey],
|
|
39
|
+
selected: cfg.selected[paramName] || '',
|
|
40
|
+
paramName: paramName,
|
|
41
|
+
placeholder: placeholder,
|
|
42
|
+
search: '',
|
|
43
|
+
open: false,
|
|
44
|
+
toggle: function() {
|
|
45
|
+
this.open = !this.open;
|
|
46
|
+
if (this.open) {
|
|
47
|
+
this.search = '';
|
|
48
|
+
var self = this;
|
|
49
|
+
this.$nextTick(function() { self.$refs.searchInput && self.$refs.searchInput.focus(); });
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
pick: function(value) {
|
|
53
|
+
this.selected = value;
|
|
54
|
+
this.open = false;
|
|
55
|
+
var self = this;
|
|
56
|
+
this.$nextTick(function() { document.getElementById('analytics-filters').submit(); });
|
|
57
|
+
},
|
|
58
|
+
label: function() {
|
|
59
|
+
if (!this.selected) return this.placeholder;
|
|
60
|
+
var s = this.selected;
|
|
61
|
+
var match = this.items.find(function(i) { return i.value === s; });
|
|
62
|
+
return match ? match.label : s;
|
|
63
|
+
},
|
|
64
|
+
filtered: function() {
|
|
65
|
+
var q = this.search.toLowerCase();
|
|
66
|
+
if (!q) return this.items;
|
|
67
|
+
return this.items.filter(function(i) { return i.label.toLowerCase().indexOf(q) >= 0 || i.value.toLowerCase().indexOf(q) >= 0; });
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
</script>
|
|
72
|
+
|
|
73
|
+
<%= form_with url: ruby_llm_agents.analytics_path, method: :get, id: "analytics-filters",
|
|
74
|
+
class: "flex flex-wrap items-center gap-2 mb-4 font-mono text-xs", data: { turbo: false } do %>
|
|
75
|
+
<input type="hidden" name="range" value="<%= @selected_range %>">
|
|
76
|
+
<% if @selected_range == "custom" %>
|
|
77
|
+
<input type="hidden" name="from" value="<%= @custom_from %>">
|
|
78
|
+
<input type="hidden" name="to" value="<%= @custom_to %>">
|
|
79
|
+
<% end %>
|
|
80
|
+
|
|
81
|
+
<%# Range selector (inline with filters) %>
|
|
82
|
+
<div class="relative" x-data="{ open: false, showCustom: false }" @click.outside="open = false; showCustom = false">
|
|
83
|
+
<button type="button" @click.stop="open = !open" class="flex items-center gap-1 w-32 px-2 py-1 bg-transparent border border-gray-200 dark:border-gray-800 rounded text-gray-900 dark:text-gray-100 focus:ring-1 focus:ring-gray-400 dark:focus:ring-gray-600">
|
|
84
|
+
<% if @selected_range == "custom" && @custom_from && @custom_to %>
|
|
85
|
+
<span class="flex-1 truncate"><%= @custom_from.strftime("%b %-d") %> – <%= @custom_to.strftime("%b %-d") %></span>
|
|
86
|
+
<% else %>
|
|
87
|
+
<span class="flex-1"><%= range_display_name(@selected_range) %></span>
|
|
88
|
+
<% end %>
|
|
89
|
+
<svg class="w-3 h-3 text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
|
|
90
|
+
</button>
|
|
91
|
+
<div x-show="open" x-cloak x-transition class="absolute left-0 mt-1 w-48 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded shadow-lg z-50 py-1">
|
|
92
|
+
<% analytics_range_presets.each do |preset| %>
|
|
93
|
+
<%= link_to preset[:label], ruby_llm_agents.analytics_path(filter_params.merge(range: preset[:value])),
|
|
94
|
+
class: "block px-3 py-1.5 #{@selected_range == preset[:value] ? 'text-gray-900 dark:text-gray-100 bg-gray-100 dark:bg-gray-800' : 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-800/50'}" %>
|
|
95
|
+
<% end %>
|
|
96
|
+
<div class="border-t border-gray-200 dark:border-gray-700 my-1"></div>
|
|
97
|
+
<button type="button" @click.stop="showCustom = !showCustom" class="w-full text-left px-3 py-1.5 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-800/50">Custom range…</button>
|
|
98
|
+
<div x-show="showCustom" x-cloak class="px-3 py-2 border-t border-gray-200 dark:border-gray-700" x-data="{ cfrom: '<%= @custom_from %>', cto: '<%= @custom_to %>' }">
|
|
99
|
+
<label class="block text-[10px] text-gray-400 dark:text-gray-500 uppercase">From</label>
|
|
100
|
+
<input type="date" x-model="cfrom" max="<%= Date.current %>" class="w-full px-2 py-1 text-xs bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded text-gray-900 dark:text-gray-100 font-mono">
|
|
101
|
+
<label class="block text-[10px] text-gray-400 dark:text-gray-500 uppercase mt-2">To</label>
|
|
102
|
+
<input type="date" x-model="cto" max="<%= Date.current %>" class="w-full px-2 py-1 text-xs bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded text-gray-900 dark:text-gray-100 font-mono">
|
|
103
|
+
<button type="button" @click="window.location = '<%= ruby_llm_agents.analytics_path %>' + '?range=custom&from=' + cfrom + '&to=' + cto + '<%= filter_params.map { |k, v| "&#{k}=#{ERB::Util.url_encode(v)}" }.join %>'"
|
|
104
|
+
class="w-full mt-2 px-2 py-1 text-xs bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded hover:bg-gray-700 dark:hover:bg-gray-300">Apply</button>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
<%
|
|
110
|
+
dropdowns = [
|
|
111
|
+
{ key: "agents", param: "agent", placeholder: "All agents" },
|
|
112
|
+
{ key: "models", param: "model", placeholder: "All models" }
|
|
113
|
+
]
|
|
114
|
+
dropdowns << { key: "tenants", param: "tenant", placeholder: "All tenants", name: "filter_tenant" } if @available_tenants.any?
|
|
115
|
+
%>
|
|
116
|
+
|
|
117
|
+
<% dropdowns.each do |dd| %>
|
|
118
|
+
<div class="relative w-44" x-data="__searchSelect('<%= dd[:key] %>', '<%= dd[:param] %>', '<%= dd[:placeholder] %>')" @click.outside="open = false">
|
|
119
|
+
<input type="hidden" name="<%= dd[:name] || dd[:param] %>" :value="selected">
|
|
120
|
+
<button type="button" @click.stop="toggle()" class="flex items-center gap-1 w-44 px-2 py-1 bg-transparent border border-gray-200 dark:border-gray-800 rounded text-left text-gray-900 dark:text-gray-100 focus:ring-1 focus:ring-gray-400 dark:focus:ring-gray-600 truncate">
|
|
121
|
+
<span x-text="label()" class="flex-1 truncate"></span>
|
|
122
|
+
<svg class="w-3 h-3 text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
|
|
123
|
+
</button>
|
|
124
|
+
<div x-show="open" class="absolute left-0 mt-1 w-56 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded shadow-lg z-50">
|
|
125
|
+
<div class="p-1.5">
|
|
126
|
+
<input type="text" x-model="search" x-ref="searchInput" placeholder="Search..." autocomplete="off"
|
|
127
|
+
class="w-full px-2 py-1 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded text-xs text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-600 focus:outline-none focus:ring-1 focus:ring-gray-400">
|
|
128
|
+
</div>
|
|
129
|
+
<div class="max-h-48 overflow-y-auto py-1">
|
|
130
|
+
<button type="button" @click="pick('')"
|
|
131
|
+
class="block w-full text-left px-3 py-1.5 text-xs hover:bg-gray-100 dark:hover:bg-gray-800"
|
|
132
|
+
:class="selected === '' ? 'text-gray-900 dark:text-gray-100 bg-gray-100 dark:bg-gray-800' : 'text-gray-500 dark:text-gray-400'">
|
|
133
|
+
<%= dd[:placeholder] %>
|
|
134
|
+
</button>
|
|
135
|
+
<template x-for="item in filtered()" :key="item.value">
|
|
136
|
+
<button type="button" @click="pick(item.value)"
|
|
137
|
+
class="block w-full text-left px-3 py-1.5 text-xs truncate hover:bg-gray-100 dark:hover:bg-gray-800"
|
|
138
|
+
:class="selected === item.value ? 'text-gray-900 dark:text-gray-100 bg-gray-100 dark:bg-gray-800' : 'text-gray-500 dark:text-gray-400'"
|
|
139
|
+
x-text="item.label"></button>
|
|
140
|
+
</template>
|
|
141
|
+
<div x-show="filtered().length === 0" class="px-3 py-1.5 text-xs text-gray-400 dark:text-gray-600">no matches</div>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
<% end %>
|
|
146
|
+
|
|
147
|
+
<% if @any_filter %>
|
|
148
|
+
<%= link_to "clear", ruby_llm_agents.analytics_path(range: @selected_range,
|
|
149
|
+
from: @custom_from, to: @custom_to),
|
|
150
|
+
class: "text-gray-400 dark:text-gray-600 hover:text-gray-700 dark:hover:text-gray-300" %>
|
|
151
|
+
<% end %>
|
|
152
|
+
<% end %>
|
|
153
|
+
|
|
154
|
+
<!-- ── summary strip ──────────────── -->
|
|
155
|
+
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 font-mono text-xs mb-2">
|
|
156
|
+
<div class="space-y-0.5">
|
|
157
|
+
<div class="text-[10px] text-gray-400 dark:text-gray-600 uppercase tracking-wider">total cost</div>
|
|
158
|
+
<div class="text-gray-900 dark:text-gray-200">$<%= number_with_precision(s[:total_cost] || 0, precision: 4) %></div>
|
|
159
|
+
<% if s[:cost_change] && s[:cost_change] != 0 %>
|
|
160
|
+
<div class="<%= s[:cost_change] > 0 ? 'text-red-500' : 'text-green-500' %> text-[10px]">
|
|
161
|
+
<%= s[:cost_change] > 0 ? "+" : "" %><%= s[:cost_change] %>% vs prior
|
|
162
|
+
</div>
|
|
163
|
+
<% elsif s[:prior_cost] %>
|
|
164
|
+
<div class="text-gray-400 dark:text-gray-600 text-[10px]">— vs prior</div>
|
|
165
|
+
<% end %>
|
|
166
|
+
</div>
|
|
167
|
+
<div class="space-y-0.5">
|
|
168
|
+
<div class="text-[10px] text-gray-400 dark:text-gray-600 uppercase tracking-wider">avg cost/run</div>
|
|
169
|
+
<div class="text-gray-900 dark:text-gray-200">$<%= number_with_precision(s[:avg_cost] || 0, precision: 4) %></div>
|
|
170
|
+
<div class="text-gray-400 dark:text-gray-600 text-[10px]"><%= number_with_delimiter(s[:avg_tokens] || 0) %> tok/run</div>
|
|
171
|
+
</div>
|
|
172
|
+
<div class="space-y-0.5">
|
|
173
|
+
<div class="text-[10px] text-gray-400 dark:text-gray-600 uppercase tracking-wider">runs</div>
|
|
174
|
+
<div class="text-gray-900 dark:text-gray-200"><%= number_with_delimiter(s[:total_runs] || 0) %></div>
|
|
175
|
+
<% if s[:runs_change] && s[:runs_change] != 0 %>
|
|
176
|
+
<div class="<%= s[:runs_change] > 0 ? 'text-gray-500' : 'text-gray-500' %> text-[10px]">
|
|
177
|
+
<%= s[:runs_change] > 0 ? "+" : "" %><%= s[:runs_change] %>% vs prior
|
|
178
|
+
</div>
|
|
179
|
+
<% end %>
|
|
180
|
+
</div>
|
|
181
|
+
<div class="space-y-0.5">
|
|
182
|
+
<div class="text-[10px] text-gray-400 dark:text-gray-600 uppercase tracking-wider">tokens</div>
|
|
183
|
+
<div class="text-gray-900 dark:text-gray-200"><%= number_with_delimiter(s[:total_tokens] || 0) %></div>
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
<!-- ── projection + savings ──────────────── -->
|
|
188
|
+
<div class="font-mono text-xs space-y-1 mb-2">
|
|
189
|
+
<% if @projection %>
|
|
190
|
+
<div class="text-gray-500 dark:text-gray-400">
|
|
191
|
+
Burning <span class="text-gray-800 dark:text-gray-200">$<%= number_with_precision(@projection[:daily_rate], precision: 4) %>/day</span>
|
|
192
|
+
<% if @projection[:days_left] > 0 %>
|
|
193
|
+
· projected <span class="text-gray-800 dark:text-gray-200">$<%= number_with_precision(@projection[:projected_month], precision: 2) %></span> by end of month
|
|
194
|
+
(<%= @projection[:days_left] %> days left)
|
|
195
|
+
<% end %>
|
|
196
|
+
</div>
|
|
197
|
+
<% end %>
|
|
198
|
+
|
|
199
|
+
<% if @savings %>
|
|
200
|
+
<div class="text-gray-500 dark:text-gray-400">
|
|
201
|
+
<span class="text-green-500">Save ~$<%= number_with_precision(@savings[:potential_savings], precision: 2) %></span>
|
|
202
|
+
by moving <span class="text-gray-800 dark:text-gray-200"><%= @savings[:expensive_runs] %></span> runs
|
|
203
|
+
from <span class="text-gray-800 dark:text-gray-200"><%= @savings[:expensive_model].to_s.split("/").last %></span>
|
|
204
|
+
($<%= number_with_precision(@savings[:expensive_cost_per_run], precision: 4) %>/run)
|
|
205
|
+
to <span class="text-gray-800 dark:text-gray-200"><%= @savings[:cheap_model].to_s.split("/").last %></span>
|
|
206
|
+
($<%= number_with_precision(@savings[:cheap_cost_per_run], precision: 4) %>/run)
|
|
207
|
+
</div>
|
|
208
|
+
<% end %>
|
|
209
|
+
|
|
210
|
+
<% if @error_total_count > 0 && total_cost > 0 %>
|
|
211
|
+
<div class="text-gray-500 dark:text-gray-400">
|
|
212
|
+
<span class="text-red-500">$<%= number_with_precision(@error_total_cost, precision: 4) %></span>
|
|
213
|
+
wasted on <span class="text-red-500"><%= number_with_delimiter(@error_total_count) %></span> failed runs
|
|
214
|
+
(<%= (@error_total_cost.to_f / total_cost * 100).round(1) %>% of spend)
|
|
215
|
+
</div>
|
|
216
|
+
<% end %>
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
<div class="border-t border-gray-200 dark:border-gray-800 mb-2"></div>
|
|
220
|
+
|
|
221
|
+
<!-- ── cost over time (current + prior overlay) ──────────────── -->
|
|
222
|
+
<div class="flex items-center gap-3 mt-4 mb-2">
|
|
223
|
+
<span class="text-[10px] font-medium text-gray-400 dark:text-gray-600 uppercase tracking-widest font-mono">cost over time</span>
|
|
224
|
+
<span class="text-[10px] font-mono text-gray-400 dark:text-gray-600">solid = current · dashed = prior period</span>
|
|
225
|
+
<div class="flex-1 border-t border-gray-200 dark:border-gray-800"></div>
|
|
226
|
+
</div>
|
|
227
|
+
<div id="analytics-cost-chart" style="width: 100%; height: 200px;"></div>
|
|
228
|
+
|
|
229
|
+
<!-- ── model efficiency ──────────────── -->
|
|
230
|
+
<% if @efficiency.present? %>
|
|
231
|
+
<div class="flex items-center gap-3 mt-6 mb-3">
|
|
232
|
+
<span class="text-[10px] font-medium text-gray-400 dark:text-gray-600 uppercase tracking-widest font-mono">model efficiency</span>
|
|
233
|
+
<div class="flex-1 border-t border-gray-200 dark:border-gray-800"></div>
|
|
234
|
+
</div>
|
|
235
|
+
|
|
236
|
+
<div class="flex items-center gap-3 px-2 -mx-2 font-mono text-[10px] text-gray-400 dark:text-gray-600 uppercase tracking-wider mb-1">
|
|
237
|
+
<span class="flex-[2] min-w-0">model</span>
|
|
238
|
+
<span class="w-12 flex-shrink-0 text-right">runs</span>
|
|
239
|
+
<span class="w-20 flex-shrink-0 text-right">cost</span>
|
|
240
|
+
<span class="w-20 flex-shrink-0 text-right">$/1k tok</span>
|
|
241
|
+
<span class="w-20 flex-shrink-0 text-right hidden sm:block">avg dur</span>
|
|
242
|
+
<span class="w-12 flex-shrink-0 text-right">ok%</span>
|
|
243
|
+
<span class="w-10 flex-shrink-0 text-right">%</span>
|
|
244
|
+
</div>
|
|
245
|
+
<div class="font-mono text-xs space-y-px">
|
|
246
|
+
<% @efficiency.each do |model| %>
|
|
247
|
+
<div class="flex items-center gap-3 py-1 px-2 -mx-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800/50">
|
|
248
|
+
<span class="flex-[2] min-w-0 truncate text-gray-900 dark:text-gray-200"><%= model[:model_id].to_s.split("/").last.truncate(28) %></span>
|
|
249
|
+
<span class="w-12 flex-shrink-0 text-right text-gray-500 dark:text-gray-400"><%= number_with_delimiter(model[:executions]) %></span>
|
|
250
|
+
<span class="w-20 flex-shrink-0 text-right text-gray-800 dark:text-gray-200">$<%= number_with_precision(model[:total_cost], precision: 4) %></span>
|
|
251
|
+
<span class="w-20 flex-shrink-0 text-right text-gray-500 dark:text-gray-400">$<%= number_with_precision(model[:cost_per_1k_tokens], precision: 4) %></span>
|
|
252
|
+
<span class="w-20 flex-shrink-0 text-right text-gray-500 dark:text-gray-400 hidden sm:block"><%= format_duration_ms(model[:avg_duration_ms]) %></span>
|
|
253
|
+
<span class="w-12 flex-shrink-0 text-right <%= model[:success_rate] >= 95 ? 'text-green-500' : model[:success_rate] >= 80 ? 'text-yellow-500' : 'text-red-500' %>"><%= model[:success_rate] %>%</span>
|
|
254
|
+
<span class="w-10 flex-shrink-0 text-right text-gray-400 dark:text-gray-600"><%= model[:cost_percentage] %>%</span>
|
|
255
|
+
</div>
|
|
256
|
+
<% end %>
|
|
257
|
+
</div>
|
|
258
|
+
<% end %>
|
|
259
|
+
|
|
260
|
+
<!-- ── error cost breakdown ──────────────── -->
|
|
261
|
+
<% if @error_breakdown.present? %>
|
|
262
|
+
<div class="flex items-center gap-3 mt-6 mb-3">
|
|
263
|
+
<span class="text-[10px] font-medium text-gray-400 dark:text-gray-600 uppercase tracking-widest font-mono">error cost breakdown</span>
|
|
264
|
+
<span class="text-[10px] font-mono text-red-400 dark:text-red-500/70">($<%= number_with_precision(@error_total_cost, precision: 4) %>)</span>
|
|
265
|
+
<div class="flex-1 border-t border-gray-200 dark:border-gray-800"></div>
|
|
266
|
+
</div>
|
|
267
|
+
|
|
268
|
+
<div class="flex items-center gap-3 px-2 -mx-2 font-mono text-[10px] text-gray-400 dark:text-gray-600 uppercase tracking-wider mb-1">
|
|
269
|
+
<span class="flex-[2] min-w-0">error</span>
|
|
270
|
+
<span class="w-28 flex-shrink-0 hidden sm:block">agent</span>
|
|
271
|
+
<span class="w-10 flex-shrink-0 text-right">count</span>
|
|
272
|
+
<span class="w-20 flex-shrink-0 text-right">cost</span>
|
|
273
|
+
<span class="w-24 flex-shrink-0 text-right">last seen</span>
|
|
274
|
+
</div>
|
|
275
|
+
<div class="font-mono text-xs space-y-px">
|
|
276
|
+
<% @error_breakdown.each do |err| %>
|
|
277
|
+
<div class="flex items-center gap-3 py-1 px-2 -mx-2">
|
|
278
|
+
<span class="w-1.5 h-1.5 rounded-full bg-red-500 flex-shrink-0"></span>
|
|
279
|
+
<span class="flex-[2] min-w-0 truncate text-gray-900 dark:text-gray-200"><%= err[:error_class].to_s.split("::").last %></span>
|
|
280
|
+
<span class="w-28 flex-shrink-0 truncate text-gray-500 dark:text-gray-400 hidden sm:block"><%= err[:agent_type].to_s.split("::").last %></span>
|
|
281
|
+
<span class="w-10 flex-shrink-0 text-right text-red-500"><%= err[:count] %></span>
|
|
282
|
+
<span class="w-20 flex-shrink-0 text-right text-gray-800 dark:text-gray-200">$<%= number_with_precision(err[:cost], precision: 4) %></span>
|
|
283
|
+
<span class="w-24 flex-shrink-0 text-right text-gray-400 dark:text-gray-600 whitespace-nowrap"><%= err[:last_seen] ? time_ago_in_words(err[:last_seen]) : "—" %></span>
|
|
284
|
+
</div>
|
|
285
|
+
<% end %>
|
|
286
|
+
</div>
|
|
287
|
+
<% end %>
|
|
288
|
+
|
|
289
|
+
<!-- ── charts ──────────────── -->
|
|
290
|
+
<script>
|
|
291
|
+
(function() {
|
|
292
|
+
var chartUrl = '<%= ruby_llm_agents.analytics_chart_data_path %>';
|
|
293
|
+
var params = new URLSearchParams({
|
|
294
|
+
range: '<%= @selected_range %>'
|
|
295
|
+
});
|
|
296
|
+
<% if @selected_range == "custom" && @custom_from && @custom_to %>
|
|
297
|
+
params.set('from', '<%= @custom_from %>');
|
|
298
|
+
params.set('to', '<%= @custom_to %>');
|
|
299
|
+
<% end %>
|
|
300
|
+
<% if @filter_agent %>params.set('agent', '<%= j @filter_agent %>');<% end %>
|
|
301
|
+
<% if @filter_model %>params.set('model', '<%= j @filter_model %>');<% end %>
|
|
302
|
+
<% if @filter_tenant %>params.set('filter_tenant', '<%= j @filter_tenant %>');<% end %>
|
|
303
|
+
chartUrl += '?' + params.toString();
|
|
304
|
+
|
|
305
|
+
function renderCostChart(data) {
|
|
306
|
+
if (!data.series || data.series.length === 0 || (data.series[0].data.length === 0 && data.series[1].data.length === 0)) {
|
|
307
|
+
document.getElementById('analytics-cost-chart').innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;font-family:ui-monospace,monospace;font-size:11px;color:' + chartColor('#9CA3AF', '#928374') + '">no data for this period</div>';
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
var series = [];
|
|
312
|
+
var s0 = data.series[0]; // current
|
|
313
|
+
var s1 = data.series[1]; // prior
|
|
314
|
+
|
|
315
|
+
series.push({
|
|
316
|
+
name: s0.name,
|
|
317
|
+
type: s0.type || 'areaspline',
|
|
318
|
+
data: s0.data,
|
|
319
|
+
color: chartColor('#8B5CF6', '#d3869b'),
|
|
320
|
+
lineWidth: 2,
|
|
321
|
+
marker: { enabled: false, states: { hover: { enabled: true, radius: 3 } } },
|
|
322
|
+
fillColor: { linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 },
|
|
323
|
+
stops: [[0, chartColorAlpha('rgba(139, 92, 246, 0.15)', 211, 134, 155, 0.15)], [1, chartColorAlpha('rgba(139, 92, 246, 0)', 211, 134, 155, 0)]] }
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
if (s1 && s1.data && s1.data.length > 0) {
|
|
327
|
+
series.push({
|
|
328
|
+
name: s1.name,
|
|
329
|
+
type: s1.type || 'spline',
|
|
330
|
+
data: s1.data,
|
|
331
|
+
dashStyle: s1.dashStyle || 'Dash',
|
|
332
|
+
color: chartColorAlpha('rgba(107, 114, 128, 0.5)', 146, 131, 116, 0.5),
|
|
333
|
+
lineWidth: 1.5,
|
|
334
|
+
marker: { enabled: false, states: { hover: { enabled: true, radius: 2 } } }
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
Highcharts.chart('analytics-cost-chart', {
|
|
339
|
+
chart: { backgroundColor: 'transparent', spacing: [5, 0, 0, 0] },
|
|
340
|
+
title: { text: null },
|
|
341
|
+
xAxis: {
|
|
342
|
+
type: 'datetime',
|
|
343
|
+
labels: { style: { color: chartColor('#6B7280', '#928374'), fontSize: '9px', fontFamily: 'ui-monospace, monospace' }, format: '{value:%b %d}' },
|
|
344
|
+
lineColor: 'transparent', tickLength: 0, gridLineWidth: 0
|
|
345
|
+
},
|
|
346
|
+
yAxis: {
|
|
347
|
+
title: { text: null }, min: 0,
|
|
348
|
+
labels: { style: { color: chartColor('#6B7280', '#928374'), fontSize: '9px', fontFamily: 'ui-monospace, monospace' }, format: '${value}' },
|
|
349
|
+
gridLineColor: chartColorAlpha('rgba(107, 114, 128, 0.08)', 146, 131, 116, 0.08)
|
|
350
|
+
},
|
|
351
|
+
legend: {
|
|
352
|
+
enabled: true, align: 'left', verticalAlign: 'top', floating: true, x: 0, y: -5,
|
|
353
|
+
itemStyle: { color: chartColor('#6B7280', '#928374'), fontSize: '9px', fontFamily: 'ui-monospace, monospace', fontWeight: 'normal' },
|
|
354
|
+
symbolHeight: 8, symbolWidth: 12, symbolRadius: 2
|
|
355
|
+
},
|
|
356
|
+
credits: { enabled: false },
|
|
357
|
+
tooltip: {
|
|
358
|
+
backgroundColor: chartColorAlpha('rgba(0, 0, 0, 0.85)', 40, 40, 40, 0.95),
|
|
359
|
+
borderColor: 'transparent', borderRadius: 3,
|
|
360
|
+
style: { color: chartColor('#E5E7EB', '#ebdbb2'), fontSize: '10px', fontFamily: 'ui-monospace, monospace' },
|
|
361
|
+
shared: true,
|
|
362
|
+
formatter: function() {
|
|
363
|
+
var html = '<span style="color:' + chartColor('#9CA3AF', '#bdae93') + '">' + Highcharts.dateFormat('%b %d', this.x) + '</span>';
|
|
364
|
+
this.points.forEach(function(p) {
|
|
365
|
+
var style = p.series.options.dashStyle === 'Dash' ? ' (prior)' : '';
|
|
366
|
+
html += '<br/><span style="color:' + p.color + '">' + p.series.name + '</span>: <b>$' + p.y.toFixed(4) + '</b>';
|
|
367
|
+
});
|
|
368
|
+
return html;
|
|
369
|
+
}
|
|
370
|
+
},
|
|
371
|
+
plotOptions: {
|
|
372
|
+
areaspline: { marker: { enabled: false } },
|
|
373
|
+
spline: { marker: { enabled: false } }
|
|
374
|
+
},
|
|
375
|
+
series: series
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function renderAll() {
|
|
380
|
+
window.__initHighchartsDefaults && window.__initHighchartsDefaults();
|
|
381
|
+
fetch(chartUrl)
|
|
382
|
+
.then(function(r) { return r.json(); })
|
|
383
|
+
.then(renderCostChart)
|
|
384
|
+
.catch(function() {
|
|
385
|
+
document.getElementById('analytics-cost-chart').innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;font-family:ui-monospace,monospace;font-size:11px;color:' + chartColor('#9CA3AF', '#928374') + '">failed to load chart</div>';
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function waitForHighcharts(attempts) {
|
|
390
|
+
if (typeof Highcharts !== 'undefined') { renderAll(); }
|
|
391
|
+
else if (attempts > 0) { setTimeout(function() { waitForHighcharts(attempts - 1); }, 100); }
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
document.readyState === 'loading'
|
|
395
|
+
? document.addEventListener('DOMContentLoaded', function() { waitForHighcharts(50); })
|
|
396
|
+
: waitForHighcharts(50);
|
|
397
|
+
})();
|
|
398
|
+
</script>
|
|
@@ -3,14 +3,15 @@
|
|
|
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
5
|
selected_models = params[:model_ids].present? ? (params[:model_ids].is_a?(Array) ? params[:model_ids] : params[:model_ids].split(",")) : []
|
|
6
|
+
selected_tenants = params[:tenant_ids].present? ? (params[:tenant_ids].is_a?(Array) ? params[:tenant_ids] : params[:tenant_ids].split(",")) : []
|
|
6
7
|
|
|
7
|
-
has_filters = selected_agents.any? || selected_statuses.any? || params[:days].present? || selected_models.any? ||
|
|
8
|
+
has_filters = selected_agents.any? || selected_statuses.any? || params[:days].present? || selected_models.any? || selected_tenants.any?
|
|
8
9
|
active_filter_count = [
|
|
9
10
|
selected_agents.any? ? 1 : 0,
|
|
10
11
|
selected_statuses.any? ? 1 : 0,
|
|
11
12
|
params[:days].present? ? 1 : 0,
|
|
12
13
|
selected_models.any? ? 1 : 0,
|
|
13
|
-
|
|
14
|
+
selected_tenants.any? ? 1 : 0
|
|
14
15
|
].sum
|
|
15
16
|
|
|
16
17
|
status_options = [
|
|
@@ -89,11 +90,14 @@
|
|
|
89
90
|
<%# Tenant Filter %>
|
|
90
91
|
<% if tenant_filter_enabled? && available_tenants.any? %>
|
|
91
92
|
<div class="md:w-auto">
|
|
92
|
-
<%= render "ruby_llm/agents/shared/
|
|
93
|
-
name: "
|
|
94
|
-
filter_id: "
|
|
95
|
-
|
|
96
|
-
|
|
93
|
+
<%= render "ruby_llm/agents/shared/filter_dropdown",
|
|
94
|
+
name: "tenant_ids[]",
|
|
95
|
+
filter_id: "tenant_ids",
|
|
96
|
+
label: "Tenants",
|
|
97
|
+
all_label: "All Tenants",
|
|
98
|
+
options: available_tenants,
|
|
99
|
+
selected: selected_tenants,
|
|
100
|
+
searchable: true,
|
|
97
101
|
icon: "M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4",
|
|
98
102
|
width: "w-40",
|
|
99
103
|
full_width: true %>
|
|
@@ -173,7 +177,7 @@
|
|
|
173
177
|
<span class="md:hidden">Clear</span>
|
|
174
178
|
<% end %>
|
|
175
179
|
<% end %>
|
|
176
|
-
<%= 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,
|
|
180
|
+
<%= 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, tenant_ids: selected_tenants.presence),
|
|
177
181
|
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",
|
|
178
182
|
title: "Export CSV" do %>
|
|
179
183
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
@@ -57,6 +57,16 @@
|
|
|
57
57
|
</div>
|
|
58
58
|
<% end %>
|
|
59
59
|
|
|
60
|
+
<!-- Soft-purge banner -->
|
|
61
|
+
<% if @execution.soft_purged? %>
|
|
62
|
+
<div class="flex items-start gap-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded px-3 py-2 mb-2">
|
|
63
|
+
<span class="text-amber-600 dark:text-amber-400 text-xs font-mono leading-5">
|
|
64
|
+
⚠ Execution details were purged<% if @execution.soft_purged_at %> on <%= @execution.soft_purged_at.strftime("%b %d, %Y") %><% end %>.
|
|
65
|
+
Cost, token, and timing analytics are preserved.
|
|
66
|
+
</span>
|
|
67
|
+
</div>
|
|
68
|
+
<% end %>
|
|
69
|
+
|
|
60
70
|
<!-- Stats inline row -->
|
|
61
71
|
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 font-mono text-xs text-gray-400 dark:text-gray-500 mb-2">
|
|
62
72
|
<span><span class="text-gray-800 dark:text-gray-200"><%= number_to_human_short(@execution.duration_ms || 0) %>ms</span> duration</span>
|
|
@@ -70,8 +80,8 @@
|
|
|
70
80
|
<!-- ── audio player ──────────────────── -->
|
|
71
81
|
<% if @execution.agent_type.to_s.match?(/Speaker|Narrator|Transcriber/i) ||
|
|
72
82
|
@execution.metadata&.dig("audio_duration_seconds").present? ||
|
|
73
|
-
@execution.
|
|
74
|
-
@execution.
|
|
83
|
+
@execution.response_hash["audio_data_uri"].present? ||
|
|
84
|
+
@execution.response_hash["audio_url"].present? %>
|
|
75
85
|
<%= render "ruby_llm/agents/executions/audio_player" %>
|
|
76
86
|
<% end %>
|
|
77
87
|
|
|
@@ -254,16 +264,18 @@
|
|
|
254
264
|
<% end %>
|
|
255
265
|
|
|
256
266
|
<!-- ── parameters ──────────────────── -->
|
|
257
|
-
|
|
258
|
-
<
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
</
|
|
266
|
-
|
|
267
|
+
<% if @execution.detail && @execution.parameters.present? %>
|
|
268
|
+
<div class="flex items-center gap-3 mt-6 mb-3">
|
|
269
|
+
<span class="text-[10px] font-medium text-gray-400 dark:text-gray-600 uppercase tracking-widest font-mono">parameters</span>
|
|
270
|
+
<div class="flex-1 border-t border-gray-200 dark:border-gray-800"></div>
|
|
271
|
+
<button
|
|
272
|
+
type="button"
|
|
273
|
+
data-copy-json="<%= Base64.strict_encode64(JSON.pretty_generate(@execution.parameters)) %>"
|
|
274
|
+
class="copy-json-btn font-mono text-xs text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
|
275
|
+
>copy</button>
|
|
276
|
+
</div>
|
|
277
|
+
<pre class="bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 rounded p-4 text-xs overflow-x-auto font-mono"><%= highlight_json(@execution.parameters) %></pre>
|
|
278
|
+
<% end %>
|
|
267
279
|
|
|
268
280
|
<!-- ── response ──────────────────────── -->
|
|
269
281
|
<% if @execution.response.present? %>
|
|
@@ -280,6 +292,7 @@
|
|
|
280
292
|
<% end %>
|
|
281
293
|
|
|
282
294
|
<!-- ── tool calls ──────────────────── -->
|
|
295
|
+
<% if @execution.detail %>
|
|
283
296
|
<% tool_calls = @execution.tool_calls || [] %>
|
|
284
297
|
<% tool_call_count = tool_calls.size %>
|
|
285
298
|
<div x-data="{ expanded: <%= tool_call_count <= 3 && tool_call_count > 0 %> }">
|
|
@@ -374,6 +387,7 @@
|
|
|
374
387
|
<p class="text-xs text-gray-400 dark:text-gray-600 font-mono italic">no tool calls</p>
|
|
375
388
|
<% end %>
|
|
376
389
|
</div>
|
|
390
|
+
<% end %>
|
|
377
391
|
|
|
378
392
|
<!-- ── metadata ──────────────────────── -->
|
|
379
393
|
<% if @execution.metadata.present? && @execution.metadata.any? %>
|
|
@@ -10,11 +10,13 @@
|
|
|
10
10
|
# { value: "success", label: "Success", color: "bg-green-500" },
|
|
11
11
|
# { value: "error", label: "Error", color: "bg-red-500" }
|
|
12
12
|
# ],
|
|
13
|
-
# selected: ["success"]
|
|
13
|
+
# selected: ["success"],
|
|
14
|
+
# searchable: true # renders a client-side search input above the options
|
|
14
15
|
|
|
15
16
|
selected = local_assigns[:selected] || []
|
|
16
17
|
show_all = local_assigns.fetch(:show_all_option, true)
|
|
17
18
|
all_label = local_assigns[:all_label] || "All"
|
|
19
|
+
searchable = local_assigns.fetch(:searchable, false)
|
|
18
20
|
|
|
19
21
|
current_label = if selected.empty?
|
|
20
22
|
label
|
|
@@ -32,8 +34,31 @@
|
|
|
32
34
|
|
|
33
35
|
has_selection = selected.any?
|
|
34
36
|
%>
|
|
35
|
-
|
|
36
|
-
|
|
37
|
+
<%#
|
|
38
|
+
Multi-select behavior:
|
|
39
|
+
- Checkbox click: toggle that option, mark dirty, keep dropdown open.
|
|
40
|
+
- Label click: clear other selections, check only this one, close, submit.
|
|
41
|
+
- Close dropdown (outside click, button toggle, Escape): if dirty, submit.
|
|
42
|
+
This batches checkbox picks into a single request when the user closes
|
|
43
|
+
the dropdown — no reliance on persisting open state across reload.
|
|
44
|
+
%>
|
|
45
|
+
<div class="relative"
|
|
46
|
+
x-data="{
|
|
47
|
+
open: false,
|
|
48
|
+
query: '',
|
|
49
|
+
dirty: false,
|
|
50
|
+
closeAndApply() {
|
|
51
|
+
this.open = false;
|
|
52
|
+
if (this.dirty) {
|
|
53
|
+
this.dirty = false;
|
|
54
|
+
this.$el.closest('form').requestSubmit();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}"
|
|
58
|
+
@click.outside="closeAndApply()"
|
|
59
|
+
@keydown.escape.window="if (open) closeAndApply()"
|
|
60
|
+
data-filter="<%= filter_id %>">
|
|
61
|
+
<button type="button" @click="open ? closeAndApply() : open = true"
|
|
37
62
|
class="flex items-center gap-1.5 px-2 py-1 font-mono text-xs rounded
|
|
38
63
|
hover:bg-gray-100 dark:hover:bg-gray-800/50 transition-colors
|
|
39
64
|
<%= has_selection ? 'text-gray-900 dark:text-gray-100' : 'text-gray-400 dark:text-gray-500' %>">
|
|
@@ -50,7 +75,19 @@
|
|
|
50
75
|
x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100"
|
|
51
76
|
x-transition:leave="transition ease-in duration-75"
|
|
52
77
|
x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
|
|
53
|
-
class="absolute z-20 mt-1 min-w-max bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1
|
|
78
|
+
class="absolute z-20 mt-1 min-w-max w-56 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1">
|
|
79
|
+
|
|
80
|
+
<% if searchable %>
|
|
81
|
+
<div class="px-2 py-1.5 border-b border-gray-100 dark:border-gray-700 sticky top-0 bg-white dark:bg-gray-800">
|
|
82
|
+
<input type="text" x-model="query" @click.stop
|
|
83
|
+
placeholder="Search…"
|
|
84
|
+
x-ref="<%= filter_id %>_search"
|
|
85
|
+
x-effect="if (open) $nextTick(() => $refs['<%= filter_id %>_search']?.focus())"
|
|
86
|
+
class="w-full px-2 py-1 text-xs font-mono rounded border border-gray-200 dark:border-gray-700 bg-transparent text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500">
|
|
87
|
+
</div>
|
|
88
|
+
<% end %>
|
|
89
|
+
|
|
90
|
+
<div class="max-h-56 overflow-y-auto">
|
|
54
91
|
|
|
55
92
|
<% if show_all %>
|
|
56
93
|
<div class="px-3 py-1.5 border-b border-gray-100 dark:border-gray-700">
|
|
@@ -60,7 +97,7 @@
|
|
|
60
97
|
<%= selected.empty? ? 'checked' : '' %>
|
|
61
98
|
@change="
|
|
62
99
|
$el.closest('[data-filter]').querySelectorAll('.filter-cb').forEach(c => c.checked = false);
|
|
63
|
-
|
|
100
|
+
dirty = true;
|
|
64
101
|
">
|
|
65
102
|
<span class="text-gray-700 dark:text-gray-200"><%= all_label %></span>
|
|
66
103
|
</label>
|
|
@@ -68,13 +105,14 @@
|
|
|
68
105
|
<% end %>
|
|
69
106
|
|
|
70
107
|
<% options.each do |option| %>
|
|
71
|
-
<div class="flex items-center gap-2 px-3 py-1.5 font-mono text-xs text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
|
|
108
|
+
<div class="flex items-center gap-2 px-3 py-1.5 font-mono text-xs text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
|
|
109
|
+
<% if searchable %>x-show="query === '' || '<%= j(option[:label].to_s.downcase) %>'.includes(query.toLowerCase())"<% end %>>
|
|
72
110
|
<input type="checkbox"
|
|
73
111
|
name="<%= name %>"
|
|
74
112
|
value="<%= option[:value] %>"
|
|
75
113
|
class="filter-cb rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 dark:bg-gray-700"
|
|
76
114
|
<%= selected.include?(option[:value].to_s) ? 'checked' : '' %>
|
|
77
|
-
@change="
|
|
115
|
+
@change="dirty = true">
|
|
78
116
|
<span class="flex-1 flex items-center gap-1.5 cursor-pointer"
|
|
79
117
|
@click.prevent="
|
|
80
118
|
$el.closest('[data-filter]').querySelectorAll('.filter-cb').forEach(c => c.checked = false);
|
|
@@ -89,5 +127,6 @@
|
|
|
89
127
|
</span>
|
|
90
128
|
</div>
|
|
91
129
|
<% end %>
|
|
130
|
+
</div>
|
|
92
131
|
</div>
|
|
93
132
|
</div>
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
class="bg-transparent border border-gray-200 dark:border-gray-800 rounded px-2 py-0.5 text-xs font-mono text-gray-900 dark:text-gray-100 focus:ring-1 focus:ring-gray-400 dark:focus:ring-gray-600 focus:border-gray-400 dark:focus:border-gray-600">
|
|
7
7
|
<option value="">all tenants</option>
|
|
8
8
|
<% available_tenants.each do |tenant| %>
|
|
9
|
-
<option value="<%= tenant %>" <%= 'selected' if tenant == current_tenant_id %>>
|
|
10
|
-
<%= tenant %>
|
|
9
|
+
<option value="<%= tenant[:value] %>" <%= 'selected' if tenant[:value] == current_tenant_id %>>
|
|
10
|
+
<%= tenant[:label] %>
|
|
11
11
|
</option>
|
|
12
12
|
<% end %>
|
|
13
13
|
</select>
|