rails_ops_dashboard 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 (31) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +22 -0
  3. data/LICENSE +21 -0
  4. data/README.md +171 -0
  5. data/app/controllers/concerns/rails_ops_dashboard/background_task_runner.rb +28 -0
  6. data/app/controllers/rails_ops_dashboard/base_controller.rb +24 -0
  7. data/app/controllers/rails_ops_dashboard/dashboard_controller.rb +19 -0
  8. data/app/controllers/rails_ops_dashboard/environments_controller.rb +16 -0
  9. data/app/controllers/rails_ops_dashboard/rake_tasks_controller.rb +63 -0
  10. data/app/controllers/rails_ops_dashboard/seeds_controller.rb +52 -0
  11. data/app/controllers/rails_ops_dashboard/sessions_controller.rb +11 -0
  12. data/app/controllers/rails_ops_dashboard/workers_controller.rb +76 -0
  13. data/app/views/layouts/rails_ops_dashboard/application.html.erb +73 -0
  14. data/app/views/rails_ops_dashboard/dashboard/index.html.erb +19 -0
  15. data/app/views/rails_ops_dashboard/environments/show.html.erb +89 -0
  16. data/app/views/rails_ops_dashboard/rake_tasks/index.html.erb +78 -0
  17. data/app/views/rails_ops_dashboard/seeds/index.html.erb +76 -0
  18. data/app/views/rails_ops_dashboard/shared/_confirmation_modal.html.erb +31 -0
  19. data/app/views/rails_ops_dashboard/shared/_flash_messages.html.erb +10 -0
  20. data/app/views/rails_ops_dashboard/workers/index.html.erb +48 -0
  21. data/config/routes.rb +30 -0
  22. data/lib/generators/rails_ops_dashboard/install/install_generator.rb +19 -0
  23. data/lib/generators/rails_ops_dashboard/install/templates/initializer.rb.tt +25 -0
  24. data/lib/generators/rails_ops_dashboard/views/views_generator.rb +19 -0
  25. data/lib/rails_ops_dashboard/configuration.rb +40 -0
  26. data/lib/rails_ops_dashboard/engine.rb +7 -0
  27. data/lib/rails_ops_dashboard/plugin.rb +23 -0
  28. data/lib/rails_ops_dashboard/plugins/workers.rb +11 -0
  29. data/lib/rails_ops_dashboard/version.rb +5 -0
  30. data/lib/rails_ops_dashboard.rb +9 -0
  31. metadata +119 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 90c0a1623fdefe1175b6c2923cacd08b3746bc6cae3f5f04e06f784fb32513eb
4
+ data.tar.gz: 96294e89a512b6af3022288d7054b675176b05867e5eb65540c14a0d310e4011
5
+ SHA512:
6
+ metadata.gz: 24d2dce15586e87f2250915c20df453876929b089169ed822c033d137935e03a849cc687abad092a2d1e0d1605a47729a2346fd2c37143300a493ae85bbf0044
7
+ data.tar.gz: 24b8f3b5ac095acbb8803cf5141bfd367be11f9f16d9ca767c50174fa5f41b0b55dbca8c15adb8f8c71a6e7c2afb9b0fbf5a09e30b0e6e79e9a231897680d77c
data/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-03-06
11
+
12
+ ### Added
13
+ - Initial release
14
+ - Core modules: Seeds, Rake Tasks, Environment
15
+ - Background task runner with status polling
16
+ - HTTP Basic Auth with timing-attack safe comparison
17
+ - Environment blocking (production blocked by default)
18
+ - Logout support
19
+ - Plugin system with Workers plugin
20
+ - Install and views generators
21
+ - Tailwind CSS via CDN (zero asset pipeline dependency)
22
+ - RSpec test suite with dummy Rails app
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rai Lima
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,171 @@
1
+ # Rails Ops Dashboard
2
+
3
+ A lightweight operations dashboard for Rails applications. Run seeds, execute rake tasks, inspect environment variables, and manage background workers, all from a single web interface protected by HTTP Basic Auth.
4
+
5
+ ## Why this gem?
6
+
7
+ This gem was born from a real pain point. In our team, running a simple rake task or seed script on staging required a full infrastructure odyssey: connect to the VPN, authenticate via AWS SSO, generate an EKS token with `aws eks get-token`, open the Kubernetes dashboard, find the right environment, locate the Rails pod, open a terminal session inside the container, and only then run the actual command. Six or seven steps just to execute `rake db:seed`.
8
+
9
+ We built an internal ops dashboard to skip all of that. Instead of tunneling through layers of infrastructure, any developer on the team could open a browser, authenticate with a simple password, and run the operation directly. After using it for a while, we realized this was a generic enough problem that other teams probably deal with the same friction, so we extracted it into this gem.
10
+
11
+ Most Rails dashboard gems focus on admin CRUD for models (Avo, Administrate, RailsAdmin, Motor Admin). None of them provide a simple developer operations panel for the tasks you actually do during development and staging: running seed scripts, checking environment variables, or kicking off rake tasks. This gem fills that gap.
12
+
13
+ ## Should I use this gem?
14
+
15
+ This gem is built for **development and staging environments**. It is blocked in production by default, and the in-memory task tracking does not persist across server restarts.
16
+
17
+ **Good fit if you:**
18
+ - Run seed scripts regularly during development or staging
19
+ - Want a quick way to execute and monitor rake tasks
20
+ - Need to inspect environment variables without a terminal
21
+ - Want to start/stop background workers (Sidekiq, etc.) from a UI during development
22
+
23
+ **Not a good fit if you:**
24
+ - Need a production admin dashboard for CRUD operations on models
25
+ - Need persistent job monitoring (use Sidekiq Web or GoodJob Dashboard instead)
26
+ - Need role-based access control beyond a single username/password
27
+
28
+ ## Installation
29
+
30
+ Add to your Gemfile:
31
+
32
+ ```ruby
33
+ gem 'rails_ops_dashboard'
34
+ ```
35
+
36
+ Run the install generator:
37
+
38
+ ```bash
39
+ bundle install
40
+ rails generate rails_ops_dashboard:install
41
+ ```
42
+
43
+ This creates an initializer at `config/initializers/rails_ops_dashboard.rb` and mounts the engine at `/ops` in your routes.
44
+
45
+ Start your server and visit `http://localhost:3000/ops`.
46
+
47
+ ## Configuration
48
+
49
+ ```ruby
50
+ # config/initializers/rails_ops_dashboard.rb
51
+
52
+ RailsOpsDashboard.configure do |config|
53
+ # HTTP Basic Auth credentials
54
+ config.username = ENV.fetch('OPS_DASHBOARD_USERNAME', 'admin')
55
+ config.password = ENV.fetch('OPS_DASHBOARD_PASSWORD', 'password')
56
+
57
+ # Environments where the dashboard returns 404
58
+ config.blocked_environments = %w[production]
59
+
60
+ # Seeds: directory to scan for seed classes
61
+ config.seeds_path = 'app/services/database_update'
62
+
63
+ # Seeds: namespace prefix for discovered classes
64
+ config.seeds_base_class = 'DatabaseUpdate'
65
+
66
+ # Seeds: how to execute a seed class (receives the class constant)
67
+ config.seeds_executor = ->(klass) { klass.new.call }
68
+
69
+ # Rake tasks: only tasks defined in this path are shown
70
+ config.rake_tasks_path = 'lib/tasks'
71
+
72
+ # Workers plugin (disabled by default)
73
+ # config.plugin :workers,
74
+ # development_only: true,
75
+ # workers: [
76
+ # { id: 'sidekiq', name: 'Sidekiq', command: 'bundle exec sidekiq' },
77
+ # { id: 'webpacker', name: 'Webpacker', command: 'bin/webpack-dev-server' }
78
+ # ]
79
+ end
80
+ ```
81
+
82
+ ## Core Modules
83
+
84
+ ### Seeds
85
+
86
+ Discovers seed classes from a configurable directory. Each Ruby file in `seeds_path` is mapped to a class under `seeds_base_class`. For example, `app/services/database_update/add_test_data.rb` maps to `DatabaseUpdate::AddTestData`.
87
+
88
+ Seeds run in background threads with real-time status polling. The dashboard validates that each class belongs to the configured namespace and has a matching file before execution.
89
+
90
+ ### Rake Tasks
91
+
92
+ Lists rake tasks defined in your configured `rake_tasks_path` directory. Built-in Rails tasks (like `db:migrate`) are filtered out, so you only see your project's custom tasks. Tasks run in background threads with status tracking.
93
+
94
+ ### Environment
95
+
96
+ Displays a searchable table of all environment variables, plus metadata cards showing the Rails environment, hostname, Ruby version, Rails version, and process ID.
97
+
98
+ In many teams, the DevOps team is responsible for creating and updating environment variables across all environments, while developers only have read access. Verifying whether a variable was actually set or changed meant going through the entire VPN, SSO, Kubernetes flow described above. In practice, we would ask DevOps to update a value, they would sometimes forget or delay it, and we had no quick way to confirm without tunneling into the pod. This screen exists so any developer can open the dashboard and immediately see the current state of all environment variables, without bothering anyone or navigating infrastructure tooling.
99
+
100
+ ## Workers Plugin
101
+
102
+ The workers plugin lets you start and stop system processes from the dashboard. It is disabled by default and must be explicitly enabled in the configuration.
103
+
104
+ ```ruby
105
+ RailsOpsDashboard.configure do |config|
106
+ config.plugin :workers,
107
+ development_only: true,
108
+ workers: [
109
+ { id: 'sidekiq', name: 'Sidekiq', command: 'bundle exec sidekiq' },
110
+ { id: 'redis', name: 'Redis', command: 'redis-server' }
111
+ ]
112
+ end
113
+ ```
114
+
115
+ When `development_only` is `true` (the default), the workers page is only accessible in the development environment.
116
+
117
+ Each worker definition requires:
118
+ - `id`: unique identifier for the worker
119
+ - `name`: display name in the UI
120
+ - `command`: shell command to start the process
121
+
122
+ ## View Customization
123
+
124
+ To override the default views, copy them to your application:
125
+
126
+ ```bash
127
+ rails generate rails_ops_dashboard:views
128
+ ```
129
+
130
+ This copies all views to `app/views/rails_ops_dashboard/` in your app, where they take precedence over the gem's built-in views. The layout uses Tailwind CSS via CDN and vanilla JavaScript, so there are no asset pipeline dependencies.
131
+
132
+ ## Security
133
+
134
+ The dashboard uses HTTP Basic Auth with timing-attack-safe credential comparison via `ActiveSupport::SecurityUtils.secure_compare`. CSRF protection is skipped since the dashboard uses stateless authentication.
135
+
136
+ In blocked environments (production by default), the dashboard returns a 404 response, which avoids revealing the existence of the endpoint.
137
+
138
+ Always configure credentials via environment variables in any shared environment.
139
+
140
+ ## Compatibility
141
+
142
+ - Ruby 3.1+
143
+ - Rails 7.0, 7.1, 7.2, 8.0
144
+
145
+ The only runtime dependency beyond Rails is `concurrent-ruby` (for thread-safe in-memory task tracking).
146
+
147
+ ## Development
148
+
149
+ After checking out the repo:
150
+
151
+ ```bash
152
+ bundle install
153
+ bundle exec rspec
154
+ ```
155
+
156
+ The test suite uses a dummy Rails app in `spec/dummy/` for integration testing.
157
+
158
+ ## Contributing
159
+
160
+ Bug reports and pull requests are welcome on GitHub at https://github.com/railima/rails_ops_dashboard.
161
+
162
+ 1. Fork the repository
163
+ 2. Create your feature branch (`git checkout -b my-feature`)
164
+ 3. Write tests for your changes
165
+ 4. Make sure all tests pass (`bundle exec rspec`)
166
+ 5. Commit your changes
167
+ 6. Push to your branch and open a pull request
168
+
169
+ ## License
170
+
171
+ This gem is available as open source under the terms of the [MIT License](LICENSE).
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsOpsDashboard
4
+ module BackgroundTaskRunner
5
+ extend ActiveSupport::Concern
6
+
7
+ TASKS = Concurrent::Map.new
8
+
9
+ def run_in_background(task_id, task_name)
10
+ TASKS[task_id] = { name: task_name, status: 'running', started_at: Time.current.iso8601 }
11
+
12
+ Thread.new do
13
+ yield
14
+ TASKS[task_id][:status] = 'completed'
15
+ rescue StandardError => e
16
+ TASKS[task_id][:status] = 'failed'
17
+ TASKS[task_id][:error] = e.message
18
+ Rails.logger.error("[RailsOpsDashboard] Task #{task_name} failed: #{e.message}")
19
+ ensure
20
+ TASKS[task_id][:finished_at] = Time.current.iso8601
21
+ end
22
+ end
23
+
24
+ def task_status(task_id)
25
+ TASKS[task_id]
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsOpsDashboard
4
+ class BaseController < ActionController::Base # rubocop:disable Rails/ApplicationController
5
+ skip_forgery_protection
6
+ before_action :block_environment!
7
+ before_action :authenticate
8
+ layout 'rails_ops_dashboard/application'
9
+
10
+ private
11
+
12
+ def block_environment!
13
+ head :not_found if RailsOpsDashboard.configuration.blocked_environments.include?(Rails.env)
14
+ end
15
+
16
+ def authenticate
17
+ config = RailsOpsDashboard.configuration
18
+ authenticate_or_request_with_http_basic('Ops Dashboard') do |username, password|
19
+ ActiveSupport::SecurityUtils.secure_compare(username, config.username) &
20
+ ActiveSupport::SecurityUtils.secure_compare(password, config.password)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsOpsDashboard
4
+ class DashboardController < BaseController
5
+ def index
6
+ config = RailsOpsDashboard.configuration
7
+ seeds_path = Rails.root.join(config.seeds_path, '*.rb')
8
+ rake_tasks_path = config.rake_tasks_path
9
+
10
+ Rails.application.load_tasks
11
+ rake_count = Rake::Task.tasks.count { |t| t.locations.any? { |l| l.include?(rake_tasks_path) } }
12
+
13
+ @stats = {
14
+ seeds: Dir.glob(seeds_path).count,
15
+ rake_tasks: rake_count
16
+ }
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsOpsDashboard
4
+ class EnvironmentsController < BaseController
5
+ def show
6
+ @env_vars = ENV.sort
7
+ @metadata = {
8
+ rails_env: Rails.env,
9
+ hostname: Socket.gethostname,
10
+ ruby_version: RUBY_VERSION,
11
+ rails_version: Rails.version,
12
+ pid: Process.pid
13
+ }
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rake'
4
+
5
+ module RailsOpsDashboard
6
+ class RakeTasksController < BaseController
7
+ include RailsOpsDashboard::BackgroundTaskRunner
8
+
9
+ def index
10
+ @rake_tasks = discover_rake_tasks
11
+ end
12
+
13
+ def execute
14
+ task_name = params[:task_name]
15
+
16
+ load_rake_tasks
17
+ begin
18
+ task = Rake::Task[task_name]
19
+ rescue RuntimeError
20
+ return redirect_to rake_tasks_path(message: "Task not found: #{task_name}", type: 'error')
21
+ end
22
+
23
+ unless custom_task?(task)
24
+ return redirect_to rake_tasks_path(message: 'Cannot execute built-in tasks', type: 'error')
25
+ end
26
+
27
+ task_id = SecureRandom.hex(8)
28
+
29
+ run_in_background(task_id, task_name) do
30
+ task.reenable
31
+ task.invoke
32
+ end
33
+
34
+ redirect_to rake_tasks_path(message: "Started: #{task_name}", type: 'info', task_id: task_id)
35
+ end
36
+
37
+ def status
38
+ task = task_status(params[:task_id])
39
+ render json: task || { status: 'not_found' }
40
+ end
41
+
42
+ private
43
+
44
+ def load_rake_tasks
45
+ Rake::TaskManager.record_task_metadata = true
46
+ Rails.application.load_tasks
47
+ end
48
+
49
+ def custom_task?(task)
50
+ tasks_path = RailsOpsDashboard.configuration.rake_tasks_path
51
+ task.locations.any? { |l| l.include?(tasks_path) }
52
+ end
53
+
54
+ def discover_rake_tasks
55
+ load_rake_tasks
56
+ tasks_path = RailsOpsDashboard.configuration.rake_tasks_path
57
+ Rake::Task.tasks
58
+ .select { |t| t.locations.any? { |l| l.include?(tasks_path) } }
59
+ .map { |t| { name: t.name, description: t.comment || '(no description)' } }
60
+ .sort_by { |t| t[:name] }
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsOpsDashboard
4
+ class SeedsController < BaseController
5
+ include RailsOpsDashboard::BackgroundTaskRunner
6
+
7
+ def index
8
+ @seeds = discover_seeds
9
+ end
10
+
11
+ def execute
12
+ config = RailsOpsDashboard.configuration
13
+ class_name = params[:class_name]
14
+
15
+ unless class_name&.start_with?("#{config.seeds_base_class}::") && valid_seed?(class_name)
16
+ return redirect_to seeds_path(message: 'Invalid seed class', type: 'error')
17
+ end
18
+
19
+ task_id = SecureRandom.hex(8)
20
+ klass = class_name.constantize
21
+
22
+ run_in_background(task_id, class_name) do
23
+ config.seeds_executor.call(klass)
24
+ end
25
+
26
+ redirect_to seeds_path(message: "Started: #{class_name}", type: 'info', task_id: task_id)
27
+ rescue NameError, LoadError
28
+ redirect_to seeds_path(message: "Class not found: #{class_name}", type: 'error')
29
+ end
30
+
31
+ def status
32
+ task = task_status(params[:task_id])
33
+ render json: task || { status: 'not_found' }
34
+ end
35
+
36
+ private
37
+
38
+ def discover_seeds
39
+ config = RailsOpsDashboard.configuration
40
+ Dir.glob(Rails.root.join(config.seeds_path, '*.rb')).map do |file|
41
+ basename = File.basename(file, '.rb')
42
+ { name: "#{config.seeds_base_class}::#{basename.camelize}", filename: basename }
43
+ end.sort_by { |s| s[:name] }
44
+ end
45
+
46
+ def valid_seed?(class_name)
47
+ config = RailsOpsDashboard.configuration
48
+ filename = class_name.sub("#{config.seeds_base_class}::", '').underscore
49
+ File.exist?(Rails.root.join(config.seeds_path, "#{filename}.rb"))
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsOpsDashboard
4
+ class SessionsController < BaseController
5
+ skip_before_action :authenticate, only: [:destroy]
6
+
7
+ def destroy
8
+ head :unauthorized
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsOpsDashboard
4
+ class WorkersController < BaseController
5
+ before_action :check_plugin_enabled!
6
+ before_action :check_development_only!
7
+
8
+ PIDS = Concurrent::Map.new
9
+
10
+ def index
11
+ @workers = plugin_workers.map do |worker|
12
+ worker.merge(status: worker_status(worker[:id]))
13
+ end
14
+ end
15
+
16
+ def start
17
+ worker = plugin_workers.find { |w| w[:id] == params[:id] }
18
+ return redirect_to workers_path(message: 'Worker not found', type: 'error') unless worker
19
+
20
+ if worker_status(params[:id]) == :running
21
+ return redirect_to workers_path(message: "#{worker[:name]} is already running", type: 'error')
22
+ end
23
+
24
+ pid = Process.spawn(worker[:command])
25
+ Process.detach(pid)
26
+ PIDS[params[:id]] = pid
27
+
28
+ redirect_to workers_path(message: "#{worker[:name]} started (PID: #{pid})", type: 'success')
29
+ end
30
+
31
+ def stop
32
+ worker = plugin_workers.find { |w| w[:id] == params[:id] }
33
+ return redirect_to workers_path(message: 'Worker not found', type: 'error') unless worker
34
+
35
+ pid = PIDS.delete(params[:id])
36
+ return redirect_to workers_path(message: 'Worker not running', type: 'error') unless pid
37
+
38
+ begin
39
+ Process.kill('TERM', pid)
40
+ rescue Errno::ESRCH # rubocop:disable Lint/SuppressedException
41
+ end
42
+
43
+ redirect_to workers_path(message: "Stop signal sent to #{worker[:name]}", type: 'success')
44
+ end
45
+
46
+ private
47
+
48
+ def plugin_workers
49
+ RailsOpsDashboard.configuration.plugins.dig(:workers, :workers) || []
50
+ end
51
+
52
+ def check_plugin_enabled!
53
+ return if RailsOpsDashboard.configuration.plugins.key?(:workers)
54
+
55
+ redirect_to root_path(message: 'Workers plugin is not enabled', type: 'error')
56
+ end
57
+
58
+ def check_development_only!
59
+ return unless RailsOpsDashboard.configuration.plugins.dig(:workers, :development_only)
60
+ return if Rails.env.development?
61
+
62
+ redirect_to root_path(message: 'Workers management is only available in development', type: 'error')
63
+ end
64
+
65
+ def worker_status(id)
66
+ pid = PIDS[id]
67
+ return :stopped unless pid
68
+
69
+ Process.kill(0, pid)
70
+ :running
71
+ rescue Errno::ESRCH
72
+ PIDS.delete(id)
73
+ :stopped
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,73 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Ops Dashboard</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ </head>
9
+ <body class="bg-gray-100 min-h-screen">
10
+ <% banner_color = case Rails.env
11
+ when 'development' then 'bg-green-600'
12
+ else 'bg-yellow-500'
13
+ end %>
14
+ <div class="<%= banner_color %> text-white text-center py-1 text-sm font-bold">
15
+ <%= Rails.env.upcase %>
16
+ </div>
17
+
18
+ <div class="flex min-h-screen">
19
+ <nav class="w-56 bg-white shadow-md p-6 flex flex-col">
20
+ <h1 class="text-lg font-bold text-gray-800 mb-8">Ops Dashboard</h1>
21
+ <ul class="space-y-1">
22
+ <% [
23
+ { path: rails_ops_dashboard.root_path, label: 'Dashboard' },
24
+ { path: rails_ops_dashboard.environment_path, label: 'Environment' },
25
+ { path: rails_ops_dashboard.seeds_path, label: 'Seeds' },
26
+ { path: rails_ops_dashboard.rake_tasks_path, label: 'Rake Tasks' }
27
+ ].each do |item| %>
28
+ <li>
29
+ <a href="<%= item[:path] %>"
30
+ class="block px-3 py-2 rounded-md text-sm <%= request.path == item[:path] ? 'bg-blue-50 text-blue-700 font-medium' : 'text-gray-600 hover:bg-gray-50' %>">
31
+ <%= item[:label] %>
32
+ </a>
33
+ </li>
34
+ <% end %>
35
+ <% if RailsOpsDashboard.configuration.plugins.key?(:workers) %>
36
+ <li>
37
+ <a href="<%= rails_ops_dashboard.workers_path %>"
38
+ class="block px-3 py-2 rounded-md text-sm <%= request.path == rails_ops_dashboard.workers_path ? 'bg-blue-50 text-blue-700 font-medium' : 'text-gray-600 hover:bg-gray-50' %>">
39
+ Workers
40
+ </a>
41
+ </li>
42
+ <% end %>
43
+ </ul>
44
+ <div class="mt-auto pt-6">
45
+ <button onclick="logout()" class="block w-full px-3 py-2 rounded-md text-sm text-gray-600 hover:bg-gray-50 text-left">
46
+ Logout
47
+ </button>
48
+ </div>
49
+ </nav>
50
+
51
+ <main class="flex-1 p-8">
52
+ <%= render 'rails_ops_dashboard/shared/flash_messages' %>
53
+ <%= yield %>
54
+ </main>
55
+ </div>
56
+
57
+ <%= render 'rails_ops_dashboard/shared/confirmation_modal' %>
58
+
59
+ <script>
60
+ function logout() {
61
+ var xhr = new XMLHttpRequest();
62
+ xhr.open('DELETE', '<%= rails_ops_dashboard.logout_path %>', true);
63
+ xhr.setRequestHeader('Authorization', 'Basic ' + btoa('logout:logout'));
64
+ xhr.onreadystatechange = function() {
65
+ if (xhr.readyState === 4) {
66
+ window.location.href = '<%= rails_ops_dashboard.root_path %>';
67
+ }
68
+ };
69
+ xhr.send();
70
+ }
71
+ </script>
72
+ </body>
73
+ </html>
@@ -0,0 +1,19 @@
1
+ <h1 class="text-2xl font-bold text-gray-800 mb-8">Dashboard</h1>
2
+
3
+ <div class="grid grid-cols-2 lg:grid-cols-3 gap-6">
4
+ <% [
5
+ { label: 'Seeds', count: @stats[:seeds], path: rails_ops_dashboard.seeds_path, color: 'yellow' },
6
+ { label: 'Rake Tasks', count: @stats[:rake_tasks], path: rails_ops_dashboard.rake_tasks_path, color: 'purple' },
7
+ { label: 'Environment', count: nil, path: rails_ops_dashboard.environment_path, color: 'blue' }
8
+ ].each do |card| %>
9
+ <div class="bg-white rounded-lg shadow p-6">
10
+ <p class="text-sm text-gray-500"><%= card[:label] %></p>
11
+ <% if card[:count] %>
12
+ <p class="text-3xl font-bold text-gray-800 mt-2"><%= card[:count] %></p>
13
+ <% end %>
14
+ <a href="<%= card[:path] %>" class="text-sm text-<%= card[:color] %>-600 hover:underline mt-2 inline-block">
15
+ View &rarr;
16
+ </a>
17
+ </div>
18
+ <% end %>
19
+ </div>
@@ -0,0 +1,89 @@
1
+ <h1 class="text-2xl font-bold text-gray-800 mb-6">Environment Variables</h1>
2
+
3
+ <div class="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
4
+ <div class="bg-white rounded-lg shadow p-4">
5
+ <p class="text-xs text-gray-500">Rails Env</p>
6
+ <p class="text-sm font-bold text-gray-800 mt-1"><%= @metadata[:rails_env] %></p>
7
+ </div>
8
+ <div class="bg-white rounded-lg shadow p-4">
9
+ <p class="text-xs text-gray-500">Hostname</p>
10
+ <p class="text-sm font-bold text-gray-800 mt-1"><%= @metadata[:hostname] %></p>
11
+ </div>
12
+ <div class="bg-white rounded-lg shadow p-4">
13
+ <p class="text-xs text-gray-500">Ruby Version</p>
14
+ <p class="text-sm font-bold text-gray-800 mt-1"><%= @metadata[:ruby_version] %></p>
15
+ </div>
16
+ <div class="bg-white rounded-lg shadow p-4">
17
+ <p class="text-xs text-gray-500">Rails Version</p>
18
+ <p class="text-sm font-bold text-gray-800 mt-1"><%= @metadata[:rails_version] %></p>
19
+ </div>
20
+ <div class="bg-white rounded-lg shadow p-4">
21
+ <p class="text-xs text-gray-500">PID</p>
22
+ <p class="text-sm font-bold text-gray-800 mt-1"><%= @metadata[:pid] %></p>
23
+ </div>
24
+ </div>
25
+
26
+ <input type="text" id="env-search" placeholder="Filter by name or value..."
27
+ class="w-full px-4 py-2 mb-4 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
28
+ oninput="filterEnvVars()">
29
+
30
+ <p class="text-xs text-gray-400 mb-2"><span id="env-count"><%= @env_vars.size %></span> env vars</p>
31
+
32
+ <div class="bg-white rounded-lg shadow overflow-hidden">
33
+ <table id="env-table" class="min-w-full divide-y divide-gray-200">
34
+ <thead class="bg-gray-50">
35
+ <tr>
36
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
37
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Value</th>
38
+ <th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase w-16"></th>
39
+ </tr>
40
+ </thead>
41
+ <tbody class="divide-y divide-gray-200">
42
+ <% @env_vars.each do |key, value| %>
43
+ <tr class="hover:bg-gray-50">
44
+ <td class="px-6 py-3 text-sm font-mono text-gray-700 whitespace-nowrap"><%= key %></td>
45
+ <td class="px-6 py-3 text-sm font-mono text-gray-600 break-all max-w-xl"><%= value %></td>
46
+ <td class="px-6 py-3 text-right">
47
+ <button type="button"
48
+ onclick="copyValue(this, '<%= j(value) %>')"
49
+ class="px-2 py-1 text-xs text-gray-500 bg-gray-100 rounded hover:bg-gray-200"
50
+ title="Copy value">
51
+ Copy
52
+ </button>
53
+ </td>
54
+ </tr>
55
+ <% end %>
56
+ </tbody>
57
+ </table>
58
+ </div>
59
+
60
+ <script>
61
+ function filterEnvVars() {
62
+ var query = document.getElementById('env-search').value.toLowerCase();
63
+ var rows = document.querySelectorAll('#env-table tbody tr');
64
+ var visible = 0;
65
+ rows.forEach(function(row) {
66
+ var cells = row.querySelectorAll('td');
67
+ var name = cells[0].textContent.toLowerCase();
68
+ var value = cells[1].textContent.toLowerCase();
69
+ var match = name.includes(query) || value.includes(query);
70
+ row.style.display = match ? '' : 'none';
71
+ if (match) visible++;
72
+ });
73
+ document.getElementById('env-count').textContent = visible;
74
+ }
75
+
76
+ function copyValue(button, value) {
77
+ navigator.clipboard.writeText(value).then(function() {
78
+ var original = button.textContent;
79
+ button.textContent = 'Copied!';
80
+ button.classList.remove('text-gray-500', 'bg-gray-100');
81
+ button.classList.add('text-green-700', 'bg-green-100');
82
+ setTimeout(function() {
83
+ button.textContent = original;
84
+ button.classList.remove('text-green-700', 'bg-green-100');
85
+ button.classList.add('text-gray-500', 'bg-gray-100');
86
+ }, 1500);
87
+ });
88
+ }
89
+ </script>
@@ -0,0 +1,78 @@
1
+ <h1 class="text-2xl font-bold text-gray-800 mb-6">Rake Tasks</h1>
2
+
3
+ <input type="text" id="rake-search" placeholder="Filter tasks..."
4
+ class="w-full px-4 py-2 mb-4 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
5
+ oninput="filterTable('rake-search', 'rake-table')">
6
+
7
+ <div class="bg-white rounded-lg shadow overflow-hidden">
8
+ <table id="rake-table" class="min-w-full divide-y divide-gray-200">
9
+ <thead class="bg-gray-50">
10
+ <tr>
11
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Task</th>
12
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Description</th>
13
+ <th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Action</th>
14
+ </tr>
15
+ </thead>
16
+ <tbody class="divide-y divide-gray-200">
17
+ <% @rake_tasks.each_with_index do |task, i| %>
18
+ <tr class="hover:bg-gray-50">
19
+ <td class="px-6 py-3 text-sm font-mono text-gray-700"><%= task[:name] %></td>
20
+ <td class="px-6 py-3 text-sm text-gray-600"><%= task[:description] %></td>
21
+ <td class="px-6 py-3 text-right">
22
+ <form method="post" action="<%= execute_rake_tasks_path %>" id="rake-form-<%= i %>">
23
+ <input type="hidden" name="task_name" value="<%= task[:name] %>">
24
+ <button type="button"
25
+ onclick="confirmAction('Execute <%= j(task[:name]) %>?', 'rake-form-<%= i %>')"
26
+ class="px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700">
27
+ Execute
28
+ </button>
29
+ </form>
30
+ </td>
31
+ </tr>
32
+ <% end %>
33
+ </tbody>
34
+ </table>
35
+ </div>
36
+
37
+ <p class="text-xs text-gray-400 mt-2"><%= @rake_tasks.size %> tasks found</p>
38
+
39
+ <% if params[:task_id].present? %>
40
+ <div id="task-status" class="mt-6 bg-white rounded-lg shadow p-4">
41
+ <p class="text-sm text-gray-600">
42
+ Task <span class="font-mono"><%= j(params[:task_id]) %></span>:
43
+ <span id="status-text" class="font-medium">running...</span>
44
+ </p>
45
+ </div>
46
+ <script>
47
+ (function() {
48
+ var taskId = '<%= j(params[:task_id]) %>';
49
+ var interval = setInterval(function() {
50
+ fetch('<%= status_rake_tasks_path %>?task_id=' + taskId, { credentials: 'same-origin' })
51
+ .then(function(r) { return r.json(); })
52
+ .then(function(data) {
53
+ var el = document.getElementById('status-text');
54
+ if (data.status === 'completed') {
55
+ el.textContent = 'Completed';
56
+ el.className = 'font-medium text-green-600';
57
+ clearInterval(interval);
58
+ } else if (data.status === 'failed') {
59
+ el.textContent = 'Failed: ' + (data.error || 'unknown error');
60
+ el.className = 'font-medium text-red-600';
61
+ clearInterval(interval);
62
+ }
63
+ });
64
+ }, 2000);
65
+ })();
66
+ </script>
67
+ <% end %>
68
+
69
+ <script>
70
+ function filterTable(inputId, tableId) {
71
+ var query = document.getElementById(inputId).value.toLowerCase();
72
+ var rows = document.querySelectorAll('#' + tableId + ' tbody tr');
73
+ rows.forEach(function(row) {
74
+ var text = row.querySelector('td').textContent.toLowerCase();
75
+ row.style.display = text.includes(query) ? '' : 'none';
76
+ });
77
+ }
78
+ </script>
@@ -0,0 +1,76 @@
1
+ <h1 class="text-2xl font-bold text-gray-800 mb-6">Seeds</h1>
2
+
3
+ <input type="text" id="seed-search" placeholder="Filter seeds..."
4
+ class="w-full px-4 py-2 mb-4 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
5
+ oninput="filterTable('seed-search', 'seeds-table')">
6
+
7
+ <div class="bg-white rounded-lg shadow overflow-hidden">
8
+ <table id="seeds-table" class="min-w-full divide-y divide-gray-200">
9
+ <thead class="bg-gray-50">
10
+ <tr>
11
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Class Name</th>
12
+ <th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Action</th>
13
+ </tr>
14
+ </thead>
15
+ <tbody class="divide-y divide-gray-200">
16
+ <% @seeds.each_with_index do |seed, i| %>
17
+ <tr class="hover:bg-gray-50">
18
+ <td class="px-6 py-3 text-sm font-mono text-gray-700"><%= seed[:name] %></td>
19
+ <td class="px-6 py-3 text-right">
20
+ <form method="post" action="<%= execute_seeds_path %>" id="seed-form-<%= i %>">
21
+ <input type="hidden" name="class_name" value="<%= seed[:name] %>">
22
+ <button type="button"
23
+ onclick="confirmAction('Execute <%= j(seed[:name]) %>?', 'seed-form-<%= i %>')"
24
+ class="px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700">
25
+ Execute
26
+ </button>
27
+ </form>
28
+ </td>
29
+ </tr>
30
+ <% end %>
31
+ </tbody>
32
+ </table>
33
+ </div>
34
+
35
+ <p class="text-xs text-gray-400 mt-2"><%= @seeds.size %> seeds found</p>
36
+
37
+ <% if params[:task_id].present? %>
38
+ <div id="task-status" class="mt-6 bg-white rounded-lg shadow p-4">
39
+ <p class="text-sm text-gray-600">
40
+ Task <span class="font-mono"><%= j(params[:task_id]) %></span>:
41
+ <span id="status-text" class="font-medium">running...</span>
42
+ </p>
43
+ </div>
44
+ <script>
45
+ (function() {
46
+ var taskId = '<%= j(params[:task_id]) %>';
47
+ var interval = setInterval(function() {
48
+ fetch('<%= status_seeds_path %>?task_id=' + taskId, { credentials: 'same-origin' })
49
+ .then(function(r) { return r.json(); })
50
+ .then(function(data) {
51
+ var el = document.getElementById('status-text');
52
+ if (data.status === 'completed') {
53
+ el.textContent = 'Completed';
54
+ el.className = 'font-medium text-green-600';
55
+ clearInterval(interval);
56
+ } else if (data.status === 'failed') {
57
+ el.textContent = 'Failed: ' + (data.error || 'unknown error');
58
+ el.className = 'font-medium text-red-600';
59
+ clearInterval(interval);
60
+ }
61
+ });
62
+ }, 2000);
63
+ })();
64
+ </script>
65
+ <% end %>
66
+
67
+ <script>
68
+ function filterTable(inputId, tableId) {
69
+ var query = document.getElementById(inputId).value.toLowerCase();
70
+ var rows = document.querySelectorAll('#' + tableId + ' tbody tr');
71
+ rows.forEach(function(row) {
72
+ var text = row.querySelector('td').textContent.toLowerCase();
73
+ row.style.display = text.includes(query) ? '' : 'none';
74
+ });
75
+ }
76
+ </script>
@@ -0,0 +1,31 @@
1
+ <div id="confirm-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
2
+ <div class="bg-white rounded-lg shadow-xl p-6 max-w-md w-full mx-4">
3
+ <h3 class="text-lg font-semibold text-gray-900 mb-2">Confirm Action</h3>
4
+ <p id="confirm-message" class="text-gray-600 mb-6"></p>
5
+ <div class="flex justify-end space-x-3">
6
+ <button onclick="closeConfirmModal()"
7
+ class="px-4 py-2 text-sm text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200">
8
+ Cancel
9
+ </button>
10
+ <button id="confirm-btn"
11
+ class="px-4 py-2 text-sm text-white bg-red-600 rounded-md hover:bg-red-700">
12
+ Confirm
13
+ </button>
14
+ </div>
15
+ </div>
16
+ </div>
17
+
18
+ <script>
19
+ function confirmAction(message, formId) {
20
+ document.getElementById('confirm-message').textContent = message;
21
+ document.getElementById('confirm-modal').classList.remove('hidden');
22
+ document.getElementById('confirm-btn').onclick = function() {
23
+ document.getElementById(formId).submit();
24
+ closeConfirmModal();
25
+ };
26
+ }
27
+
28
+ function closeConfirmModal() {
29
+ document.getElementById('confirm-modal').classList.add('hidden');
30
+ }
31
+ </script>
@@ -0,0 +1,10 @@
1
+ <% if params[:message].present? %>
2
+ <% alert_class = case params[:type]
3
+ when 'success' then 'bg-green-100 border-green-400 text-green-700'
4
+ when 'error' then 'bg-red-100 border-red-400 text-red-700'
5
+ else 'bg-blue-100 border-blue-400 text-blue-700'
6
+ end %>
7
+ <div class="<%= alert_class %> border px-4 py-3 rounded mb-6" role="alert">
8
+ <span><%= params[:message] %></span>
9
+ </div>
10
+ <% end %>
@@ -0,0 +1,48 @@
1
+ <h1 class="text-2xl font-bold text-gray-800 mb-6">Workers</h1>
2
+
3
+ <div class="bg-white rounded-lg shadow overflow-hidden">
4
+ <table class="min-w-full divide-y divide-gray-200">
5
+ <thead class="bg-gray-50">
6
+ <tr>
7
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Worker</th>
8
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Command</th>
9
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
10
+ <th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Action</th>
11
+ </tr>
12
+ </thead>
13
+ <tbody class="divide-y divide-gray-200">
14
+ <% @workers.each do |worker| %>
15
+ <tr class="hover:bg-gray-50">
16
+ <td class="px-6 py-3 text-sm font-medium text-gray-700"><%= worker[:name] %></td>
17
+ <td class="px-6 py-3 text-sm font-mono text-gray-600"><%= worker[:command] %></td>
18
+ <td class="px-6 py-3 text-sm">
19
+ <% if worker[:status] == :running %>
20
+ <span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-700">Running</span>
21
+ <% else %>
22
+ <span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-600">Stopped</span>
23
+ <% end %>
24
+ </td>
25
+ <td class="px-6 py-3 text-right">
26
+ <% if worker[:status] == :running %>
27
+ <form method="post" action="<%= stop_worker_path(worker[:id]) %>" id="stop-<%= worker[:id] %>">
28
+ <button type="button"
29
+ onclick="confirmAction('Stop <%= j(worker[:name]) %>?', 'stop-<%= worker[:id] %>')"
30
+ class="px-3 py-1 text-xs bg-red-600 text-white rounded hover:bg-red-700">
31
+ Stop
32
+ </button>
33
+ </form>
34
+ <% else %>
35
+ <form method="post" action="<%= start_worker_path(worker[:id]) %>" id="start-<%= worker[:id] %>">
36
+ <button type="button"
37
+ onclick="confirmAction('Start <%= j(worker[:name]) %>?', 'start-<%= worker[:id] %>')"
38
+ class="px-3 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700">
39
+ Start
40
+ </button>
41
+ </form>
42
+ <% end %>
43
+ </td>
44
+ </tr>
45
+ <% end %>
46
+ </tbody>
47
+ </table>
48
+ </div>
data/config/routes.rb ADDED
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ RailsOpsDashboard::Engine.routes.draw do
4
+ root to: 'dashboard#index'
5
+
6
+ resources :seeds, only: [:index] do
7
+ collection do
8
+ post :execute
9
+ get :status
10
+ end
11
+ end
12
+
13
+ resources :rake_tasks, only: [:index] do
14
+ collection do
15
+ post :execute
16
+ get :status
17
+ end
18
+ end
19
+
20
+ resource :environment, only: [:show]
21
+
22
+ resources :workers, only: [:index] do
23
+ member do
24
+ post :start
25
+ post :stop
26
+ end
27
+ end
28
+
29
+ delete :logout, to: 'sessions#destroy'
30
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsOpsDashboard
4
+ module Generators
5
+ class InstallGenerator < Rails::Generators::Base
6
+ source_root File.expand_path('templates', __dir__)
7
+
8
+ desc 'Creates a RailsOpsDashboard initializer and mounts the engine in routes.'
9
+
10
+ def copy_initializer
11
+ template 'initializer.rb.tt', 'config/initializers/rails_ops_dashboard.rb'
12
+ end
13
+
14
+ def mount_engine
15
+ route "mount RailsOpsDashboard::Engine, at: '/ops'"
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ RailsOpsDashboard.configure do |config|
4
+ # Credentials for HTTP Basic Auth
5
+ config.username = ENV.fetch('OPS_DASHBOARD_USERNAME', 'admin')
6
+ config.password = ENV.fetch('OPS_DASHBOARD_PASSWORD', 'password')
7
+
8
+ # Environments where the dashboard is blocked (returns 404)
9
+ config.blocked_environments = %w[production]
10
+
11
+ # Seeds configuration
12
+ # config.seeds_path = 'app/services/database_update'
13
+ # config.seeds_base_class = 'DatabaseUpdate'
14
+ # config.seeds_executor = ->(klass) { klass.new.call }
15
+
16
+ # Rake tasks configuration
17
+ # config.rake_tasks_path = 'lib/tasks'
18
+
19
+ # Plugins
20
+ # config.plugin :workers,
21
+ # development_only: true,
22
+ # workers: [
23
+ # { id: 'sidekiq', name: 'Sidekiq', command: 'bundle exec sidekiq' }
24
+ # ]
25
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsOpsDashboard
4
+ module Generators
5
+ class ViewsGenerator < Rails::Generators::Base
6
+ desc 'Copies RailsOpsDashboard views to your application for customization.'
7
+
8
+ def copy_views
9
+ directory engine_views_path, Rails.root.join('app/views/rails_ops_dashboard')
10
+ end
11
+
12
+ private
13
+
14
+ def engine_views_path
15
+ File.expand_path('../../../../app/views/rails_ops_dashboard', __dir__)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsOpsDashboard
4
+ class Configuration
5
+ attr_accessor :username, :password, :blocked_environments,
6
+ :seeds_path, :seeds_base_class, :seeds_executor,
7
+ :rake_tasks_path
8
+
9
+ attr_reader :plugins
10
+
11
+ def initialize
12
+ @username = 'admin'
13
+ @password = 'password'
14
+ @blocked_environments = %w[production]
15
+ @seeds_path = 'app/services/database_update'
16
+ @seeds_base_class = 'DatabaseUpdate'
17
+ @seeds_executor = ->(klass) { klass.new.call }
18
+ @rake_tasks_path = 'lib/tasks'
19
+ @plugins = {}
20
+ end
21
+
22
+ def plugin(name, **options)
23
+ @plugins[name] = options
24
+ end
25
+ end
26
+
27
+ class << self
28
+ def configuration
29
+ @configuration ||= Configuration.new
30
+ end
31
+
32
+ def configure
33
+ yield(configuration)
34
+ end
35
+
36
+ def reset_configuration!
37
+ @configuration = Configuration.new
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsOpsDashboard
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace RailsOpsDashboard
6
+ end
7
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsOpsDashboard
4
+ class Plugin
5
+ class << self
6
+ def nav_items
7
+ @nav_items ||= []
8
+ end
9
+
10
+ def nav_item(label, path:)
11
+ nav_items << { label: label, path: path }
12
+ end
13
+
14
+ def config_options
15
+ @config_options ||= {}
16
+ end
17
+
18
+ def config_option(name, default: nil)
19
+ config_options[name] = default
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsOpsDashboard
4
+ module Plugins
5
+ class Workers < RailsOpsDashboard::Plugin
6
+ nav_item 'Workers', path: :workers_path
7
+ config_option :workers, default: []
8
+ config_option :development_only, default: true
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsOpsDashboard
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent-ruby'
4
+ require 'rails_ops_dashboard/version'
5
+ require 'rails_ops_dashboard/configuration'
6
+ require 'rails_ops_dashboard/engine'
7
+
8
+ module RailsOpsDashboard
9
+ end
metadata ADDED
@@ -0,0 +1,119 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rails_ops_dashboard
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Rai Lima
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-03-06 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: concurrent-ruby
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: railties
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '7.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '7.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec-rails
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '6.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '6.0'
55
+ description: 'Lightweight ops dashboard for Rails: run seeds, execute rake tasks,
56
+ inspect environment variables, and manage workers. All behind HTTP Basic Auth, blocked
57
+ in production by default.'
58
+ email:
59
+ - railima@users.noreply.github.com
60
+ executables: []
61
+ extensions: []
62
+ extra_rdoc_files: []
63
+ files:
64
+ - CHANGELOG.md
65
+ - LICENSE
66
+ - README.md
67
+ - app/controllers/concerns/rails_ops_dashboard/background_task_runner.rb
68
+ - app/controllers/rails_ops_dashboard/base_controller.rb
69
+ - app/controllers/rails_ops_dashboard/dashboard_controller.rb
70
+ - app/controllers/rails_ops_dashboard/environments_controller.rb
71
+ - app/controllers/rails_ops_dashboard/rake_tasks_controller.rb
72
+ - app/controllers/rails_ops_dashboard/seeds_controller.rb
73
+ - app/controllers/rails_ops_dashboard/sessions_controller.rb
74
+ - app/controllers/rails_ops_dashboard/workers_controller.rb
75
+ - app/views/layouts/rails_ops_dashboard/application.html.erb
76
+ - app/views/rails_ops_dashboard/dashboard/index.html.erb
77
+ - app/views/rails_ops_dashboard/environments/show.html.erb
78
+ - app/views/rails_ops_dashboard/rake_tasks/index.html.erb
79
+ - app/views/rails_ops_dashboard/seeds/index.html.erb
80
+ - app/views/rails_ops_dashboard/shared/_confirmation_modal.html.erb
81
+ - app/views/rails_ops_dashboard/shared/_flash_messages.html.erb
82
+ - app/views/rails_ops_dashboard/workers/index.html.erb
83
+ - config/routes.rb
84
+ - lib/generators/rails_ops_dashboard/install/install_generator.rb
85
+ - lib/generators/rails_ops_dashboard/install/templates/initializer.rb.tt
86
+ - lib/generators/rails_ops_dashboard/views/views_generator.rb
87
+ - lib/rails_ops_dashboard.rb
88
+ - lib/rails_ops_dashboard/configuration.rb
89
+ - lib/rails_ops_dashboard/engine.rb
90
+ - lib/rails_ops_dashboard/plugin.rb
91
+ - lib/rails_ops_dashboard/plugins/workers.rb
92
+ - lib/rails_ops_dashboard/version.rb
93
+ homepage: https://github.com/railima/rails_ops_dashboard
94
+ licenses:
95
+ - MIT
96
+ metadata:
97
+ homepage_uri: https://github.com/railima/rails_ops_dashboard
98
+ source_code_uri: https://github.com/railima/rails_ops_dashboard
99
+ changelog_uri: https://github.com/railima/rails_ops_dashboard/blob/main/CHANGELOG.md
100
+ post_install_message:
101
+ rdoc_options: []
102
+ require_paths:
103
+ - lib
104
+ required_ruby_version: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - ">="
107
+ - !ruby/object:Gem::Version
108
+ version: 3.1.0
109
+ required_rubygems_version: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - ">="
112
+ - !ruby/object:Gem::Version
113
+ version: '0'
114
+ requirements: []
115
+ rubygems_version: 3.5.22
116
+ signing_key:
117
+ specification_version: 4
118
+ summary: A developer operations dashboard for Rails applications
119
+ test_files: []