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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +22 -0
- data/LICENSE +21 -0
- data/README.md +171 -0
- data/app/controllers/concerns/rails_ops_dashboard/background_task_runner.rb +28 -0
- data/app/controllers/rails_ops_dashboard/base_controller.rb +24 -0
- data/app/controllers/rails_ops_dashboard/dashboard_controller.rb +19 -0
- data/app/controllers/rails_ops_dashboard/environments_controller.rb +16 -0
- data/app/controllers/rails_ops_dashboard/rake_tasks_controller.rb +63 -0
- data/app/controllers/rails_ops_dashboard/seeds_controller.rb +52 -0
- data/app/controllers/rails_ops_dashboard/sessions_controller.rb +11 -0
- data/app/controllers/rails_ops_dashboard/workers_controller.rb +76 -0
- data/app/views/layouts/rails_ops_dashboard/application.html.erb +73 -0
- data/app/views/rails_ops_dashboard/dashboard/index.html.erb +19 -0
- data/app/views/rails_ops_dashboard/environments/show.html.erb +89 -0
- data/app/views/rails_ops_dashboard/rake_tasks/index.html.erb +78 -0
- data/app/views/rails_ops_dashboard/seeds/index.html.erb +76 -0
- data/app/views/rails_ops_dashboard/shared/_confirmation_modal.html.erb +31 -0
- data/app/views/rails_ops_dashboard/shared/_flash_messages.html.erb +10 -0
- data/app/views/rails_ops_dashboard/workers/index.html.erb +48 -0
- data/config/routes.rb +30 -0
- data/lib/generators/rails_ops_dashboard/install/install_generator.rb +19 -0
- data/lib/generators/rails_ops_dashboard/install/templates/initializer.rb.tt +25 -0
- data/lib/generators/rails_ops_dashboard/views/views_generator.rb +19 -0
- data/lib/rails_ops_dashboard/configuration.rb +40 -0
- data/lib/rails_ops_dashboard/engine.rb +7 -0
- data/lib/rails_ops_dashboard/plugin.rb +23 -0
- data/lib/rails_ops_dashboard/plugins/workers.rb +11 -0
- data/lib/rails_ops_dashboard/version.rb +5 -0
- data/lib/rails_ops_dashboard.rb +9 -0
- 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,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 →
|
|
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,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
|
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: []
|