ruby_llm-agents 3.11.0 → 3.12.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/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/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 +89 -4
- data/app/views/ruby_llm/agents/agents/show.html.erb +14 -0
- data/app/views/ruby_llm/agents/analytics/index.html.erb +398 -0
- 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/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 +5 -1
- 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/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/stream_event.rb +2 -10
- data/lib/ruby_llm/agents/tool.rb +1 -1
- data/lib/ruby_llm/agents.rb +0 -3
- metadata +6 -3
- data/lib/ruby_llm/agents/agent_tool.rb +0 -143
- data/lib/ruby_llm/agents/dsl/agents.rb +0 -141
|
@@ -7,24 +7,44 @@
|
|
|
7
7
|
has_total_timeout = config[:total_timeout].present?
|
|
8
8
|
has_circuit_breaker = config[:circuit_breaker].present?
|
|
9
9
|
has_any_reliability = has_retries || has_fallbacks || has_total_timeout || has_circuit_breaker
|
|
10
|
+
|
|
11
|
+
overridable = config[:overridable_fields] || []
|
|
12
|
+
overrides = config[:active_overrides] || {}
|
|
13
|
+
has_overrides = overrides.any?
|
|
10
14
|
%>
|
|
11
15
|
|
|
12
|
-
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-4 font-mono text-xs">
|
|
16
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-4 font-mono text-xs" x-data="{ editing: false }">
|
|
13
17
|
<!-- Basic -->
|
|
14
18
|
<div>
|
|
15
|
-
<
|
|
16
|
-
|
|
19
|
+
<div class="flex items-center gap-2">
|
|
20
|
+
<span class="text-[10px] text-gray-400 dark:text-gray-600 uppercase tracking-wider">basic</span>
|
|
21
|
+
<% if overridable.any? %>
|
|
22
|
+
<button @click="editing = !editing" class="text-[10px] text-gray-400 dark:text-gray-600 hover:text-gray-700 dark:hover:text-gray-300 transition-colors" x-text="editing ? 'cancel' : 'edit'"></button>
|
|
23
|
+
<% end %>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<%# Read-only view %>
|
|
27
|
+
<div class="mt-1.5 space-y-0.5" x-show="!editing">
|
|
17
28
|
<div class="flex items-center gap-3 py-1">
|
|
18
29
|
<span class="w-20 text-gray-400 dark:text-gray-600">model</span>
|
|
19
30
|
<span class="text-gray-900 dark:text-gray-200"><%= config[:model] %></span>
|
|
31
|
+
<% if overrides["model"] %>
|
|
32
|
+
<span class="text-[10px] text-yellow-600 dark:text-yellow-500">overridden</span>
|
|
33
|
+
<% end %>
|
|
20
34
|
</div>
|
|
21
35
|
<div class="flex items-center gap-3 py-1">
|
|
22
36
|
<span class="w-20 text-gray-400 dark:text-gray-600">temperature</span>
|
|
23
37
|
<span class="text-gray-900 dark:text-gray-200"><%= config[:temperature] %></span>
|
|
38
|
+
<% if overrides["temperature"] %>
|
|
39
|
+
<span class="text-[10px] text-yellow-600 dark:text-yellow-500">overridden</span>
|
|
40
|
+
<% end %>
|
|
24
41
|
</div>
|
|
25
42
|
<div class="flex items-center gap-3 py-1">
|
|
26
43
|
<span class="w-20 text-gray-400 dark:text-gray-600">timeout</span>
|
|
27
44
|
<span class="text-gray-900 dark:text-gray-200"><%= config[:timeout] %>s</span>
|
|
45
|
+
<% if overrides["timeout"] %>
|
|
46
|
+
<span class="text-[10px] text-yellow-600 dark:text-yellow-500">overridden</span>
|
|
47
|
+
<% end %>
|
|
28
48
|
</div>
|
|
29
49
|
<div class="flex items-center gap-3 py-1">
|
|
30
50
|
<span class="w-20 text-gray-400 dark:text-gray-600">cache</span>
|
|
@@ -36,6 +56,71 @@
|
|
|
36
56
|
<% end %>
|
|
37
57
|
</div>
|
|
38
58
|
</div>
|
|
59
|
+
|
|
60
|
+
<%# Edit form (only shown when editing) %>
|
|
61
|
+
<% if overridable.any? %>
|
|
62
|
+
<div x-show="editing" x-cloak>
|
|
63
|
+
<%= form_tag ruby_llm_agents.agent_path(@agent_type), method: :patch, class: "mt-1.5 space-y-1.5" do %>
|
|
64
|
+
<% if overridable.include?(:model) %>
|
|
65
|
+
<div class="flex items-center gap-3 py-1">
|
|
66
|
+
<label class="w-20 text-gray-400 dark:text-gray-600" for="override_model">model</label>
|
|
67
|
+
<input type="text" name="override[model]" id="override_model"
|
|
68
|
+
value="<%= overrides["model"] || config[:model] %>"
|
|
69
|
+
class="bg-transparent border border-gray-300 dark:border-gray-700 rounded px-2 py-0.5 text-gray-900 dark:text-gray-200 text-xs font-mono w-48 focus:outline-none focus:border-gray-500 dark:focus:border-gray-400">
|
|
70
|
+
</div>
|
|
71
|
+
<% else %>
|
|
72
|
+
<div class="flex items-center gap-3 py-1">
|
|
73
|
+
<span class="w-20 text-gray-400 dark:text-gray-600">model</span>
|
|
74
|
+
<span class="text-gray-900 dark:text-gray-200"><%= config[:model] %></span>
|
|
75
|
+
<span class="text-[10px] text-gray-400 dark:text-gray-600">locked</span>
|
|
76
|
+
</div>
|
|
77
|
+
<% end %>
|
|
78
|
+
|
|
79
|
+
<% if overridable.include?(:temperature) %>
|
|
80
|
+
<div class="flex items-center gap-3 py-1">
|
|
81
|
+
<label class="w-20 text-gray-400 dark:text-gray-600" for="override_temperature">temperature</label>
|
|
82
|
+
<input type="number" name="override[temperature]" id="override_temperature"
|
|
83
|
+
value="<%= overrides["temperature"] || config[:temperature] %>"
|
|
84
|
+
step="0.1" min="0" max="2"
|
|
85
|
+
class="bg-transparent border border-gray-300 dark:border-gray-700 rounded px-2 py-0.5 text-gray-900 dark:text-gray-200 text-xs font-mono w-24 focus:outline-none focus:border-gray-500 dark:focus:border-gray-400">
|
|
86
|
+
</div>
|
|
87
|
+
<% else %>
|
|
88
|
+
<div class="flex items-center gap-3 py-1">
|
|
89
|
+
<span class="w-20 text-gray-400 dark:text-gray-600">temperature</span>
|
|
90
|
+
<span class="text-gray-900 dark:text-gray-200"><%= config[:temperature] %></span>
|
|
91
|
+
<span class="text-[10px] text-gray-400 dark:text-gray-600">locked</span>
|
|
92
|
+
</div>
|
|
93
|
+
<% end %>
|
|
94
|
+
|
|
95
|
+
<% if overridable.include?(:timeout) %>
|
|
96
|
+
<div class="flex items-center gap-3 py-1">
|
|
97
|
+
<label class="w-20 text-gray-400 dark:text-gray-600" for="override_timeout">timeout</label>
|
|
98
|
+
<input type="number" name="override[timeout]" id="override_timeout"
|
|
99
|
+
value="<%= overrides["timeout"] || config[:timeout] %>"
|
|
100
|
+
min="1" max="600"
|
|
101
|
+
class="bg-transparent border border-gray-300 dark:border-gray-700 rounded px-2 py-0.5 text-gray-900 dark:text-gray-200 text-xs font-mono w-24 focus:outline-none focus:border-gray-500 dark:focus:border-gray-400">
|
|
102
|
+
<span class="text-gray-400 dark:text-gray-600">s</span>
|
|
103
|
+
</div>
|
|
104
|
+
<% else %>
|
|
105
|
+
<div class="flex items-center gap-3 py-1">
|
|
106
|
+
<span class="w-20 text-gray-400 dark:text-gray-600">timeout</span>
|
|
107
|
+
<span class="text-gray-900 dark:text-gray-200"><%= config[:timeout] %>s</span>
|
|
108
|
+
<span class="text-[10px] text-gray-400 dark:text-gray-600">locked</span>
|
|
109
|
+
</div>
|
|
110
|
+
<% end %>
|
|
111
|
+
|
|
112
|
+
<div class="flex items-center gap-2 pt-2">
|
|
113
|
+
<button type="submit" class="text-[10px] px-2 py-0.5 rounded bg-gray-800 dark:bg-gray-200 text-white dark:text-gray-900 hover:bg-gray-700 dark:hover:bg-gray-300 transition-colors">save overrides</button>
|
|
114
|
+
<% if has_overrides %>
|
|
115
|
+
<%= link_to "reset all", ruby_llm_agents.reset_overrides_agent_path(@agent_type),
|
|
116
|
+
method: :delete,
|
|
117
|
+
class: "text-[10px] text-red-500 hover:text-red-400 transition-colors",
|
|
118
|
+
data: { confirm: "Remove all dashboard overrides for this agent?" } %>
|
|
119
|
+
<% end %>
|
|
120
|
+
</div>
|
|
121
|
+
<% end %>
|
|
122
|
+
</div>
|
|
123
|
+
<% end %>
|
|
39
124
|
</div>
|
|
40
125
|
|
|
41
126
|
<!-- Reliability -->
|
|
@@ -56,7 +141,7 @@
|
|
|
56
141
|
<span class="w-1.5 h-1.5 rounded-full flex-shrink-0 <%= has_fallbacks ? 'bg-green-500' : 'bg-gray-400' %>"></span>
|
|
57
142
|
<span class="w-16 text-gray-400 dark:text-gray-600">fallbacks</span>
|
|
58
143
|
<% if has_fallbacks %>
|
|
59
|
-
<span class="text-gray-900 dark:text-gray-200 truncate"><%= fallback_models.join("
|
|
144
|
+
<span class="text-gray-900 dark:text-gray-200 truncate"><%= fallback_models.join(" → ") %></span>
|
|
60
145
|
<% else %>
|
|
61
146
|
<span class="text-gray-400 dark:text-gray-600">—</span>
|
|
62
147
|
<% end %>
|
|
@@ -3,6 +3,18 @@
|
|
|
3
3
|
<%= link_to "← agents", ruby_llm_agents.agents_path(subtab: params[:subtab]), class: "text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" %>
|
|
4
4
|
</nav>
|
|
5
5
|
|
|
6
|
+
<%# Override banner %>
|
|
7
|
+
<% if @config && @config[:has_overrides] %>
|
|
8
|
+
<div class="flex items-center gap-2 px-3 py-1.5 mb-4 rounded border border-yellow-300 dark:border-yellow-700 bg-yellow-50 dark:bg-yellow-900/20 font-mono text-xs text-yellow-700 dark:text-yellow-400">
|
|
9
|
+
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"></path></svg>
|
|
10
|
+
<span>dashboard overrides active: <strong><%= @config[:active_overrides].keys.join(", ") %></strong></span>
|
|
11
|
+
<%= link_to "reset", ruby_llm_agents.reset_overrides_agent_path(@agent_type),
|
|
12
|
+
method: :delete,
|
|
13
|
+
class: "ml-auto text-yellow-600 dark:text-yellow-500 hover:text-yellow-800 dark:hover:text-yellow-300",
|
|
14
|
+
data: { confirm: "Remove all dashboard overrides?" } %>
|
|
15
|
+
</div>
|
|
16
|
+
<% end %>
|
|
17
|
+
|
|
6
18
|
<!-- ── agent header ──────────────── -->
|
|
7
19
|
<div class="flex items-start justify-between gap-8 mb-2">
|
|
8
20
|
<!-- Left: identity -->
|
|
@@ -67,6 +79,8 @@
|
|
|
67
79
|
<span><span class="text-gray-800 dark:text-gray-200">$<%= number_with_precision(@stats[:total_cost] || 0, precision: 4) %></span> cost</span>
|
|
68
80
|
</div>
|
|
69
81
|
<div class="flex items-center justify-end gap-x-4 text-gray-500 dark:text-gray-600">
|
|
82
|
+
<span><span class="text-gray-600 dark:text-gray-400">$<%= number_with_precision(@stats[:avg_cost] || 0, precision: 4) %></span> avg cost</span>
|
|
83
|
+
<span><span class="text-gray-600 dark:text-gray-400"><%= number_with_delimiter(@stats[:avg_tokens] || 0) %></span> avg tokens</span>
|
|
70
84
|
<span><span class="text-gray-600 dark:text-gray-400"><%= number_with_delimiter(@stats[:total_tokens] || 0) %></span> tokens</span>
|
|
71
85
|
<span><span class="text-gray-600 dark:text-gray-400"><%= number_with_delimiter(@stats[:avg_duration_ms] || 0) %></span>ms avg</span>
|
|
72
86
|
<% if (@config && @config[:cache_enabled]) || @cache_hit_rate > 0 %>
|
|
@@ -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>
|
|
@@ -65,7 +65,8 @@
|
|
|
65
65
|
when "soft" then "badge-timeout"
|
|
66
66
|
else ""
|
|
67
67
|
end
|
|
68
|
-
last_execution = tenant.
|
|
68
|
+
last_execution = @tenant_last_executions[tenant.tenant_id]
|
|
69
|
+
tenant_cost = @tenant_costs[tenant.tenant_id] || 0
|
|
69
70
|
%>
|
|
70
71
|
<div class="group flex items-center gap-3 py-1.5 px-2 -mx-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800/50 cursor-pointer"
|
|
71
72
|
onclick="window.location='<%= tenant_path(tenant) %>'">
|
|
@@ -96,7 +97,7 @@
|
|
|
96
97
|
<span class="text-gray-300 dark:text-gray-700">none</span>
|
|
97
98
|
<% end %>
|
|
98
99
|
</span>
|
|
99
|
-
<span class="w-16 flex-shrink-0 text-right text-gray-500 dark:text-gray-400 hidden md:inline">$<%= number_with_precision(
|
|
100
|
+
<span class="w-16 flex-shrink-0 text-right text-gray-500 dark:text-gray-400 hidden md:inline">$<%= number_with_precision(tenant_cost, precision: 2) %></span>
|
|
100
101
|
<span class="w-24 flex-shrink-0 text-gray-400 dark:text-gray-600 text-right whitespace-nowrap">
|
|
101
102
|
<% if last_execution %>
|
|
102
103
|
<%= time_ago_in_words(last_execution) %>
|