rails_studio 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 +21 -0
- data/MIT-LICENSE +20 -0
- data/README.md +137 -0
- data/Rakefile +6 -0
- data/app/assets/stylesheets/rails_studio/application.css +15 -0
- data/app/controllers/rails_studio/api/console_controller.rb +17 -0
- data/app/controllers/rails_studio/api/overview_controller.rb +11 -0
- data/app/controllers/rails_studio/api/query_controller.rb +18 -0
- data/app/controllers/rails_studio/api/records_controller.rb +96 -0
- data/app/controllers/rails_studio/api/tables_controller.rb +12 -0
- data/app/controllers/rails_studio/application_controller.rb +39 -0
- data/app/controllers/rails_studio/dashboard_controller.rb +59 -0
- data/app/helpers/rails_studio/application_helper.rb +4 -0
- data/app/jobs/rails_studio/application_job.rb +4 -0
- data/app/mailers/rails_studio/application_mailer.rb +6 -0
- data/app/models/rails_studio/application_record.rb +5 -0
- data/app/models/rails_studio/console.rb +150 -0
- data/app/models/rails_studio/database.rb +76 -0
- data/app/models/rails_studio/query.rb +114 -0
- data/app/models/rails_studio/read_only_error.rb +5 -0
- data/app/models/rails_studio/table.rb +495 -0
- data/app/views/layouts/rails_studio/application.html.erb +17 -0
- data/app/views/rails_studio/dashboard/index.html.erb +51 -0
- data/config/routes.rb +23 -0
- data/lib/rails_studio/configuration.rb +32 -0
- data/lib/rails_studio/engine.rb +5 -0
- data/lib/rails_studio/version.rb +3 -0
- data/lib/rails_studio.rb +52 -0
- data/lib/tasks/rails_studio_tasks.rake +4 -0
- data/public/assets/index.css +1 -0
- data/public/assets/index.js +367 -0
- metadata +91 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 527dfed966038785c8daa627f6dd25d9dd1c161eb72cae0e6a1f292573e089e1
|
|
4
|
+
data.tar.gz: fddc652d53d6ecd07542ef13ff6fa531d3cb7fb3afe75672eb05def6bff9ef2c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 3815a4daac5fa289c97c873fbcde262c91c2953e8a1e30d6226e98d1f26144ab96cb4d3e8683ae0baa8e5fbbf92aecd05aa2766bd877c521d6f47ccd52ceba6b
|
|
7
|
+
data.tar.gz: 9824be2b206f1032848d8bbb3306b866e7efbafb811edfb3e57e460aceefa903bfc7ef7bdb2d6473ac350e3ce61866e79a4e280192e0154fba963b771f3d9268
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.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-09-21
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- First public release of Rails Studio as a mountable Rails engine
|
|
15
|
+
- Spreadsheet-like table browser with inline editing and staged commits
|
|
16
|
+
- Foreign key drawer and relation navigation
|
|
17
|
+
- SQL query runner and interactive Rails console
|
|
18
|
+
- Command palette, keyboard shortcuts, and light/dark themes
|
|
19
|
+
|
|
20
|
+
[Unreleased]: https://github.com/yuler/rails_studio/compare/v0.1.0...HEAD
|
|
21
|
+
[0.1.0]: https://github.com/yuler/rails_studio/releases/tag/v0.1.0
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright yuler
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="assets/logo-dark.svg">
|
|
4
|
+
<source media="(prefers-color-scheme: light)" srcset="assets/logo-light.svg">
|
|
5
|
+
<img alt="Rails Studio" src="assets/logo.svg" width="160">
|
|
6
|
+
</picture>
|
|
7
|
+
</p>
|
|
8
|
+
|
|
9
|
+
<p align="center">
|
|
10
|
+
<strong>A modern, fast, and beautiful Web Database Studio for Rails development.</strong><br>
|
|
11
|
+
Inspired by <a href="https://github.com/prisma/studio">Prisma Studio</a> and <a href="https://orm.drizzle.team/drizzle-studio/overview">Drizzle Studio</a>.
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
<p align="center">
|
|
15
|
+
<a href="https://rubygems.org/gems/rails_studio"><img src="https://img.shields.io/gem/v/rails_studio?color=e11d48&label=gem" alt="Gem Version"></a>
|
|
16
|
+
<a href="https://github.com/yuler/rails_studio/blob/main/MIT-LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License"></a>
|
|
17
|
+
<a href="https://rubyonrails.org/"><img src="https://img.shields.io/badge/rails-%3E%3D%207.0-red.svg" alt="Rails 7+"></a>
|
|
18
|
+
<a href="#-inspiration"><img src="https://img.shields.io/badge/inspired%20by-Prisma%20%26%20Drizzle%20Studio-6366f1.svg" alt="Inspiration"></a>
|
|
19
|
+
</p>
|
|
20
|
+
|
|
21
|
+
<p align="center">
|
|
22
|
+
<video src="assets/intro.mp4" poster="assets/intro-poster.jpg" width="800" autoplay loop muted playsinline controls>
|
|
23
|
+
<a href="assets/intro.mp4"><img src="assets/intro-poster.jpg" alt="Rails Studio intro: browse tables, filter, inline edit, SQL, and Rails console" width="800"></a>
|
|
24
|
+
</video>
|
|
25
|
+
</p>
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
Rails Studio is packaged as a **mountable Rails Engine** with zero dependencies required by the host application (pre-compiled SPA bundled with the gem — **no Node.js or NPM needed in your Rails app!**).
|
|
30
|
+
|
|
31
|
+
## 💡 Inspiration
|
|
32
|
+
|
|
33
|
+
Rails Studio was heavily inspired by the remarkable developer experience of two pioneering tools in the modern TypeScript & full-stack ecosystem:
|
|
34
|
+
|
|
35
|
+
- **[Prisma Studio](https://github.com/prisma/studio)** — The visual, spreadsheet-like database browser for the Prisma ecosystem.
|
|
36
|
+
- **[Drizzle Studio](https://orm.drizzle.team/drizzle-studio/overview)** — The lightning-fast, modern database studio for Drizzle ORM.
|
|
37
|
+
|
|
38
|
+
We wanted to bring that exact same fluid, intuitive, spreadsheet-like database exploration and editing experience to the **Ruby on Rails** and **Active Record** ecosystem — with zero configuration, packaged cleanly as a mountable Rails engine.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## ✨ Features
|
|
43
|
+
|
|
44
|
+
- **🚀 One-Line Integration**: Mount directly inside your Rails `routes.rb` under development with zero configuration.
|
|
45
|
+
- **📦 Zero-Dependency Delivery**: Pre-compiled SPA bundled with the gem—no Node.js, Webpack, or asset pipeline setup required.
|
|
46
|
+
- **📊 Spreadsheet-like UX**: Browse, filter, sort, and inline-edit records in an interactive table with column badges and sticky headers.
|
|
47
|
+
- **🔗 Association Navigation**: Click foreign key badges to inspect and traverse referenced records in a side drawer.
|
|
48
|
+
- **💾 Git-like Staging**: Review edited cells locally before committing multiple updates atomically in a single database transaction.
|
|
49
|
+
- **⚡ SQL Query Runner**: Execute arbitrary SQL queries with query templates, execution timers, and CSV export.
|
|
50
|
+
- **💻 Embedded Rails Console**: Run Ruby and Active Record code in an integrated browser terminal with auto-completion and SQL traces.
|
|
51
|
+
- **⌨️ Keyboard-First & Command Palette**: Navigate tables and trigger actions rapidly via `Cmd+K` and built-in keyboard shortcuts.
|
|
52
|
+
- **🎨 Dark & Light Modes**: Seamless theme switching with automatic system detection and persistent user preferences.
|
|
53
|
+
- **🗄️ Database Agnostic**: Works out of the box with PostgreSQL, MySQL, SQLite, Trilogy, and any adapter supported by Active Record.
|
|
54
|
+
- **🧩 Modern Rails Ready**: Fully compatible with Rails 7.1+, 8.0+, composite primary keys, and multi-database architectures.
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## 🔍 How It Works
|
|
59
|
+
|
|
60
|
+
Rails Studio operates as a lightweight, self-contained Rails Engine:
|
|
61
|
+
|
|
62
|
+
1. **Mountable Engine**: Plugs directly into your Rails application pipeline via `config/routes.rb` without affecting your application logic.
|
|
63
|
+
2. **Schema & Association Reflection**: Dynamically introspects your Active Record models, column types, and relationships using your existing database connections.
|
|
64
|
+
3. **Pre-bundled SPA**: Delivers a modern React frontend directly from the gem's static assets, requiring no JavaScript toolchain in your host app.
|
|
65
|
+
4. **Safe Transactions & In-Process Console**: Communicates with internal engine API endpoints to stage edits, commit atomic transactions, and evaluate console commands safely within your Rails runtime.
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## 🚀 Quickstart
|
|
70
|
+
|
|
71
|
+
Add to your `Gemfile` and mount the engine in `config/routes.rb`:
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
# Gemfile
|
|
75
|
+
group :development do
|
|
76
|
+
gem "rails_studio"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# config/routes.rb
|
|
80
|
+
Rails.application.routes.draw do
|
|
81
|
+
mount RailsStudio::Engine => "/rails_studio" if Rails.env.development?
|
|
82
|
+
end
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Start your server (`bin/rails server`) and visit [http://localhost:3000/rails_studio](http://localhost:3000/rails_studio).
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## ⚙️ Configuration (Optional)
|
|
90
|
+
|
|
91
|
+
You can customize Rails Studio by adding an initializer: `config/initializers/rails_studio.rb`:
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
RailsStudio.configure do |config|
|
|
95
|
+
# Allow access in production (Default: false, strongly recommended to keep false)
|
|
96
|
+
config.allow_in_production = false
|
|
97
|
+
|
|
98
|
+
# Read-only mode: prevents updates, creates, and deletes (Default: false)
|
|
99
|
+
config.read_only = false
|
|
100
|
+
|
|
101
|
+
# Default pagination records per page (Default: 50)
|
|
102
|
+
config.default_per_page = 50
|
|
103
|
+
|
|
104
|
+
# Maximum records allowed per page (Default: 200)
|
|
105
|
+
config.max_per_page = 200
|
|
106
|
+
|
|
107
|
+
# Tables excluded from sidebar and inspection
|
|
108
|
+
config.excluded_tables += %w[my_secret_table]
|
|
109
|
+
end
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## 🛠️ Development
|
|
115
|
+
|
|
116
|
+
We use [`mise`](https://mise.jdx.dev/) to manage development tasks:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
mise run setup # Install dependencies, build assets & prepare test DB
|
|
120
|
+
mise run dev # Start Rails server with frontend build watch
|
|
121
|
+
mise run build # Compile frontend assets into public/assets/
|
|
122
|
+
mise run test # Run automated test suite
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
> **Manual setup**: `bundle install`, `pnpm --dir frontend install && pnpm --dir frontend build`, `cd test/dummy && bin/rails db:migrate db:seed`, then `bin/rails server`.
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## 📦 Releasing
|
|
130
|
+
|
|
131
|
+
Maintainers: bump `lib/rails_studio/version.rb`, update `CHANGELOG.md`, run `mise run build`, then `bundle exec rake release`. See [RELEASING.md](RELEASING.md).
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## 📄 License
|
|
136
|
+
|
|
137
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This is a manifest file that'll be compiled into application.css, which will include all the files
|
|
3
|
+
* listed below.
|
|
4
|
+
*
|
|
5
|
+
* Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
|
|
6
|
+
* or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
|
|
7
|
+
*
|
|
8
|
+
* You're free to add application-wide styles to this file and they'll appear at the bottom of the
|
|
9
|
+
* compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
|
|
10
|
+
* files in this directory. Styles in this file should be added after the last require_* statement.
|
|
11
|
+
* It is generally better to create a new file per style scope.
|
|
12
|
+
*
|
|
13
|
+
*= require_tree .
|
|
14
|
+
*= require_self
|
|
15
|
+
*/
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsStudio
|
|
4
|
+
module Api
|
|
5
|
+
class ConsoleController < ApplicationController
|
|
6
|
+
def execute
|
|
7
|
+
result = Console.execute(params[:command])
|
|
8
|
+
render json: result
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def completions
|
|
12
|
+
result = Console.completions
|
|
13
|
+
render json: result
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsStudio
|
|
4
|
+
module Api
|
|
5
|
+
class QueryController < ApplicationController
|
|
6
|
+
def execute
|
|
7
|
+
result = Query.execute(params[:sql])
|
|
8
|
+
render json: result
|
|
9
|
+
rescue ActiveRecord::StatementInvalid => e
|
|
10
|
+
render json: { error: e.message }, status: :unprocessable_entity
|
|
11
|
+
rescue RailsStudio::ReadOnlyError => e
|
|
12
|
+
render json: { error: e.message }, status: :forbidden
|
|
13
|
+
rescue StandardError => e
|
|
14
|
+
render json: { error: e.message }, status: :internal_server_error
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsStudio
|
|
4
|
+
module Api
|
|
5
|
+
class RecordsController < ApplicationController
|
|
6
|
+
def index
|
|
7
|
+
table = Table.find(params[:table_name])
|
|
8
|
+
filters = parse_filters(params[:filters])
|
|
9
|
+
|
|
10
|
+
result = table.records(
|
|
11
|
+
page: params[:page],
|
|
12
|
+
per_page: params[:per_page],
|
|
13
|
+
sort_by: params[:sort_by],
|
|
14
|
+
sort_order: params[:sort_order],
|
|
15
|
+
filters: filters
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
render json: result
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def show
|
|
22
|
+
table = Table.find(params[:table_name])
|
|
23
|
+
record = table.find_record(params[:id])
|
|
24
|
+
if record
|
|
25
|
+
render json: { record: record }
|
|
26
|
+
else
|
|
27
|
+
render json: { error: "Record not found" }, status: :not_found
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def create
|
|
32
|
+
table = Table.find(params[:table_name])
|
|
33
|
+
record = table.create_record(record_params)
|
|
34
|
+
render json: { success: true, record: record }, status: :created
|
|
35
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
36
|
+
render json: { success: false, error: e.message, errors: e.record.errors.full_messages }, status: :unprocessable_entity
|
|
37
|
+
rescue ActiveRecord::StatementInvalid => e
|
|
38
|
+
render json: { success: false, error: e.message }, status: :unprocessable_entity
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def update
|
|
42
|
+
table = Table.find(params[:table_name])
|
|
43
|
+
record = table.update_record(params[:id], record_params)
|
|
44
|
+
render json: { success: true, record: record }
|
|
45
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
46
|
+
render json: { success: false, error: e.message, errors: e.record.errors.full_messages }, status: :unprocessable_entity
|
|
47
|
+
rescue ActiveRecord::StatementInvalid => e
|
|
48
|
+
render json: { success: false, error: e.message }, status: :unprocessable_entity
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def destroy
|
|
52
|
+
table = Table.find(params[:table_name])
|
|
53
|
+
table.destroy_record(params[:id])
|
|
54
|
+
render json: { success: true }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def batch
|
|
58
|
+
table = Table.find(params[:table_name])
|
|
59
|
+
results = table.batch_process(
|
|
60
|
+
updates: params[:updates] || [],
|
|
61
|
+
creates: params[:creates] || [],
|
|
62
|
+
deletes: params[:deletes] || []
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
render json: { success: true, results: results }
|
|
66
|
+
rescue StandardError => e
|
|
67
|
+
render json: { success: false, error: e.message }, status: :unprocessable_entity
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def record_params
|
|
73
|
+
if params[:record].respond_to?(:permit!)
|
|
74
|
+
params.require(:record).permit!.to_h
|
|
75
|
+
elsif params[:record].is_a?(Hash)
|
|
76
|
+
params[:record]
|
|
77
|
+
else
|
|
78
|
+
{}
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def parse_filters(raw_filters)
|
|
83
|
+
return [] if raw_filters.blank?
|
|
84
|
+
|
|
85
|
+
if raw_filters.is_a?(String)
|
|
86
|
+
parsed = JSON.parse(raw_filters) rescue []
|
|
87
|
+
parsed.is_a?(Array) ? parsed : []
|
|
88
|
+
elsif raw_filters.is_a?(Array)
|
|
89
|
+
raw_filters.map { |f| f.respond_to?(:to_unsafe_h) ? f.to_unsafe_h : f }
|
|
90
|
+
else
|
|
91
|
+
[]
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsStudio
|
|
4
|
+
class ApplicationController < ActionController::Base
|
|
5
|
+
skip_forgery_protection
|
|
6
|
+
before_action :ensure_access_allowed
|
|
7
|
+
|
|
8
|
+
rescue_from StandardError, with: :handle_standard_error
|
|
9
|
+
rescue_from ActionDispatch::Http::Parameters::ParseError, with: :handle_parse_error
|
|
10
|
+
rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found
|
|
11
|
+
rescue_from RailsStudio::ReadOnlyError, with: :handle_read_only
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def ensure_access_allowed
|
|
16
|
+
return if RailsStudio.configuration.allow_in_production?
|
|
17
|
+
return if Rails.env.development? || Rails.env.test?
|
|
18
|
+
|
|
19
|
+
render plain: "Rails Studio is only accessible in development or test environment.", status: :forbidden
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def handle_not_found(exception)
|
|
23
|
+
render json: { error: exception.message }, status: :not_found
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def handle_parse_error(exception)
|
|
27
|
+
render json: { error: "Invalid JSON format: #{exception.message}" }, status: :bad_request
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def handle_read_only(exception)
|
|
31
|
+
render json: { error: exception.message }, status: :forbidden
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def handle_standard_error(exception)
|
|
35
|
+
logger.error("[RailsStudio] #{exception.class}: #{exception.message}\n#{exception.backtrace&.first(5)&.join("\n")}")
|
|
36
|
+
render json: { error: exception.message }, status: :internal_server_error
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsStudio
|
|
4
|
+
class DashboardController < ApplicationController
|
|
5
|
+
LOGO_FILES = %w[logo.svg logo-light.svg logo-dark.svg].freeze
|
|
6
|
+
|
|
7
|
+
def index
|
|
8
|
+
script_name = request.script_name.presence || ""
|
|
9
|
+
# Normalizes prefix (removes trailing slash)
|
|
10
|
+
@base_path = script_name.sub(%r{/\z}, "")
|
|
11
|
+
|
|
12
|
+
respond_to do |format|
|
|
13
|
+
format.html { render "rails_studio/dashboard/index", layout: false }
|
|
14
|
+
format.all { render "rails_studio/dashboard/index", layout: false, content_type: "text/html" }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def asset
|
|
19
|
+
relative_path = params[:path].to_s
|
|
20
|
+
relative_path = "#{relative_path}.#{params[:format]}" if params[:format].present? && !relative_path.end_with?(".#{params[:format]}")
|
|
21
|
+
public_dir = File.expand_path(File.join(Engine.root, "public", "assets"))
|
|
22
|
+
file_path = File.expand_path(File.join(public_dir, relative_path))
|
|
23
|
+
|
|
24
|
+
unless File.file?(file_path)
|
|
25
|
+
logo_dir = File.expand_path(File.join(Engine.root, "assets"))
|
|
26
|
+
logo_path = File.expand_path(File.join(logo_dir, File.basename(relative_path)))
|
|
27
|
+
if LOGO_FILES.include?(File.basename(logo_path)) && logo_path.start_with?("#{logo_dir}/") && File.file?(logo_path)
|
|
28
|
+
file_path = logo_path
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
served_from_public = file_path == public_dir || file_path.start_with?("#{public_dir}/")
|
|
33
|
+
served_from_logos = LOGO_FILES.include?(File.basename(file_path)) &&
|
|
34
|
+
file_path.start_with?("#{File.expand_path(File.join(Engine.root, "assets"))}/")
|
|
35
|
+
|
|
36
|
+
if (served_from_public || served_from_logos) && File.file?(file_path)
|
|
37
|
+
ext = File.extname(file_path).delete_prefix(".")
|
|
38
|
+
content_type = case ext
|
|
39
|
+
when "js", "mjs" then "application/javascript"
|
|
40
|
+
when "css" then "text/css"
|
|
41
|
+
when "svg" then "image/svg+xml"
|
|
42
|
+
when "json" then "application/json"
|
|
43
|
+
when "woff2" then "font/woff2"
|
|
44
|
+
when "woff" then "font/woff"
|
|
45
|
+
when "ttf" then "font/ttf"
|
|
46
|
+
when "png" then "image/png"
|
|
47
|
+
when "jpg", "jpeg" then "image/jpeg"
|
|
48
|
+
when "ico" then "image/x-icon"
|
|
49
|
+
else "application/octet-stream"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
response.headers["Cache-Control"] = "public, max-age=31536000, immutable" if Rails.env.production?
|
|
53
|
+
send_file file_path, type: content_type, disposition: "inline"
|
|
54
|
+
else
|
|
55
|
+
head :not_found
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
require "pp"
|
|
5
|
+
|
|
6
|
+
module RailsStudio
|
|
7
|
+
class Console
|
|
8
|
+
EVAL_MUTEX = Mutex.new
|
|
9
|
+
|
|
10
|
+
DANGEROUS_PATTERNS = [
|
|
11
|
+
/\b(create!|create|destroy_all|destroy|delete_all|delete|update_all|update!|update|drop_table|execute|truncate)\b/,
|
|
12
|
+
/\b(save!|save|update_attribute|update_column|update_columns|toggle!)\b/
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def execute(command)
|
|
17
|
+
clean_code = command.to_s.strip
|
|
18
|
+
if clean_code.blank?
|
|
19
|
+
return { result: nil, result_type: "NilClass", stdout: "", queries: [], duration_ms: 0 }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
if RailsStudio.configuration.read_only?
|
|
23
|
+
validate_read_only_code!(clean_code)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
queries = []
|
|
27
|
+
current_thread = Thread.current
|
|
28
|
+
subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
|
|
29
|
+
next unless Thread.current == current_thread
|
|
30
|
+
|
|
31
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
32
|
+
unless event.payload[:name] == "SCHEMA"
|
|
33
|
+
queries << {
|
|
34
|
+
sql: event.payload[:sql],
|
|
35
|
+
duration_ms: event.duration.round(2)
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
stdout_buf = StringIO.new
|
|
41
|
+
duration_ms = 0
|
|
42
|
+
|
|
43
|
+
begin
|
|
44
|
+
result = nil
|
|
45
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
46
|
+
|
|
47
|
+
EVAL_MUTEX.synchronize do
|
|
48
|
+
old_stdout = $stdout
|
|
49
|
+
$stdout = stdout_buf
|
|
50
|
+
begin
|
|
51
|
+
result = if RailsStudio.configuration.read_only? && ActiveRecord::Base.respond_to?(:while_preventing_writes)
|
|
52
|
+
ActiveRecord::Base.while_preventing_writes do
|
|
53
|
+
TOPLEVEL_BINDING.eval(clean_code)
|
|
54
|
+
end
|
|
55
|
+
else
|
|
56
|
+
TOPLEVEL_BINDING.eval(clean_code)
|
|
57
|
+
end
|
|
58
|
+
ensure
|
|
59
|
+
$stdout = old_stdout
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
|
|
64
|
+
|
|
65
|
+
formatted_result = if result.nil?
|
|
66
|
+
"nil"
|
|
67
|
+
elsif result.is_a?(String)
|
|
68
|
+
result.inspect
|
|
69
|
+
else
|
|
70
|
+
PP.pp(result, String.new, 100).strip
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
{
|
|
74
|
+
result: formatted_result,
|
|
75
|
+
result_type: result.class.name,
|
|
76
|
+
stdout: stdout_buf.string,
|
|
77
|
+
queries: queries,
|
|
78
|
+
duration_ms: duration_ms
|
|
79
|
+
}
|
|
80
|
+
rescue Exception => e
|
|
81
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2) rescue 0
|
|
82
|
+
{
|
|
83
|
+
error: {
|
|
84
|
+
class: e.class.name,
|
|
85
|
+
message: e.message,
|
|
86
|
+
backtrace: clean_backtrace(e.backtrace)
|
|
87
|
+
},
|
|
88
|
+
stdout: stdout_buf.string,
|
|
89
|
+
queries: queries,
|
|
90
|
+
duration_ms: duration_ms
|
|
91
|
+
}
|
|
92
|
+
ensure
|
|
93
|
+
ActiveSupport::Notifications.unsubscribe(subscriber)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def completions
|
|
98
|
+
Rails.application.eager_load! rescue nil
|
|
99
|
+
|
|
100
|
+
all_models = ActiveRecord::Base.descendants.reject(&:abstract_class?).select { |m| m.name.present? }
|
|
101
|
+
app_models = all_models.reject { |m| m.name.to_s.start_with?("Action", "ActiveStorage") }
|
|
102
|
+
framework_models = all_models.select { |m| m.name.to_s.start_with?("Action", "ActiveStorage") }
|
|
103
|
+
sorted_models = app_models + framework_models
|
|
104
|
+
|
|
105
|
+
models = sorted_models.map do |model|
|
|
106
|
+
{
|
|
107
|
+
name: model.name,
|
|
108
|
+
table_name: model.table_name,
|
|
109
|
+
columns: (model.column_names rescue []),
|
|
110
|
+
associations: (model.reflect_on_all_associations.map(&:name).map(&:to_s) rescue [])
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
common_methods = %w[
|
|
115
|
+
all count first last find find_by where pluck order limit
|
|
116
|
+
group having select distinct joins includes create create!
|
|
117
|
+
new build destroy_all delete_all update_all to_sql explain
|
|
118
|
+
columns column_names table_name primary_key
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
rails_globals = %w[
|
|
122
|
+
Rails.env Rails.application Rails.version Rails.root
|
|
123
|
+
ActiveRecord::Base
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
{
|
|
127
|
+
models: models,
|
|
128
|
+
common_methods: common_methods,
|
|
129
|
+
globals: rails_globals
|
|
130
|
+
}
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def validate_read_only_code!(code)
|
|
136
|
+
if DANGEROUS_PATTERNS.any? { |pattern| code =~ pattern }
|
|
137
|
+
raise RailsStudio::ReadOnlyError, "Modifying operations are disabled in read-only mode."
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def clean_backtrace(backtrace)
|
|
142
|
+
return [] unless backtrace.is_a?(Array)
|
|
143
|
+
|
|
144
|
+
backtrace.first(6).map do |line|
|
|
145
|
+
line.sub(Rails.root.to_s, ".")
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|