nquery 0.1.2 → 0.1.3
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 +4 -4
- data/CHANGELOG.md +19 -0
- data/README.md +54 -0
- data/app/assets/builds/nquery/application.css +22 -2
- data/app/assets/builds/nquery/application.js +48 -34
- data/app/controllers/concerns/nquery/breadcrumbs.rb +2 -13
- data/app/controllers/concerns/nquery/chart_actions.rb +4 -4
- data/app/controllers/concerns/nquery/chart_results.rb +18 -4
- data/app/controllers/concerns/nquery/sharing_page.rb +38 -0
- data/app/controllers/nquery/application_controller.rb +2 -0
- data/app/controllers/nquery/dashboards_controller.rb +8 -3
- data/app/controllers/nquery/embed/charts_controller.rb +15 -13
- data/app/controllers/nquery/embed/dashboards_controller.rb +18 -8
- data/app/controllers/nquery/public/charts_controller.rb +19 -0
- data/app/controllers/nquery/public/dashboards_controller.rb +20 -0
- data/app/controllers/nquery/public_resources_controller.rb +79 -0
- data/app/controllers/nquery/queries_controller.rb +5 -49
- data/app/controllers/nquery/sharing/base_controller.rb +89 -0
- data/app/controllers/nquery/sharing/embed_tokens_controller.rb +41 -0
- data/app/controllers/nquery/sharing/embeddings_controller.rb +17 -0
- data/app/controllers/nquery/sharing/public_links_controller.rb +21 -0
- data/app/helpers/nquery/charts_helper.rb +13 -0
- data/app/helpers/nquery/sharing_helper.rb +67 -0
- data/app/javascript/nquery/controllers/chart_preview_controller.js +19 -1
- data/app/javascript/nquery/controllers/clipboard_controller.js +23 -0
- data/app/models/concerns/nquery/shareable.rb +43 -0
- data/app/models/nquery/chart.rb +14 -2
- data/app/models/nquery/dashboard.rb +2 -0
- data/app/models/nquery/data_source.rb +1 -1
- data/app/models/nquery/query.rb +4 -0
- data/app/views/layouts/nquery/embed.html.erb +3 -1
- data/app/views/nquery/charts/_builder_form.html.erb +1 -6
- data/app/views/nquery/charts/embed.html.erb +5 -11
- data/app/views/nquery/charts/new.html.erb +2 -1
- data/app/views/nquery/dashboard/charts/new.html.erb +2 -1
- data/app/views/nquery/dashboards/embed.html.erb +9 -0
- data/app/views/nquery/dashboards/show.html.erb +4 -0
- data/app/views/nquery/embed/charts/show.html.erb +7 -8
- data/app/views/nquery/embed/dashboards/show.html.erb +12 -6
- data/app/views/nquery/embed/unavailable.html.erb +1 -0
- data/app/views/nquery/shared/_icon.html.erb +6 -0
- data/app/views/nquery/shared/_shared_chart_card.html.erb +20 -0
- data/app/views/nquery/shared/_sharing_panel.html.erb +94 -0
- data/config/routes.rb +20 -1
- data/db/migrate/20260907120000_add_sharing_to_nquery_charts_and_dashboards.rb +19 -0
- data/db/migrate/20260907130000_remove_embedding_params_from_nquery_charts_and_dashboards.rb +8 -0
- data/lib/nquery/boolean.rb +9 -0
- data/lib/nquery/configuration.rb +6 -0
- data/lib/nquery/embed_token_service.rb +10 -5
- data/lib/nquery/encryption.rb +1 -1
- data/lib/nquery/seeder.rb +20 -6
- data/lib/nquery/version.rb +1 -1
- data/lib/nquery.rb +1 -0
- metadata +20 -5
- data/app/javascript/nquery/controllers/query_editor_controller.js +0 -41
- data/app/views/nquery/queries/edit.html.erb +0 -40
- data/app/views/nquery/queries/new.html.erb +0 -37
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 16f10a8cbf379951ca3ba531d4692f3caa98ff41bedd49ebdb77fcc44244a6c7
|
|
4
|
+
data.tar.gz: 32a28cf89669ce5b093e090a22fe54cbd7c40b2f6ca9cd374498c601ad63d735
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e3a624fc4b6737a76b3a702a3d7a3c700f7ff6e416e5878b60c744ad2efc949b3002d4cf632201a91851ff6b9724fe23793f2ce3eea0c8147dad7529e71b352d
|
|
7
|
+
data.tar.gz: 4364b0dada29cb6324dba7c2e1649d47ea714f5a6edaf3900a05865066798dbe4852bd2f614863a9295f3fd3f7406b5ad0e0893572ad97983256b9d015daee54
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
## [0.1.3] - 2026-09-08
|
|
6
|
+
|
|
7
|
+
Host apps should `bundle update nquery` and run `rails db:migrate`. Re-run `rails generate nquery:install` (or add the sharing/embed keys to `config/initializers/nquery.rb`) if you want public links or static embeds.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- Public sharing links and static signed embeds for charts and dashboards (Metabase-style)
|
|
11
|
+
- Sharing UI with expiry picker, revoke/regenerate, and copy-to-clipboard
|
|
12
|
+
- Real query execution on public and embed pages, including dashboard card grids
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- Public embed endpoints require `static_embedding_enabled` and per-resource `enable_embedding`
|
|
16
|
+
- Embed layout loads application JS so chart previews initialize, and allows cross-origin iframes via CSP `frame-ancestors`
|
|
17
|
+
- Remove standalone query pages so SQL lives only on charts
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- Query runner and schema endpoints honor the engine mount path (#25)
|
|
21
|
+
|
|
3
22
|
## [0.1.2] - 2026-09-07
|
|
4
23
|
|
|
5
24
|
### Changed
|
data/README.md
CHANGED
|
@@ -21,6 +21,7 @@ nquery lets a host Rails app expose a self-contained analytics UI. It:
|
|
|
21
21
|
- [First admin onboarding](#first-admin-onboarding)
|
|
22
22
|
- [Mail and SMTP](#mail-and-smtp)
|
|
23
23
|
- [Data sources](#data-sources)
|
|
24
|
+
- [Sharing and embedding](#sharing-and-embedding)
|
|
24
25
|
- [Active Storage](#active-storage)
|
|
25
26
|
- [Development](#development)
|
|
26
27
|
- [Contributing](#contributing)
|
|
@@ -135,6 +136,9 @@ end
|
|
|
135
136
|
| `query_timeout` | `15` | Query timeout (seconds) |
|
|
136
137
|
| `query_row_limit` | `10_000` | Max rows returned by a query |
|
|
137
138
|
| `embed_secret` | `nil` | Override for embed token signing (falls back to `secret_key_base`) |
|
|
139
|
+
| `public_sharing_enabled` | `false` | Allow public UUID links for charts and dashboards |
|
|
140
|
+
| `static_embedding_enabled` | `false` | Allow signed iframe embeds for charts and dashboards |
|
|
141
|
+
| `embed_frame_ancestors` | `nil` | CSP `frame-ancestors` origins (`nil` allows any origin) |
|
|
138
142
|
|
|
139
143
|
**Not supported in config:** storing database credentials for data sources, SSO/hybrid auth hooks, or public self-registration.
|
|
140
144
|
|
|
@@ -233,6 +237,56 @@ config.default_data_source = :main
|
|
|
233
237
|
|
|
234
238
|
After setup, review sources under **Admin → Data sources**.
|
|
235
239
|
|
|
240
|
+
## Sharing and embedding
|
|
241
|
+
|
|
242
|
+
nquery mirrors Metabase’s two sharing mechanisms for **charts** and **dashboards** (not collections). Both are **off by default** and must be enabled in the host initializer:
|
|
243
|
+
|
|
244
|
+
```ruby
|
|
245
|
+
Nquery.configure do |config|
|
|
246
|
+
config.public_sharing_enabled = true
|
|
247
|
+
config.static_embedding_enabled = true
|
|
248
|
+
config.embed_secret = Rails.application.secret_key_base
|
|
249
|
+
# Optional: restrict which sites may iframe embeds
|
|
250
|
+
# config.embed_frame_ancestors = ["https://app.example.com"]
|
|
251
|
+
end
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Users with **view** access can open **Embed** on a chart or dashboard. Creating, revoking, or regenerating links and tokens requires **curate** access on the collection and `view_data` on the underlying data sources.
|
|
255
|
+
|
|
256
|
+
Public and embed endpoints rate-limit to 60 requests per minute per IP when the host uses Rails 7.2+ `rate_limit`. Older Rails versions log a warning and skip rate limiting.
|
|
257
|
+
|
|
258
|
+
### Public links
|
|
259
|
+
|
|
260
|
+
An unguessable UUID stored on the resource. No expiry and no signature. Removing the link clears the UUID.
|
|
261
|
+
|
|
262
|
+
| Resource | Path |
|
|
263
|
+
|----------|------|
|
|
264
|
+
| Chart | `/public/charts/:uuid` |
|
|
265
|
+
| Dashboard | `/public/dashboards/:uuid` |
|
|
266
|
+
|
|
267
|
+
### Static embeds
|
|
268
|
+
|
|
269
|
+
HMAC-signed tokens (`Nquery::EmbedToken` + `EmbedTokenService`) with optional expiry. The resource must also have **Enable embedding** turned on. Revoked or expired tokens return 403.
|
|
270
|
+
|
|
271
|
+
| Resource | Path |
|
|
272
|
+
|----------|------|
|
|
273
|
+
| Chart | `/embed/charts/show?token=…` |
|
|
274
|
+
| Dashboard | `/embed/dashboards/show?token=…` |
|
|
275
|
+
|
|
276
|
+
Tokens stay in the query string because the signed value contains a `.` that would collide with Rails format parsing in a path segment.
|
|
277
|
+
|
|
278
|
+
### Hosting embeds (CSP / framing)
|
|
279
|
+
|
|
280
|
+
Public and embed responses:
|
|
281
|
+
|
|
282
|
+
* Delete Rails’ default `X-Frame-Options: SAMEORIGIN` so cross-origin iframes work
|
|
283
|
+
* Set `Content-Security-Policy: frame-ancestors *` (or the origins in `embed_frame_ancestors`)
|
|
284
|
+
* Send `X-Robots-Tag: noindex`
|
|
285
|
+
|
|
286
|
+
Host apps that set a global CSP should allow the nquery origin in `frame-src` (or `child-src`) on the page that contains the iframe.
|
|
287
|
+
|
|
288
|
+
Suggested iframe defaults from the Embed page: `width="800"` `height="600"` (charts) or `800` (dashboards), `loading="lazy"`. Optional query params: `titled=false`, `bordered=false`, `theme=night` or `theme=transparent`.
|
|
289
|
+
|
|
236
290
|
## Active Storage
|
|
237
291
|
|
|
238
292
|
Required for organization `logo` and `cover_image` during company onboarding (and later branding):
|
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
--nq-muted: #64748b;
|
|
10
10
|
--nq-card: #ffffff;
|
|
11
11
|
--nq-danger: #ef4444;
|
|
12
|
+
--nq-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
12
13
|
}
|
|
13
14
|
* { box-sizing: border-box; }
|
|
14
|
-
body.nq-body { margin: 0; font-family: -
|
|
15
|
+
body.nq-body { margin: 0; font-family: var(--nq-font); color: var(--nq-text); background: var(--nq-bg); }
|
|
15
16
|
.nq-layout { display: flex; min-height: 100vh; }
|
|
16
17
|
.nq-sidebar { width: 240px; background: var(--nq-sidebar); color: #fff; padding: 1rem; flex-shrink: 0; }
|
|
17
18
|
.nq-logo { font-size: 1.5rem; font-weight: 700; margin-bottom: 2rem; color: var(--nq-primary); }
|
|
@@ -279,7 +280,8 @@ button.nq-menu-item-button.is-loading::before { content: ""; width: 1em; height:
|
|
|
279
280
|
.nq-radio { display: inline-flex; align-items: center; gap: 0.25rem; margin-right: 1rem; }
|
|
280
281
|
.nq-checkbox { display: block; margin-bottom: 0.25rem; }
|
|
281
282
|
.nq-csv-preview { margin: 1rem 0; overflow: auto; }
|
|
282
|
-
.nq-embed-body { margin: 0; padding: 1rem; background: #fff; }
|
|
283
|
+
.nq-embed-body { margin: 0; padding: 1rem; background: #fff; font-family: var(--nq-font); color: var(--nq-text); }
|
|
284
|
+
.nq-embed-body h1 { margin: 0 0 1rem; font-size: 1.5rem; font-weight: 600; color: inherit; }
|
|
283
285
|
.nq-icon-lg { width: 2.5rem; height: 2.5rem; color: var(--nq-muted); }
|
|
284
286
|
.nq-type-badge { display: inline-flex; align-items: center; gap: 0.375rem; padding: 0.25rem 0.625rem; border-radius: 999px; font-size: 0.75rem; font-weight: 600; white-space: nowrap; }
|
|
285
287
|
.nq-type-badge-folder { background: #fef3c7; color: #92400e; }
|
|
@@ -394,3 +396,21 @@ button.nq-menu-item-button.is-loading::before { content: ""; width: 1em; height:
|
|
|
394
396
|
.nq-logs-empty td { display: block; }
|
|
395
397
|
.nq-logs-empty td::before { content: none; }
|
|
396
398
|
}
|
|
399
|
+
.nq-sharing-section { margin-bottom: 1.25rem; }
|
|
400
|
+
.nq-sharing-section h2 { margin-top: 0; }
|
|
401
|
+
.nq-sharing-section h3 { margin: 1rem 0 0.5rem; font-size: 0.9375rem; }
|
|
402
|
+
.nq-sharing-section > .nq-actions { margin-top: 1rem; }
|
|
403
|
+
.nq-sharing-token-form { display: flex; flex-direction: column; gap: 0.75rem; margin-top: 1rem; }
|
|
404
|
+
.nq-sharing-token-form .nq-form-row { margin-bottom: 0; }
|
|
405
|
+
.nq-copy-field { display: flex; align-items: flex-start; gap: 0.5rem; }
|
|
406
|
+
.nq-copy-field .nq-code { flex: 1; margin: 0; white-space: pre-wrap; word-break: break-all; }
|
|
407
|
+
.nq-copy-field .nq-btn { flex-shrink: 0; }
|
|
408
|
+
.nq-embed-grid { margin-top: 1rem; }
|
|
409
|
+
.nq-chart-error { margin: 0; padding: 1rem; border-radius: 6px; background: #fef2f2; color: #991b1b; }
|
|
410
|
+
.nq-chart-preview.is-error { min-height: 0; }
|
|
411
|
+
.nq-embed-body.nq-embed-untitled h1 { display: none; }
|
|
412
|
+
.nq-embed-body.nq-embed-borderless .nq-dashboard-card { border-color: transparent; box-shadow: none; }
|
|
413
|
+
.nq-embed-body.nq-embed-theme-night { background: #0f172a; color: #e2e8f0; }
|
|
414
|
+
.nq-embed-body.nq-embed-theme-night .nq-dashboard-card { background: #1e293b; border-color: #334155; }
|
|
415
|
+
.nq-embed-body.nq-embed-theme-night .nq-chart-error { background: #450a0a; color: #fecaca; }
|
|
416
|
+
.nq-embed-body.nq-embed-theme-transparent { background: transparent; }
|
|
@@ -263,7 +263,7 @@ function initButtonLoaders() {
|
|
|
263
263
|
|
|
264
264
|
document.addEventListener("click", (event) => {
|
|
265
265
|
const button = event.target.closest("a.nq-btn, button.nq-btn[type=button], input.nq-btn[type=button]")
|
|
266
|
-
if (!button || button.classList.contains("is-loading") || button.dataset.managesLoading === "true") return
|
|
266
|
+
if (!button || button.classList.contains("is-loading") || button.dataset.managesLoading === "true" || button.hasAttribute("data-copy-button")) return
|
|
267
267
|
setButtonLoading(button, true)
|
|
268
268
|
})
|
|
269
269
|
|
|
@@ -343,12 +343,23 @@ function buildPreviewChartConfig(type, data, xCol, yCol) {
|
|
|
343
343
|
}
|
|
344
344
|
}
|
|
345
345
|
|
|
346
|
+
const NQ_CHART_FONT = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
|
|
347
|
+
|
|
348
|
+
function applyChartFontDefaults() {
|
|
349
|
+
if (typeof Chart === "undefined") return
|
|
350
|
+
|
|
351
|
+
Chart.defaults.font.family = NQ_CHART_FONT
|
|
352
|
+
Chart.defaults.color = document.body.classList.contains("nq-embed-theme-night") ? "#e2e8f0" : "#1e293b"
|
|
353
|
+
}
|
|
354
|
+
|
|
346
355
|
function initChartPreviews() {
|
|
347
356
|
if (typeof Chart === "undefined") {
|
|
348
357
|
requestAnimationFrame(initChartPreviews)
|
|
349
358
|
return
|
|
350
359
|
}
|
|
351
360
|
|
|
361
|
+
applyChartFontDefaults()
|
|
362
|
+
|
|
352
363
|
document.querySelectorAll("[data-controller='chart-preview']").forEach(el => {
|
|
353
364
|
if (el.dataset.chartPreviewInitialized === "true") return
|
|
354
365
|
el.dataset.chartPreviewInitialized = "true"
|
|
@@ -368,11 +379,17 @@ function initChartPreviews() {
|
|
|
368
379
|
rows: [["Jan", 1200], ["Feb", 1800], ["Mar", 2400], ["Apr", 2100]]
|
|
369
380
|
}
|
|
370
381
|
|
|
382
|
+
if (data?.error) {
|
|
383
|
+
el.classList.add("is-error")
|
|
384
|
+
el.innerHTML = `<div class="nq-chart-error">${escapeHtml(data.error)}</div>`
|
|
385
|
+
return
|
|
386
|
+
}
|
|
387
|
+
|
|
371
388
|
if (type === "number") {
|
|
372
389
|
const yIndex = columnIndex(demo.columns || [], yCol || demo.columns?.[1] || demo.columns?.[0])
|
|
373
390
|
const value = demo.rows?.[0]?.[yIndex] ?? "—"
|
|
374
391
|
el.classList.add("is-number")
|
|
375
|
-
el.innerHTML = `<div class="nq-number-display">${value}</div>`
|
|
392
|
+
el.innerHTML = `<div class="nq-number-display">${escapeHtml(value)}</div>`
|
|
376
393
|
return
|
|
377
394
|
}
|
|
378
395
|
|
|
@@ -448,6 +465,8 @@ function initChartBuilders() {
|
|
|
448
465
|
root.querySelector("[data-action*='chart-builder#format']")
|
|
449
466
|
const saveStatus = root.querySelector("[data-chart-builder-target='saveStatus']")
|
|
450
467
|
const querySaveUrl = root.dataset.querySaveUrl
|
|
468
|
+
const queryRunUrl = root.dataset.queryRunUrl
|
|
469
|
+
const querySchemaUrl = root.dataset.querySchemaUrl
|
|
451
470
|
|
|
452
471
|
let currentResult = null
|
|
453
472
|
let chartInstance = null
|
|
@@ -678,7 +697,7 @@ function initChartBuilders() {
|
|
|
678
697
|
const yCol = yAxis?.value || result.columns[1] || result.columns[0]
|
|
679
698
|
const yIndex = columnIndex(result.columns, yCol)
|
|
680
699
|
const value = result.rows[0]?.[yIndex] ?? "—"
|
|
681
|
-
numberWrap.innerHTML = `<div class="nq-number-display">${value}</div>`
|
|
700
|
+
numberWrap.innerHTML = `<div class="nq-number-display">${escapeHtml(value)}</div>`
|
|
682
701
|
chartWrap?.setAttribute("hidden", "")
|
|
683
702
|
numberWrap?.removeAttribute("hidden")
|
|
684
703
|
}
|
|
@@ -830,7 +849,7 @@ function initChartBuilders() {
|
|
|
830
849
|
setButtonLoading(button, true)
|
|
831
850
|
try {
|
|
832
851
|
const csrf = document.querySelector('meta[name="csrf-token"]')?.content
|
|
833
|
-
const res = await fetch(
|
|
852
|
+
const res = await fetch(queryRunUrl, {
|
|
834
853
|
method: "POST",
|
|
835
854
|
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf },
|
|
836
855
|
body: JSON.stringify({ statement: getStatement(), data_source_id: dataSource?.value })
|
|
@@ -851,7 +870,7 @@ function initChartBuilders() {
|
|
|
851
870
|
|
|
852
871
|
const loadSchema = async () => {
|
|
853
872
|
if (!schema || !dataSource) return
|
|
854
|
-
const response = await fetch(
|
|
873
|
+
const response = await fetch(`${querySchemaUrl}?data_source_id=${dataSource.value}`)
|
|
855
874
|
const data = await response.json()
|
|
856
875
|
renderSchemaTree(schema, data.tables)
|
|
857
876
|
}
|
|
@@ -959,34 +978,6 @@ function initChartBuilders() {
|
|
|
959
978
|
})
|
|
960
979
|
}
|
|
961
980
|
|
|
962
|
-
function initQueryEditors() {
|
|
963
|
-
document.querySelectorAll("[data-controller='query-editor']").forEach(el => {
|
|
964
|
-
const statement = el.querySelector("[data-query-editor-target='statement']")
|
|
965
|
-
const results = el.querySelector("[data-query-editor-target='results']")
|
|
966
|
-
const dataSource = el.querySelector("[data-query-editor-target='dataSource']")
|
|
967
|
-
el.querySelector("[data-action*='query-editor#run']")?.addEventListener("click", async (event) => {
|
|
968
|
-
const button = event.currentTarget
|
|
969
|
-
setButtonLoading(button, true)
|
|
970
|
-
try {
|
|
971
|
-
const csrf = document.querySelector('meta[name="csrf-token"]')?.content
|
|
972
|
-
const res = await fetch("/queries/run", {
|
|
973
|
-
method: "POST",
|
|
974
|
-
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf },
|
|
975
|
-
body: JSON.stringify({ statement: statement?.value, data_source_id: dataSource?.value })
|
|
976
|
-
})
|
|
977
|
-
results.textContent = JSON.stringify(await res.json(), null, 2)
|
|
978
|
-
} finally {
|
|
979
|
-
setButtonLoading(button, false)
|
|
980
|
-
}
|
|
981
|
-
})
|
|
982
|
-
el.querySelectorAll("[data-action*='insertTable']").forEach(item => {
|
|
983
|
-
item.addEventListener("click", () => {
|
|
984
|
-
if (statement) statement.value = `SELECT * FROM ${item.dataset.table} LIMIT 100`
|
|
985
|
-
})
|
|
986
|
-
})
|
|
987
|
-
})
|
|
988
|
-
}
|
|
989
|
-
|
|
990
981
|
function initDataSourceForms() {
|
|
991
982
|
document.querySelectorAll("[data-controller='data-source-form']").forEach(root => {
|
|
992
983
|
if (root.dataset.dataSourceFormInitialized === "true") return
|
|
@@ -1060,15 +1051,38 @@ function initDataSourceForms() {
|
|
|
1060
1051
|
})
|
|
1061
1052
|
}
|
|
1062
1053
|
|
|
1054
|
+
function initCopyButtons() {
|
|
1055
|
+
document.querySelectorAll("[data-copy-button]").forEach(button => {
|
|
1056
|
+
if (button.dataset.copyInitialized === "true") return
|
|
1057
|
+
button.dataset.copyInitialized = "true"
|
|
1058
|
+
|
|
1059
|
+
button.addEventListener("click", async () => {
|
|
1060
|
+
const target = button.dataset.copyTarget ? document.querySelector(button.dataset.copyTarget) : null
|
|
1061
|
+
const text = target ? target.textContent : (button.dataset.copyText || "")
|
|
1062
|
+
const original = button.textContent
|
|
1063
|
+
|
|
1064
|
+
try {
|
|
1065
|
+
await navigator.clipboard.writeText(text.trim())
|
|
1066
|
+
button.textContent = "Copied"
|
|
1067
|
+
} catch {
|
|
1068
|
+
button.textContent = "Copy failed"
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
setTimeout(() => { button.textContent = original }, 1500)
|
|
1072
|
+
})
|
|
1073
|
+
})
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1063
1076
|
function initPage() {
|
|
1077
|
+
applyChartFontDefaults()
|
|
1064
1078
|
initButtonLoaders()
|
|
1065
1079
|
initToastEvents()
|
|
1066
1080
|
observeToastStack()
|
|
1067
1081
|
initFlashCards()
|
|
1068
|
-
initQueryEditors()
|
|
1069
1082
|
initChartBuilders()
|
|
1070
1083
|
initChartPreviews()
|
|
1071
1084
|
initDataSourceForms()
|
|
1085
|
+
initCopyButtons()
|
|
1072
1086
|
}
|
|
1073
1087
|
|
|
1074
1088
|
function bootPage() {
|
|
@@ -53,9 +53,9 @@ module Nquery
|
|
|
53
53
|
case action_name
|
|
54
54
|
when "show"
|
|
55
55
|
append_resource_breadcrumb(crumbs, breadcrumb_dashboard)
|
|
56
|
-
when "edit"
|
|
56
|
+
when "edit", "embed"
|
|
57
57
|
append_resource_breadcrumb(crumbs, breadcrumb_dashboard, path: dashboard_path(breadcrumb_dashboard), link: true)
|
|
58
|
-
append_terminal_breadcrumb(crumbs,
|
|
58
|
+
append_terminal_breadcrumb(crumbs, action_name.titleize)
|
|
59
59
|
when "new"
|
|
60
60
|
append_terminal_breadcrumb(crumbs, "New dashboard")
|
|
61
61
|
end
|
|
@@ -86,9 +86,6 @@ module Nquery
|
|
|
86
86
|
when "new"
|
|
87
87
|
append_terminal_breadcrumb(crumbs, "New chart")
|
|
88
88
|
end
|
|
89
|
-
when "nquery/queries"
|
|
90
|
-
append_section_breadcrumb(crumbs, "Queries", nil)
|
|
91
|
-
append_terminal_breadcrumb(crumbs, query_breadcrumb_label)
|
|
92
89
|
when "nquery/charts"
|
|
93
90
|
append_section_breadcrumb(crumbs, "Collections", collections_path)
|
|
94
91
|
if breadcrumb_chart&.collection
|
|
@@ -208,14 +205,6 @@ module Nquery
|
|
|
208
205
|
@breadcrumb_data_source ||= @data_source || DataSource.find_by(id: params[:id])
|
|
209
206
|
end
|
|
210
207
|
|
|
211
|
-
def query_breadcrumb_label
|
|
212
|
-
case action_name
|
|
213
|
-
when "new" then "New SQL query"
|
|
214
|
-
when "edit" then "Edit query: #{@query&.name.presence || 'Untitled'}"
|
|
215
|
-
else @query&.name.presence || "Query"
|
|
216
|
-
end
|
|
217
|
-
end
|
|
218
|
-
|
|
219
208
|
def append_section_breadcrumb(crumbs, label, path)
|
|
220
209
|
crumbs << { label: label, path: path, section: true }
|
|
221
210
|
end
|
|
@@ -6,6 +6,7 @@ module Nquery
|
|
|
6
6
|
|
|
7
7
|
included do
|
|
8
8
|
include ChartResults
|
|
9
|
+
include SharingPage
|
|
9
10
|
before_action :set_chart, only: %i[show edit update embed destroy archive]
|
|
10
11
|
before_action :authorize_chart_view!, only: %i[show embed]
|
|
11
12
|
before_action :authorize_chart_curate!, only: %i[edit update destroy archive]
|
|
@@ -47,8 +48,7 @@ module Nquery
|
|
|
47
48
|
end
|
|
48
49
|
|
|
49
50
|
def embed
|
|
50
|
-
|
|
51
|
-
@embed_url = @embed_token ? embed_public_chart_url(token: EmbedTokenService.signed_token_for(@embed_token)) : nil
|
|
51
|
+
assign_sharing_page(@chart)
|
|
52
52
|
render "nquery/charts/embed"
|
|
53
53
|
end
|
|
54
54
|
|
|
@@ -94,8 +94,8 @@ module Nquery
|
|
|
94
94
|
|
|
95
95
|
def load_chart_builder_assigns
|
|
96
96
|
@data_sources = DataSource.active.order(:name)
|
|
97
|
-
@schema = schema_for(@chart&.
|
|
98
|
-
@result = chart_builder_result(@chart) if @chart&.persisted? && @chart.
|
|
97
|
+
@schema = schema_for(@chart&.data_source)
|
|
98
|
+
@result = chart_builder_result(@chart) if @chart&.persisted? && @chart.statement?
|
|
99
99
|
end
|
|
100
100
|
|
|
101
101
|
def schema_for(data_source = nil)
|
|
@@ -7,7 +7,7 @@ module Nquery
|
|
|
7
7
|
private
|
|
8
8
|
|
|
9
9
|
def chart_result(chart)
|
|
10
|
-
return demo_result unless chart.
|
|
10
|
+
return demo_result unless chart.statement?
|
|
11
11
|
|
|
12
12
|
chart_query_result(chart)
|
|
13
13
|
rescue StandardError
|
|
@@ -15,7 +15,7 @@ module Nquery
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def chart_builder_result(chart)
|
|
18
|
-
return nil unless chart.
|
|
18
|
+
return nil unless chart.statement?
|
|
19
19
|
|
|
20
20
|
chart_query_result(chart, audit: false)
|
|
21
21
|
rescue QueryRunner::PermissionError, QueryRunner::Error => e
|
|
@@ -24,10 +24,20 @@ module Nquery
|
|
|
24
24
|
{ error: e.message }
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
def shared_chart_result(chart)
|
|
28
|
+
return { error: "This chart has no query." } unless chart.statement?
|
|
29
|
+
return { error: shared_chart_load_error } unless chart.data_source
|
|
30
|
+
|
|
31
|
+
chart_query_result(chart, audit: false)
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
Rails.logger.error("[nquery] shared chart #{chart.id} failed: #{e.class}: #{e.message}")
|
|
34
|
+
{ error: shared_chart_load_error }
|
|
35
|
+
end
|
|
36
|
+
|
|
27
37
|
def chart_query_result(chart, audit: true)
|
|
28
38
|
QueryRunner.new(
|
|
29
|
-
data_source: chart.
|
|
30
|
-
statement: chart.
|
|
39
|
+
data_source: chart.data_source || DataSource.first,
|
|
40
|
+
statement: chart.statement,
|
|
31
41
|
user: current_nquery_user,
|
|
32
42
|
query: chart.query
|
|
33
43
|
).run(audit: audit)
|
|
@@ -40,5 +50,9 @@ module Nquery
|
|
|
40
50
|
row_count: 4
|
|
41
51
|
}
|
|
42
52
|
end
|
|
53
|
+
|
|
54
|
+
def shared_chart_load_error
|
|
55
|
+
"This chart could not be loaded."
|
|
56
|
+
end
|
|
43
57
|
end
|
|
44
58
|
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nquery
|
|
4
|
+
module SharingPage
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def assign_sharing_page(resource)
|
|
10
|
+
@shareable = resource
|
|
11
|
+
@embed_token = resource.active_embed_token
|
|
12
|
+
@embed_url = signed_embed_url_for(resource)
|
|
13
|
+
@public_url = public_share_url_for(resource)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def signed_embed_url_for(resource)
|
|
17
|
+
token = resource.active_embed_token
|
|
18
|
+
return unless token
|
|
19
|
+
|
|
20
|
+
signed = EmbedTokenService.signed_token_for(token)
|
|
21
|
+
if resource.is_a?(Dashboard)
|
|
22
|
+
embed_public_dashboard_url(token: signed)
|
|
23
|
+
else
|
|
24
|
+
embed_public_chart_url(token: signed)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def public_share_url_for(resource)
|
|
29
|
+
return unless resource.publicly_shared?
|
|
30
|
+
|
|
31
|
+
if resource.is_a?(Dashboard)
|
|
32
|
+
public_dashboard_url(resource.public_uuid)
|
|
33
|
+
else
|
|
34
|
+
public_chart_url(resource.public_uuid)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -4,9 +4,10 @@ module Nquery
|
|
|
4
4
|
class DashboardsController < ApplicationController
|
|
5
5
|
include ChartResults
|
|
6
6
|
include Browsable
|
|
7
|
+
include SharingPage
|
|
7
8
|
|
|
8
|
-
before_action :set_dashboard, only: %i[show edit update destroy archive unarchive update_layout]
|
|
9
|
-
before_action :authorize_dashboard_view!, only: %i[show]
|
|
9
|
+
before_action :set_dashboard, only: %i[show edit update destroy archive unarchive update_layout embed]
|
|
10
|
+
before_action :authorize_dashboard_view!, only: %i[show embed]
|
|
10
11
|
before_action :authorize_dashboard_curate!, only: %i[edit update destroy archive unarchive update_layout]
|
|
11
12
|
|
|
12
13
|
def index
|
|
@@ -44,6 +45,10 @@ module Nquery
|
|
|
44
45
|
@card_results = @dashboard_cards.index_with { |card| chart_result(card.chart) }
|
|
45
46
|
end
|
|
46
47
|
|
|
48
|
+
def embed
|
|
49
|
+
assign_sharing_page(@dashboard)
|
|
50
|
+
end
|
|
51
|
+
|
|
47
52
|
def edit
|
|
48
53
|
@collections = assignable_collections
|
|
49
54
|
end
|
|
@@ -85,7 +90,7 @@ module Nquery
|
|
|
85
90
|
private
|
|
86
91
|
|
|
87
92
|
def set_dashboard
|
|
88
|
-
@dashboard = if action_name.in?(%w[show edit])
|
|
93
|
+
@dashboard = if action_name.in?(%w[show edit embed])
|
|
89
94
|
Dashboard.includes(:creator, dashboard_cards: :chart).find(params[:id])
|
|
90
95
|
else
|
|
91
96
|
Dashboard.find(params[:id])
|
|
@@ -2,23 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
module Nquery
|
|
4
4
|
module Embed
|
|
5
|
-
class ChartsController <
|
|
6
|
-
include Nquery::Engine.routes.url_helpers
|
|
7
|
-
protect_from_forgery with: :exception
|
|
8
|
-
layout "nquery/embed"
|
|
9
|
-
|
|
5
|
+
class ChartsController < PublicResourcesController
|
|
10
6
|
def show
|
|
7
|
+
unless Nquery.configuration.static_embedding_enabled
|
|
8
|
+
return render_forbidden("Embedding is disabled")
|
|
9
|
+
end
|
|
10
|
+
|
|
11
11
|
payload = EmbedTokenService.verify(params[:token])
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
render plain: "Invalid or expired embed token", status: :forbidden
|
|
16
|
-
end
|
|
12
|
+
unless payload[:resource_type] == "Nquery::Chart"
|
|
13
|
+
return render_forbidden
|
|
14
|
+
end
|
|
17
15
|
|
|
18
|
-
|
|
16
|
+
@chart = Chart.active.find(payload[:resource_id])
|
|
17
|
+
unless @chart.enable_embedding?
|
|
18
|
+
return render_forbidden("Embedding is disabled")
|
|
19
|
+
end
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
@result = shared_chart_result(@chart)
|
|
22
|
+
rescue EmbedTokenService::Error, ActiveRecord::RecordNotFound
|
|
23
|
+
render_forbidden
|
|
22
24
|
end
|
|
23
25
|
end
|
|
24
26
|
end
|
|
@@ -2,16 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
module Nquery
|
|
4
4
|
module Embed
|
|
5
|
-
class DashboardsController <
|
|
6
|
-
include Nquery::Engine.routes.url_helpers
|
|
7
|
-
protect_from_forgery with: :exception
|
|
8
|
-
layout "nquery/embed"
|
|
9
|
-
|
|
5
|
+
class DashboardsController < PublicResourcesController
|
|
10
6
|
def show
|
|
7
|
+
unless Nquery.configuration.static_embedding_enabled
|
|
8
|
+
return render_forbidden("Embedding is disabled")
|
|
9
|
+
end
|
|
10
|
+
|
|
11
11
|
payload = EmbedTokenService.verify(params[:token])
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
unless payload[:resource_type] == "Nquery::Dashboard"
|
|
13
|
+
return render_forbidden
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
@dashboard = Dashboard.active.find(payload[:resource_id])
|
|
17
|
+
unless @dashboard.enable_embedding?
|
|
18
|
+
return render_forbidden("Embedding is disabled")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
@dashboard_cards = load_dashboard_cards(@dashboard)
|
|
22
|
+
@card_results = @dashboard_cards.index_with { |card| shared_chart_result(card.chart) }
|
|
23
|
+
rescue EmbedTokenService::Error, ActiveRecord::RecordNotFound
|
|
24
|
+
render_forbidden
|
|
15
25
|
end
|
|
16
26
|
end
|
|
17
27
|
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nquery
|
|
4
|
+
module Public
|
|
5
|
+
class ChartsController < PublicResourcesController
|
|
6
|
+
def show
|
|
7
|
+
unless Nquery.configuration.public_sharing_enabled
|
|
8
|
+
return render_unavailable(status: :not_found)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
@chart = Chart.active.find_by!(public_uuid: params[:uuid])
|
|
12
|
+
@result = shared_chart_result(@chart)
|
|
13
|
+
render template: "nquery/embed/charts/show"
|
|
14
|
+
rescue ActiveRecord::RecordNotFound
|
|
15
|
+
render_unavailable(status: :not_found)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nquery
|
|
4
|
+
module Public
|
|
5
|
+
class DashboardsController < PublicResourcesController
|
|
6
|
+
def show
|
|
7
|
+
unless Nquery.configuration.public_sharing_enabled
|
|
8
|
+
return render_unavailable(status: :not_found)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
@dashboard = Dashboard.active.find_by!(public_uuid: params[:uuid])
|
|
12
|
+
@dashboard_cards = load_dashboard_cards(@dashboard)
|
|
13
|
+
@card_results = @dashboard_cards.index_with { |card| shared_chart_result(card.chart) }
|
|
14
|
+
render template: "nquery/embed/dashboards/show"
|
|
15
|
+
rescue ActiveRecord::RecordNotFound
|
|
16
|
+
render_unavailable(status: :not_found)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|