admin_suite 0.1.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 (128) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +10 -0
  3. data/Gemfile +13 -0
  4. data/LICENSE.txt +22 -0
  5. data/README.md +7 -0
  6. data/Rakefile +11 -0
  7. data/app/assets/admin_suite.css +444 -0
  8. data/app/assets/admin_suite_tailwind.css +8 -0
  9. data/app/assets/builds/admin_suite_tailwind.css +8 -0
  10. data/app/assets/rouge.css +218 -0
  11. data/app/assets/tailwind/admin_suite.css +22 -0
  12. data/app/controllers/admin_suite/application_controller.rb +118 -0
  13. data/app/controllers/admin_suite/dashboard_controller.rb +258 -0
  14. data/app/controllers/admin_suite/docs_controller.rb +155 -0
  15. data/app/controllers/admin_suite/portals_controller.rb +22 -0
  16. data/app/controllers/admin_suite/resources_controller.rb +238 -0
  17. data/app/helpers/admin_suite/base_helper.rb +1199 -0
  18. data/app/helpers/admin_suite/icon_helper.rb +61 -0
  19. data/app/helpers/admin_suite/panels_helper.rb +52 -0
  20. data/app/helpers/admin_suite/resources_helper.rb +15 -0
  21. data/app/helpers/admin_suite/theme_helper.rb +99 -0
  22. data/app/javascript/controllers/admin_suite/click_actions_controller.js +73 -0
  23. data/app/javascript/controllers/admin_suite/clipboard_controller.js +57 -0
  24. data/app/javascript/controllers/admin_suite/code_editor_controller.js +45 -0
  25. data/app/javascript/controllers/admin_suite/file_upload_controller.js +238 -0
  26. data/app/javascript/controllers/admin_suite/json_editor_controller.js +62 -0
  27. data/app/javascript/controllers/admin_suite/live_filter_controller.js +71 -0
  28. data/app/javascript/controllers/admin_suite/markdown_editor_controller.js +67 -0
  29. data/app/javascript/controllers/admin_suite/searchable_select_controller.js +171 -0
  30. data/app/javascript/controllers/admin_suite/sidebar_controller.js +33 -0
  31. data/app/javascript/controllers/admin_suite/tag_select_controller.js +193 -0
  32. data/app/javascript/controllers/admin_suite/toggle_switch_controller.js +66 -0
  33. data/app/views/admin_suite/dashboard/index.html.erb +21 -0
  34. data/app/views/admin_suite/docs/index.html.erb +86 -0
  35. data/app/views/admin_suite/panels/_cards.html.erb +107 -0
  36. data/app/views/admin_suite/panels/_chart.html.erb +47 -0
  37. data/app/views/admin_suite/panels/_health.html.erb +44 -0
  38. data/app/views/admin_suite/panels/_recent.html.erb +56 -0
  39. data/app/views/admin_suite/panels/_stat.html.erb +64 -0
  40. data/app/views/admin_suite/panels/_table.html.erb +36 -0
  41. data/app/views/admin_suite/portals/show.html.erb +75 -0
  42. data/app/views/admin_suite/resources/_form.html.erb +32 -0
  43. data/app/views/admin_suite/resources/edit.html.erb +24 -0
  44. data/app/views/admin_suite/resources/index.html.erb +315 -0
  45. data/app/views/admin_suite/resources/new.html.erb +22 -0
  46. data/app/views/admin_suite/resources/show.html.erb +184 -0
  47. data/app/views/admin_suite/shared/_flash.html.erb +30 -0
  48. data/app/views/admin_suite/shared/_form.html.erb +60 -0
  49. data/app/views/admin_suite/shared/_json_editor_field.html.erb +52 -0
  50. data/app/views/admin_suite/shared/_sidebar.html.erb +94 -0
  51. data/app/views/admin_suite/shared/_toggle_cell.html.erb +34 -0
  52. data/app/views/admin_suite/shared/_topbar.html.erb +47 -0
  53. data/app/views/layouts/admin_suite/application.html.erb +79 -0
  54. data/lib/admin/base/action_executor.rb +155 -0
  55. data/lib/admin/base/action_handler.rb +31 -0
  56. data/lib/admin/base/filter_builder.rb +121 -0
  57. data/lib/admin/base/resource.rb +541 -0
  58. data/lib/admin_suite/configuration.rb +42 -0
  59. data/lib/admin_suite/engine.rb +101 -0
  60. data/lib/admin_suite/markdown_renderer.rb +115 -0
  61. data/lib/admin_suite/portal_definition.rb +64 -0
  62. data/lib/admin_suite/portal_registry.rb +32 -0
  63. data/lib/admin_suite/theme_palette.rb +36 -0
  64. data/lib/admin_suite/ui/dashboard_definition.rb +69 -0
  65. data/lib/admin_suite/ui/field_renderer_registry.rb +119 -0
  66. data/lib/admin_suite/ui/form_field_renderer.rb +48 -0
  67. data/lib/admin_suite/ui/show_formatter_registry.rb +120 -0
  68. data/lib/admin_suite/ui/show_value_formatter.rb +70 -0
  69. data/lib/admin_suite/version.rb +10 -0
  70. data/lib/admin_suite.rb +54 -0
  71. data/lib/generators/admin_suite/install/install_generator.rb +23 -0
  72. data/lib/generators/admin_suite/install/templates/admin_suite.rb +60 -0
  73. data/lib/generators/admin_suite/resource/resource_generator.rb +83 -0
  74. data/lib/generators/admin_suite/resource/templates/resource.rb.tt +47 -0
  75. data/lib/generators/admin_suite/scaffold/scaffold_generator.rb +28 -0
  76. data/lib/tasks/admin_suite_tailwind.rake +28 -0
  77. data/lib/tasks/admin_suite_test.rake +11 -0
  78. data/test/dummy/Gemfile +21 -0
  79. data/test/dummy/README.md +24 -0
  80. data/test/dummy/Rakefile +6 -0
  81. data/test/dummy/app/assets/stylesheets/application.css +10 -0
  82. data/test/dummy/app/controllers/application_controller.rb +4 -0
  83. data/test/dummy/app/helpers/application_helper.rb +2 -0
  84. data/test/dummy/app/models/application_record.rb +2 -0
  85. data/test/dummy/app/views/layouts/application.html.erb +28 -0
  86. data/test/dummy/app/views/pwa/manifest.json.erb +22 -0
  87. data/test/dummy/app/views/pwa/service-worker.js +26 -0
  88. data/test/dummy/bin/ci +6 -0
  89. data/test/dummy/bin/dev +2 -0
  90. data/test/dummy/bin/rails +4 -0
  91. data/test/dummy/bin/rake +4 -0
  92. data/test/dummy/bin/setup +35 -0
  93. data/test/dummy/config/application.rb +43 -0
  94. data/test/dummy/config/boot.rb +3 -0
  95. data/test/dummy/config/ci.rb +19 -0
  96. data/test/dummy/config/database.yml +31 -0
  97. data/test/dummy/config/environment.rb +5 -0
  98. data/test/dummy/config/environments/development.rb +57 -0
  99. data/test/dummy/config/environments/production.rb +67 -0
  100. data/test/dummy/config/environments/test.rb +42 -0
  101. data/test/dummy/config/initializers/assets.rb +7 -0
  102. data/test/dummy/config/initializers/content_security_policy.rb +29 -0
  103. data/test/dummy/config/initializers/filter_parameter_logging.rb +8 -0
  104. data/test/dummy/config/initializers/inflections.rb +16 -0
  105. data/test/dummy/config/locales/en.yml +31 -0
  106. data/test/dummy/config/puma.rb +39 -0
  107. data/test/dummy/config/routes.rb +16 -0
  108. data/test/dummy/config.ru +6 -0
  109. data/test/dummy/db/seeds.rb +9 -0
  110. data/test/dummy/log/test.log +441 -0
  111. data/test/dummy/public/400.html +135 -0
  112. data/test/dummy/public/404.html +135 -0
  113. data/test/dummy/public/406-unsupported-browser.html +135 -0
  114. data/test/dummy/public/422.html +135 -0
  115. data/test/dummy/public/500.html +135 -0
  116. data/test/dummy/public/icon.png +0 -0
  117. data/test/dummy/public/icon.svg +3 -0
  118. data/test/dummy/public/robots.txt +1 -0
  119. data/test/dummy/test/test_helper.rb +15 -0
  120. data/test/dummy/tmp/local_secret.txt +1 -0
  121. data/test/fixtures/docs/progress/PROGRESS_REPORT.md +6 -0
  122. data/test/integration/dashboard_test.rb +13 -0
  123. data/test/integration/docs_test.rb +46 -0
  124. data/test/integration/theme_test.rb +27 -0
  125. data/test/lib/markdown_renderer_test.rb +20 -0
  126. data/test/lib/theme_palette_test.rb +24 -0
  127. data/test/test_helper.rb +11 -0
  128. metadata +264 -0
@@ -0,0 +1,218 @@
1
+ .highlight table td { padding: 5px; }
2
+ .highlight table pre { margin: 0; }
3
+ .highlight .c, .highlight .ch, .highlight .cd, .highlight .cpf {
4
+ color: #75715e;
5
+ font-style: italic;
6
+ }
7
+ .highlight .cm {
8
+ color: #75715e;
9
+ font-style: italic;
10
+ }
11
+ .highlight .c1 {
12
+ color: #75715e;
13
+ font-style: italic;
14
+ }
15
+ .highlight .cp {
16
+ color: #75715e;
17
+ font-weight: bold;
18
+ }
19
+ .highlight .cs {
20
+ color: #75715e;
21
+ font-weight: bold;
22
+ font-style: italic;
23
+ }
24
+ .highlight .err {
25
+ color: #960050;
26
+ background-color: #1e0010;
27
+ }
28
+ .highlight .gi {
29
+ color: #ffffff;
30
+ background-color: #324932;
31
+ }
32
+ .highlight .gd {
33
+ color: #ffffff;
34
+ background-color: #493131;
35
+ }
36
+ .highlight .ge {
37
+ font-style: italic;
38
+ }
39
+ .highlight .ges {
40
+ font-weight: bold;
41
+ font-style: italic;
42
+ }
43
+ .highlight .gr {
44
+ color: #aa0000;
45
+ }
46
+ .highlight .gt {
47
+ color: #aa0000;
48
+ }
49
+ .highlight .gh {
50
+ color: #999999;
51
+ }
52
+ .highlight .go {
53
+ color: #888888;
54
+ }
55
+ .highlight .gp {
56
+ color: #555555;
57
+ }
58
+ .highlight .gs {
59
+ font-weight: bold;
60
+ }
61
+ .highlight .gu {
62
+ color: #aaaaaa;
63
+ }
64
+ .highlight .k, .highlight .kv {
65
+ color: #66d9ef;
66
+ font-weight: bold;
67
+ }
68
+ .highlight .kc {
69
+ color: #66d9ef;
70
+ font-weight: bold;
71
+ }
72
+ .highlight .kd {
73
+ color: #66d9ef;
74
+ font-weight: bold;
75
+ }
76
+ .highlight .kp {
77
+ color: #66d9ef;
78
+ font-weight: bold;
79
+ }
80
+ .highlight .kr {
81
+ color: #66d9ef;
82
+ font-weight: bold;
83
+ }
84
+ .highlight .kt {
85
+ color: #66d9ef;
86
+ font-weight: bold;
87
+ }
88
+ .highlight .kn {
89
+ color: #f92672;
90
+ font-weight: bold;
91
+ }
92
+ .highlight .ow {
93
+ color: #f92672;
94
+ font-weight: bold;
95
+ }
96
+ .highlight .o {
97
+ color: #f92672;
98
+ font-weight: bold;
99
+ }
100
+ .highlight .mf {
101
+ color: #ae81ff;
102
+ }
103
+ .highlight .mh {
104
+ color: #ae81ff;
105
+ }
106
+ .highlight .il {
107
+ color: #ae81ff;
108
+ }
109
+ .highlight .mi {
110
+ color: #ae81ff;
111
+ }
112
+ .highlight .mo {
113
+ color: #ae81ff;
114
+ }
115
+ .highlight .m, .highlight .mb, .highlight .mx {
116
+ color: #ae81ff;
117
+ }
118
+ .highlight .se {
119
+ color: #ae81ff;
120
+ }
121
+ .highlight .sa {
122
+ color: #66d9ef;
123
+ font-weight: bold;
124
+ }
125
+ .highlight .sb {
126
+ color: #e6db74;
127
+ }
128
+ .highlight .sc {
129
+ color: #e6db74;
130
+ }
131
+ .highlight .sd {
132
+ color: #e6db74;
133
+ }
134
+ .highlight .s2 {
135
+ color: #e6db74;
136
+ }
137
+ .highlight .sh {
138
+ color: #e6db74;
139
+ }
140
+ .highlight .si {
141
+ color: #e6db74;
142
+ }
143
+ .highlight .sx {
144
+ color: #e6db74;
145
+ }
146
+ .highlight .sr {
147
+ color: #e6db74;
148
+ }
149
+ .highlight .s1 {
150
+ color: #e6db74;
151
+ }
152
+ .highlight .ss {
153
+ color: #e6db74;
154
+ }
155
+ .highlight .s, .highlight .dl {
156
+ color: #e6db74;
157
+ }
158
+ .highlight .na {
159
+ color: #a6e22e;
160
+ }
161
+ .highlight .nc {
162
+ color: #a6e22e;
163
+ font-weight: bold;
164
+ }
165
+ .highlight .nd {
166
+ color: #a6e22e;
167
+ font-weight: bold;
168
+ }
169
+ .highlight .ne {
170
+ color: #a6e22e;
171
+ font-weight: bold;
172
+ }
173
+ .highlight .nf, .highlight .fm {
174
+ color: #a6e22e;
175
+ font-weight: bold;
176
+ }
177
+ .highlight .no {
178
+ color: #66d9ef;
179
+ }
180
+ .highlight .bp {
181
+ color: #f8f8f2;
182
+ }
183
+ .highlight .nb {
184
+ color: #f8f8f2;
185
+ }
186
+ .highlight .ni {
187
+ color: #f8f8f2;
188
+ }
189
+ .highlight .nn {
190
+ color: #f8f8f2;
191
+ }
192
+ .highlight .vc {
193
+ color: #f8f8f2;
194
+ }
195
+ .highlight .vg {
196
+ color: #f8f8f2;
197
+ }
198
+ .highlight .vi {
199
+ color: #f8f8f2;
200
+ }
201
+ .highlight .nv, .highlight .vm {
202
+ color: #f8f8f2;
203
+ }
204
+ .highlight .w {
205
+ color: #f8f8f2;
206
+ }
207
+ .highlight .nl {
208
+ color: #f8f8f2;
209
+ font-weight: bold;
210
+ }
211
+ .highlight .nt {
212
+ color: #f92672;
213
+ }
214
+ .highlight {
215
+ color: #f8f8f2;
216
+ background-color: #49483e;
217
+ }
218
+
@@ -0,0 +1,22 @@
1
+ /* AdminSuite dark mode
2
+ -------------------
3
+ The host app may use `.dark` on <html> for its own UI.
4
+ To avoid unintentionally darkening the AdminSuite engine, we scope the Tailwind
5
+ `dark:` variant to `.admin-suite-dark` instead.
6
+ (AdminSuite currently does not enable `.admin-suite-dark`, so the engine renders
7
+ consistently in light mode.)
8
+ */
9
+ @custom-variant dark (&&:where(.admin-suite-dark, .admin-suite-dark *));
10
+
11
+ @import "tailwindcss";
12
+
13
+ /* Engine-build mode (Tailwind v4)
14
+ ------------------------------
15
+ We explicitly list engine sources so the compiled CSS includes utilities used
16
+ by AdminSuite views/components, independent of the host app.
17
+ */
18
+ @source "../../views/**/*.erb";
19
+ @source "../../helpers/**/*.rb";
20
+ @source "../../javascript/**/*.js";
21
+ @source "../../../lib/**/*.rb";
22
+
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdminSuite
4
+ class ApplicationController < ::ApplicationController
5
+ include ActionView::RecordIdentifier
6
+
7
+ # Host apps often include global auth concerns in `ApplicationController`.
8
+ # The engine uses `AdminSuite.config.authenticate` instead, so we defensively
9
+ # skip any host-level authentication before_actions that would otherwise
10
+ # redirect to missing routes (e.g. `new_session_path`).
11
+ skip_before_action :require_authentication, raise: false
12
+
13
+ before_action :admin_suite_authenticate!
14
+ layout "admin_suite/application"
15
+
16
+ helper AdminSuite::BaseHelper
17
+ helper_method :admin_suite_actor, :navigation_items
18
+
19
+ private
20
+
21
+ # Runs the host-app authentication hook (if configured).
22
+ #
23
+ # @return [void]
24
+ def admin_suite_authenticate!
25
+ hook = AdminSuite.config.authenticate
26
+ hook&.call(self)
27
+ end
28
+
29
+ # Returns the configured actor for actions/auditing/authorization.
30
+ #
31
+ # @return [Object, nil]
32
+ def admin_suite_actor
33
+ AdminSuite.config.current_actor&.call(self)
34
+ rescue StandardError
35
+ nil
36
+ end
37
+
38
+ # Loads resource definition files in development when needed.
39
+ #
40
+ # @return [void]
41
+ def ensure_resources_loaded!
42
+ require "admin/base/resource" unless defined?(Admin::Base::Resource)
43
+ return unless Rails.env.development?
44
+ return if Admin::Base::Resource.registered_resources.any?
45
+
46
+ Array(AdminSuite.config.resource_globs).flat_map { |g| Dir[g] }.uniq.each do |file|
47
+ require file
48
+ end
49
+ rescue NameError
50
+ # Ensure base DSL is loaded first.
51
+ require "admin/base/resource"
52
+ retry
53
+ end
54
+
55
+ # Loads portal definition files in development (safe to call per-request).
56
+ #
57
+ # @return [void]
58
+ def ensure_portals_loaded!
59
+ globs = Array(AdminSuite.config.portal_globs).flat_map { |g| Dir[g] }.uniq
60
+ return if globs.empty?
61
+
62
+ if Rails.env.development?
63
+ # Re-evaluate definitions on each request in development.
64
+ AdminSuite::PortalRegistry.reset!
65
+ globs.each { |file| load file }
66
+ else
67
+ # In non-dev, load once (typically at boot / first request).
68
+ return if AdminSuite::PortalRegistry.all.any?
69
+ globs.each { |file| require file }
70
+ end
71
+ rescue NameError
72
+ require "admin_suite"
73
+ retry
74
+ end
75
+
76
+ # Builds the navigation structure from registered resources.
77
+ #
78
+ # @return [Hash]
79
+ def navigation_items
80
+ ensure_resources_loaded!
81
+ ensure_portals_loaded!
82
+
83
+ portals = AdminSuite.config.portals
84
+ navigation = portals.each_with_object({}) do |(key, meta), h|
85
+ meta = meta.respond_to?(:symbolize_keys) ? meta.symbolize_keys : {}
86
+ h[key.to_sym] = meta.merge(sections: {})
87
+ end
88
+
89
+ # Merge any DSL-defined portal metadata into navigation.
90
+ AdminSuite::PortalRegistry.all.each do |key, definition|
91
+ navigation[key.to_sym] ||= { label: key.to_s.humanize, order: 100, sections: {} }
92
+ navigation[key.to_sym].merge!(definition.to_nav_meta)
93
+ navigation[key.to_sym][:sections] ||= {}
94
+ end
95
+
96
+ Admin::Base::Resource.registered_resources.each do |resource|
97
+ next unless resource.portal_name && resource.section_name
98
+
99
+ portal = resource.portal_name.to_sym
100
+ section = resource.section_name.to_sym
101
+
102
+ navigation[portal] ||= { label: portal.to_s.humanize, order: 100, sections: {} }
103
+ navigation[portal][:sections][section] ||= { label: section.to_s.humanize, items: [] }
104
+
105
+ label = resource.nav_label.presence || resource.human_name_plural
106
+ navigation[portal][:sections][section][:items] << {
107
+ label: label,
108
+ path: resources_path(portal: portal, resource_name: resource.resource_name_plural),
109
+ resource: resource,
110
+ icon: resource.nav_icon,
111
+ order: resource.nav_order
112
+ }
113
+ end
114
+
115
+ navigation
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdminSuite
4
+ class DashboardController < ApplicationController
5
+ def index
6
+ # Ensure portal/resource metadata is available.
7
+ items = navigation_items
8
+
9
+ @health = build_root_health
10
+ @stats = build_root_stats(items)
11
+ @recent = build_root_recent
12
+
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
26
+
27
+ @dashboard_sections = build_sections
28
+ end
29
+
30
+ private
31
+
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
+ }
112
+ rescue StandardError
113
+ { total_resources: 0, portals: 0, ops_resources: 0, email_resources: 0, ai_resources: 0, assistant_resources: 0 }
114
+ end
115
+
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
+ }
132
+ end
133
+
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
+ }
143
+
144
+ { status: :healthy, metrics: metrics }
145
+ rescue StandardError
146
+ { status: :unknown, metrics: {} }
147
+ end
148
+
149
+ def scraping_health
150
+ return { status: :unknown, metrics: {} } unless defined?(::ScrapingAttempt)
151
+
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
157
+
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)
191
+
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
201
+
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: {} }
214
+ end
215
+
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: {} }
248
+ end
249
+
250
+ def safe_count(klass, scope_proc = nil)
251
+ rel = klass.all
252
+ rel = scope_proc.call(rel) if scope_proc
253
+ rel.count
254
+ rescue StandardError
255
+ "—"
256
+ end
257
+ end
258
+ end