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.
- checksums.yaml +7 -0
- data/.gitignore +10 -0
- data/Gemfile +13 -0
- data/LICENSE.txt +22 -0
- data/README.md +7 -0
- data/Rakefile +11 -0
- data/app/assets/admin_suite.css +444 -0
- data/app/assets/admin_suite_tailwind.css +8 -0
- data/app/assets/builds/admin_suite_tailwind.css +8 -0
- data/app/assets/rouge.css +218 -0
- data/app/assets/tailwind/admin_suite.css +22 -0
- data/app/controllers/admin_suite/application_controller.rb +118 -0
- data/app/controllers/admin_suite/dashboard_controller.rb +258 -0
- data/app/controllers/admin_suite/docs_controller.rb +155 -0
- data/app/controllers/admin_suite/portals_controller.rb +22 -0
- data/app/controllers/admin_suite/resources_controller.rb +238 -0
- data/app/helpers/admin_suite/base_helper.rb +1199 -0
- data/app/helpers/admin_suite/icon_helper.rb +61 -0
- data/app/helpers/admin_suite/panels_helper.rb +52 -0
- data/app/helpers/admin_suite/resources_helper.rb +15 -0
- data/app/helpers/admin_suite/theme_helper.rb +99 -0
- data/app/javascript/controllers/admin_suite/click_actions_controller.js +73 -0
- data/app/javascript/controllers/admin_suite/clipboard_controller.js +57 -0
- data/app/javascript/controllers/admin_suite/code_editor_controller.js +45 -0
- data/app/javascript/controllers/admin_suite/file_upload_controller.js +238 -0
- data/app/javascript/controllers/admin_suite/json_editor_controller.js +62 -0
- data/app/javascript/controllers/admin_suite/live_filter_controller.js +71 -0
- data/app/javascript/controllers/admin_suite/markdown_editor_controller.js +67 -0
- data/app/javascript/controllers/admin_suite/searchable_select_controller.js +171 -0
- data/app/javascript/controllers/admin_suite/sidebar_controller.js +33 -0
- data/app/javascript/controllers/admin_suite/tag_select_controller.js +193 -0
- data/app/javascript/controllers/admin_suite/toggle_switch_controller.js +66 -0
- data/app/views/admin_suite/dashboard/index.html.erb +21 -0
- data/app/views/admin_suite/docs/index.html.erb +86 -0
- data/app/views/admin_suite/panels/_cards.html.erb +107 -0
- data/app/views/admin_suite/panels/_chart.html.erb +47 -0
- data/app/views/admin_suite/panels/_health.html.erb +44 -0
- data/app/views/admin_suite/panels/_recent.html.erb +56 -0
- data/app/views/admin_suite/panels/_stat.html.erb +64 -0
- data/app/views/admin_suite/panels/_table.html.erb +36 -0
- data/app/views/admin_suite/portals/show.html.erb +75 -0
- data/app/views/admin_suite/resources/_form.html.erb +32 -0
- data/app/views/admin_suite/resources/edit.html.erb +24 -0
- data/app/views/admin_suite/resources/index.html.erb +315 -0
- data/app/views/admin_suite/resources/new.html.erb +22 -0
- data/app/views/admin_suite/resources/show.html.erb +184 -0
- data/app/views/admin_suite/shared/_flash.html.erb +30 -0
- data/app/views/admin_suite/shared/_form.html.erb +60 -0
- data/app/views/admin_suite/shared/_json_editor_field.html.erb +52 -0
- data/app/views/admin_suite/shared/_sidebar.html.erb +94 -0
- data/app/views/admin_suite/shared/_toggle_cell.html.erb +34 -0
- data/app/views/admin_suite/shared/_topbar.html.erb +47 -0
- data/app/views/layouts/admin_suite/application.html.erb +79 -0
- data/lib/admin/base/action_executor.rb +155 -0
- data/lib/admin/base/action_handler.rb +31 -0
- data/lib/admin/base/filter_builder.rb +121 -0
- data/lib/admin/base/resource.rb +541 -0
- data/lib/admin_suite/configuration.rb +42 -0
- data/lib/admin_suite/engine.rb +101 -0
- data/lib/admin_suite/markdown_renderer.rb +115 -0
- data/lib/admin_suite/portal_definition.rb +64 -0
- data/lib/admin_suite/portal_registry.rb +32 -0
- data/lib/admin_suite/theme_palette.rb +36 -0
- data/lib/admin_suite/ui/dashboard_definition.rb +69 -0
- data/lib/admin_suite/ui/field_renderer_registry.rb +119 -0
- data/lib/admin_suite/ui/form_field_renderer.rb +48 -0
- data/lib/admin_suite/ui/show_formatter_registry.rb +120 -0
- data/lib/admin_suite/ui/show_value_formatter.rb +70 -0
- data/lib/admin_suite/version.rb +10 -0
- data/lib/admin_suite.rb +54 -0
- data/lib/generators/admin_suite/install/install_generator.rb +23 -0
- data/lib/generators/admin_suite/install/templates/admin_suite.rb +60 -0
- data/lib/generators/admin_suite/resource/resource_generator.rb +83 -0
- data/lib/generators/admin_suite/resource/templates/resource.rb.tt +47 -0
- data/lib/generators/admin_suite/scaffold/scaffold_generator.rb +28 -0
- data/lib/tasks/admin_suite_tailwind.rake +28 -0
- data/lib/tasks/admin_suite_test.rake +11 -0
- data/test/dummy/Gemfile +21 -0
- data/test/dummy/README.md +24 -0
- data/test/dummy/Rakefile +6 -0
- data/test/dummy/app/assets/stylesheets/application.css +10 -0
- data/test/dummy/app/controllers/application_controller.rb +4 -0
- data/test/dummy/app/helpers/application_helper.rb +2 -0
- data/test/dummy/app/models/application_record.rb +2 -0
- data/test/dummy/app/views/layouts/application.html.erb +28 -0
- data/test/dummy/app/views/pwa/manifest.json.erb +22 -0
- data/test/dummy/app/views/pwa/service-worker.js +26 -0
- data/test/dummy/bin/ci +6 -0
- data/test/dummy/bin/dev +2 -0
- data/test/dummy/bin/rails +4 -0
- data/test/dummy/bin/rake +4 -0
- data/test/dummy/bin/setup +35 -0
- data/test/dummy/config/application.rb +43 -0
- data/test/dummy/config/boot.rb +3 -0
- data/test/dummy/config/ci.rb +19 -0
- data/test/dummy/config/database.yml +31 -0
- data/test/dummy/config/environment.rb +5 -0
- data/test/dummy/config/environments/development.rb +57 -0
- data/test/dummy/config/environments/production.rb +67 -0
- data/test/dummy/config/environments/test.rb +42 -0
- data/test/dummy/config/initializers/assets.rb +7 -0
- data/test/dummy/config/initializers/content_security_policy.rb +29 -0
- data/test/dummy/config/initializers/filter_parameter_logging.rb +8 -0
- data/test/dummy/config/initializers/inflections.rb +16 -0
- data/test/dummy/config/locales/en.yml +31 -0
- data/test/dummy/config/puma.rb +39 -0
- data/test/dummy/config/routes.rb +16 -0
- data/test/dummy/config.ru +6 -0
- data/test/dummy/db/seeds.rb +9 -0
- data/test/dummy/log/test.log +441 -0
- data/test/dummy/public/400.html +135 -0
- data/test/dummy/public/404.html +135 -0
- data/test/dummy/public/406-unsupported-browser.html +135 -0
- data/test/dummy/public/422.html +135 -0
- data/test/dummy/public/500.html +135 -0
- data/test/dummy/public/icon.png +0 -0
- data/test/dummy/public/icon.svg +3 -0
- data/test/dummy/public/robots.txt +1 -0
- data/test/dummy/test/test_helper.rb +15 -0
- data/test/dummy/tmp/local_secret.txt +1 -0
- data/test/fixtures/docs/progress/PROGRESS_REPORT.md +6 -0
- data/test/integration/dashboard_test.rb +13 -0
- data/test/integration/docs_test.rb +46 -0
- data/test/integration/theme_test.rb +27 -0
- data/test/lib/markdown_renderer_test.rb +20 -0
- data/test/lib/theme_palette_test.rb +24 -0
- data/test/test_helper.rb +11 -0
- 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
|