ruby_llm-agents 1.0.0 → 1.2.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 (152) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/ruby_llm/agents/paginatable.rb +9 -3
  3. data/app/controllers/concerns/ruby_llm/agents/sortable.rb +58 -0
  4. data/app/controllers/ruby_llm/agents/agents_controller.rb +59 -16
  5. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +144 -20
  6. data/app/controllers/ruby_llm/agents/executions_controller.rb +13 -16
  7. data/app/controllers/ruby_llm/agents/workflows_controller.rb +279 -90
  8. data/app/helpers/ruby_llm/agents/application_helper.rb +100 -0
  9. data/app/mailers/ruby_llm/agents/alert_mailer.rb +84 -0
  10. data/app/mailers/ruby_llm/agents/application_mailer.rb +28 -0
  11. data/app/models/ruby_llm/agents/execution/analytics.rb +170 -20
  12. data/app/models/ruby_llm/agents/execution/scopes.rb +0 -31
  13. data/app/models/ruby_llm/agents/execution/workflow.rb +0 -129
  14. data/app/models/ruby_llm/agents/execution.rb +50 -14
  15. data/app/models/ruby_llm/agents/tenant/budgetable.rb +277 -0
  16. data/app/models/ruby_llm/agents/tenant/configurable.rb +135 -0
  17. data/app/models/ruby_llm/agents/tenant/trackable.rb +310 -0
  18. data/app/models/ruby_llm/agents/tenant.rb +146 -0
  19. data/app/models/ruby_llm/agents/tenant_budget.rb +12 -253
  20. data/app/services/ruby_llm/agents/agent_registry.rb +18 -12
  21. data/app/views/layouts/ruby_llm/agents/application.html.erb +72 -76
  22. data/app/views/ruby_llm/agents/agents/_agent.html.erb +0 -12
  23. data/app/views/ruby_llm/agents/agents/_sortable_header.html.erb +56 -0
  24. data/app/views/ruby_llm/agents/agents/_workflow.html.erb +5 -15
  25. data/app/views/ruby_llm/agents/agents/index.html.erb +271 -100
  26. data/app/views/ruby_llm/agents/agents/show.html.erb +1 -0
  27. data/app/views/ruby_llm/agents/alert_mailer/alert_notification.html.erb +107 -0
  28. data/app/views/ruby_llm/agents/alert_mailer/alert_notification.text.erb +18 -0
  29. data/app/views/ruby_llm/agents/api_configurations/show.html.erb +4 -1
  30. data/app/views/ruby_llm/agents/dashboard/_agent_comparison.html.erb +66 -359
  31. data/app/views/ruby_llm/agents/dashboard/_model_comparison.html.erb +56 -0
  32. data/app/views/ruby_llm/agents/dashboard/_model_cost_breakdown.html.erb +115 -0
  33. data/app/views/ruby_llm/agents/dashboard/_now_strip.html.erb +35 -60
  34. data/app/views/ruby_llm/agents/dashboard/_top_errors.html.erb +17 -6
  35. data/app/views/ruby_llm/agents/dashboard/index.html.erb +373 -72
  36. data/app/views/ruby_llm/agents/executions/_execution.html.erb +0 -1
  37. data/app/views/ruby_llm/agents/executions/_filters.html.erb +51 -39
  38. data/app/views/ruby_llm/agents/executions/_list.html.erb +53 -195
  39. data/app/views/ruby_llm/agents/executions/_workflow_summary.html.erb +5 -20
  40. data/app/views/ruby_llm/agents/executions/index.html.erb +7 -83
  41. data/app/views/ruby_llm/agents/executions/show.html.erb +10 -20
  42. data/app/views/ruby_llm/agents/shared/_agent_type_badge.html.erb +2 -1
  43. data/app/views/ruby_llm/agents/shared/_doc_link.html.erb +12 -0
  44. data/app/views/ruby_llm/agents/shared/_executions_table.html.erb +3 -15
  45. data/app/views/ruby_llm/agents/shared/_filter_dropdown.html.erb +1 -1
  46. data/app/views/ruby_llm/agents/shared/_select_dropdown.html.erb +1 -1
  47. data/app/views/ruby_llm/agents/shared/_sortable_header.html.erb +53 -0
  48. data/app/views/ruby_llm/agents/shared/_status_badge.html.erb +7 -0
  49. data/app/views/ruby_llm/agents/shared/_status_dot.html.erb +1 -1
  50. data/app/views/ruby_llm/agents/shared/_workflow_type_badge.html.erb +9 -35
  51. data/app/views/ruby_llm/agents/system_config/show.html.erb +4 -1
  52. data/app/views/ruby_llm/agents/tenants/index.html.erb +4 -1
  53. data/app/views/ruby_llm/agents/workflows/_step_performance.html.erb +7 -15
  54. data/app/views/ruby_llm/agents/workflows/_structure_dsl.html.erb +539 -0
  55. data/app/views/ruby_llm/agents/workflows/_workflow_diagram.html.erb +920 -0
  56. data/app/views/ruby_llm/agents/workflows/index.html.erb +179 -0
  57. data/app/views/ruby_llm/agents/workflows/show.html.erb +164 -139
  58. data/config/routes.rb +1 -1
  59. data/lib/generators/ruby_llm_agents/agent_generator.rb +6 -36
  60. data/lib/generators/ruby_llm_agents/background_remover_generator.rb +7 -37
  61. data/lib/generators/ruby_llm_agents/embedder_generator.rb +5 -38
  62. data/lib/generators/ruby_llm_agents/image_analyzer_generator.rb +7 -37
  63. data/lib/generators/ruby_llm_agents/image_editor_generator.rb +7 -37
  64. data/lib/generators/ruby_llm_agents/image_generator_generator.rb +8 -41
  65. data/lib/generators/ruby_llm_agents/image_pipeline_generator.rb +18 -46
  66. data/lib/generators/ruby_llm_agents/image_transformer_generator.rb +7 -37
  67. data/lib/generators/ruby_llm_agents/image_upscaler_generator.rb +7 -37
  68. data/lib/generators/ruby_llm_agents/image_variator_generator.rb +7 -37
  69. data/lib/generators/ruby_llm_agents/install_generator.rb +33 -56
  70. data/lib/generators/ruby_llm_agents/migrate_structure_generator.rb +480 -0
  71. data/lib/generators/ruby_llm_agents/multi_tenancy_generator.rb +42 -22
  72. data/lib/generators/ruby_llm_agents/restructure_generator.rb +2 -2
  73. data/lib/generators/ruby_llm_agents/speaker_generator.rb +8 -39
  74. data/lib/generators/ruby_llm_agents/templates/add_tenant_to_executions_migration.rb.tt +13 -2
  75. data/lib/generators/ruby_llm_agents/templates/agent.rb.tt +5 -8
  76. data/lib/generators/ruby_llm_agents/templates/application_agent.rb.tt +40 -42
  77. data/lib/generators/ruby_llm_agents/templates/application_background_remover.rb.tt +20 -22
  78. data/lib/generators/ruby_llm_agents/templates/application_embedder.rb.tt +24 -26
  79. data/lib/generators/ruby_llm_agents/templates/application_image_analyzer.rb.tt +20 -22
  80. data/lib/generators/ruby_llm_agents/templates/application_image_editor.rb.tt +19 -17
  81. data/lib/generators/ruby_llm_agents/templates/application_image_generator.rb.tt +31 -33
  82. data/lib/generators/ruby_llm_agents/templates/application_image_pipeline.rb.tt +125 -127
  83. data/lib/generators/ruby_llm_agents/templates/application_image_transformer.rb.tt +20 -18
  84. data/lib/generators/ruby_llm_agents/templates/application_image_upscaler.rb.tt +19 -17
  85. data/lib/generators/ruby_llm_agents/templates/application_image_variator.rb.tt +19 -17
  86. data/lib/generators/ruby_llm_agents/templates/application_speaker.rb.tt +38 -40
  87. data/lib/generators/ruby_llm_agents/templates/application_transcriber.rb.tt +42 -44
  88. data/lib/generators/ruby_llm_agents/templates/application_workflow.rb.tt +48 -0
  89. data/lib/generators/ruby_llm_agents/templates/background_remover.rb.tt +19 -21
  90. data/lib/generators/ruby_llm_agents/templates/create_tenant_budgets_migration.rb.tt +11 -0
  91. data/lib/generators/ruby_llm_agents/templates/create_tenants_migration.rb.tt +72 -0
  92. data/lib/generators/ruby_llm_agents/templates/embedder.rb.tt +19 -21
  93. data/lib/generators/ruby_llm_agents/templates/image_analyzer.rb.tt +20 -22
  94. data/lib/generators/ruby_llm_agents/templates/image_editor.rb.tt +15 -17
  95. data/lib/generators/ruby_llm_agents/templates/image_generator.rb.tt +25 -27
  96. data/lib/generators/ruby_llm_agents/templates/image_pipeline.rb.tt +19 -21
  97. data/lib/generators/ruby_llm_agents/templates/image_transformer.rb.tt +20 -22
  98. data/lib/generators/ruby_llm_agents/templates/image_upscaler.rb.tt +17 -19
  99. data/lib/generators/ruby_llm_agents/templates/image_variator.rb.tt +15 -17
  100. data/lib/generators/ruby_llm_agents/templates/rename_tenant_budgets_to_tenants_migration.rb.tt +34 -0
  101. data/lib/generators/ruby_llm_agents/templates/skills/AGENTS.md.tt +87 -24
  102. data/lib/generators/ruby_llm_agents/templates/skills/BACKGROUND_REMOVERS.md.tt +21 -27
  103. data/lib/generators/ruby_llm_agents/templates/skills/EMBEDDERS.md.tt +46 -54
  104. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_ANALYZERS.md.tt +31 -39
  105. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_EDITORS.md.tt +22 -28
  106. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_GENERATORS.md.tt +53 -63
  107. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_PIPELINES.md.tt +46 -56
  108. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_TRANSFORMERS.md.tt +23 -31
  109. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_UPSCALERS.md.tt +22 -30
  110. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_VARIATORS.md.tt +23 -31
  111. data/lib/generators/ruby_llm_agents/templates/skills/SPEAKERS.md.tt +38 -46
  112. data/lib/generators/ruby_llm_agents/templates/skills/TOOLS.md.tt +7 -7
  113. data/lib/generators/ruby_llm_agents/templates/skills/TRANSCRIBERS.md.tt +59 -71
  114. data/lib/generators/ruby_llm_agents/templates/skills/WORKFLOWS.md.tt +274 -23
  115. data/lib/generators/ruby_llm_agents/templates/speaker.rb.tt +29 -31
  116. data/lib/generators/ruby_llm_agents/templates/transcriber.rb.tt +28 -30
  117. data/lib/generators/ruby_llm_agents/transcriber_generator.rb +10 -43
  118. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +26 -0
  119. data/lib/ruby_llm/agents/core/configuration.rb +55 -43
  120. data/lib/ruby_llm/agents/core/llm_tenant.rb +60 -60
  121. data/lib/ruby_llm/agents/core/version.rb +1 -1
  122. data/lib/ruby_llm/agents/infrastructure/alert_manager.rb +26 -0
  123. data/lib/ruby_llm/agents/infrastructure/budget/config_resolver.rb +4 -2
  124. data/lib/ruby_llm/agents/pipeline.rb +69 -0
  125. data/lib/ruby_llm/agents/workflow/approval.rb +205 -0
  126. data/lib/ruby_llm/agents/workflow/approval_store.rb +179 -0
  127. data/lib/ruby_llm/agents/workflow/dsl/executor.rb +467 -0
  128. data/lib/ruby_llm/agents/workflow/dsl/input_schema.rb +244 -0
  129. data/lib/ruby_llm/agents/workflow/dsl/iteration_executor.rb +289 -0
  130. data/lib/ruby_llm/agents/workflow/dsl/parallel_group.rb +107 -0
  131. data/lib/ruby_llm/agents/workflow/dsl/route_builder.rb +150 -0
  132. data/lib/ruby_llm/agents/workflow/dsl/schedule_helpers.rb +187 -0
  133. data/lib/ruby_llm/agents/workflow/dsl/step_config.rb +352 -0
  134. data/lib/ruby_llm/agents/workflow/dsl/step_executor.rb +415 -0
  135. data/lib/ruby_llm/agents/workflow/dsl/wait_config.rb +257 -0
  136. data/lib/ruby_llm/agents/workflow/dsl/wait_executor.rb +317 -0
  137. data/lib/ruby_llm/agents/workflow/dsl.rb +576 -0
  138. data/lib/ruby_llm/agents/workflow/instrumentation.rb +2 -7
  139. data/lib/ruby_llm/agents/workflow/notifiers/base.rb +117 -0
  140. data/lib/ruby_llm/agents/workflow/notifiers/email.rb +117 -0
  141. data/lib/ruby_llm/agents/workflow/notifiers/slack.rb +180 -0
  142. data/lib/ruby_llm/agents/workflow/notifiers/webhook.rb +121 -0
  143. data/lib/ruby_llm/agents/workflow/notifiers.rb +70 -0
  144. data/lib/ruby_llm/agents/workflow/orchestrator.rb +190 -23
  145. data/lib/ruby_llm/agents/workflow/result.rb +202 -0
  146. data/lib/ruby_llm/agents/workflow/throttle_manager.rb +206 -0
  147. data/lib/ruby_llm/agents/workflow/wait_result.rb +213 -0
  148. metadata +43 -6
  149. data/app/views/ruby_llm/agents/dashboard/_execution_item.html.erb +0 -66
  150. data/lib/ruby_llm/agents/workflow/parallel.rb +0 -299
  151. data/lib/ruby_llm/agents/workflow/pipeline.rb +0 -306
  152. data/lib/ruby_llm/agents/workflow/router.rb +0 -429
@@ -1,23 +1,101 @@
1
+ <!-- Page Header -->
2
+ <div class="mb-6">
3
+ <div class="flex items-center gap-2">
4
+ <h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Dashboard</h1>
5
+ <%= render "ruby_llm/agents/shared/doc_link" %>
6
+ </div>
7
+ <p class="text-gray-500 dark:text-gray-400 mt-1">Overview of agent executions and performance metrics</p>
8
+ </div>
9
+
1
10
  <!-- Action Center (only when critical alerts exist) -->
2
11
  <%= render partial: "ruby_llm/agents/dashboard/action_center", locals: { critical_alerts: @critical_alerts } %>
3
12
 
4
- <!-- Now Strip -->
5
- <%= render partial: "ruby_llm/agents/dashboard/now_strip", locals: { now_strip: @now_strip } %>
6
-
7
- <!-- Tenant Budget (when viewing specific tenant) -->
8
- <%= render partial: "ruby_llm/agents/dashboard/tenant_budget", locals: { tenant_budget: @tenant_budget } %>
9
-
10
- <!-- Activity Chart -->
13
+ <!-- Unified Metrics + Chart Component (Plausible-style) -->
11
14
  <div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700 mb-6">
12
- <div class="px-6 py-3 border-b border-gray-100 dark:border-gray-700">
15
+ <!-- Header with range picker -->
16
+ <div class="px-6 py-4 border-b border-gray-100 dark:border-gray-700">
13
17
  <div class="flex items-center justify-between">
14
- <h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Activity</h3>
15
- <span id="chart-totals" class="text-sm text-gray-500 dark:text-gray-400"></span>
18
+ <h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Activity</h3>
19
+ <div class="relative range-dropdown-container">
20
+ <button type="button"
21
+ data-range-dropdown
22
+ class="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
23
+ <span><%= range_display_name(@selected_range) %></span>
24
+ <svg class="w-4 h-4" 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="range-dropdown-menu hidden absolute right-0 mt-2 w-56 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-10">
29
+ <div class="py-1">
30
+ <%= link_to "Today", ruby_llm_agents.root_path(range: "today"), class: "block px-4 py-2 text-sm #{@selected_range == 'today' ? 'bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}" %>
31
+ <%= link_to "Last 7 Days", ruby_llm_agents.root_path(range: "7d"), class: "block px-4 py-2 text-sm #{@selected_range == '7d' ? 'bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}" %>
32
+ <%= link_to "Last 30 Days", ruby_llm_agents.root_path(range: "30d"), class: "block px-4 py-2 text-sm #{@selected_range == '30d' ? 'bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}" %>
33
+ <%= link_to "Last 60 Days", ruby_llm_agents.root_path(range: "60d"), class: "block px-4 py-2 text-sm #{@selected_range == '60d' ? 'bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}" %>
34
+ <%= link_to "Last 90 Days", ruby_llm_agents.root_path(range: "90d"), class: "block px-4 py-2 text-sm #{@selected_range == '90d' ? 'bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}" %>
35
+ <hr class="border-gray-200 dark:border-gray-700 my-1">
36
+ <button type="button" data-custom-range-toggle class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
37
+ Custom Range...
38
+ </button>
39
+ </div>
40
+ <div data-custom-range-form class="hidden border-t border-gray-200 dark:border-gray-700 p-3">
41
+ <div class="space-y-3">
42
+ <div>
43
+ <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">From</label>
44
+ <input type="date"
45
+ name="range_from"
46
+ class="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
47
+ </div>
48
+ <div>
49
+ <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">To</label>
50
+ <input type="date"
51
+ name="range_to"
52
+ class="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
53
+ </div>
54
+ <button type="button"
55
+ data-apply-custom-range
56
+ class="w-full px-3 py-1.5 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded-md transition-colors">
57
+ Apply
58
+ </button>
59
+ </div>
60
+ </div>
61
+ </div>
62
+ </div>
63
+ </div>
64
+ </div>
65
+
66
+ <!-- Metric Row (Plausible-style: simple text, no cards) -->
67
+ <div class="px-6 py-4 border-b border-gray-100 dark:border-gray-700">
68
+ <div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-4">
69
+ <% metrics = [
70
+ { key: "success", label: "SUCCESS", value: @now_strip[:success_today], change: @now_strip.dig(:comparisons, :success_change) },
71
+ { key: "errors", label: "ERRORS", value: @now_strip[:errors_today], change: @now_strip.dig(:comparisons, :errors_change), is_error: @now_strip[:errors_today] > 0 },
72
+ { key: "cost", label: "COST", value: "$#{number_with_precision(@now_strip[:cost_today], precision: 2)}", change: @now_strip.dig(:comparisons, :cost_change) },
73
+ { key: "duration", label: "AVG TIME", value: format_duration_ms(@now_strip[:avg_duration_ms]), change: @now_strip.dig(:comparisons, :duration_change) },
74
+ { key: "tokens", label: "TOKENS", value: number_to_human_short(@now_strip[:total_tokens]), change: @now_strip.dig(:comparisons, :tokens_change) }
75
+ ] %>
76
+
77
+ <% metrics.each_with_index do |metric, i| %>
78
+ <button type="button"
79
+ data-metric="<%= metric[:key] %>"
80
+ data-index="<%= i %>"
81
+ class="metric-btn group text-left transition-all pb-2 <%= i == 0 ? 'border-b-2 border-indigo-500' : 'border-b-2 border-transparent hover:border-gray-300 dark:hover:border-gray-600' %>">
82
+ <span class="text-xs font-medium tracking-wide text-gray-500 dark:text-gray-400 uppercase"><%= metric[:label] %></span>
83
+ <div class="flex items-baseline gap-1">
84
+ <span class="text-2xl font-bold <%= metric[:is_error] ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-gray-100' %>"><%= metric[:value] %></span>
85
+ <% if metric[:change] %>
86
+ <span class="text-xs font-medium <%= metric[:change] > 0 ? (metric[:key].in?(%w[success tokens]) ? 'text-green-600' : 'text-red-600') : (metric[:key].in?(%w[success tokens]) ? 'text-red-600' : 'text-green-600') %>">
87
+ <%= metric[:change] > 0 ? '+' : '' %><%= metric[:change] %>%
88
+ </span>
89
+ <% end %>
90
+ </div>
91
+ </button>
92
+ <% end %>
16
93
  </div>
17
94
  </div>
18
95
 
19
- <div id="activity-chart-container" class="p-4 h-[320px] sm:h-[380px]">
20
- <div id="activity-chart" style="width: 100%; height: 100%;"></div>
96
+ <!-- Chart -->
97
+ <div id="activity-chart-container" class="px-4 pt-4 pb-6">
98
+ <div id="activity-chart" style="width: 100%; height: 280px;"></div>
21
99
  </div>
22
100
 
23
101
  <script>
@@ -27,6 +105,35 @@
27
105
  const hourMs = 3600000;
28
106
  const dayMs = 24 * hourMs;
29
107
 
108
+ let chartData = null;
109
+ let chart = null;
110
+ let selectedMetric = 0;
111
+
112
+ const metricConfig = [
113
+ { name: 'Success', color: '#6366F1' },
114
+ { name: 'Errors', color: '#6366F1' },
115
+ { name: 'Cost', color: '#6366F1', isCurrency: true },
116
+ { name: 'Duration', color: '#6366F1', isDuration: true },
117
+ { name: 'Tokens', color: '#6366F1' }
118
+ ];
119
+
120
+ // Calculate time range in milliseconds based on range string
121
+ function getTimeRangeMs(range) {
122
+ if (range === 'today') return dayMs;
123
+ if (range === '7d') return 7 * dayMs;
124
+ if (range === '30d') return 30 * dayMs;
125
+ if (range === '60d') return 60 * dayMs;
126
+ if (range === '90d') return 90 * dayMs;
127
+ // Custom range: calculate from dates
128
+ if (range.includes('_')) {
129
+ const [from, to] = range.split('_');
130
+ const fromDate = new Date(from);
131
+ const toDate = new Date(to);
132
+ return toDate - fromDate + dayMs;
133
+ }
134
+ return dayMs;
135
+ }
136
+
30
137
  function convertToDatetimePoints(data, range) {
31
138
  const now = Date.now();
32
139
  const numPoints = data.length;
@@ -41,93 +148,189 @@
41
148
  return num.toLocaleString();
42
149
  }
43
150
 
151
+ function formatDuration(ms) {
152
+ if (!ms || ms === 0) return '0ms';
153
+ if (ms < 1000) return ms.toFixed(0) + 'ms';
154
+ if (ms < 60000) return (ms / 1000).toFixed(1) + 's';
155
+ return (ms / 60000).toFixed(1) + 'm';
156
+ }
157
+
158
+ function updateChart(metricIndex) {
159
+ if (!chartData || !chart) return;
160
+
161
+ const config = metricConfig[metricIndex];
162
+ const series = chartData.series[metricIndex];
163
+
164
+ chart.series[0].update({
165
+ name: config.name,
166
+ data: convertToDatetimePoints(series.data, range),
167
+ color: config.color
168
+ }, false);
169
+
170
+ chart.yAxis[0].update({
171
+ labels: {
172
+ style: { color: '#9CA3AF' },
173
+ formatter: function() {
174
+ if (config.isCurrency) return '$' + this.value;
175
+ if (config.isDuration) return formatDuration(this.value);
176
+ return this.value;
177
+ }
178
+ }
179
+ }, false);
180
+
181
+ chart.tooltip.update({
182
+ formatter: function() {
183
+ const dateFormat = range === 'today' ? '%H:%M' : '%b %d';
184
+ const dateStr = Highcharts.dateFormat(dateFormat, this.x);
185
+ let valueStr;
186
+ if (config.isCurrency) valueStr = '$' + this.y.toFixed(4);
187
+ else if (config.isDuration) valueStr = formatDuration(this.y);
188
+ else valueStr = formatNumber(this.y);
189
+ return '<b>' + dateStr + '</b><br/>' + config.name + ': ' + valueStr;
190
+ }
191
+ });
192
+
193
+ chart.redraw();
194
+
195
+ // Update selection styles
196
+ document.querySelectorAll('.metric-btn').forEach((btn, i) => {
197
+ btn.classList.remove('border-indigo-500', 'border-transparent');
198
+ btn.classList.add(i === metricIndex ? 'border-indigo-500' : 'border-transparent');
199
+ });
200
+ }
201
+
44
202
  function initChart() {
45
203
  fetch(chartUrl)
46
204
  .then(res => res.json())
47
205
  .then(data => {
48
- const now = Date.now();
49
- const successData = convertToDatetimePoints(data.series[0].data, range);
50
- const failedData = convertToDatetimePoints(data.series[1].data, range);
51
- const costData = convertToDatetimePoints(data.series[2].data, range);
52
-
53
- // Update totals in header
54
- const totals = data.totals;
55
- const totalExecs = totals.success + totals.failed;
56
- document.getElementById('chart-totals').textContent =
57
- formatNumber(totalExecs) + ' executions • $' + totals.cost.toFixed(2);
58
-
59
- const timeRange = range === 'today' ? dayMs : (range === '7d' ? 7 * dayMs : 30 * dayMs);
206
+ chartData = data;
207
+ const config = metricConfig[0];
208
+ const timeRange = getTimeRangeMs(range);
60
209
  const dateFormat = range === 'today' ? '%H:%M' : '%b %d';
210
+ const now = Date.now();
61
211
 
62
- Highcharts.chart('activity-chart', {
63
- chart: { type: 'areaspline', backgroundColor: 'transparent' },
212
+ chart = Highcharts.chart('activity-chart', {
213
+ chart: { type: 'areaspline', backgroundColor: 'transparent', spacing: [10, 0, 10, 0] },
64
214
  title: { text: null },
65
215
  xAxis: {
66
216
  type: 'datetime',
67
217
  min: now - timeRange,
68
218
  max: now,
69
- labels: { style: { color: '#9CA3AF' }, format: '{value:' + dateFormat + '}' },
70
- lineColor: 'rgba(156, 163, 175, 0.2)'
219
+ labels: { style: { color: '#9CA3AF', fontSize: '11px' }, format: '{value:' + dateFormat + '}' },
220
+ lineColor: 'transparent',
221
+ tickLength: 0
71
222
  },
72
- yAxis: [{
223
+ yAxis: {
73
224
  title: { text: null },
74
225
  min: 0,
75
226
  allowDecimals: false,
76
- labels: { style: { color: '#9CA3AF' } },
77
- gridLineColor: 'rgba(156, 163, 175, 0.2)'
78
- }, {
79
- title: { text: null },
80
- min: 0,
81
- labels: { style: { color: '#F59E0B' }, format: '${value}' },
82
- opposite: true,
83
- gridLineWidth: 0
84
- }],
85
- legend: {
86
- enabled: true,
87
- align: 'center',
88
- verticalAlign: 'bottom',
89
- itemStyle: { color: '#9CA3AF', fontWeight: 'normal' },
90
- itemHoverStyle: { color: '#D1D5DB' }
227
+ labels: { style: { color: '#9CA3AF', fontSize: '11px' } },
228
+ gridLineColor: 'rgba(156, 163, 175, 0.15)'
91
229
  },
230
+ legend: { enabled: false },
92
231
  credits: { enabled: false },
93
232
  tooltip: {
94
- shared: true,
95
233
  backgroundColor: 'rgba(17, 24, 39, 0.95)',
96
- borderColor: 'rgba(75, 85, 99, 0.5)',
97
- style: { color: '#F3F4F6' },
98
- xDateFormat: range === 'today' ? '%H:%M' : '%b %d'
234
+ borderColor: 'transparent',
235
+ borderRadius: 8,
236
+ style: { color: '#F3F4F6', fontSize: '12px' },
237
+ formatter: function() {
238
+ return '<b>' + Highcharts.dateFormat(dateFormat, this.x) + '</b><br/>' +
239
+ config.name + ': ' + formatNumber(this.y);
240
+ }
99
241
  },
100
242
  plotOptions: {
101
243
  areaspline: {
102
- fillOpacity: 0.15,
103
- lineWidth: 2,
104
- marker: { enabled: false, states: { hover: { enabled: true, radius: 4 } } }
105
- },
106
- spline: {
244
+ fillColor: {
245
+ linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 },
246
+ stops: [[0, 'rgba(99, 102, 241, 0.3)'], [1, 'rgba(99, 102, 241, 0)']]
247
+ },
107
248
  lineWidth: 2,
108
249
  marker: { enabled: false, states: { hover: { enabled: true, radius: 4 } } }
109
250
  }
110
251
  },
111
- series: [
112
- { name: 'Success', type: 'areaspline', color: '#10B981', data: successData, yAxis: 0 },
113
- { name: 'Failed', type: 'areaspline', color: '#EF4444', data: failedData, yAxis: 0 },
114
- { name: 'Cost', type: 'spline', color: '#F59E0B', data: costData, yAxis: 1, tooltip: { valuePrefix: '$' } }
115
- ]
252
+ series: [{
253
+ name: config.name,
254
+ data: convertToDatetimePoints(data.series[0].data, range),
255
+ color: config.color
256
+ }]
116
257
  });
117
258
  })
118
259
  .catch(err => console.log('[RubyLLM::Agents] Chart error:', err));
260
+
261
+ document.querySelectorAll('.metric-btn').forEach(btn => {
262
+ btn.addEventListener('click', function() {
263
+ const index = parseInt(this.dataset.index);
264
+ selectedMetric = index;
265
+ updateChart(index);
266
+ });
267
+ });
268
+ }
269
+
270
+ // Dropdown toggle functionality
271
+ function initDropdown() {
272
+ const dropdownBtn = document.querySelector('[data-range-dropdown]');
273
+ const dropdownMenu = document.querySelector('.range-dropdown-menu');
274
+ const customRangeToggle = document.querySelector('[data-custom-range-toggle]');
275
+ const customRangeForm = document.querySelector('[data-custom-range-form]');
276
+ const applyBtn = document.querySelector('[data-apply-custom-range]');
277
+
278
+ if (dropdownBtn && dropdownMenu) {
279
+ // Toggle dropdown on button click
280
+ dropdownBtn.addEventListener('click', function(e) {
281
+ e.stopPropagation();
282
+ dropdownMenu.classList.toggle('hidden');
283
+ });
284
+
285
+ // Close dropdown when clicking outside
286
+ document.addEventListener('click', function(e) {
287
+ if (!e.target.closest('.range-dropdown-container')) {
288
+ dropdownMenu.classList.add('hidden');
289
+ if (customRangeForm) customRangeForm.classList.add('hidden');
290
+ }
291
+ });
292
+ }
293
+
294
+ // Toggle custom range form
295
+ if (customRangeToggle && customRangeForm) {
296
+ customRangeToggle.addEventListener('click', function(e) {
297
+ e.stopPropagation();
298
+ customRangeForm.classList.toggle('hidden');
299
+ });
300
+ }
301
+
302
+ // Apply custom range
303
+ if (applyBtn) {
304
+ applyBtn.addEventListener('click', function() {
305
+ const fromInput = document.querySelector('[name="range_from"]');
306
+ const toInput = document.querySelector('[name="range_to"]');
307
+ const from = fromInput ? fromInput.value : '';
308
+ const to = toInput ? toInput.value : '';
309
+
310
+ if (from && to) {
311
+ window.location.href = '?range=' + from + '_' + to;
312
+ }
313
+ });
314
+ }
119
315
  }
120
316
 
121
317
  if (document.readyState === 'loading') {
122
- document.addEventListener('DOMContentLoaded', initChart);
318
+ document.addEventListener('DOMContentLoaded', function() {
319
+ initChart();
320
+ initDropdown();
321
+ });
123
322
  } else {
124
323
  initChart();
324
+ initDropdown();
125
325
  }
126
326
  })();
127
327
  </script>
128
328
  </div>
129
329
 
130
- <!-- Live Activity Feed -->
330
+ <!-- Tenant Budget (when viewing specific tenant) -->
331
+ <%= render partial: "ruby_llm/agents/dashboard/tenant_budget", locals: { tenant_budget: @tenant_budget } %>
332
+
333
+ <!-- Recent Activity Table -->
131
334
  <div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700 mb-6">
132
335
  <div class="px-6 py-3 border-b border-gray-100 dark:border-gray-700">
133
336
  <div class="flex justify-between items-center">
@@ -135,21 +338,119 @@
135
338
  <%= link_to "View All", ruby_llm_agents.executions_path, class: "text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 text-sm font-medium" %>
136
339
  </div>
137
340
  </div>
138
- <div id="activity-feed" class="px-6 py-2">
139
- <% if @recent_executions.any? %>
140
- <% @recent_executions.first(5).each do |execution| %>
141
- <%= render partial: "ruby_llm/agents/dashboard/execution_item", locals: { execution: execution } %>
142
- <% end %>
143
- <% else %>
144
- <div class="py-8 text-center text-gray-500 dark:text-gray-400">
145
- <p class="text-sm">No executions yet</p>
146
- </div>
147
- <% end %>
148
- </div>
341
+
342
+ <% if @recent_executions.any? %>
343
+ <div class="overflow-x-auto">
344
+ <table class="w-full text-sm">
345
+ <thead>
346
+ <tr class="text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider border-b border-gray-100 dark:border-gray-700">
347
+ <th class="px-4 py-3 w-8"></th>
348
+ <th class="px-4 py-3">Agent</th>
349
+ <th class="px-4 py-3 hidden sm:table-cell">Model</th>
350
+ <th class="px-4 py-3 text-right">Tokens</th>
351
+ <th class="px-4 py-3 text-right hidden sm:table-cell">Cost</th>
352
+ <th class="px-4 py-3 text-right hidden md:table-cell">Time</th>
353
+ <th class="px-4 py-3 text-right">When</th>
354
+ </tr>
355
+ </thead>
356
+ <tbody class="divide-y divide-gray-100 dark:divide-gray-700">
357
+ <% @recent_executions.first(5).each do |execution| %>
358
+ <tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors cursor-pointer <%= execution.status_error? ? 'bg-red-50/50 dark:bg-red-900/10' : '' %>"
359
+ onclick="window.location='<%= ruby_llm_agents.execution_path(execution) %>'"
360
+ <% if execution.status_error? && execution.error_message.present? %>
361
+ title="<%= execution.error_class %>: <%= execution.error_message.truncate(100) %>"
362
+ <% end %>>
363
+ <!-- Status -->
364
+ <td class="px-4 py-3">
365
+ <%= render "ruby_llm/agents/shared/status_dot", status: execution.status %>
366
+ </td>
367
+ <!-- Agent + Badges -->
368
+ <td class="px-4 py-3">
369
+ <div class="flex items-center gap-1.5 min-w-0">
370
+ <% if execution.respond_to?(:workflow_type) && execution.workflow_type.present? %>
371
+ <%= render "ruby_llm/agents/shared/workflow_type_badge", workflow_type: execution.workflow_type, size: :xs, show_label: false %>
372
+ <% end %>
373
+ <span class="font-medium text-gray-900 dark:text-gray-100 truncate">
374
+ <%= execution.agent_type.gsub(/Agent$|Workflow$|Pipeline$|Parallel$|Router$/, '') %>
375
+ </span>
376
+ <% unless execution.status_running? %>
377
+ <% if execution.streaming? %>
378
+ <svg class="w-3.5 h-3.5 text-cyan-500 flex-shrink-0" title="Streaming" fill="none" stroke="currentColor" viewBox="0 0 24 24">
379
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
380
+ </svg>
381
+ <% end %>
382
+ <% if execution.cache_hit? %>
383
+ <svg class="w-3.5 h-3.5 text-purple-500 flex-shrink-0" title="Cached" fill="none" stroke="currentColor" viewBox="0 0 24 24">
384
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4"/>
385
+ </svg>
386
+ <% end %>
387
+ <% if execution.rate_limited? %>
388
+ <svg class="w-3.5 h-3.5 text-orange-500 flex-shrink-0" title="Rate Limited" fill="none" stroke="currentColor" viewBox="0 0 24 24">
389
+ <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-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
390
+ </svg>
391
+ <% end %>
392
+ <% end %>
393
+ <% if execution.status_error? %>
394
+ <svg class="w-3.5 h-3.5 text-red-500 flex-shrink-0" title="Error" fill="none" stroke="currentColor" viewBox="0 0 24 24">
395
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
396
+ </svg>
397
+ <% end %>
398
+ </div>
399
+ </td>
400
+ <!-- Model -->
401
+ <td class="px-4 py-3 hidden sm:table-cell">
402
+ <span class="font-mono text-xs text-gray-500 dark:text-gray-400 truncate max-w-[120px] block">
403
+ <%= execution.model_id || '-' %>
404
+ </span>
405
+ </td>
406
+ <!-- Tokens -->
407
+ <td class="px-4 py-3 text-right text-gray-600 dark:text-gray-300">
408
+ <% if execution.status_running? %>
409
+ <span class="text-blue-500 dark:text-blue-400 animate-pulse">...</span>
410
+ <% else %>
411
+ <%= execution.total_tokens ? number_to_human_short(execution.total_tokens) : '-' %>
412
+ <% end %>
413
+ </td>
414
+ <!-- Cost -->
415
+ <td class="px-4 py-3 text-right text-gray-600 dark:text-gray-300 hidden sm:table-cell">
416
+ <% if execution.status_running? %>
417
+ <span class="text-blue-500 dark:text-blue-400 animate-pulse">...</span>
418
+ <% else %>
419
+ <%= execution.total_cost ? "$#{number_with_precision(execution.total_cost, precision: 2)}" : '-' %>
420
+ <% end %>
421
+ </td>
422
+ <!-- Duration -->
423
+ <td class="px-4 py-3 text-right text-gray-600 dark:text-gray-300 hidden md:table-cell">
424
+ <% if execution.status_running? %>
425
+ <span class="text-blue-500 dark:text-blue-400 animate-pulse">...</span>
426
+ <% else %>
427
+ <%= execution.duration_ms ? format_duration_ms(execution.duration_ms) : '-' %>
428
+ <% end %>
429
+ </td>
430
+ <!-- When -->
431
+ <td class="px-4 py-3 text-right text-gray-500 dark:text-gray-400 whitespace-nowrap">
432
+ <%= time_ago_in_words(execution.created_at) %> ago
433
+ </td>
434
+ </tr>
435
+ <% end %>
436
+ </tbody>
437
+ </table>
438
+ </div>
439
+ <% else %>
440
+ <div class="py-8 text-center text-gray-500 dark:text-gray-400">
441
+ <p class="text-sm">No executions yet</p>
442
+ </div>
443
+ <% end %>
149
444
  </div>
150
445
 
151
446
  <!-- Agent Comparison + Top Errors -->
152
- <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
447
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
153
448
  <%= render partial: "ruby_llm/agents/dashboard/agent_comparison", locals: { agent_stats: @agent_stats } %>
154
449
  <%= render partial: "ruby_llm/agents/dashboard/top_errors", locals: { top_errors: @top_errors } %>
155
450
  </div>
451
+
452
+ <!-- Model Performance + Cost Breakdown -->
453
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
454
+ <%= render partial: "ruby_llm/agents/dashboard/model_comparison", locals: { model_stats: @model_stats } %>
455
+ <%= render partial: "ruby_llm/agents/dashboard/model_cost_breakdown", locals: { model_stats: @model_stats } %>
456
+ </div>
@@ -11,7 +11,6 @@
11
11
  <p class="font-medium text-gray-900">
12
12
  <%= execution.agent_type.gsub(/Agent$/, '') %>
13
13
  </p>
14
-
15
14
  <p class="text-sm text-gray-500">
16
15
  v<%= execution.agent_version %> · <%= execution.model_id %>
17
16
  </p>