nquery 0.1.0 → 0.1.2

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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -0
  3. data/README.md +263 -24
  4. data/app/assets/builds/nquery/application.css +61 -4
  5. data/app/assets/builds/nquery/application.js +107 -12
  6. data/app/controllers/concerns/nquery/breadcrumbs.rb +5 -3
  7. data/app/controllers/concerns/nquery/chart_actions.rb +1 -0
  8. data/app/controllers/concerns/nquery/chart_results.rb +17 -3
  9. data/app/controllers/nquery/admin/data_sources_controller.rb +49 -2
  10. data/app/controllers/nquery/admin/groups_controller.rb +8 -2
  11. data/app/controllers/nquery/admin/users_controller.rb +6 -1
  12. data/app/controllers/nquery/application_controller.rb +45 -28
  13. data/app/controllers/nquery/dashboards_controller.rb +30 -0
  14. data/app/controllers/nquery/embed/charts_controller.rb +1 -0
  15. data/app/controllers/nquery/embed/dashboards_controller.rb +1 -0
  16. data/app/controllers/nquery/errors_controller.rb +11 -0
  17. data/app/controllers/nquery/home_controller.rb +1 -0
  18. data/app/controllers/nquery/onboarding/admins_controller.rb +42 -0
  19. data/app/controllers/nquery/onboarding/base_controller.rb +27 -0
  20. data/app/controllers/nquery/onboarding/companies_controller.rb +30 -0
  21. data/app/controllers/nquery/onboarding/confirmations_controller.rb +48 -0
  22. data/app/controllers/nquery/onboarding/congrats_controller.rb +15 -0
  23. data/app/controllers/nquery/queries_controller.rb +0 -1
  24. data/app/controllers/nquery/sessions_controller.rb +14 -17
  25. data/app/helpers/nquery/data_sources_helper.rb +13 -0
  26. data/app/helpers/nquery/groups_helper.rb +19 -0
  27. data/app/javascript/nquery/controllers/data_source_form_controller.js +25 -0
  28. data/app/mailers/nquery/application_mailer.rb +7 -0
  29. data/app/mailers/nquery/devise_mailer.rb +9 -0
  30. data/app/models/nquery/audit.rb +2 -2
  31. data/app/models/nquery/collection.rb +1 -1
  32. data/app/models/nquery/dashboard.rb +1 -1
  33. data/app/models/nquery/data_source.rb +139 -4
  34. data/app/models/nquery/organization.rb +38 -0
  35. data/app/models/nquery/query.rb +8 -0
  36. data/app/models/nquery/user.rb +45 -10
  37. data/app/views/nquery/admin/data_sources/_form.html.erb +121 -0
  38. data/app/views/nquery/admin/data_sources/edit.html.erb +6 -8
  39. data/app/views/nquery/admin/data_sources/new.html.erb +6 -7
  40. data/app/views/nquery/admin/groups/_form.html.erb +47 -0
  41. data/app/views/nquery/admin/groups/edit.html.erb +15 -6
  42. data/app/views/nquery/admin/groups/index.html.erb +25 -20
  43. data/app/views/nquery/admin/groups/new.html.erb +6 -6
  44. data/app/views/nquery/admin/groups/show.html.erb +98 -19
  45. data/app/views/nquery/admin/logs/index.html.erb +26 -30
  46. data/app/views/nquery/charts/_builder_form.html.erb +6 -4
  47. data/app/views/nquery/charts/edit.html.erb +2 -1
  48. data/app/views/nquery/dashboards/index.html.erb +6 -0
  49. data/app/views/nquery/dashboards/new.html.erb +34 -0
  50. data/app/views/nquery/devise_mailer/confirmation_instructions.html.erb +5 -0
  51. data/app/views/nquery/devise_mailer/confirmation_instructions.text.erb +5 -0
  52. data/app/views/nquery/errors/internal_server_error.html.erb +10 -0
  53. data/app/views/nquery/errors/not_found.html.erb +10 -0
  54. data/app/views/nquery/home/index.html.erb +7 -15
  55. data/app/views/nquery/onboarding/admins/new.html.erb +21 -0
  56. data/app/views/nquery/onboarding/companies/new.html.erb +25 -0
  57. data/app/views/nquery/onboarding/confirmations/show.html.erb +17 -0
  58. data/app/views/nquery/onboarding/congrats/show.html.erb +11 -0
  59. data/app/views/nquery/sessions/new.html.erb +8 -9
  60. data/app/views/nquery/shared/_auth_shell.html.erb +1 -1
  61. data/app/views/nquery/shared/_topbar.html.erb +0 -4
  62. data/config/routes.rb +26 -7
  63. data/db/migrate/20260721000001_create_nquery_tables.rb +1 -1
  64. data/db/migrate/20260722000001_add_key_to_nquery_data_sources.rb +8 -0
  65. data/db/migrate/20260722000002_create_nquery_organizations.rb +11 -0
  66. data/db/migrate/20260722000003_add_devise_to_nquery_users.rb +35 -0
  67. data/db/migrate/20260722000004_add_onboarding_completed_at_to_nquery_organizations.rb +7 -0
  68. data/lib/generators/nquery/install_generator.rb +9 -17
  69. data/lib/nquery/configuration.rb +7 -23
  70. data/lib/nquery/csv_importer.rb +3 -3
  71. data/lib/nquery/data_sources/adapter.rb +4 -0
  72. data/lib/nquery/data_sources/postgresql_adapter.rb +15 -1
  73. data/lib/nquery/data_sources/rails_adapter.rb +5 -0
  74. data/lib/nquery/data_sources/syncer.rb +50 -0
  75. data/lib/nquery/encryption.rb +50 -0
  76. data/lib/nquery/engine.rb +42 -3
  77. data/lib/nquery/onboarding/admin_provisioner.rb +40 -0
  78. data/lib/nquery/onboarding/password_confirmation.rb +46 -0
  79. data/lib/nquery/onboarding.rb +40 -0
  80. data/lib/nquery/permissions/resolver.rb +14 -4
  81. data/lib/nquery/query_runner.rb +5 -9
  82. data/lib/nquery/readonly_sql.rb +25 -0
  83. data/lib/nquery/seeder.rb +25 -34
  84. data/lib/nquery/setup.rb +75 -0
  85. data/lib/nquery/version.rb +1 -1
  86. data/lib/nquery.rb +9 -0
  87. data/lib/tasks/nquery.rake +6 -0
  88. data/nquery.gemspec +11 -5
  89. metadata +135 -26
  90. data/app/controllers/nquery/registrations_controller.rb +0 -31
  91. data/app/views/nquery/registrations/new.html.erb +0 -34
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d4c0dfe128eef7e904add8f3f2509e4aff906effad952ebb9bbddf162011152b
4
- data.tar.gz: a5b587385a1af1d99823b88b0c85fbe7fdef6c0a9810f7ad4d4deb17877de763
3
+ metadata.gz: 0460bb4897b3a7fbd9516dc0c8e39a69eb397fb4ab221bc2c27f5879a40fc04e
4
+ data.tar.gz: ffd6e74059060bb039706b611a1ee6b193b7d71a6a18a02ea01f88d58c8e3a79
5
5
  SHA512:
6
- metadata.gz: abe36e2af6121fa02337da23f3b3db2241766a2dd2ce020c9d79da663839473185bdd414a89b97aec678ca3107795753266d12e776c98cf2791278d5d0447caf
7
- data.tar.gz: e630e96d93edc3e5187ce0a31fc76206b7c045111f6c5f91b4d5c23895a00866c0d10827eced9a3960f7bf29f2b83711b0677033a6af0993a47b7375b86af419
6
+ metadata.gz: b2631cd177c4840a24d2c81f07669a02b168bd0a15f9ac90f80e0cb7f027348624521807f64c50783e8d8e66cb74554f01dfc8ac06941d70cfe9b8b768a0122d
7
+ data.tar.gz: 182717511432fa142146e3fcc097667ae8fbda4f923447aa6ebfabd3e39a23ce0ca23b61c8b4a3f006ad6f00d1a37d50335308222697443d4e3e50767d9a32a5
data/CHANGELOG.md ADDED
@@ -0,0 +1,32 @@
1
+ # Changelog
2
+
3
+ ## [0.1.2] - 2026-09-07
4
+
5
+ ### Changed
6
+ - Publish homepage URL https://github.com/caiohenrique12/nquery (RubyGems does not allow repushing 0.1.1)
7
+
8
+ ## [0.1.1] - 2026-09-07
9
+
10
+ Host apps should `bundle update nquery` and run `rails db:migrate`. Re-run `rails generate nquery:install` if you installed 0.1.0 and need the updated initializer.
11
+
12
+ ### Added
13
+ - PostgreSQL data sources with a Test Connection action on the admin form
14
+ - Dashboard creation from the home page, with dashboards belonging to a collection
15
+ - Post-install setup (`nquery:setup`) and first-admin onboarding via Devise
16
+ - Encrypted data source credentials and improved admin data source forms
17
+ - GitHub Actions CI with a shared `bin/ci` suite
18
+
19
+ ### Changed
20
+ - Hide the home New dashboard CTA when the user cannot curate
21
+ - Tighten Devise (`>= 5.0.4`) and Puma (`>= 7.2.1`) minimums for known CVEs
22
+ - Bump sqlite3 to `>= 2.9.6` (GHSA-mwm8-39rw-8826)
23
+ - Point gem homepage and source URLs to https://github.com/caiohenrique12/nquery
24
+
25
+ ### Fixed
26
+ - PostgreSQL adapter connections
27
+ - Read-only SQL enforcement on query create/save
28
+ - JSON error when a test-connection data source id is missing
29
+
30
+ ## [0.1.0] - 2026-07-22
31
+
32
+ Initial public release.
data/README.md CHANGED
@@ -1,52 +1,291 @@
1
1
  # nquery
2
2
 
3
- SQL charts, dashboards, and data visualization for Rails — mountable engine gem.
3
+ SQL charts, dashboards, and data visualization for Rails — a mountable engine gem.
4
4
 
5
- ## What this is
5
+ nquery lets a host Rails app expose a self-contained analytics UI. It:
6
6
 
7
- - **`lib/` + `app/`** the gem (mount in any Rails app)
8
- - **`server/`** thin demo app to run the UI locally (SQLite, no external database)
9
- - **Docker + Makefile** local dev only (MVP)
7
+ * Ships as a Rails engine you mount at a path (for example `/nquery`)
8
+ * Provides query builders, charts, dashboards, collections, groups, and permissions
9
+ * Provisions groups and data sources via `rails nquery:setup`
10
+ * Creates the first administrator through a one-time onboarding wizard (no public signup)
10
11
 
11
- ## Quick start (no Ruby required)
12
+ ## Table of contents
12
13
 
13
- ```bash
14
- cp .env.example .env
15
- make up
16
- ```
14
+ - [Requirements](#requirements)
15
+ - [Installation](#installation)
16
+ - [Usage](#usage)
17
+ - [What `nquery:setup` does](#what-nquerysetup-does)
18
+ - [After onboarding](#after-onboarding)
19
+ - [Configuration](#configuration)
20
+ - [Authentication](#authentication)
21
+ - [First admin onboarding](#first-admin-onboarding)
22
+ - [Mail and SMTP](#mail-and-smtp)
23
+ - [Data sources](#data-sources)
24
+ - [Active Storage](#active-storage)
25
+ - [Development](#development)
26
+ - [Contributing](#contributing)
27
+ - [License](#license)
17
28
 
18
- `make up` builds the image, runs migrations, seeds demo data, and starts the server — no separate seed step needed.
29
+ ## Requirements
19
30
 
20
- Open http://localhost:3000 login: `admin@nquery.dev` / `password123`
31
+ * Ruby `>= 3.2`
32
+ * Rails `>= 7.1`, `< 9`
33
+ * Active Storage (for organization logo / cover uploads)
21
34
 
22
- ## Dev commands
35
+ ## Installation
23
36
 
24
- | Command | Description |
25
- |---------|-------------|
26
- | `make up` | Build, migrate, seed, and start server |
27
- | `make setup` | Migrate and seed only (without starting server) |
28
- | `make test` | Run RSpec |
29
- | `make console` | Rails console |
37
+ Install the gem and add it to your application's Gemfile by executing:
38
+
39
+ ```bash
40
+ bundle add nquery
41
+ ```
42
+
43
+ If Bundler is not being used to manage dependencies, install the gem by executing:
44
+
45
+ ```bash
46
+ gem install nquery
47
+ ```
30
48
 
31
- ## Use as a gem (in a Rails app)
49
+ Or add it manually:
32
50
 
33
51
  ```ruby
34
52
  # Gemfile
35
53
  gem "nquery"
54
+ ```
36
55
 
37
- # config/routes.rb
38
- mount Nquery::Engine, at: "/nquery"
56
+ ```bash
57
+ bundle install
39
58
  ```
40
59
 
60
+ Then run the install generator and complete setup:
61
+
41
62
  ```bash
42
63
  rails generate nquery:install
64
+ rails active_storage:install # required for organization logo/cover
65
+ rails db:migrate # engine migrations load from the gem
66
+ rails nquery:setup
67
+ ```
68
+
69
+ Mount the engine in `config/routes.rb`:
70
+
71
+ ```ruby
72
+ mount Nquery::Engine, at: "/nquery"
73
+ ```
74
+
75
+ Edit `config/initializers/nquery.rb` to set `mailer_sender` and `smtp` (required for confirmation email). Visit `/nquery` and complete the [first admin onboarding](#first-admin-onboarding) wizard.
76
+
77
+ | Task | When to use | Creates users? | Creates demo charts? |
78
+ |------|-------------|----------------|----------------------|
79
+ | `rails nquery:setup` | Host / production install | No | No |
80
+ | `rails nquery:seed` | Local demo only (`make up`) | Yes | Yes |
81
+
82
+ Prefer `nquery:setup` in host apps. `nquery:seed` calls setup first, then adds sample content for the Docker demo.
83
+
84
+ ## Usage
85
+
86
+ ### What `nquery:setup` does
87
+
88
+ `Nquery::Setup.run!` (invoked by `rails nquery:setup`) is idempotent and safe to re-run. It:
89
+
90
+ 1. Ensures system groups `administrators` and `all_users`
91
+ 2. Syncs `config.data_sources` into `Nquery::DataSource` rows (via `Nquery::DataSources::Syncer`)
92
+ 3. Ensures the root collection (`Our analytics`)
93
+ 4. Seeds default collection, data, and application permissions for those groups
94
+
95
+ It intentionally does **not** create an organization, users, or demo charts. Those belong to onboarding (first admin) or `nquery:seed` (demo only).
96
+
97
+ ### After onboarding
98
+
99
+ 1. Sign in at `/nquery/login` (or your mount path + `/login`)
100
+ 2. Browse **Collections**, build queries/charts, assemble dashboards
101
+ 3. Manage **Users**, **Groups**, **Permissions**, and **Data sources** as an admin
102
+
103
+ Default permissions from setup give administrators full application features and query access on the default data source; `all_users` gets view-oriented defaults on the root collection.
104
+
105
+ ## Configuration
106
+
107
+ `rails generate nquery:install` copies `config/initializers/nquery.rb`. Example:
108
+
109
+ ```ruby
110
+ Nquery.configure do |config|
111
+ config.mailer_sender = "noreply@example.com"
112
+ config.smtp = {
113
+ address: "smtp.example.com",
114
+ port: 587,
115
+ domain: "example.com",
116
+ user_name: "smtp-user",
117
+ password: "smtp-password",
118
+ authentication: :plain,
119
+ enable_starttls_auto: true
120
+ }
121
+
122
+ config.data_sources = {
123
+ main: { adapter: :rails, name: "Application database" }
124
+ }
125
+ config.default_data_source = :main
126
+ end
127
+ ```
128
+
129
+ | Option | Default | Description |
130
+ |--------|---------|-------------|
131
+ | `mailer_sender` | `nil` | From address for Devise / app mail |
132
+ | `smtp` | `{}` | Passed to `ActionMailer::Base.smtp_settings` when present |
133
+ | `data_sources` | `{ main: { adapter: :rails, name: "Application database" } }` | Identity-only map of configured sources |
134
+ | `default_data_source` | `:main` | Key of the default source used by setup permissions |
135
+ | `query_timeout` | `15` | Query timeout (seconds) |
136
+ | `query_row_limit` | `10_000` | Max rows returned by a query |
137
+ | `embed_secret` | `nil` | Override for embed token signing (falls back to `secret_key_base`) |
138
+
139
+ **Not supported in config:** storing database credentials for data sources, SSO/hybrid auth hooks, or public self-registration.
140
+
141
+ ## Authentication
142
+
143
+ Authentication is handled by [Devise](https://github.com/heartcombo/devise) end-to-end:
144
+
145
+ * **Sessions** — `Devise::SessionsController` (sign in / sign out via Warden)
146
+ * **Passwords** — Devise `:database_authenticatable` + `:validatable` on `Nquery::User`
147
+ * **Confirmation** — Devise `:confirmable` (first admin sets a password from the confirmation link; tokens expire after 1 hour)
148
+
149
+ There is **no** public signup route (`:registerable` is not enabled). Password reset (`:recoverable`) and remember-me (`:rememberable`) are not enabled. The first admin is created through [onboarding](#first-admin-onboarding); additional users are invited by an administrator under **Admin → Users** (confirmation email; invitee sets their password via the confirmation link).
150
+
151
+ Login and logout use Devise routes (`/login`, `/logout`) backed by Warden. Authentication is Devise-only; custom session cookie auth and SSO/hybrid hooks are not supported.
152
+
153
+ ## First admin onboarding
154
+
155
+ After install + `nquery:setup`, the first person to open the engine creates the organization and the first administrator. There is no seed admin in a production-style install.
156
+
157
+ ### When the wizard runs
158
+
159
+ Onboarding is required until:
160
+
161
+ * An `Nquery::Organization` exists
162
+ * Onboarding is complete (a confirmed admin exists in `administrators`, or onboarding has been latched via `onboarding_completed_at`)
163
+
164
+ Until then, visiting the engine redirects into the wizard instead of the normal login page. `nquery:setup` alone never completes onboarding — it only prepares groups and data sources.
165
+
166
+ ### Wizard steps
167
+
168
+ Paths assume `mount Nquery::Engine, at: "/nquery"`. Drop the `/nquery` prefix when mounted at `/`.
169
+
170
+ | Step | Path | What the user does | Result |
171
+ |------|------|--------------------|--------|
172
+ | 1. Company | `GET /nquery/onboarding/company/new` | Name, optional website, logo, cover | Creates `Organization`; advances to admin step |
173
+ | 2. Admin | `GET /nquery/onboarding/admin/new` | First name, last name, email | Provisions admin into `administrators` + `all_users`; sends Devise confirmation email |
174
+ | 3. Congrats | `GET /nquery/onboarding/congrats` | Read instructions | Explains that a confirmation email was sent |
175
+ | 4. Confirm | `GET /nquery/onboarding/confirm?confirmation_token=…` | Choose password | Confirms the user, signs them in, locks the wizard |
176
+
177
+ ### Flow
178
+
179
+ ```text
180
+ Visit /nquery
181
+ → Company form
182
+ → Admin form (email + name)
183
+ → Congrats (“check your email”)
184
+ → Open confirmation link
185
+ → Set password
186
+ → Signed in as admin; wizard locked
187
+ ```
188
+
189
+ Mail must work for step 2 → 4. If SMTP is misconfigured, the admin user is still created and the wizard continues to congrats with a warning flash — fix mail settings and resend or use the confirmation token before continuing. The Docker demo writes mail to `server/tmp/mail` (`:file` delivery) instead of SMTP.
190
+
191
+ ### After the first admin
192
+
193
+ * Revisiting onboarding routes redirects away (wizard locked)
194
+ * `/signup` is not routed; the login page has no “Create an account” link
195
+ * New users: **Admin → Users**
196
+
197
+ ### Checklist before first visit (host app)
198
+
199
+ 1. `rails nquery:setup` succeeded
200
+ 2. Engine mounted
201
+ 3. `mailer_sender` + `smtp` configured
202
+ 4. Action Mailer default URL options set in the host app (so confirmation links resolve)
203
+ 5. Active Storage installed if you want logo/cover uploads during company setup
204
+
205
+ ## Mail and SMTP
206
+
207
+ Confirmation mail uses `Nquery::DeviseMailer` and `Nquery.configuration.mailer_sender`.
208
+
209
+ Also configure the host app’s Action Mailer URL options so links in email are absolute:
210
+
211
+ ```ruby
212
+ # config/environments/production.rb (example)
213
+ config.action_mailer.default_url_options = { host: "analytics.example.com", protocol: "https" }
214
+ ```
215
+
216
+ In development, Letter Opener or `:test` / `:file` delivery works as long as confirmation URLs use a reachable host.
217
+
218
+ ## Data sources
219
+
220
+ `config.data_sources` describes **identity** for sources that setup syncs into the database:
221
+
222
+ ```ruby
223
+ config.data_sources = {
224
+ main: { adapter: :rails, name: "Application database" }
225
+ }
226
+ config.default_data_source = :main
227
+ ```
228
+
229
+ * Keys become the unique `key` on `nquery_data_sources` (e.g. `"main"`)
230
+ * For `adapter: :rails`, connection config is empty — nquery uses the host app’s Active Record connection
231
+ * Do **not** put database credentials in `Nquery.configure`; external adapters are managed in the admin UI after install
232
+ * `Nquery::DataSources::Syncer` ignores credential-like keys if they appear in the hash
233
+
234
+ After setup, review sources under **Admin → Data sources**.
235
+
236
+ ## Active Storage
237
+
238
+ Required for organization `logo` and `cover_image` during company onboarding (and later branding):
239
+
240
+ ```bash
241
+ rails active_storage:install
43
242
  rails db:migrate
44
243
  ```
45
244
 
245
+ Configure `config.active_storage.service` in each environment (e.g. `:local` in development).
246
+
247
+ ## Development
248
+
249
+ This repository includes a thin demo Rails app under `server/` (SQLite) for local UI work. Host apps install nquery migrations into their own database; the demo app is for contributors and exploration.
250
+
251
+ After checking out the repo:
252
+
253
+ ```bash
254
+ cp .env.example .env
255
+ make up
256
+ ```
257
+
258
+ `make up` builds the Docker image, runs migrations, seeds demo data (`nquery:seed`), and starts the server at http://localhost:3000.
259
+
260
+ | Email | Password |
261
+ |-------|----------|
262
+ | `admin@nquery.dev` | `password123` |
263
+
264
+ The demo seeds an organization + confirmed admin so you skip the first-run wizard and can sign in with Devise immediately.
265
+
266
+ | Command | Description |
267
+ |---------|-------------|
268
+ | `make up` | Build, migrate, seed, and start the demo server |
269
+ | `make setup` | Migrate and seed only (requires a running `nquery` service) |
270
+ | `make tests` | Run RSpec via `docker compose exec` (requires running `nquery`) |
271
+ | `make console` | Rails console for the demo app |
272
+ | `make bash` | Shell inside the `nquery` container |
273
+
274
+ | Path | Purpose |
275
+ |------|---------|
276
+ | `lib/` + `app/` | Mountable engine gem |
277
+ | `spec/` | RSpec suite for the gem |
278
+ | `server/` | Thin demo Rails app |
279
+ | Docker + Makefile | Local development (recommended) |
280
+
281
+ To install this gem onto your local machine from the checkout, use your usual Bundler path / gem build workflow against `nquery.gemspec`.
282
+
46
283
  ## Contributing
47
284
 
48
- Bug reports and pull requests are welcome. Please read [CONTRIBUTING.md](CONTRIBUTING.md) for the workflow we follow (issue discussion, focused PRs, tests, and development setup).
285
+ Bug reports and pull requests are welcome on GitHub at https://github.com/caiohenrique12/nquery.
286
+
287
+ Please read [CONTRIBUTING.md](CONTRIBUTING.md) for the workflow (issue discussion, focused PRs, tests, and development setup). AI / agent contributors should follow [AGENTS.md](AGENTS.md).
49
288
 
50
289
  ## License
51
290
 
52
- MIT
291
+ The gem is available as open source under the terms of the [MIT License](LICENSE).
@@ -28,7 +28,7 @@ body.nq-body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe
28
28
  .nq-breadcrumb-item a[aria-current="page"] { color: var(--nq-text); font-weight: 500; }
29
29
  .nq-nav-section { font-size: 0.75rem; text-transform: uppercase; color: #94a3b8; margin: 1rem 0 0.5rem; }
30
30
  .nq-main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
31
- .nq-topbar { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1.5rem; background: var(--nq-card); border-bottom: 1px solid var(--nq-border); }
31
+ .nq-topbar { display: flex; justify-content: flex-end; align-items: center; padding: 0.75rem 1.5rem; background: var(--nq-card); border-bottom: 1px solid var(--nq-border); }
32
32
  .nq-content { padding: 1.5rem; flex: 1; }
33
33
  .nq-page-header { display: grid; grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "title actions" "meta meta"; gap: 0.375rem 1rem; align-items: start; margin-bottom: 1.5rem; }
34
34
  .nq-page-header h1 { grid-area: title; margin: 0; font-size: 1.5rem; }
@@ -134,6 +134,26 @@ button.nq-menu-item-button.is-loading::before { content: ""; width: 1em; height:
134
134
  .nq-textarea { font-family: ui-monospace, monospace; resize: vertical; }
135
135
  .nq-form-row { margin-bottom: 1rem; }
136
136
  .nq-form-row label { display: block; margin-bottom: 0.25rem; font-weight: 500; font-size: 0.875rem; }
137
+ .nq-form-grid { margin-bottom: 1rem; }
138
+ .nq-form-grid .nq-form-row { margin-bottom: 0; }
139
+ .nq-form-card { max-width: 40rem; }
140
+ .nq-form-section { margin-bottom: 0.5rem; }
141
+ .nq-form-section-title { margin: 0 0 1rem; font-size: 0.875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--nq-muted); }
142
+ .nq-form-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; justify-content: flex-end; align-items: center; margin-top: 1.5rem; padding-top: 1.25rem; border-top: 1px solid var(--nq-border); }
143
+ .nq-form-actions-start { margin-right: auto; }
144
+ .nq-connection-status { margin: 0.75rem 0 0; font-size: 0.875rem; line-height: 1.4; }
145
+ .nq-connection-status.is-success { color: #166534; }
146
+ .nq-connection-status.is-error { color: #991b1b; }
147
+ .nq-form-errors { margin-bottom: 1.25rem; padding: 0.875rem 1rem; border-radius: 8px; background: #fef2f2; border: 1px solid #fecaca; color: #991b1b; font-size: 0.875rem; }
148
+ .nq-form-errors p { margin: 0 0 0.5rem; }
149
+ .nq-form-errors ul { margin: 0; padding-left: 1.25rem; }
150
+ .nq-field-hint { margin: 0.375rem 0 0; font-size: 0.8125rem; color: var(--nq-muted); line-height: 1.4; }
151
+ .nq-textarea-prose { font-family: inherit; resize: vertical; min-height: 5rem; }
152
+ .nq-badge-system { background: #dbeafe; color: #1d4ed8; }
153
+ .nq-badge-custom { background: #e2e8f0; color: #334155; }
154
+ .nq-group-description p { margin: 0; line-height: 1.5; color: var(--nq-text); }
155
+ .nq-group-layout { align-items: start; }
156
+ .nq-add-member-form .nq-form-row:last-of-type { margin-bottom: 1.25rem; }
137
157
  .nq-table { width: 100%; border-collapse: collapse; }
138
158
  .nq-table th, .nq-table td { text-align: left; padding: 0.75rem; border-bottom: 1px solid var(--nq-border); }
139
159
  .nq-card-table { padding: 0; overflow: hidden; }
@@ -148,6 +168,28 @@ button.nq-menu-item-button.is-loading::before { content: ""; width: 1em; height:
148
168
  .nq-card-table .nq-table td .nq-actions { justify-content: flex-end; }
149
169
  .nq-card-table .nq-table td .nq-actions form { display: inline-flex; margin: 0; }
150
170
  .nq-card-table .nq-table th:last-child { text-align: right; }
171
+ .nq-card-table.nq-logs-table { overflow-x: auto; overflow-y: hidden; -webkit-overflow-scrolling: touch; }
172
+ .nq-logs-table .nq-table { width: 100%; table-layout: fixed; }
173
+ .nq-logs-table .nq-table th,
174
+ .nq-logs-table .nq-table td { vertical-align: top; }
175
+ .nq-logs-table .nq-table th:nth-child(1),
176
+ .nq-logs-table .nq-table td:nth-child(1) { width: 18%; }
177
+ .nq-logs-table .nq-table th:nth-child(2),
178
+ .nq-logs-table .nq-table td:nth-child(2) { width: 34%; }
179
+ .nq-logs-table .nq-table th:nth-child(3),
180
+ .nq-logs-table .nq-table td:nth-child(3),
181
+ .nq-logs-table .nq-table th:nth-child(4),
182
+ .nq-logs-table .nq-table td:nth-child(4) { width: 14%; }
183
+ .nq-logs-table .nq-table th:nth-child(5),
184
+ .nq-logs-table .nq-table td:nth-child(5) { width: 6.5rem; }
185
+ .nq-logs-table .nq-table th:nth-child(6),
186
+ .nq-logs-table .nq-table td:nth-child(6) { width: 8.5rem; }
187
+ .nq-logs-table .nq-table td { overflow: hidden; text-overflow: ellipsis; }
188
+ .nq-logs-table .nq-table td:nth-child(5),
189
+ .nq-logs-table .nq-table td:nth-child(6) { overflow: visible; text-overflow: unset; white-space: nowrap; }
190
+ .nq-logs-query { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.75rem; line-height: 1.4; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; background: #f8fafc; padding: 0.25rem 0.5rem; border-radius: 4px; }
191
+ .nq-badge-success { background: #dcfce7; color: #15803d; }
192
+ .nq-badge-error { background: #fee2e2; color: #b91c1c; }
151
193
  .nq-list { list-style: none; padding: 0; margin: 0; }
152
194
  .nq-list li { padding: 0.5rem 0; border-bottom: 1px solid var(--nq-border); }
153
195
  .nq-badge { display: inline-block; padding: 0.125rem 0.5rem; background: #e2e8f0; border-radius: 999px; font-size: 0.75rem; }
@@ -229,7 +271,6 @@ button.nq-menu-item-button.is-loading::before { content: ""; width: 1em; height:
229
271
  .nq-auth-footer { display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: 0.375rem; margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid var(--nq-border); font-size: 0.875rem; }
230
272
  .nq-auth-link { color: var(--nq-primary); font-weight: 600; text-decoration: none; }
231
273
  .nq-auth-link:hover { color: var(--nq-primary-dark); text-decoration: underline; }
232
- .nq-search .nq-input { width: 320px; }
233
274
  .nq-topbar-actions { display: flex; align-items: center; gap: 1rem; }
234
275
  .nq-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; justify-content: flex-end; }
235
276
  .nq-actions form { display: inline-flex; margin: 0; }
@@ -292,14 +333,13 @@ button.nq-menu-item-button.is-loading::before { content: ""; width: 1em; height:
292
333
  .nq-home-subtitle { margin: 0.5rem 0 0; font-size: 0.9375rem; }
293
334
  .nq-home-hero-actions { display: flex; align-items: center; gap: 0.75rem; flex-shrink: 0; }
294
335
  .nq-home-hero-cta { flex-shrink: 0; }
295
- .nq-home-actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1rem; }
336
+ .nq-home-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; }
296
337
  .nq-home-action-card { display: flex; flex-direction: column; gap: 0.5rem; padding: 1.25rem; border: 1px solid var(--nq-border); border-radius: 8px; background: var(--nq-card); text-decoration: none; color: inherit; transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s; }
297
338
  .nq-home-action-card:hover { border-color: var(--nq-primary); box-shadow: 0 8px 24px rgba(80, 158, 227, 0.12); transform: translateY(-1px); }
298
339
  .nq-home-action-icon { display: inline-flex; align-items: center; justify-content: center; width: 2.5rem; height: 2.5rem; border-radius: 10px; }
299
340
  .nq-home-action-icon .nq-icon { width: 1.25rem; height: 1.25rem; }
300
341
  .nq-home-action-icon-folder { background: #fef3c7; color: #b45309; }
301
342
  .nq-home-action-icon-dashboard { background: #dcfce7; color: #15803d; }
302
- .nq-home-action-icon-chart { background: #dbeafe; color: #1d4ed8; }
303
343
  .nq-home-action-label { font-size: 1rem; font-weight: 600; color: var(--nq-text); }
304
344
  .nq-home-action-desc { font-size: 0.8125rem; line-height: 1.4; }
305
345
  .nq-home-recents { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; align-items: start; }
@@ -336,4 +376,21 @@ button.nq-menu-item-button.is-loading::before { content: ""; width: 1em; height:
336
376
  .nq-home-actions, .nq-home-recents { grid-template-columns: 1fr; }
337
377
  .nq-home-list-time { display: none; }
338
378
  .nq-auth-form-row-split { grid-template-columns: 1fr; }
379
+ .nq-logs-table .nq-table { min-width: 0; table-layout: auto; }
380
+ .nq-logs-table .nq-table thead { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); border: 0; }
381
+ .nq-logs-table .nq-table,
382
+ .nq-logs-table .nq-table tbody,
383
+ .nq-logs-table .nq-table tr,
384
+ .nq-logs-table .nq-table td { display: block; width: 100%; }
385
+ .nq-logs-table .nq-table th:nth-child(n),
386
+ .nq-logs-table .nq-table td:nth-child(n) { width: 100%; overflow: visible; text-overflow: unset; white-space: normal; }
387
+ .nq-logs-table .nq-table tr { padding: 1rem 1.25rem; border-bottom: 1px solid var(--nq-border); }
388
+ .nq-logs-table .nq-table tbody tr:last-child { border-bottom: none; }
389
+ .nq-logs-table .nq-table td { display: grid; grid-template-columns: 6.5rem minmax(0, 1fr); align-items: start; gap: 0.35rem 0.75rem; padding: 0.35rem 0; border-bottom: none; text-align: left; }
390
+ .nq-logs-table .nq-table td::before { content: attr(data-label); font-size: 0.75rem; font-weight: 600; color: var(--nq-muted); text-transform: uppercase; letter-spacing: 0.04em; }
391
+ .nq-logs-table .nq-table th:last-child,
392
+ .nq-logs-table .nq-table td:last-child { text-align: left; }
393
+ .nq-logs-query { max-width: 100%; white-space: normal; overflow: visible; text-overflow: unset; word-break: break-word; }
394
+ .nq-logs-empty td { display: block; }
395
+ .nq-logs-empty td::before { content: none; }
339
396
  }
@@ -808,6 +808,23 @@ function initChartBuilders() {
808
808
  selectWorkspaceTab("output")
809
809
  }
810
810
 
811
+ const applyResult = (data, { outputTab } = {}) => {
812
+ if (!data?.columns) return
813
+
814
+ currentResult = data
815
+ if (outputTab) currentOutputTab = outputTab
816
+ populateAxisSelects(data.columns, data.rows)
817
+ renderTable(data)
818
+ if (meta) {
819
+ const parts = []
820
+ if (data.row_count != null) parts.push(`${data.row_count} rows`)
821
+ if (data.duration_ms != null) parts.push(`${data.duration_ms} ms`)
822
+ meta.textContent = parts.join(" · ")
823
+ meta.removeAttribute("hidden")
824
+ }
825
+ showResults()
826
+ }
827
+
811
828
  const runQuery = async (button) => {
812
829
  syncStatementField()
813
830
  setButtonLoading(button, true)
@@ -824,18 +841,7 @@ function initChartBuilders() {
824
841
  return
825
842
  }
826
843
 
827
- currentResult = data
828
- currentOutputTab = "table"
829
- populateAxisSelects(data.columns, data.rows)
830
- renderTable(data)
831
- if (meta) {
832
- const parts = []
833
- if (data.row_count != null) parts.push(`${data.row_count} rows`)
834
- if (data.duration_ms != null) parts.push(`${data.duration_ms} ms`)
835
- meta.textContent = parts.join(" · ")
836
- meta.removeAttribute("hidden")
837
- }
838
- showResults()
844
+ applyResult(data, { outputTab: "table" })
839
845
  } catch (e) {
840
846
  showError(e.message)
841
847
  } finally {
@@ -935,6 +941,21 @@ function initChartBuilders() {
935
941
 
936
942
  xAxis?.addEventListener("change", updateMapping)
937
943
  yAxis?.addEventListener("change", updateMapping)
944
+
945
+ // Saved charts: seed Output with the persisted query result and plot the saved viz.
946
+ const initialResultRaw = root.dataset.initialResult
947
+ if (initialResultRaw) {
948
+ try {
949
+ const initialResult = JSON.parse(initialResultRaw)
950
+ if (initialResult.error) {
951
+ showError(initialResult.error)
952
+ } else {
953
+ applyResult(initialResult)
954
+ }
955
+ } catch (_error) {
956
+ // Ignore malformed bootstrap payloads and keep the empty Output state.
957
+ }
958
+ }
938
959
  })
939
960
  }
940
961
 
@@ -966,6 +987,79 @@ function initQueryEditors() {
966
987
  })
967
988
  }
968
989
 
990
+ function initDataSourceForms() {
991
+ document.querySelectorAll("[data-controller='data-source-form']").forEach(root => {
992
+ if (root.dataset.dataSourceFormInitialized === "true") return
993
+ root.dataset.dataSourceFormInitialized = "true"
994
+
995
+ const adapterField = root.querySelector("[data-data-source-form-target='adapter']")
996
+ const remoteFields = root.querySelector("[data-data-source-form-target='remoteFields']")
997
+ const sqliteFields = root.querySelector("[data-data-source-form-target='sqliteFields']")
998
+ const railsHint = root.querySelector("[data-data-source-form-target='railsHint']")
999
+ const portField = root.querySelector("[data-data-source-form-target='port']")
1000
+ const testButton = root.querySelector("[data-data-source-form-target='testButton']")
1001
+ const testStatus = root.querySelector("[data-data-source-form-target='testStatus']")
1002
+
1003
+ const toggle = () => {
1004
+ const adapter = adapterField?.value || root.dataset.dataSourceFormAdapterValue || "postgresql"
1005
+ const isRemote = adapter === "postgresql" || adapter === "mysql"
1006
+ const isSqlite = adapter === "sqlite"
1007
+ const isRails = adapter === "rails"
1008
+
1009
+ remoteFields?.toggleAttribute("hidden", !isRemote)
1010
+ sqliteFields?.toggleAttribute("hidden", !isSqlite)
1011
+ railsHint?.toggleAttribute("hidden", !isRails)
1012
+
1013
+ if (portField) {
1014
+ portField.placeholder = adapter === "mysql" ? "3306" : "5432"
1015
+ }
1016
+ }
1017
+
1018
+ adapterField?.addEventListener("change", toggle)
1019
+ toggle()
1020
+
1021
+ testButton?.addEventListener("click", async (event) => {
1022
+ event.preventDefault()
1023
+ const csrf = document.querySelector('meta[name="csrf-token"]')?.content
1024
+ const formData = new FormData(root)
1025
+ formData.delete("_method")
1026
+ const dataSourceId = root.dataset.dataSourceFormIdValue
1027
+ if (dataSourceId) formData.append("data_source_id", dataSourceId)
1028
+
1029
+ setButtonLoading(testButton, true)
1030
+ if (testStatus) {
1031
+ testStatus.hidden = true
1032
+ testStatus.textContent = ""
1033
+ testStatus.classList.remove("is-success", "is-error")
1034
+ }
1035
+
1036
+ try {
1037
+ const response = await fetch(root.dataset.dataSourceFormTestUrlValue, {
1038
+ method: "POST",
1039
+ headers: { Accept: "application/json", "X-CSRF-Token": csrf },
1040
+ body: formData
1041
+ })
1042
+ const data = await response.json()
1043
+ const success = response.ok && data.ok
1044
+ if (testStatus) {
1045
+ testStatus.textContent = success ? (data.message || "Connection successful.") : (data.error || "Connection failed.")
1046
+ testStatus.classList.toggle("is-success", success)
1047
+ testStatus.classList.toggle("is-error", !success)
1048
+ testStatus.hidden = false
1049
+ }
1050
+ } catch (error) {
1051
+ if (testStatus) {
1052
+ testStatus.textContent = error.message || "Connection failed."
1053
+ testStatus.classList.add("is-error")
1054
+ testStatus.hidden = false
1055
+ }
1056
+ } finally {
1057
+ setButtonLoading(testButton, false)
1058
+ }
1059
+ })
1060
+ })
1061
+ }
1062
+
969
1063
  function initPage() {
970
1064
  initButtonLoaders()
971
1065
  initToastEvents()
@@ -974,6 +1068,7 @@ function initPage() {
974
1068
  initQueryEditors()
975
1069
  initChartBuilders()
976
1070
  initChartPreviews()
1071
+ initDataSourceForms()
977
1072
  }
978
1073
 
979
1074
  function bootPage() {
@@ -56,6 +56,8 @@ module Nquery
56
56
  when "edit"
57
57
  append_resource_breadcrumb(crumbs, breadcrumb_dashboard, path: dashboard_path(breadcrumb_dashboard), link: true)
58
58
  append_terminal_breadcrumb(crumbs, "Edit")
59
+ when "new"
60
+ append_terminal_breadcrumb(crumbs, "New dashboard")
59
61
  end
60
62
  when "nquery/collection/dashboards"
61
63
  append_section_breadcrumb(crumbs, "Collections", collections_path)
@@ -169,11 +171,11 @@ module Nquery
169
171
  def breadcrumb_parent_collection
170
172
  @breadcrumb_parent_collection ||= if @parent_collection
171
173
  @parent_collection
172
- elsif @collection&.persisted?
174
+ elsif @collection&.persisted?
173
175
  @collection
174
- elsif params[:collection_id].present?
176
+ elsif params[:collection_id].present?
175
177
  Collection.find_by(id: params[:collection_id])
176
- end
178
+ end
177
179
  end
178
180
 
179
181
  def breadcrumb_dashboard
@@ -95,6 +95,7 @@ module Nquery
95
95
  def load_chart_builder_assigns
96
96
  @data_sources = DataSource.active.order(:name)
97
97
  @schema = schema_for(@chart&.query&.data_source)
98
+ @result = chart_builder_result(@chart) if @chart&.persisted? && @chart.query&.statement.present?
98
99
  end
99
100
 
100
101
  def schema_for(data_source = nil)