admin_suite 0.2.1 → 0.2.2

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/admin_suite.css +128 -0
  3. data/app/controllers/admin_suite/application_controller.rb +31 -0
  4. data/app/controllers/admin_suite/dashboard_controller.rb +59 -226
  5. data/app/helpers/admin_suite/base_helper.rb +108 -108
  6. data/app/helpers/admin_suite/panels_helper.rb +1 -1
  7. data/app/javascript/controllers/admin_suite/file_upload_controller.js +9 -9
  8. data/app/javascript/controllers/admin_suite/json_editor_controller.js +8 -8
  9. data/app/javascript/controllers/admin_suite/searchable_select_controller.js +2 -2
  10. data/app/javascript/controllers/admin_suite/tag_select_controller.js +1 -1
  11. data/app/javascript/controllers/admin_suite/toggle_switch_controller.js +1 -1
  12. data/app/views/admin_suite/dashboard/index.html.erb +6 -15
  13. data/app/views/admin_suite/panels/_cards.html.erb +6 -6
  14. data/app/views/admin_suite/panels/_chart.html.erb +12 -12
  15. data/app/views/admin_suite/panels/_health.html.erb +14 -14
  16. data/app/views/admin_suite/panels/_recent.html.erb +11 -11
  17. data/app/views/admin_suite/panels/_stat.html.erb +24 -24
  18. data/app/views/admin_suite/panels/_table.html.erb +10 -10
  19. data/app/views/admin_suite/portals/show.html.erb +1 -1
  20. data/app/views/admin_suite/resources/_form.html.erb +1 -1
  21. data/app/views/admin_suite/resources/edit.html.erb +4 -4
  22. data/app/views/admin_suite/resources/index.html.erb +23 -23
  23. data/app/views/admin_suite/resources/new.html.erb +4 -4
  24. data/app/views/admin_suite/resources/show.html.erb +17 -17
  25. data/app/views/admin_suite/shared/_form.html.erb +8 -8
  26. data/app/views/admin_suite/shared/_json_editor_field.html.erb +4 -4
  27. data/app/views/admin_suite/shared/_sidebar.html.erb +4 -4
  28. data/app/views/admin_suite/shared/_topbar.html.erb +1 -1
  29. data/app/views/layouts/admin_suite/application.html.erb +4 -4
  30. data/docs/configuration.md +25 -0
  31. data/docs/portals.md +42 -0
  32. data/lib/admin_suite/configuration.rb +10 -0
  33. data/lib/admin_suite/engine.rb +9 -0
  34. data/lib/admin_suite/ui/field_renderer_registry.rb +2 -2
  35. data/lib/admin_suite/ui/form_field_renderer.rb +2 -2
  36. data/lib/admin_suite/ui/show_formatter_registry.rb +5 -5
  37. data/lib/admin_suite/ui/show_value_formatter.rb +1 -1
  38. data/lib/admin_suite/version.rb +1 -1
  39. data/lib/admin_suite.rb +31 -0
  40. data/test/dummy/log/test.log +1328 -440
  41. data/test/dummy/tmp/local_secret.txt +1 -1
  42. data/test/integration/dashboard_test.rb +57 -1
  43. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8a3f83815ccce6bcb0e0a38e07e0b90d6d969309b2e3302e1870135f02793762
4
- data.tar.gz: 2564e620eaa8c66030d8f774b1d4246eaa4359955ce2a5670f62f069a88a58af
3
+ metadata.gz: 22d3fb1dd5649136ae88ea54aa4b948790c1e1fd7a81681ffd07707f82d6494a
4
+ data.tar.gz: 00c3ba3433eb3d9d59289c70d75fe9b0a3f613a043b6dc1d109b7af4a375272e
5
5
  SHA512:
6
- metadata.gz: '071608f05dc059b788b90350e9cfbd590d417bf124f3327dbf151202d32f2c71359f3c6ba442e13fba7138d8fc7b28d96e52687825bf64beb66c4058dd640eb9'
7
- data.tar.gz: ac7a393a423342b228c9489492d361e34f98afaaf9e9527a1a71639ecc3d0ee7ce59ddfae3988d1d83ccac474a69764ce7b7d7afed0e346026642bd9c69bb3f2
6
+ metadata.gz: b104f41152647c491a4f318beb6d1b2ea7f69619af6c7cb9ee05fef3a32b719aef73f48200e6b0bd5efc4c2ad7fe85637db40dc3023d63a3281cd39882539b7f
7
+ data.tar.gz: 8d27c97e8790b50d59f769abacdc1a19158158fcb284d31b6e3785fcb8064e3020ef8577e3b1960681038314ea865e1502ba408e7db8359924ea5accc39bb99e
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  body.admin-suite {
12
+ color-scheme: light;
12
13
  --admin-suite-bg: #f8fafc; /* slate-50 */
13
14
  --admin-suite-fg: #0f172a; /* slate-900 */
14
15
  --admin-suite-muted: #475569; /* slate-600 */
@@ -265,6 +266,133 @@ body.admin-suite .admin-suite-sidebar .admin-suite-sidebar-muted {
265
266
  color: rgba(255, 255, 255, 0.75);
266
267
  }
267
268
 
269
+ /* Responsive layout fallbacks (engine-owned)
270
+ ----------------------------------------
271
+ Tailwind v4 emits media queries like `@media (width >= 64rem)`, which can fail
272
+ in some embedded/older browsers. For critical layout, we provide classic
273
+ `min-width` media queries so the UI remains usable everywhere. */
274
+
275
+ /* Dashboard row grid (>= 1024px)
276
+ Used by `render_dashboard_rows` to honor panel `span`. */
277
+ body.admin-suite .admin-suite-dashboard-row {
278
+ display: grid;
279
+ grid-template-columns: repeat(1, minmax(0, 1fr));
280
+ gap: 1.5rem; /* ~ gap-6 */
281
+ }
282
+
283
+ @media (min-width: 1024px) {
284
+ body.admin-suite .admin-suite-dashboard-row {
285
+ grid-template-columns: repeat(12, minmax(0, 1fr));
286
+ }
287
+ }
288
+
289
+ /* Cards panel grids (portals/resources) */
290
+ body.admin-suite .admin-suite-cards-grid {
291
+ display: grid;
292
+ grid-template-columns: repeat(1, minmax(0, 1fr));
293
+ }
294
+
295
+ body.admin-suite .admin-suite-cards-grid--portals {
296
+ gap: 1rem; /* ~ gap-4 */
297
+ }
298
+
299
+ body.admin-suite .admin-suite-cards-grid--resources {
300
+ gap: 0.75rem; /* ~ gap-3 */
301
+ }
302
+
303
+ @media (min-width: 640px) {
304
+ body.admin-suite .admin-suite-cards-grid--resources {
305
+ grid-template-columns: repeat(2, minmax(0, 1fr)); /* sm:grid-cols-2 */
306
+ }
307
+ }
308
+
309
+ @media (min-width: 768px) {
310
+ body.admin-suite .admin-suite-cards-grid--portals {
311
+ grid-template-columns: repeat(2, minmax(0, 1fr)); /* md:grid-cols-2 */
312
+ }
313
+ }
314
+
315
+ @media (min-width: 1024px) {
316
+ body.admin-suite .admin-suite-cards-grid--portals {
317
+ grid-template-columns: repeat(4, minmax(0, 1fr)); /* lg:grid-cols-4 */
318
+ }
319
+
320
+ body.admin-suite .admin-suite-cards-grid--resources {
321
+ grid-template-columns: repeat(3, minmax(0, 1fr)); /* lg:grid-cols-3 */
322
+ }
323
+ }
324
+
325
+ /* Portal fallback sections grid (>= 1024px) */
326
+ body.admin-suite .admin-suite-portal-sections-grid {
327
+ display: grid;
328
+ grid-template-columns: repeat(1, minmax(0, 1fr));
329
+ gap: 1.5rem; /* ~ gap-6 */
330
+ }
331
+
332
+ @media (min-width: 1024px) {
333
+ body.admin-suite .admin-suite-portal-sections-grid {
334
+ grid-template-columns: repeat(2, minmax(0, 1fr));
335
+ }
336
+ }
337
+
338
+ /* Desktop sidebar visibility (>= 1024px) */
339
+ body.admin-suite .admin-suite-desktop-sidebar {
340
+ display: none;
341
+ flex-shrink: 0;
342
+ }
343
+
344
+ /* Prevent flex children from overflowing (required for nested tables). */
345
+ body.admin-suite .admin-suite-main {
346
+ min-width: 0;
347
+ }
348
+
349
+ @media (min-width: 1024px) {
350
+ body.admin-suite .admin-suite-desktop-sidebar {
351
+ display: flex;
352
+ }
353
+
354
+ /* Ensure the mobile menu button/overlay never show on desktop */
355
+ body.admin-suite .admin-suite-mobile-menu-button {
356
+ display: none;
357
+ }
358
+
359
+ body.admin-suite .admin-suite-mobile-overlay,
360
+ body.admin-suite .admin-suite-mobile-sidebar {
361
+ display: none !important;
362
+ }
363
+ }
364
+
365
+ /* Two-column resource layouts */
366
+ body.admin-suite .admin-suite-two-col {
367
+ display: flex;
368
+ flex-direction: column;
369
+ gap: 1.5rem; /* ~ gap-6 */
370
+ }
371
+
372
+ @media (min-width: 768px) {
373
+ body.admin-suite .admin-suite-two-col {
374
+ flex-direction: row;
375
+ }
376
+
377
+ body.admin-suite .admin-suite-two-col__sidebar {
378
+ flex: 0 0 auto;
379
+ }
380
+
381
+ body.admin-suite .admin-suite-two-col__sidebar--filters {
382
+ width: 16rem; /* ~ w-64 */
383
+ }
384
+
385
+ body.admin-suite .admin-suite-two-col__sidebar--details {
386
+ width: 20rem; /* ~ w-80 */
387
+ }
388
+ }
389
+
390
+ /* Ensure the engine sidebar has a stable width even without Tailwind. */
391
+ body.admin-suite .admin-suite-sidebar {
392
+ width: 18rem; /* ~ w-72 */
393
+ height: 100%;
394
+ }
395
+
268
396
  .form-group { margin-bottom: 1rem; }
269
397
  .form-label {
270
398
  display: block;
@@ -72,6 +72,37 @@ module AdminSuite
72
72
  retry
73
73
  end
74
74
 
75
+ # Loads the root dashboard definition files (safe to call per-request).
76
+ #
77
+ # Host apps typically define this in:
78
+ # - `config/admin_suite/dashboard.rb`
79
+ # - `app/admin_suite/dashboard.rb`
80
+ #
81
+ # @return [void]
82
+ def ensure_root_dashboard_loaded!
83
+ if Rails.env.development?
84
+ globs = Array(AdminSuite.config.dashboard_globs).flat_map { |g| Dir[g] }.uniq
85
+ # Re-evaluate dashboard layout on each request in development.
86
+ # Always reset, even when no files match, so removed dashboards are cleared.
87
+ AdminSuite.reset_root_dashboard!
88
+ globs.each { |file| load file }
89
+ else
90
+ # In non-dev, load once.
91
+ return if AdminSuite.config.root_dashboard_loaded
92
+ globs = Array(AdminSuite.config.dashboard_globs).flat_map { |g| Dir[g] }.uniq
93
+ if globs.empty?
94
+ # Avoid hitting the filesystem on every request when no dashboard files exist.
95
+ AdminSuite.config.root_dashboard_loaded = true
96
+ return
97
+ end
98
+ globs.each { |file| require file }
99
+ AdminSuite.config.root_dashboard_loaded = true
100
+ end
101
+ rescue NameError
102
+ require "admin_suite"
103
+ retry
104
+ end
105
+
75
106
  # Builds the navigation structure from registered resources.
76
107
  #
77
108
  # @return [Hash]
@@ -3,256 +3,89 @@
3
3
  module AdminSuite
4
4
  class DashboardController < ApplicationController
5
5
  def index
6
- # Ensure portal/resource metadata is available.
7
- items = navigation_items
6
+ ensure_root_dashboard_loaded!
8
7
 
9
- @health = build_root_health
10
- @stats = build_root_stats(items)
11
- @recent = build_root_recent
8
+ items = navigation_items
9
+ @portal_cards = build_portal_cards(items)
12
10
 
13
- @portal_cards =
14
- items.sort_by { |(_k, v)| (v[:order] || 100).to_i }.map do |portal_key, portal|
15
- color = portal[:color].presence || default_portal_color(portal_key)
16
- {
17
- key: portal_key,
18
- label: portal[:label] || portal_key.to_s.humanize,
19
- description: portal[:description],
20
- color: color,
21
- icon: portal[:icon],
22
- path: portal_path(portal: portal_key),
23
- count: portal[:sections].values.sum { |s| s[:items].size }
24
- }
25
- end
11
+ @page_title = eval_setting(AdminSuite.config.root_dashboard_title, default: "Admin Suite")
12
+ @page_description =
13
+ eval_setting(
14
+ AdminSuite.config.root_dashboard_description,
15
+ default: "Admin framework for managing application resources across one or more portals."
16
+ )
26
17
 
27
- @dashboard_sections = build_sections
18
+ @dashboard_rows = resolve_root_dashboard_rows(items)
28
19
  end
29
20
 
30
21
  private
31
22
 
32
- def default_portal_color(portal_key)
33
- case portal_key.to_sym
34
- when :ops then "amber"
35
- when :email then "emerald"
36
- when :ai then "cyan"
37
- when :assistant then "violet"
38
- when :payments then "emerald"
39
- else "slate"
40
- end
41
- end
42
-
43
- def build_sections
44
- sections = []
45
-
46
- sections << {
47
- title: "System Health",
48
- subtitle: nil,
49
- rows: [
50
- AdminSuite::UI::RowDefinition.new(panels: [
51
- AdminSuite::UI::PanelDefinition.new(type: :health, title: "Application", options: { span: 3, status: @health.dig(:app, :status), metrics: @health.dig(:app, :metrics) }),
52
- AdminSuite::UI::PanelDefinition.new(type: :health, title: "Scraping Pipeline", options: { span: 3, status: @health.dig(:scraping, :status), metrics: @health.dig(:scraping, :metrics) }),
53
- AdminSuite::UI::PanelDefinition.new(type: :health, title: "LLM API", options: { span: 3, status: @health.dig(:llm, :status), metrics: @health.dig(:llm, :metrics) }),
54
- AdminSuite::UI::PanelDefinition.new(type: :health, title: "Assistant", options: { span: 3, status: @health.dig(:assistant, :status), metrics: @health.dig(:assistant, :metrics) })
55
- ])
56
- ]
57
- }
58
-
59
- sections << {
60
- title: nil,
61
- subtitle: nil,
62
- rows: [
63
- AdminSuite::UI::RowDefinition.new(panels: [
64
- AdminSuite::UI::PanelDefinition.new(
65
- type: :cards,
66
- title: "Portals",
67
- options: { span: 12, variant: :portals, resources: @portal_cards }
68
- )
69
- ])
70
- ]
71
- }
72
-
73
- sections << {
74
- title: nil,
75
- subtitle: nil,
76
- rows: [
77
- AdminSuite::UI::RowDefinition.new(panels: [
78
- AdminSuite::UI::PanelDefinition.new(type: :stat, title: "Total Resources", options: { span: 3, variant: :mini, color: :slate, value: @stats[:total_resources] }),
79
- AdminSuite::UI::PanelDefinition.new(type: :stat, title: "Ops Resources", options: { span: 3, variant: :mini, color: :amber, value: @stats[:ops_resources] }),
80
- AdminSuite::UI::PanelDefinition.new(type: :stat, title: "Email Resources", options: { span: 2, variant: :mini, color: :emerald, value: @stats[:email_resources] }),
81
- AdminSuite::UI::PanelDefinition.new(type: :stat, title: "AI Resources", options: { span: 2, variant: :mini, color: :cyan, value: @stats[:ai_resources] }),
82
- AdminSuite::UI::PanelDefinition.new(type: :stat, title: "Assistant Resources", options: { span: 2, variant: :mini, color: :violet, value: @stats[:assistant_resources] })
83
- ])
84
- ]
85
- }
86
-
87
- sections << {
88
- title: "Recent Activity",
89
- subtitle: nil,
90
- rows: [
91
- AdminSuite::UI::RowDefinition.new(panels: [
92
- AdminSuite::UI::PanelDefinition.new(type: :recent, title: "Recent Signups", options: { span: 3, scope: @recent[:recent_users], view_all_path: ->(view) { view.resources_path(portal: :ops, resource_name: "users") } }),
93
- AdminSuite::UI::PanelDefinition.new(type: :recent, title: "Recent Applications", options: { span: 3, scope: @recent[:recent_applications], view_all_path: ->(view) { view.resources_path(portal: :ops, resource_name: "interview_applications") } }),
94
- AdminSuite::UI::PanelDefinition.new(type: :recent, title: "Recent Assistant", options: { span: 3, scope: @recent[:recent_threads], view_all_path: ->(view) { view.resources_path(portal: :assistant, resource_name: "assistant_threads") } }),
95
- AdminSuite::UI::PanelDefinition.new(type: :recent, title: "Recent Scraping", options: { span: 3, scope: @recent[:recent_scraping], view_all_path: ->(view) { view.resources_path(portal: :ops, resource_name: "scraping_attempts") } })
96
- ])
97
- ]
98
- }
99
-
100
- sections
101
- end
102
-
103
- def build_root_stats(items)
104
- {
105
- total_resources: Admin::Base::Resource.registered_resources.count,
106
- portals: items.keys.count,
107
- ops_resources: Admin::Base::Resource.resources_for_portal(:ops).count,
108
- email_resources: Admin::Base::Resource.resources_for_portal(:email).count,
109
- ai_resources: Admin::Base::Resource.resources_for_portal(:ai).count,
110
- assistant_resources: Admin::Base::Resource.resources_for_portal(:assistant).count
111
- }
23
+ def eval_setting(value, default:)
24
+ evaluated = value.respond_to?(:call) ? value.call(self) : value
25
+ evaluated.to_s.presence || default
112
26
  rescue StandardError
113
- { total_resources: 0, portals: 0, ops_resources: 0, email_resources: 0, ai_resources: 0, assistant_resources: 0 }
27
+ default
114
28
  end
115
29
 
116
- def build_root_recent
117
- {
118
- recent_users: -> { defined?(::User) ? ::User.order(created_at: :desc).limit(5) : [] },
119
- recent_applications: -> { defined?(::InterviewApplication) ? ::InterviewApplication.order(created_at: :desc).limit(5) : [] },
120
- recent_threads: -> { defined?(::Assistant::ChatThread) ? ::Assistant::ChatThread.order(created_at: :desc).limit(5) : [] },
121
- recent_scraping: -> { defined?(::ScrapingAttempt) ? ::ScrapingAttempt.order(created_at: :desc).limit(5) : [] }
122
- }
123
- end
124
-
125
- def build_root_health
126
- {
127
- app: app_health,
128
- scraping: scraping_health,
129
- llm: llm_health,
130
- assistant: assistant_health
131
- }
30
+ def build_portal_cards(items)
31
+ items.sort_by { |(_k, v)| (v[:order] || 100).to_i }.map do |portal_key, portal|
32
+ color = portal[:color].presence || default_portal_color(portal_key)
33
+ {
34
+ key: portal_key,
35
+ label: portal[:label] || portal_key.to_s.humanize,
36
+ description: portal[:description],
37
+ color: color,
38
+ icon: portal[:icon],
39
+ path: portal_path(portal: portal_key),
40
+ count: (portal[:sections] || {}).values.sum { |s| Array(s[:items]).size }
41
+ }
42
+ end
132
43
  end
133
44
 
134
- def app_health
135
- return { status: :unknown, metrics: {} } unless defined?(::User)
136
-
137
- metrics = {
138
- "Users" => safe_count(::User),
139
- "24h signups" => safe_count(::User, ->(rel) { rel.where("created_at > ?", 24.hours.ago) }),
140
- "Applications" => (defined?(::InterviewApplication) ? safe_count(::InterviewApplication) : "—"),
141
- "Job listings" => (defined?(::JobListing) ? safe_count(::JobListing) : "—")
142
- }
45
+ def resolve_root_dashboard_rows(items)
46
+ definition = AdminSuite.root_dashboard_definition
47
+ configured_rows = definition&.rows
48
+ return configured_rows unless configured_rows.nil?
143
49
 
144
- { status: :healthy, metrics: metrics }
145
- rescue StandardError
146
- { status: :unknown, metrics: {} }
50
+ build_default_root_dashboard_rows(items)
147
51
  end
148
52
 
149
- def scraping_health
150
- return { status: :unknown, metrics: {} } unless defined?(::ScrapingAttempt)
53
+ def build_default_root_dashboard_rows(items)
54
+ portal_cards = @portal_cards
55
+ total_portals = items.keys.count
56
+ total_resources = safe_resource_count
151
57
 
152
- recent_attempts = ::ScrapingAttempt.where("created_at > ?", 24.hours.ago)
153
- total = recent_attempts.count
154
- successful = recent_attempts.where(status: :completed).count
155
- failed = recent_attempts.where(status: :failed).count
156
- stuck = recent_attempts.where(status: :processing).where("updated_at < ?", 1.hour.ago).count
58
+ definition = AdminSuite::UI::DashboardDefinition.new
59
+ dsl = AdminSuite::UI::DashboardDSL.new(definition)
157
60
 
158
- success_rate = total > 0 ? (successful.to_f / total * 100).round : 0
159
- status =
160
- if stuck > 5 || (total > 10 && success_rate < 50)
161
- :critical
162
- elsif stuck > 0 || (total > 10 && success_rate < 80)
163
- :degraded
164
- else
165
- :healthy
166
- end
167
-
168
- {
169
- status: status,
170
- metrics: {
171
- "24h attempts" => total,
172
- "success rate" => "#{success_rate}%",
173
- "failed" => failed,
174
- "stuck" => stuck
175
- }
176
- }
177
- rescue StandardError
178
- { status: :unknown, metrics: {} }
179
- end
180
-
181
- def llm_health
182
- return { status: :unknown, metrics: {} } unless defined?(::Ai::LlmApiLog)
183
-
184
- recent_logs = ::Ai::LlmApiLog.where("created_at > ?", 24.hours.ago)
185
- total = recent_logs.count
186
- successful = recent_logs.where(status: :success).count
187
- failed = recent_logs.where(status: :failed).count
188
- avg_latency = recent_logs.where(status: :success).average(:latency_ms)&.round || 0
189
- total_cost_cents = recent_logs.sum(:estimated_cost_cents) || 0
190
- total_cost = (total_cost_cents / 100.0).round(2)
61
+ dsl.row do
62
+ cards_panel "Portals", span: 12, variant: :portals, resources: portal_cards
63
+ end
191
64
 
192
- success_rate = total > 0 ? (successful.to_f / total * 100).round : 0
193
- status =
194
- if total > 10 && success_rate < 80
195
- :critical
196
- elsif total > 10 && success_rate < 95
197
- :degraded
198
- else
199
- :healthy
200
- end
65
+ dsl.row do
66
+ stat_panel "Portals", total_portals, span: 6, variant: :mini, color: :slate
67
+ stat_panel "Resources", total_resources, span: 6, variant: :mini, color: :slate
68
+ end
201
69
 
202
- {
203
- status: status,
204
- metrics: {
205
- "24h calls" => total,
206
- "success rate" => "#{success_rate}%",
207
- "avg latency" => "#{avg_latency}ms",
208
- "24h cost" => "$#{total_cost}",
209
- "failed" => failed
210
- }
211
- }
212
- rescue StandardError
213
- { status: :unknown, metrics: {} }
70
+ definition.rows
214
71
  end
215
72
 
216
- def assistant_health
217
- return { status: :unknown, metrics: {} } unless defined?(::Assistant::ToolExecution)
218
-
219
- recent_threads = (defined?(::Assistant::ChatThread) ? ::Assistant::ChatThread.where("created_at > ?", 24.hours.ago) : nil)
220
- recent_executions = ::Assistant::ToolExecution.where("created_at > ?", 24.hours.ago)
221
-
222
- total_executions = recent_executions.count
223
- successful = recent_executions.where(status: :completed).count
224
- failed = recent_executions.where(status: :failed).count
225
- pending = ::Assistant::ToolExecution.where(status: :pending_approval).count
226
-
227
- success_rate = total_executions > 0 ? (successful.to_f / total_executions * 100).round : 100
228
- status =
229
- if failed > 10
230
- :critical
231
- elsif pending > 20 || (total_executions > 10 && success_rate < 70)
232
- :degraded
233
- else
234
- :healthy
235
- end
236
-
237
- {
238
- status: status,
239
- metrics: {
240
- "24h threads" => (recent_threads ? recent_threads.count : "—"),
241
- "24h tool runs" => total_executions,
242
- "success rate" => "#{success_rate}%",
243
- "pending" => pending
244
- }
245
- }
246
- rescue StandardError
247
- { status: :unknown, metrics: {} }
73
+ def default_portal_color(portal_key)
74
+ case portal_key.to_sym
75
+ when :ops then "amber"
76
+ when :email then "emerald"
77
+ when :ai then "cyan"
78
+ when :assistant then "violet"
79
+ else "slate"
80
+ end
248
81
  end
249
82
 
250
- def safe_count(klass, scope_proc = nil)
251
- rel = klass.all
252
- rel = scope_proc.call(rel) if scope_proc
253
- rel.count
83
+ def safe_resource_count
84
+ return 0 unless defined?(Admin::Base::Resource)
85
+
86
+ Admin::Base::Resource.registered_resources.count
254
87
  rescue StandardError
255
- "—"
88
+ 0
256
89
  end
257
90
  end
258
91
  end