Compare commits

...

110 Commits

Author SHA1 Message Date
snickerjp
43ee21ac20 Feature/catch notsupported exception (#7573)
* Handle NotSupported exception in refresh_schema

- Add NotSupported exception handling to refresh_schema()
- Log unsupported datasources at DEBUG level
- Avoid error metrics for datasources without schema support

* Add test for NotSupported exception handling

- Test that NotSupported exceptions are caught and logged at DEBUG level
- Verify no warning logs are generated for unsupported datasources

* Fix import order (ruff)

* Remove test for NotSupported exception handling

As suggested by @yoshiokatsuneo, testing logging details for 3 lines of code
is excessive and may hurt maintainability. The existing tests already ensure
the functionality works correctly.
2025-12-19 11:15:45 +09:00
Eric Radman
262d46f465 Multi-org: format base path, not including protocol (#7260)
Remove hard-coded 'https://' when MULTI_ORG is enabled
2025-12-17 19:34:30 -05:00
gaojingyu
bc68b1c38b fix(destinations): Handle unicode characters in webhook notifications (#7586)
* fix(destinations): Handle unicode characters in webhook notifications

Previously, webhook notifications would fail if they contained unicode characters in the alert data. This was because the JSON payload was not UTF-8 encoded before being sent.

This commit fixes the issue by explicitly encoding the JSON data to UTF-8 and adds a test to verify the fix.

* move test function to new file

---------

Co-authored-by: gaojingyu <gaojingyu>
2025-12-16 00:02:30 +09:00
Vladislav Denisov
4353a82c7a Persist updated values and apply saved dashboard parameters (#7570)
Add support for saving dashboard parameters after clicking the Apply button. Parameters are applied in the following order: URL, dashboard parameters, query parameters.

Persist the queued values only when “Done Editing” is clicked, keeping Query and Dashboard editors aligned.
2025-12-12 11:59:05 +09:00
Nicolas Ferrandini
761eb0b68b Add ibm-db package to enable DB2 as datasource: (#7581)
* Add ibm-db package to enable DB2 as datasource:

* Review poetry format

* Added condition on platform for ibm-db, as support is restricted

---------

Co-authored-by: nicof38 <nicolas@FB-L-230557.soitec.net>
Co-authored-by: Tsuneo Yoshioka <yoshiokatsuneo@gmail.com>
2025-12-09 14:25:05 +00:00
github-actions[bot]
9743820efe Snapshot: 25.12.0-dev 2025-12-01 00:46:06 +00:00
Eric Radman
9d49e0457f PostgreSQL: allow connection parameters to be specified (#7579)
As documented in
https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-PARAMKEYWORDS

Multiple parameters are separated by a space.
2025-11-26 09:32:11 -05:00
Eric Radman
b5781a8ebe Add lineShape option for Line and Area charts (#7582)
Linear
Spline
Horizontal-Vertical
Vertical-Horizontal
2025-11-25 11:43:15 -05:00
Tsuneo Yoshioka
b6f4159be9 Add "Last 10 years" option for dynamic date range (#7422) 2025-11-21 10:23:58 +09:00
Sarvesh Vazarkar
d5fbf547cf pg: fix has_privileges function to quote schema and table names (#7574) 2025-11-20 10:14:03 -05:00
github-actions[bot]
772b160a79 Snapshot: 25.11.0-dev 2025-11-01 00:39:20 +00:00
Tsuneo Yoshioka
bac2160e2a Advanced query search syntax for multi byte search (#7546)
* Advanced query search syntax for multi byte search

* Advanced search for my queries

* Add advanced query seearch tooltip

* Revert "Add advanced query seearch tooltip"

This reverts commit 43148ba6ac.
2025-10-15 16:12:00 +00:00
Tsuneo Yoshioka
c5aa5da6a2 Update queries.latest_query_data on save (#7560)
* Update queries.latest_query_data on save

* Add wait on test as loading query and query results may re-render DOM and that makes test fraky

* Fix styling report by prettier
2025-10-14 22:59:36 +09:00
Eric Radman
9503cc9fb8 Correct custom chart help text: use newPlot() (#7557) 2025-10-08 07:44:29 -04:00
Tsuneo Yoshioka
b353057f9a Update ace-builds/react-ace to the latest versions (#7532) 2025-10-06 11:14:37 -04:00
Eric Radman
8747d02bbe Use standard PostgreSQL image and drop clean-all target (#7555) 2025-10-06 09:48:49 -04:00
Kamil Frydel
5b463b0d83 Make details visualization configurable (#7535)
- Added possibility to select visible columns and reordering
- Added formatting options as in Table visualization
- Set default alignment to left
2025-10-06 08:10:33 -04:00
Tsuneo Yoshioka
ea589ad477 Query Serach: avoid concurrent search API request (#7551) 2025-10-02 14:52:57 +00:00
Tsuneo Yoshioka
617124850b SchemaBrowser: on column comment tooltip, show newlines correctly (#7552) 2025-10-02 23:22:01 +09:00
Zafer Balkan
1cc200843c Add duckdb support (#7548) 2025-10-01 23:27:13 +09:00
github-actions[bot]
e0410e2ffe Snapshot: 25.10.0-dev 2025-10-01 00:39:34 +00:00
Tsuneo Yoshioka
7e39b3668d MySQL: add column type, comment, and table comment (#7544) 2025-09-30 19:20:34 +00:00
Tsuneo Yoshioka
92f15a3ccb BigQuery: Add table description on Schema Browser (#7543) 2025-10-01 03:52:36 +09:00
Tsuneo Yoshioka
9a1d33381c BigQuery: support multiple locations (#7540) 2025-09-25 22:01:17 +09:00
Tsuneo Yoshioka
56c06adc24 BigQuery: Remove "Job ID" metadata on annotaton to avoid cache misses (#7541) 2025-09-24 23:44:40 +09:00
Tsuneo Yoshioka
5e8915afe5 BigQuery: show column description(comment) on Schema Browser (#7538)
* BigQuery: add column description to Schema Browser

* fix restyled prettier error

* Remove column-description
2025-09-23 23:54:01 +09:00
Tsuneo Yoshioka
b8ebf49436 Make favorite queries/dashboard order by starred at(favorited at) (#7351)
* Make favorite queries/dashboard order by starred at(favorited at)

* fix styling for restyled error
2025-09-16 16:24:03 +09:00
Tsuneo Yoshioka
59951eda3d Fix/too many history replace state (#7530)
* Fix too many history.replaceState() error on Safari

* fix restyled error by running prettier for client/app/services/location.js
2025-09-12 03:41:04 +09:00
Artem Safiiulin
777153e7a0 Update jql.py (jira datasource) to use jira api v3 updated. (#7527)
* Update jql.py (jira datasource) to use jira api v3 updated.

* fix spaces in blank lines

* Add condition for empty "fields"

---------

Co-authored-by: Artem Safiiulin <asafiiulin@cloudlinux.com>
Co-authored-by: Tsuneo Yoshioka <yoshiokatsuneo@gmail.com>
2025-09-10 23:43:05 +09:00
Tsuneo Yoshioka
47b1309f13 Add range slider to the chart (#7525) 2025-09-09 19:22:53 +00:00
Tsuneo Yoshioka
120250152f Add "Missing and NULL values" option to scatter chart (#7523) 2025-09-08 23:54:11 +00:00
Tsuneo Yoshioka
ac81f0b223 keep ordering on search (#7520) 2025-09-08 17:22:35 +00:00
Tsuneo Yoshioka
7838058953 fix: webpack missing source-map warning for @plotly/msgbox-gl (#7522) 2025-09-08 14:18:10 +00:00
Eric Radman
f95156e924 Rely on information_schema.columns for views and foreign tables (#7521)
This prevents duplicate entries in the schema list.  Materialized views are the
only table-like object not found information_schema. Also ensure that the schema
and table found in information_schema is accessible by the current user.
2025-09-08 09:41:28 -04:00
Tsuneo Yoshioka
74de676bdf Allow HTTP request line more than 4096 bytes (#7506) 2025-09-04 18:05:05 +00:00
Tsuneo Yoshioka
2762f1fc85 Fix: null is not shown for text with "Allow HTML content" (#7519) 2025-09-03 10:55:26 -04:00
github-actions[bot]
438efd0826 Snapshot: 25.09.0-dev 2025-09-01 00:43:37 +00:00
Tsuneo Yoshioka
e586ab708b Fix stacking bar chart (#7516) 2025-08-30 03:31:47 +09:00
Tsuneo Yoshioka
24ca5135aa Update plotly.js to 3.1.0 (#7514) 2025-08-28 17:06:54 +09:00
Eric Radman
fae354fcce Update Poetry to 2.1.4 (#7509)
* Relocate [tool.poetry] to [project] section
* Add dependencies section to [project]
* Format authors and maintainers as objects
2025-08-26 08:08:19 -04:00
Tsuneo Yoshioka
4ae372f022 Update from webpack4 to webpack5 (#7507) 2025-08-25 20:50:18 +00:00
Tsuneo Yoshioka
0b5907f12b Fix css height for mobile safari not to overlap URL bar (#7334) 2025-08-22 18:42:36 +00:00
Adrian Oesch
00a97d9266 Add private_key auth method to snowflake query runner (#7371)
* add private_key auth method

* fix casing

* fix private_key parsing

* use params and add optional pwd

* use private_key_b64

* add file option

* remove __contains__

* fix pem pwd

* fix lint issues

* fix black

---------

Co-authored-by: Tsuneo Yoshioka <yoshiokatsuneo@gmail.com>
2025-08-08 17:38:22 +00:00
github-actions[bot]
35afe880a1 Snapshot: 25.08.0-dev 2025-08-01 00:45:49 +00:00
Tsuneo Yoshioka
a6298f2753 MongoDB: fix for empty username/password (#7487) 2025-07-31 23:39:39 +09:00
Zach Liu
e69283f488 clickhouse: display data types (#7490) 2025-07-31 13:08:40 +00:00
Eric Radman
09ed3c4b81 Clickhouse: do not display INFORMATION_SCHEMA tables (#7489)
As with other query runners, do not show system tables in the schema list.
2025-07-31 08:07:40 -04:00
Eric Radman
f5e2a4c0fc Sort Dashboard and Query tags by name (#7484) 2025-07-23 11:34:26 -04:00
Lee2532
4e200b4a08 bigquery load schema diff locations ignore (#7289)
* diff locations ignore

* add logging message

* Processing Location is not specified
2025-07-22 15:45:37 +00:00
Костятнин Дементьєв
5ae1f70d9e Add support for Google OAuth Scheme Override (#7178)
* Added support for Google Oauth Scheme Override (through environment variable)

* Refactoring

* Refactoring

* Applied formatting

* Refactoring

* Refactoring

* Updated comment for `GOOGLE_OAUTH_SCHEME_OVERRIDE` variable

* Updated comment for `GOOGLE_OAUTH_SCHEME_OVERRIDE` variable

* Removed duplication of url_for function

---------

Co-authored-by: kostiantyn-dementiev-op <kostiantyn.dementiev@observepoint.com>
2025-07-21 00:05:43 +09:00
Eric Radman
3f781d262b Push by tag name for Docker repository "redash" (#7321) 2025-07-17 14:50:13 -04:00
Tsuneo Yoshioka
a34c1591e3 Upgrade prettier version to the same version that CI is using (#7367) 2025-07-18 00:04:55 +09:00
Eric Radman
9f76fda18c Use 12-column layout for dashboard grid (#7396)
* Use 12-column layout for dashboard grid

Set minSizeX, minSizeY for widgets to 2 since a value of 1 breaks all
assumptions of the UI layout.

Migration provide transition from 6 to 12 columns for all widgets.

* Restyled by prettier
2025-07-16 01:24:21 +00:00
Tsuneo Yoshioka
d8ae679937 Make NULL values visible (#7439)
* Make NULL value visible
* Make the representation of NULL value configurable
* use display-as-null css class for null-value styling
2025-07-16 00:48:36 +00:00
Elliot Maincourt
f3b0b60abd feat(flask): make refresh cookie name configurable (#7473) 2025-07-09 12:09:24 -04:00
Kamil Frydel
df8be91a07 Add migration to set default alert selector (#7475)
In commits fc1e1f7 and e44fcdb a new Selector option was added to
alerts, which may be "first", "min" or "max".  This migration sets the
default to "first" for existing alerts.
2025-07-09 13:20:12 +00:00
github-actions[bot]
c9ddd2a7d6 Snapshot: 25.07.0-dev 2025-07-01 00:43:15 +00:00
github-actions[bot]
6b1e910126 Snapshot: 25.06.0-dev 2025-06-01 00:45:45 +00:00
Tsuneo Yoshioka
14550a9a6c Fix: saving empty query with auto limit crashes (#7430)
Co-authored-by: Eric Radman <eradman@starfishstorage.com>
2025-05-20 14:26:17 +00:00
Emmanuel Ferdman
b80c5f6a7c Update assertion method in JSON dumps test (#7424)
Signed-off-by: Emmanuel Ferdman <emmanuelferdman@gmail.com>
Co-authored-by: snickerjp <snickerjp@gmail.com>
2025-05-18 12:03:41 -07:00
Tsuneo Yoshioka
e46d44f208 include Plotly.js localization (#7323) 2025-05-16 19:17:32 -04:00
Tsuneo Yoshioka
a1a4bc9d3e TypeScript sourcemap for viz-lib (#7336) 2025-05-12 18:08:33 -04:00
Tsuneo Yoshioka
0900178d24 Change query processing wait time to make response quick (#7320)
* Change query processing wait time to make more response quick

* Fix styling errors reportered by restyled
2025-05-07 01:22:35 +00:00
Gleb Lesnikov
5d31429ca8 Update Azure Data Explorer query runner to latest version (#7411)
* Update Azure Data Explorer query runner to latest version

* Fix black issue

* downgrade azure-kusto-data to 4.6.3

* Freeze numpy to 1.24.4 because of 2.0.0 incompatibility

* Fix failing test

* Reformat test
2025-05-05 06:53:07 +00:00
Eric Radman
2f35ceb803 Push image using DOCKER_REPOSITORY (#7428)
Preview images work for personal repositories, but we missed another variable
when publishing official images:

  #34 [auth] arikfr/redash:pull,push token for registry-1.docker.io
  #34 DONE 0.0s
  #33 exporting to image
  #33 pushing layers 15.5s done
  #33 pushing manifest for docker.io/arikfr/redash
  #33 pushing manifest for docker.io/arikfr/redash 1.6s done
  #33 ...
  #35 [auth] arikfr/preview:pull,push token for registry-1.docker.io
  #35 DONE 0.0s
2025-05-04 23:18:53 -07:00
Lucas Fernando Cardoso Nunes
8e6c02ecde ci: snapshot only on default branch (#7355) 2025-05-01 13:15:57 +00:00
github-actions[bot]
231fd36d46 Snapshot: 25.05.0-dev 2025-05-01 00:39:58 +00:00
Tsuneo Yoshioka
0b6a53a079 Add translate="no" to html tag to prevent redash from translating and crashing (#7421) 2025-04-29 12:36:26 -04:00
Tsuneo Yoshioka
6167edf97c Change BigQuery super class from BaseQueryRunner to BaseSQLQueryRunner (#7378) 2025-04-16 16:28:17 +09:00
Tsuneo Yoshioka
4ed0ad3c9c BigQuery: Avoid too long(10 seconds) interval for bigquery api to get results (#7342) 2025-04-14 11:40:24 +00:00
Eric Radman
2375f0b05f Partiallly Revert "Remove workaround from check_csrf() (#6919)" (#7327)
This workaround was missing 'if view is not None ' as found in
https://github.com/pallets-eco/flask-wtf/pull/419/files

Tested with MULTI_ORG enabled.
2025-04-10 22:25:49 +00:00
Eric Radman
eced377ae4 Require vars.DOCKER_REPOSITORY to publish image (#7400)
To allow user arikfr to publish images to redash/redash and redash/preview.
Only use vars.DOCKER_USER and secrets.DOCKER_PASSWORD for authorization.
2025-04-03 15:27:11 -04:00
Tsuneo Yoshioka
84262fe143 Fix table item list ordering (#7366)
Fix query list item list sorting

- descending order, no triangle mark
- ascending order, up triangle mark(▲)
- descending order, down triangle mark(▼)
- ascending order, no triangle mark
- descending order, up triangle mark(▲)
- ascending order, down triangle mark(▼)
- descending order, no triangle mark

"sorting order" have 2-click cycle, but "triangle mark" have 3-click cycle.
2025-04-03 16:51:20 +00:00
github-actions[bot]
612eb8c630 Snapshot: 25.04.0-dev 2025-04-01 00:39:21 +00:00
dependabot[bot]
866fb48afb Bump tar-fs from 2.1.1 to 2.1.2 (#7385) 2025-03-29 04:56:15 +00:00
Tsuneo Yoshioka
353776e8e1 Fix to make "show data labels" on bar chart works (#7363) 2025-03-17 11:43:02 -04:00
Tsuneo Yoshioka
594e2f24ef Upgrade plotly.js to version 2 to fix the UI crashing issue (#7359)
* Upgrade plotly.js to version 2

* Fix styling error reported by styled
2025-03-05 14:30:28 +00:00
github-actions[bot]
3275a9e459 Snapshot: 25.03.0-dev 2025-03-01 00:35:44 +00:00
Shunki
3bad8c8e8c TiDB: Exclude INFORMATION_SCHEMA (#7352)
Co-authored-by: snickerjp <snickerjp@gmail.com>
2025-02-28 11:09:46 +09:00
Tsuneo Yoshioka
d0af4499d6 Sanitize NaN, Infinite, -Infinite causing error when saving as PostgreSQL JSON #7339 (2nd try) (#7348)
* Sanitize NaN, Infinite, -Infinite causing error when saving as PostgreSQL JSON #7339 (2nd try)

* Move json nsanitaize to on the top of json_dumps

* Fix comment
2025-02-27 01:40:43 -08:00
Ran Benita
4357ea56ae Fix UnboundLocalError when checking alerts for query (#7346)
This fixes the following exception:

```
UnboundLocalError: local variable 'value_is_number' referenced before assignment
  File "rq/worker.py", line 1431, in perform_job
    rv = job.perform()
  File "rq/job.py", line 1280, in perform
    self._result = self._execute()
  File "rq/job.py", line 1317, in _execute
    result = self.func(*self.args, **self.kwargs)
  File "redash/tasks/alerts.py", line 36, in check_alerts_for_query
    new_state = alert.evaluate()
  File "redash/models/__init__.py", line 1002, in evaluate
    new_state = next_state(op, value, threshold)
  File "redash/models/__init__.py", line 928, in next_state
    elif not value_is_number and op not in [OPERATORS.get("!="), OPERATORS.get("=="), OPERATORS.get("equals")]:
```
2025-02-25 09:15:20 -05:00
Tsuneo Yoshioka
5df5ca87a2 add NULLS LAST option for Query order (#7341) 2025-02-25 10:58:48 +08:00
Tsuneo Yoshioka
8387fe6fcb Fix the issue that chart(scatter, line, bubble...) having same x-value have wrong y-value (#7330) 2025-02-18 20:04:12 +00:00
snickerjp
e95de2ee4c Update oracledb package to version 2.5.1 and adjust Python version compatibility (#7316) 2025-02-18 23:00:09 +09:00
Lee2532
71902e5933 FIX : redash docker image TAG (#7280)
Co-authored-by: snickerjp <snickerjp@gmail.com>
2025-02-15 01:38:23 +09:00
Tsuneo Yoshioka
53eab14cef Make autocomplete always available (#7326) 2025-02-13 15:25:39 -05:00
Eric Radman
925bb91d8e Use absolute path for image resources (#7322)
When MULTI_ORG is enabled, 'static/' resolves to '<org>/static/'
2025-02-12 08:37:40 -05:00
Tsuneo Yoshioka
ec2ca6f986 BigQuery: show column type on Schema Browser (#7257) 2025-02-05 18:25:39 +00:00
Matt Nelson
96ea0194e8 Fix errors in webex alert destination. Add formatting support for QUERY_RESULT_TABLE. (#7296)
* prevent text values in payload being detected as 'set' on send.
Webex send ERROR:: Object of type set is not JSON serializable

Signed-off-by: Matt Nelson <metheos@gmail.com>

* add support for formatted QUERY_RESULT_TABLE in webex card

Signed-off-by: Matt Nelson <metheos@gmail.com>

* don't try to send to blank destinations

Signed-off-by: Matt Nelson <metheos@gmail.com>

* fix handling of the encoded QUERY_RESULTS_TABLE text

Signed-off-by: Matt Nelson <metheos@gmail.com>

* re-sort imports for ruff

Signed-off-by: Matt Nelson <metheos@gmail.com>

* change formatter to black

Signed-off-by: Matt Nelson <metheos@gmail.com>

* Add additional tests for Webex notification handling

ensure blank entries are handled for room IDs and person emails.
ensure that the API is not called when no valid destinations are provided.
ensure proper attachment formatting for alerts containing 2D arrays.

Signed-off-by: Matt Nelson <metheos@gmail.com>

* Add test for Webex notification with 1D array handling

This commit introduces a new test case to verify that the Webex
notification function correctly handles a 1D array input in the alert body.
The test ensures that the expected payload is constructed properly and that
the requests.post method is called with the correct parameters.

Signed-off-by: Matt Nelson <metheos@gmail.com>

---------

Signed-off-by: Matt Nelson <metheos@gmail.com>
2025-02-04 11:05:13 +00:00
github-actions[bot]
2776992101 Snapshot: 25.02.0-dev 2025-02-01 00:33:52 +00:00
Arik Fraimovich
85f001982e GitHub Actions Workflow updates (#7298)
* Split out secrets requiring workflows

* Update target

* Update Cypress run command
2025-01-31 10:20:04 +02:00
Motoi Washida
d03a2c4096 Fix error in rehash DB migration with Elasticsearch queries (#7292)
Fixes #7272
2025-01-22 21:19:59 -05:00
SeongTae Jeong
8c5890482a Use ARM64 runners instead of virtualization for ARM64 image builds (#7291) 2025-01-19 16:00:19 +10:00
Ezra Odio
10ce280a96 Default to not allow HTML content in tables (#7064)
Co-authored-by: Ezra Odio <eodio@starfishstorage.com>
2025-01-15 10:09:24 -05:00
dependabot[bot]
0dd7ac3d2e Bump virtualenv from 20.25.0 to 20.26.6 (#7276) 2025-01-14 01:45:58 +00:00
github-actions[bot]
4ee53a9445 Snapshot: 25.01.0-dev 2025-01-01 00:35:12 +00:00
SeongTae Jeong
c08292d90e Use Codecov token (#7265) 2024-12-30 21:06:09 +00:00
SeongTae Jeong
3142131cdd Bump actions/upload-artifact from v3 to v4 (#7266)
Related: https://github.blog/changelog/2024-04-16-deprecation-notice-v3-of-the-artifact-actions/
2024-12-30 15:31:03 -05:00
Daisuke Taniwaki
530c1a0734 Support result reuse in Athena data sources (#7202)
* Support result reuse

* Update pyathena to 2.25.2

* Separate options

* Regenerate the Poetry lock file

---------

Co-authored-by: SeongTae Jeong <seongtaejg@gmail.com>
2024-12-28 05:50:16 +09:00
dependabot[bot]
52dc1769a1 Bump jinja2 from 3.1.4 to 3.1.5 (#7262)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-27 13:55:21 +10:00
Eric Radman
b9583c0b48 Create workflow trigger for publishing release image (#7259)
Co-authored-by: Justin Clift <justin@postgresql.org>
2024-12-27 12:19:32 +10:00
Arik Fraimovich
89d7f54e90 Handle the case when query runner configuration is an empty dict. (#7258) 2024-12-24 09:42:39 -05:00
Tsuneo Yoshioka
d884da2b0b BigQuery: add date, datetime type mapping (#7252) 2024-12-18 14:24:45 +02:00
dependabot[bot]
f7d485082c Bump nanoid from 3.3.6 to 3.3.8 (#7249)
Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.6 to 3.3.8.
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.3.6...3.3.8)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-13 17:57:05 +09:00
Eric Radman
130ab1fe1a Update to paramiko-3.4.1 (#7240)
Solves the deprecation warning for TripleDES
Related: https://github.com/paramiko/paramiko/issues/2419
2024-12-07 11:23:45 +09:00
github-actions[bot]
2ff83679fe Snapshot: 24.12.0-dev 2024-12-01 00:40:40 +00:00
Eric Radman
de49b73855 Replace ptvsd with debugpy to match modern VS Code (#7234) 2024-11-27 08:19:05 +10:00
thiagogds
c12e68f5d1 Only evaluate the next state if there's a value (#7222)
I've experience this on my Redash in production. I'm not sure what can cause the value to exist, but be None. I guess it depends on the SQL query.

I followed the same idea of returning a self.UNKNOWN_STATE for cases that we can't know what's happening.
2024-11-26 12:57:34 -05:00
Eric Radman
baa9bbd505 Use head.sha for restyled checkout (#7227) 2024-11-22 10:34:16 +10:00
Arik Fraimovich
349cd5d031 Bring back version check & beacon reporting (#7211)
Co-authored-by: Restyled.io <commits@restyled.io>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-11-06 01:21:03 +00:00
190 changed files with 8251 additions and 6482 deletions

View File

@@ -18,7 +18,7 @@ services:
image: redis:7-alpine image: redis:7-alpine
restart: unless-stopped restart: unless-stopped
postgres: postgres:
image: pgautoupgrade/pgautoupgrade:latest image: postgres:18-alpine
command: "postgres -c fsync=off -c full_page_writes=off -c synchronous_commit=OFF" command: "postgres -c fsync=off -c full_page_writes=off -c synchronous_commit=OFF"
restart: unless-stopped restart: unless-stopped
environment: environment:

View File

@@ -66,7 +66,7 @@ services:
image: redis:7-alpine image: redis:7-alpine
restart: unless-stopped restart: unless-stopped
postgres: postgres:
image: pgautoupgrade/pgautoupgrade:latest image: postgres:18-alpine
command: "postgres -c fsync=off -c full_page_writes=off -c synchronous_commit=OFF" command: "postgres -c fsync=off -c full_page_writes=off -c synchronous_commit=OFF"
restart: unless-stopped restart: unless-stopped
environment: environment:

View File

@@ -3,7 +3,7 @@ on:
push: push:
branches: branches:
- master - master
pull_request_target: pull_request:
branches: branches:
- master - master
env: env:
@@ -60,15 +60,17 @@ jobs:
mkdir -p /tmp/test-results/unit-tests mkdir -p /tmp/test-results/unit-tests
docker cp tests:/app/coverage.xml ./coverage.xml docker cp tests:/app/coverage.xml ./coverage.xml
docker cp tests:/app/junit.xml /tmp/test-results/unit-tests/results.xml docker cp tests:/app/junit.xml /tmp/test-results/unit-tests/results.xml
- name: Upload coverage reports to Codecov # - name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v3 # uses: codecov/codecov-action@v3
# with:
# token: ${{ secrets.CODECOV_TOKEN }}
- name: Store Test Results - name: Store Test Results
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: test-results name: backend-test-results
path: /tmp/test-results path: /tmp/test-results
- name: Store Coverage Results - name: Store Coverage Results
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: coverage name: coverage
path: coverage.xml path: coverage.xml
@@ -94,9 +96,9 @@ jobs:
- name: Run Lint - name: Run Lint
run: yarn lint:ci run: yarn lint:ci
- name: Store Test Results - name: Store Test Results
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: test-results name: frontend-test-results
path: /tmp/test-results path: /tmp/test-results
frontend-unit-tests: frontend-unit-tests:
@@ -132,9 +134,9 @@ jobs:
COMPOSE_PROJECT_NAME: cypress COMPOSE_PROJECT_NAME: cypress
CYPRESS_INSTALL_BINARY: 0 CYPRESS_INSTALL_BINARY: 0
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: 1 PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: 1
PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }} # PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }}
CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }} # CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }}
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} # CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
steps: steps:
- if: github.event.pull_request.mergeable == 'false' - if: github.event.pull_request.mergeable == 'false'
name: Exit if PR is not mergeable name: Exit if PR is not mergeable
@@ -169,7 +171,7 @@ jobs:
- name: Copy Code Coverage Results - name: Copy Code Coverage Results
run: docker cp cypress:/usr/src/app/coverage ./coverage || true run: docker cp cypress:/usr/src/app/coverage ./coverage || true
- name: Store Coverage Results - name: Store Coverage Results
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: coverage name: coverage
path: coverage path: coverage

View File

@@ -2,7 +2,7 @@ name: Periodic Snapshot
on: on:
schedule: schedule:
- cron: '10 0 1 * *' # 10 minutes after midnight on the first of every month - cron: '10 0 1 * *' # 10 minutes after midnight on the first day of every month
workflow_dispatch: workflow_dispatch:
inputs: inputs:
bump: bump:
@@ -24,6 +24,7 @@ permissions:
jobs: jobs:
bump-version-and-tag: bump-version-and-tag:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.ref_name == github.event.repository.default_branch
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:

View File

@@ -4,6 +4,15 @@ on:
tags: tags:
- '*-dev' - '*-dev'
workflow_dispatch: workflow_dispatch:
inputs:
dockerRepository:
description: 'Docker repository'
required: true
default: 'preview'
type: choice
options:
- preview
- redash
env: env:
NODE_VERSION: 18 NODE_VERSION: 18
@@ -23,6 +32,9 @@ jobs:
elif [[ "${{ secrets.DOCKER_PASS }}" == '' ]]; then elif [[ "${{ secrets.DOCKER_PASS }}" == '' ]]; then
echo 'Docker password is empty. Skipping build+push' echo 'Docker password is empty. Skipping build+push'
echo skip=true >> "$GITHUB_OUTPUT" echo skip=true >> "$GITHUB_OUTPUT"
elif [[ "${{ vars.DOCKER_REPOSITORY }}" == '' ]]; then
echo 'Docker repository is empty. Skipping build+push'
echo skip=true >> "$GITHUB_OUTPUT"
else else
echo 'Docker user and password are set and branch is `master`.' echo 'Docker user and password are set and branch is `master`.'
echo 'Building + pushing `preview` image.' echo 'Building + pushing `preview` image.'
@@ -30,7 +42,20 @@ jobs:
fi fi
build-docker-image: build-docker-image:
runs-on: ubuntu-22.04 runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
arch:
- amd64
- arm64
include:
- arch: amd64
os: ubuntu-22.04
- arch: arm64
os: ubuntu-22.04-arm
outputs:
VERSION_TAG: ${{ steps.version.outputs.VERSION_TAG }}
needs: needs:
- build-skip-check - build-skip-check
if: needs.build-skip-check.outputs.skip == 'false' if: needs.build-skip-check.outputs.skip == 'false'
@@ -45,11 +70,6 @@ jobs:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: 'yarn' cache: 'yarn'
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: arm64
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@@ -60,6 +80,8 @@ jobs:
password: ${{ secrets.DOCKER_PASS }} password: ${{ secrets.DOCKER_PASS }}
- name: Install Dependencies - name: Install Dependencies
env:
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: true
run: | run: |
npm install --global --force yarn@1.22.22 npm install --global --force yarn@1.22.22
yarn cache clean && yarn --frozen-lockfile --network-concurrency 1 yarn cache clean && yarn --frozen-lockfile --network-concurrency 1
@@ -72,23 +94,92 @@ jobs:
VERSION_TAG=$(jq -r .version package.json) VERSION_TAG=$(jq -r .version package.json)
echo "VERSION_TAG=$VERSION_TAG" >> "$GITHUB_OUTPUT" echo "VERSION_TAG=$VERSION_TAG" >> "$GITHUB_OUTPUT"
# TODO: We can use GitHub Actions's matrix option to reduce the build time.
- name: Build and push preview image to Docker Hub - name: Build and push preview image to Docker Hub
id: build-preview
uses: docker/build-push-action@v4 uses: docker/build-push-action@v4
if: ${{ github.event.inputs.dockerRepository == 'preview' || !github.event.workflow_run }}
with: with:
push: true
tags: | tags: |
redash/redash:preview ${{ vars.DOCKER_REPOSITORY }}/redash
redash/preview:${{ steps.version.outputs.VERSION_TAG }} ${{ vars.DOCKER_REPOSITORY }}/preview
context: . context: .
build-args: | build-args: |
test_all_deps=true test_all_deps=true
cache-from: type=gha,scope=multi-platform outputs: type=image,push-by-digest=true,push=true
cache-to: type=gha,mode=max,scope=multi-platform cache-from: type=gha,scope=${{ matrix.arch }}
platforms: linux/amd64,linux/arm64 cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
env:
DOCKER_CONTENT_TRUST: true
- name: Build and push release image to Docker Hub
id: build-release
uses: docker/build-push-action@v4
if: ${{ github.event.inputs.dockerRepository == 'redash' }}
with:
tags: |
${{ vars.DOCKER_REPOSITORY }}/redash:${{ steps.version.outputs.VERSION_TAG }}
context: .
build-args: |
test_all_deps=true
outputs: type=image,push-by-digest=false,push=true
cache-from: type=gha,scope=${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
env: env:
DOCKER_CONTENT_TRUST: true DOCKER_CONTENT_TRUST: true
- name: "Failure: output container logs to console" - name: "Failure: output container logs to console"
if: failure() if: failure()
run: docker compose logs run: docker compose logs
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
if [[ "${{ github.event.inputs.dockerRepository }}" == 'preview' || !github.event.workflow_run ]]; then
digest="${{ steps.build-preview.outputs.digest}}"
else
digest="${{ steps.build-release.outputs.digest}}"
fi
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.arch }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
merge-docker-image:
runs-on: ubuntu-22.04
needs: build-docker-image
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASS }}
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Create and push manifest for the preview image
if: ${{ github.event.inputs.dockerRepository == 'preview' || !github.event.workflow_run }}
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create -t ${{ vars.DOCKER_REPOSITORY }}/redash:preview \
$(printf '${{ vars.DOCKER_REPOSITORY }}/redash:preview@sha256:%s ' *)
docker buildx imagetools create -t ${{ vars.DOCKER_REPOSITORY }}/preview:${{ needs.build-docker-image.outputs.VERSION_TAG }} \
$(printf '${{ vars.DOCKER_REPOSITORY }}/preview:${{ needs.build-docker-image.outputs.VERSION_TAG }}@sha256:%s ' *)
- name: Create and push manifest for the release image
if: ${{ github.event.inputs.dockerRepository == 'redash' }}
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create -t ${{ vars.DOCKER_REPOSITORY }}/redash:${{ needs.build-docker-image.outputs.VERSION_TAG }} \
$(printf '${{ vars.DOCKER_REPOSITORY }}/redash:${{ needs.build-docker-image.outputs.VERSION_TAG }}@sha256:%s ' *)

View File

@@ -95,7 +95,7 @@ EOF
WORKDIR /app WORKDIR /app
ENV POETRY_VERSION=1.8.3 ENV POETRY_VERSION=2.1.4
ENV POETRY_HOME=/etc/poetry ENV POETRY_HOME=/etc/poetry
ENV POETRY_VIRTUALENVS_CREATE=false ENV POETRY_VIRTUALENVS_CREATE=false
RUN curl -sSL https://install.python-poetry.org | python3 - RUN curl -sSL https://install.python-poetry.org | python3 -

View File

@@ -1,4 +1,4 @@
.PHONY: compose_build up test_db create_database clean clean-all down tests lint backend-unit-tests frontend-unit-tests test build watch start redis-cli bash .PHONY: compose_build up test_db create_database clean down tests lint backend-unit-tests frontend-unit-tests test build watch start redis-cli bash
compose_build: .env compose_build: .env
COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose build COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose build
@@ -32,11 +32,6 @@ clean:
docker image prune --force docker image prune --force
docker volume prune --force docker volume prune --force
clean-all: clean
docker image rm --force \
redash/redash:10.1.0.b50633 redis:7-alpine maildev/maildev:latest \
pgautoupgrade/pgautoupgrade:15-alpine3.8 pgautoupgrade/pgautoupgrade:latest
down: down:
docker compose down docker compose down

View File

@@ -46,7 +46,7 @@ server() {
MAX_REQUESTS=${MAX_REQUESTS:-1000} MAX_REQUESTS=${MAX_REQUESTS:-1000}
MAX_REQUESTS_JITTER=${MAX_REQUESTS_JITTER:-100} MAX_REQUESTS_JITTER=${MAX_REQUESTS_JITTER:-100}
TIMEOUT=${REDASH_GUNICORN_TIMEOUT:-60} TIMEOUT=${REDASH_GUNICORN_TIMEOUT:-60}
exec /usr/local/bin/gunicorn -b 0.0.0.0:5000 --name redash -w${REDASH_WEB_WORKERS:-4} redash.wsgi:app --max-requests $MAX_REQUESTS --max-requests-jitter $MAX_REQUESTS_JITTER --timeout $TIMEOUT exec /usr/local/bin/gunicorn -b 0.0.0.0:5000 --name redash -w${REDASH_WEB_WORKERS:-4} redash.wsgi:app --max-requests $MAX_REQUESTS --max-requests-jitter $MAX_REQUESTS_JITTER --timeout $TIMEOUT --limit-request-line ${REDASH_GUNICORN_LIMIT_REQUEST_LINE:-0}
} }
create_db() { create_db() {
@@ -67,7 +67,7 @@ help() {
echo "" echo ""
echo "shell -- open shell" echo "shell -- open shell"
echo "dev_server -- start Flask development server with debugger and auto reload" echo "dev_server -- start Flask development server with debugger and auto reload"
echo "debug -- start Flask development server with remote debugger via ptvsd" echo "debug -- start Flask development server with remote debugger via debugpy"
echo "create_db -- create database tables" echo "create_db -- create database tables"
echo "manage -- CLI to manage redash" echo "manage -- CLI to manage redash"
echo "tests -- run tests" echo "tests -- run tests"

View File

@@ -15,7 +15,7 @@ body {
display: table; display: table;
width: 100%; width: 100%;
padding: 10px; padding: 10px;
height: calc(100vh - 116px); height: calc(100% - 116px);
} }
@media (min-width: 992px) { @media (min-width: 992px) {

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -20,7 +20,7 @@ html {
html, html,
body { body {
min-height: 100vh; height: 100%;
} }
body { body {
@@ -35,7 +35,7 @@ body {
} }
#application-root { #application-root {
min-height: 100vh; height: 100%;
} }
#application-root, #application-root,

View File

@@ -10,7 +10,7 @@
vertical-align: middle; vertical-align: middle;
display: inline-block; display: inline-block;
width: 1px; width: 1px;
height: 100vh; height: 100%;
} }
} }
@@ -135,4 +135,4 @@
} }
} }

View File

@@ -8,7 +8,7 @@ body.fixed-layout {
padding-bottom: 0; padding-bottom: 0;
width: 100vw; width: 100vw;
height: 100vh; height: 100%;
.application-layout-content > div { .application-layout-content > div {
display: flex; display: flex;
@@ -90,7 +90,7 @@ body.fixed-layout {
.embed__vis { .embed__vis {
display: flex; display: flex;
flex-flow: column; flex-flow: column;
height: calc(~'100vh - 25px'); height: calc(~'100% - 25px');
> .embed-heading { > .embed-heading {
flex: 0 0 auto; flex: 0 0 auto;

View File

@@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import { clientConfig } from "@/services/auth"; import Link from "@/components/Link";
import { clientConfig, currentUser } from "@/services/auth";
import frontendVersion from "@/version.json"; import frontendVersion from "@/version.json";
export default function VersionInfo() { export default function VersionInfo() {
@@ -9,6 +10,15 @@ export default function VersionInfo() {
Version: {clientConfig.version} Version: {clientConfig.version}
{frontendVersion !== clientConfig.version && ` (${frontendVersion.substring(0, 8)})`} {frontendVersion !== clientConfig.version && ` (${frontendVersion.substring(0, 8)})`}
</div> </div>
{clientConfig.newVersionAvailable && currentUser.hasPermission("super_admin") && (
<div className="m-t-10">
{/* eslint-disable react/jsx-no-target-blank */}
<Link href="https://version.redash.io/" className="update-available" target="_blank" rel="noopener">
Update Available <i className="fa fa-external-link m-l-5" aria-hidden="true" />
<span className="sr-only">(opens in a new tab)</span>
</Link>
</div>
)}
</React.Fragment> </React.Fragment>
); );
} }

View File

@@ -7,10 +7,10 @@ body #application-root {
flex-direction: row; flex-direction: row;
justify-content: stretch; justify-content: stretch;
padding-bottom: 0 !important; padding-bottom: 0 !important;
height: 100vh; height: 100%;
.application-layout-side-menu { .application-layout-side-menu {
height: 100vh; height: 100%;
position: relative; position: relative;
@media @mobileBreakpoint { @media @mobileBreakpoint {
@@ -47,6 +47,10 @@ body #application-root {
} }
} }
body > section {
height: 100%;
}
body.fixed-layout #application-root { body.fixed-layout #application-root {
.application-layout-content { .application-layout-content {
padding-bottom: 0; padding-bottom: 0;

View File

@@ -0,0 +1,79 @@
import React, { useState } from "react";
import Card from "antd/lib/card";
import Button from "antd/lib/button";
import Typography from "antd/lib/typography";
import { clientConfig } from "@/services/auth";
import Link from "@/components/Link";
import HelpTrigger from "@/components/HelpTrigger";
import DynamicComponent from "@/components/DynamicComponent";
import OrgSettings from "@/services/organizationSettings";
const Text = Typography.Text;
function BeaconConsent() {
const [hide, setHide] = useState(false);
if (!clientConfig.showBeaconConsentMessage || hide) {
return null;
}
const hideConsentCard = () => {
clientConfig.showBeaconConsentMessage = false;
setHide(true);
};
const confirmConsent = (confirm) => {
let message = "🙏 Thank you.";
if (!confirm) {
message = "Settings Saved.";
}
OrgSettings.save({ beacon_consent: confirm }, message)
// .then(() => {
// // const settings = get(response, 'settings');
// // this.setState({ settings, formValues: { ...settings } });
// })
.finally(hideConsentCard);
};
return (
<DynamicComponent name="BeaconConsent">
<div className="m-t-10 tiled">
<Card
title={
<>
Would you be ok with sharing anonymous usage data with the Redash team?{" "}
<HelpTrigger type="USAGE_DATA_SHARING" />
</>
}
bordered={false}
>
<Text>Help Redash improve by automatically sending anonymous usage data:</Text>
<div className="m-t-5">
<ul>
<li> Number of users, queries, dashboards, alerts, widgets and visualizations.</li>
<li> Types of data sources, alert destinations and visualizations.</li>
</ul>
</div>
<Text>All data is aggregated and will never include any sensitive or private data.</Text>
<div className="m-t-5">
<Button type="primary" className="m-r-5" onClick={() => confirmConsent(true)}>
Yes
</Button>
<Button type="default" onClick={() => confirmConsent(false)}>
No
</Button>
</div>
<div className="m-t-15">
<Text type="secondary">
You can change this setting anytime from the <Link href="settings/general">Settings</Link> page.
</Text>
</div>
</Card>
</div>
</DynamicComponent>
);
}
export default BeaconConsent;

View File

@@ -23,6 +23,7 @@ export const TYPES = mapValues(
VALUE_SOURCE_OPTIONS: ["/user-guide/querying/query-parameters#Value-Source-Options", "Guide: Value Source Options"], VALUE_SOURCE_OPTIONS: ["/user-guide/querying/query-parameters#Value-Source-Options", "Guide: Value Source Options"],
SHARE_DASHBOARD: ["/user-guide/dashboards/sharing-dashboards", "Guide: Sharing and Embedding Dashboards"], SHARE_DASHBOARD: ["/user-guide/dashboards/sharing-dashboards", "Guide: Sharing and Embedding Dashboards"],
AUTHENTICATION_OPTIONS: ["/user-guide/users/authentication-options", "Guide: Authentication Options"], AUTHENTICATION_OPTIONS: ["/user-guide/users/authentication-options", "Guide: Authentication Options"],
USAGE_DATA_SHARING: ["/open-source/admin-guide/usage-data", "Help: Anonymous Usage Data Sharing"],
DS_ATHENA: ["/data-sources/amazon-athena-setup", "Guide: Help Setting up Amazon Athena"], DS_ATHENA: ["/data-sources/amazon-athena-setup", "Guide: Help Setting up Amazon Athena"],
DS_BIGQUERY: ["/data-sources/bigquery-setup", "Guide: Help Setting up BigQuery"], DS_BIGQUERY: ["/data-sources/bigquery-setup", "Guide: Help Setting up BigQuery"],
DS_URL: ["/data-sources/querying-urls", "Guide: Help Setting up URL"], DS_URL: ["/data-sources/querying-urls", "Guide: Help Setting up URL"],
@@ -100,7 +101,7 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
clearTimeout(this.iframeLoadingTimeout); clearTimeout(this.iframeLoadingTimeout);
} }
loadIframe = url => { loadIframe = (url) => {
clearTimeout(this.iframeLoadingTimeout); clearTimeout(this.iframeLoadingTimeout);
this.setState({ loading: true, error: false }); this.setState({ loading: true, error: false });
@@ -115,8 +116,8 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
clearTimeout(this.iframeLoadingTimeout); clearTimeout(this.iframeLoadingTimeout);
}; };
onPostMessageReceived = event => { onPostMessageReceived = (event) => {
if (!some(allowedDomains, domain => startsWith(event.origin, domain))) { if (!some(allowedDomains, (domain) => startsWith(event.origin, domain))) {
return; return;
} }
@@ -133,7 +134,7 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
return helpTriggerType ? helpTriggerType[0] : this.props.href; return helpTriggerType ? helpTriggerType[0] : this.props.href;
}; };
openDrawer = e => { openDrawer = (e) => {
// keep "open in new tab" behavior // keep "open in new tab" behavior
if (!e.shiftKey && !e.ctrlKey && !e.metaKey) { if (!e.shiftKey && !e.ctrlKey && !e.metaKey) {
e.preventDefault(); e.preventDefault();
@@ -143,7 +144,7 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
} }
}; };
closeDrawer = event => { closeDrawer = (event) => {
if (event) { if (event) {
event.preventDefault(); event.preventDefault();
} }
@@ -160,7 +161,7 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
const tooltip = get(types, `${this.props.type}[1]`, this.props.title); const tooltip = get(types, `${this.props.type}[1]`, this.props.title);
const className = cx("help-trigger", this.props.className); const className = cx("help-trigger", this.props.className);
const url = this.state.currentUrl; const url = this.state.currentUrl;
const isAllowedDomain = some(allowedDomains, domain => startsWith(url || targetUrl, domain)); const isAllowedDomain = some(allowedDomains, (domain) => startsWith(url || targetUrl, domain));
const shouldRenderAsLink = this.props.renderAsLink || !isAllowedDomain; const shouldRenderAsLink = this.props.renderAsLink || !isAllowedDomain;
return ( return (
@@ -179,13 +180,15 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
)} )}
</> </>
) : null ) : null
}> }
>
<Link <Link
href={url || this.getUrl()} href={url || this.getUrl()}
className={className} className={className}
rel="noopener noreferrer" rel="noopener noreferrer"
target="_blank" target="_blank"
onClick={shouldRenderAsLink ? () => {} : this.openDrawer}> onClick={shouldRenderAsLink ? () => {} : this.openDrawer}
>
{this.props.children} {this.props.children}
</Link> </Link>
</Tooltip> </Tooltip>
@@ -196,7 +199,8 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
visible={this.state.visible} visible={this.state.visible}
className={cx("help-drawer", drawerClassName)} className={cx("help-drawer", drawerClassName)}
destroyOnClose destroyOnClose
width={400}> width={400}
>
<div className="drawer-wrapper"> <div className="drawer-wrapper">
<div className="drawer-menu"> <div className="drawer-menu">
{url && ( {url && (

View File

@@ -69,7 +69,7 @@ UserPreviewCard.defaultProps = {
// DataSourcePreviewCard // DataSourcePreviewCard
export function DataSourcePreviewCard({ dataSource, withLink, children, ...props }) { export function DataSourcePreviewCard({ dataSource, withLink, children, ...props }) {
const imageUrl = `static/images/db-logos/${dataSource.type}.png`; const imageUrl = `/static/images/db-logos/${dataSource.type}.png`;
const title = withLink ? <Link href={"data_sources/" + dataSource.id}>{dataSource.name}</Link> : dataSource.name; const title = withLink ? <Link href={"data_sources/" + dataSource.id}>{dataSource.name}</Link> : dataSource.name;
return ( return (
<PreviewCard {...props} imageUrl={imageUrl} title={title}> <PreviewCard {...props} imageUrl={imageUrl} title={title}>

View File

@@ -51,7 +51,7 @@
right: 0; right: 0;
background: linear-gradient(to bottom, transparent, transparent 2px, #f6f8f9 2px, #f6f8f9 5px), background: linear-gradient(to bottom, transparent, transparent 2px, #f6f8f9 2px, #f6f8f9 5px),
linear-gradient(to left, #b3babf, #b3babf 1px, transparent 1px, transparent); linear-gradient(to left, #b3babf, #b3babf 1px, transparent 1px, transparent);
background-size: calc((100% + 15px) / 6) 5px; background-size: calc((100% + 15px) / 12) 5px;
background-position: -7px 1px; background-position: -7px 1px;
} }
} }

View File

@@ -9,121 +9,85 @@ const DYNAMIC_DATE_OPTIONS = [
name: "This week", name: "This week",
value: getDynamicDateRangeFromString("d_this_week"), value: getDynamicDateRangeFromString("d_this_week"),
label: () => label: () =>
getDynamicDateRangeFromString("d_this_week") getDynamicDateRangeFromString("d_this_week").value()[0].format("MMM D") +
.value()[0]
.format("MMM D") +
" - " + " - " +
getDynamicDateRangeFromString("d_this_week") getDynamicDateRangeFromString("d_this_week").value()[1].format("MMM D"),
.value()[1]
.format("MMM D"),
}, },
{ {
name: "This month", name: "This month",
value: getDynamicDateRangeFromString("d_this_month"), value: getDynamicDateRangeFromString("d_this_month"),
label: () => label: () => getDynamicDateRangeFromString("d_this_month").value()[0].format("MMMM"),
getDynamicDateRangeFromString("d_this_month")
.value()[0]
.format("MMMM"),
}, },
{ {
name: "This year", name: "This year",
value: getDynamicDateRangeFromString("d_this_year"), value: getDynamicDateRangeFromString("d_this_year"),
label: () => label: () => getDynamicDateRangeFromString("d_this_year").value()[0].format("YYYY"),
getDynamicDateRangeFromString("d_this_year")
.value()[0]
.format("YYYY"),
}, },
{ {
name: "Last week", name: "Last week",
value: getDynamicDateRangeFromString("d_last_week"), value: getDynamicDateRangeFromString("d_last_week"),
label: () => label: () =>
getDynamicDateRangeFromString("d_last_week") getDynamicDateRangeFromString("d_last_week").value()[0].format("MMM D") +
.value()[0]
.format("MMM D") +
" - " + " - " +
getDynamicDateRangeFromString("d_last_week") getDynamicDateRangeFromString("d_last_week").value()[1].format("MMM D"),
.value()[1]
.format("MMM D"),
}, },
{ {
name: "Last month", name: "Last month",
value: getDynamicDateRangeFromString("d_last_month"), value: getDynamicDateRangeFromString("d_last_month"),
label: () => label: () => getDynamicDateRangeFromString("d_last_month").value()[0].format("MMMM"),
getDynamicDateRangeFromString("d_last_month")
.value()[0]
.format("MMMM"),
}, },
{ {
name: "Last year", name: "Last year",
value: getDynamicDateRangeFromString("d_last_year"), value: getDynamicDateRangeFromString("d_last_year"),
label: () => label: () => getDynamicDateRangeFromString("d_last_year").value()[0].format("YYYY"),
getDynamicDateRangeFromString("d_last_year")
.value()[0]
.format("YYYY"),
}, },
{ {
name: "Last 7 days", name: "Last 7 days",
value: getDynamicDateRangeFromString("d_last_7_days"), value: getDynamicDateRangeFromString("d_last_7_days"),
label: () => label: () => getDynamicDateRangeFromString("d_last_7_days").value()[0].format("MMM D") + " - Today",
getDynamicDateRangeFromString("d_last_7_days")
.value()[0]
.format("MMM D") + " - Today",
}, },
{ {
name: "Last 14 days", name: "Last 14 days",
value: getDynamicDateRangeFromString("d_last_14_days"), value: getDynamicDateRangeFromString("d_last_14_days"),
label: () => label: () => getDynamicDateRangeFromString("d_last_14_days").value()[0].format("MMM D") + " - Today",
getDynamicDateRangeFromString("d_last_14_days")
.value()[0]
.format("MMM D") + " - Today",
}, },
{ {
name: "Last 30 days", name: "Last 30 days",
value: getDynamicDateRangeFromString("d_last_30_days"), value: getDynamicDateRangeFromString("d_last_30_days"),
label: () => label: () => getDynamicDateRangeFromString("d_last_30_days").value()[0].format("MMM D") + " - Today",
getDynamicDateRangeFromString("d_last_30_days")
.value()[0]
.format("MMM D") + " - Today",
}, },
{ {
name: "Last 60 days", name: "Last 60 days",
value: getDynamicDateRangeFromString("d_last_60_days"), value: getDynamicDateRangeFromString("d_last_60_days"),
label: () => label: () => getDynamicDateRangeFromString("d_last_60_days").value()[0].format("MMM D") + " - Today",
getDynamicDateRangeFromString("d_last_60_days")
.value()[0]
.format("MMM D") + " - Today",
}, },
{ {
name: "Last 90 days", name: "Last 90 days",
value: getDynamicDateRangeFromString("d_last_90_days"), value: getDynamicDateRangeFromString("d_last_90_days"),
label: () => label: () => getDynamicDateRangeFromString("d_last_90_days").value()[0].format("MMM D") + " - Today",
getDynamicDateRangeFromString("d_last_90_days")
.value()[0]
.format("MMM D") + " - Today",
}, },
{ {
name: "Last 12 months", name: "Last 12 months",
value: getDynamicDateRangeFromString("d_last_12_months"), value: getDynamicDateRangeFromString("d_last_12_months"),
label: null, label: null,
}, },
{
name: "Last 10 years",
value: getDynamicDateRangeFromString("d_last_10_years"),
label: null,
},
]; ];
const DYNAMIC_DATETIME_OPTIONS = [ const DYNAMIC_DATETIME_OPTIONS = [
{ {
name: "Today", name: "Today",
value: getDynamicDateRangeFromString("d_today"), value: getDynamicDateRangeFromString("d_today"),
label: () => label: () => getDynamicDateRangeFromString("d_today").value()[0].format("MMM D"),
getDynamicDateRangeFromString("d_today")
.value()[0]
.format("MMM D"),
}, },
{ {
name: "Yesterday", name: "Yesterday",
value: getDynamicDateRangeFromString("d_yesterday"), value: getDynamicDateRangeFromString("d_yesterday"),
label: () => label: () => getDynamicDateRangeFromString("d_yesterday").value()[0].format("MMM D"),
getDynamicDateRangeFromString("d_yesterday")
.value()[0]
.format("MMM D"),
}, },
...DYNAMIC_DATE_OPTIONS, ...DYNAMIC_DATE_OPTIONS,
]; ];

View File

@@ -96,7 +96,7 @@ function EmptyState({
}, []); }, []);
// Show if `onboardingMode=false` or any requested step not completed // Show if `onboardingMode=false` or any requested step not completed
const shouldShow = !onboardingMode || some(keys(isAvailable), step => isAvailable[step] && !isCompleted[step]); const shouldShow = !onboardingMode || some(keys(isAvailable), (step) => isAvailable[step] && !isCompleted[step]);
if (!shouldShow) { if (!shouldShow) {
return null; return null;
@@ -181,7 +181,7 @@ function EmptyState({
]; ];
const stepsItems = getStepsItems ? getStepsItems(defaultStepsItems) : defaultStepsItems; const stepsItems = getStepsItems ? getStepsItems(defaultStepsItems) : defaultStepsItems;
const imageSource = illustrationPath ? illustrationPath : "static/images/illustrations/" + illustration + ".svg"; const imageSource = illustrationPath ? illustrationPath : "/static/images/illustrations/" + illustration + ".svg";
return ( return (
<div className="empty-state-wrapper"> <div className="empty-state-wrapper">
@@ -196,7 +196,7 @@ function EmptyState({
</div> </div>
<div className="empty-state__steps"> <div className="empty-state__steps">
<h4>Let&apos;s get started</h4> <h4>Let&apos;s get started</h4>
<ol>{stepsItems.map(item => item.node)}</ol> <ol>{stepsItems.map((item) => item.node)}</ol>
{helpMessage} {helpMessage}
</div> </div>
</div> </div>

View File

@@ -10,6 +10,10 @@ export interface PaginationOptions {
itemsPerPage?: number; itemsPerPage?: number;
} }
export interface SearchOptions {
isServerSideFTS?: boolean;
}
export interface Controller<I, P = any> { export interface Controller<I, P = any> {
params: P; // TODO: Find out what params is (except merging with props) params: P; // TODO: Find out what params is (except merging with props)
@@ -18,7 +22,7 @@ export interface Controller<I, P = any> {
// search // search
searchTerm?: string; searchTerm?: string;
updateSearch: (searchTerm: string) => void; updateSearch: (searchTerm: string, searchOptions?: SearchOptions) => void;
// tags // tags
selectedTags: string[]; selectedTags: string[];
@@ -28,6 +32,7 @@ export interface Controller<I, P = any> {
orderByField?: string; orderByField?: string;
orderByReverse: boolean; orderByReverse: boolean;
toggleSorting: (orderByField: string) => void; toggleSorting: (orderByField: string) => void;
setSorting: (orderByField: string, orderByReverse: boolean) => void;
// pagination // pagination
page: number; page: number;
@@ -93,7 +98,7 @@ export interface ItemsListWrappedComponentProps<I, P = any> {
export function wrap<I, P = any>( export function wrap<I, P = any>(
WrappedComponent: React.ComponentType<ItemsListWrappedComponentProps<I>>, WrappedComponent: React.ComponentType<ItemsListWrappedComponentProps<I>>,
createItemsSource: () => ItemsSource, createItemsSource: () => ItemsSource,
createStateStorage: () => StateStorage createStateStorage: ( { ...props }) => StateStorage
) { ) {
class ItemsListWrapper extends React.Component<ItemsListWrapperProps, ItemsListWrapperState<I, P>> { class ItemsListWrapper extends React.Component<ItemsListWrapperProps, ItemsListWrapperState<I, P>> {
private _itemsSource: ItemsSource; private _itemsSource: ItemsSource;
@@ -116,7 +121,7 @@ export function wrap<I, P = any>(
constructor(props: ItemsListWrapperProps) { constructor(props: ItemsListWrapperProps) {
super(props); super(props);
const stateStorage = createStateStorage(); const stateStorage = createStateStorage({ ...props });
const itemsSource = createItemsSource(); const itemsSource = createItemsSource();
this._itemsSource = itemsSource; this._itemsSource = itemsSource;
@@ -139,11 +144,33 @@ export function wrap<I, P = any>(
this.props.onError!(error); this.props.onError!(error);
const initialState = this.getState({ ...itemsSource.getState(), isLoaded: false }); const initialState = this.getState({ ...itemsSource.getState(), isLoaded: false });
const { updatePagination, toggleSorting, updateSearch, updateSelectedTags, update, handleError } = itemsSource; const { updatePagination, toggleSorting, setSorting, updateSearch, updateSelectedTags, update, handleError } = itemsSource;
let isRunningUpdateSearch = false;
let pendingUpdateSearchParams: any[] | null = null;
const debouncedUpdateSearch = debounce(async (...params) => {
// Avoid running multiple updateSerch concurrently.
// If an updateSearch is already running, we save the params for the latest call.
// When the current updateSearch is finished, we call debouncedUpdateSearch again with the saved params.
if (isRunningUpdateSearch) {
pendingUpdateSearchParams = params;
return;
}
isRunningUpdateSearch = true;
await updateSearch(...params);
isRunningUpdateSearch = false;
if (pendingUpdateSearchParams) {
const pendingParams = pendingUpdateSearchParams;
pendingUpdateSearchParams = null;
debouncedUpdateSearch(...pendingParams);
}
}, 200);
this.state = { this.state = {
...initialState, ...initialState,
toggleSorting, // eslint-disable-line react/no-unused-state toggleSorting, // eslint-disable-line react/no-unused-state
updateSearch: debounce(updateSearch, 200), // eslint-disable-line react/no-unused-state setSorting, // eslint-disable-line react/no-unused-state
updateSearch: debouncedUpdateSearch, // eslint-disable-line react/no-unused-state
updateSelectedTags, // eslint-disable-line react/no-unused-state updateSelectedTags, // eslint-disable-line react/no-unused-state
updatePagination, // eslint-disable-line react/no-unused-state updatePagination, // eslint-disable-line react/no-unused-state
update, // eslint-disable-line react/no-unused-state update, // eslint-disable-line react/no-unused-state

View File

@@ -39,14 +39,12 @@ export class ItemsSource {
const customParams = {}; const customParams = {};
const context = { const context = {
...this.getCallbackContext(), ...this.getCallbackContext(),
setCustomParams: params => { setCustomParams: (params) => {
extend(customParams, params); extend(customParams, params);
}, },
}; };
return this._beforeUpdate().then(() => { return this._beforeUpdate().then(() => {
const fetchToken = Math.random() const fetchToken = Math.random().toString(36).substr(2);
.toString(36)
.substr(2);
this._currentFetchToken = fetchToken; this._currentFetchToken = fetchToken;
return this._fetcher return this._fetcher
.fetch(changes, state, context) .fetch(changes, state, context)
@@ -59,7 +57,7 @@ export class ItemsSource {
return this._afterUpdate(); return this._afterUpdate();
} }
}) })
.catch(error => this.handleError(error)); .catch((error) => this.handleError(error));
}); });
} }
@@ -124,28 +122,35 @@ export class ItemsSource {
}); });
}; };
toggleSorting = orderByField => { toggleSorting = (orderByField) => {
this._sorter.toggleField(orderByField); this._sorter.toggleField(orderByField);
this._savedOrderByField = this._sorter.field; this._savedOrderByField = this._sorter.field;
this._changed({ sorting: true }); this._changed({ sorting: true });
}; };
updateSearch = searchTerm => { setSorting = (orderByField, orderByReverse) => {
this._sorter.setField(orderByField);
this._sorter.setReverse(orderByReverse);
this._savedOrderByField = this._sorter.field;
this._changed({ sorting: true });
};
updateSearch = (searchTerm, options) => {
// here we update state directly, but later `fetchData` will update it properly // here we update state directly, but later `fetchData` will update it properly
this._searchTerm = searchTerm; this._searchTerm = searchTerm;
// in search mode ignore the ordering and use the ranking order // in search mode ignore the ordering and use the ranking order
// provided by the server-side FTS backend instead, unless it was // provided by the server-side FTS backend instead, unless it was
// requested by the user by actively ordering in search mode // requested by the user by actively ordering in search mode
if (searchTerm === "") { if (searchTerm === "" || !options?.isServerSideFTS) {
this._sorter.setField(this._savedOrderByField); // restore ordering this._sorter.setField(this._savedOrderByField); // restore ordering
} else { } else {
this._sorter.setField(null); this._sorter.setField(null);
} }
this._paginator.setPage(1); this._paginator.setPage(1);
this._changed({ search: true, pagination: { page: true } }); return this._changed({ search: true, pagination: { page: true } });
}; };
updateSelectedTags = selectedTags => { updateSelectedTags = (selectedTags) => {
this._selectedTags = selectedTags; this._selectedTags = selectedTags;
this._paginator.setPage(1); this._paginator.setPage(1);
this._changed({ tags: true, pagination: { page: true } }); this._changed({ tags: true, pagination: { page: true } });
@@ -153,7 +158,7 @@ export class ItemsSource {
update = () => this._changed(); update = () => this._changed();
handleError = error => { handleError = (error) => {
if (isFunction(this.onError)) { if (isFunction(this.onError)) {
this.onError(error); this.onError(error);
} }
@@ -172,7 +177,7 @@ export class ResourceItemsSource extends ItemsSource {
processResults: (results, context) => { processResults: (results, context) => {
let processItem = getItemProcessor(context); let processItem = getItemProcessor(context);
processItem = isFunction(processItem) ? processItem : identity; processItem = isFunction(processItem) ? processItem : identity;
return map(results, item => processItem(item, context)); return map(results, (item) => processItem(item, context));
}, },
}); });
} }

View File

@@ -44,7 +44,7 @@ export const Columns = {
date(overrides) { date(overrides) {
return extend( return extend(
{ {
render: text => formatDate(text), render: (text) => formatDate(text),
}, },
overrides overrides
); );
@@ -52,7 +52,7 @@ export const Columns = {
dateTime(overrides) { dateTime(overrides) {
return extend( return extend(
{ {
render: text => formatDateTime(text), render: (text) => formatDateTime(text),
}, },
overrides overrides
); );
@@ -62,7 +62,7 @@ export const Columns = {
{ {
width: "1%", width: "1%",
className: "text-nowrap", className: "text-nowrap",
render: text => durationHumanize(text), render: (text) => durationHumanize(text),
}, },
overrides overrides
); );
@@ -70,7 +70,7 @@ export const Columns = {
timeAgo(overrides, timeAgoCustomProps = undefined) { timeAgo(overrides, timeAgoCustomProps = undefined) {
return extend( return extend(
{ {
render: value => <TimeAgo date={value} {...timeAgoCustomProps} />, render: (value) => <TimeAgo date={value} {...timeAgoCustomProps} />,
}, },
overrides overrides
); );
@@ -110,6 +110,7 @@ export default class ItemsTable extends React.Component {
orderByField: PropTypes.string, orderByField: PropTypes.string,
orderByReverse: PropTypes.bool, orderByReverse: PropTypes.bool,
toggleSorting: PropTypes.func, toggleSorting: PropTypes.func,
setSorting: PropTypes.func,
"data-test": PropTypes.string, "data-test": PropTypes.string,
rowKey: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), rowKey: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
}; };
@@ -127,18 +128,15 @@ export default class ItemsTable extends React.Component {
}; };
prepareColumns() { prepareColumns() {
const { orderByField, orderByReverse, toggleSorting } = this.props; const { orderByField, orderByReverse } = this.props;
const orderByDirection = orderByReverse ? "descend" : "ascend"; const orderByDirection = orderByReverse ? "descend" : "ascend";
return map( return map(
map( map(
filter(this.props.columns, column => (isFunction(column.isAvailable) ? column.isAvailable() : true)), filter(this.props.columns, (column) => (isFunction(column.isAvailable) ? column.isAvailable() : true)),
column => extend(column, { orderByField: column.orderByField || column.field }) (column) => extend(column, { orderByField: column.orderByField || column.field })
), ),
(column, index) => { (column, index) => {
// Bind click events only to sortable columns
const onHeaderCell = column.sorter ? () => ({ onClick: () => toggleSorting(column.orderByField) }) : null;
// Wrap render function to pass correct arguments // Wrap render function to pass correct arguments
const render = isFunction(column.render) ? (text, row) => column.render(text, row.item) : identity; const render = isFunction(column.render) ? (text, row) => column.render(text, row.item) : identity;
@@ -146,14 +144,13 @@ export default class ItemsTable extends React.Component {
key: "column" + index, key: "column" + index,
dataIndex: ["item", column.field], dataIndex: ["item", column.field],
defaultSortOrder: column.orderByField === orderByField ? orderByDirection : null, defaultSortOrder: column.orderByField === orderByField ? orderByDirection : null,
onHeaderCell,
render, render,
}); });
} }
); );
} }
getRowKey = record => { getRowKey = (record) => {
const { rowKey } = this.props; const { rowKey } = this.props;
if (rowKey) { if (rowKey) {
if (isFunction(rowKey)) { if (isFunction(rowKey)) {
@@ -172,22 +169,43 @@ export default class ItemsTable extends React.Component {
// Bind events only if `onRowClick` specified // Bind events only if `onRowClick` specified
const onTableRow = isFunction(this.props.onRowClick) const onTableRow = isFunction(this.props.onRowClick)
? row => ({ ? (row) => ({
onClick: event => { onClick: (event) => {
this.props.onRowClick(event, row.item); this.props.onRowClick(event, row.item);
}, },
}) })
: null; : null;
const onChange = (pagination, filters, sorter, extra) => {
const action = extra?.action;
if (action === "sort") {
const propsColumn = this.props.columns.find((column) => column.field === sorter.field[1]);
if (!propsColumn.sorter) {
return;
}
let orderByField = propsColumn.orderByField;
const orderByReverse = sorter.order === "descend";
if (orderByReverse === undefined) {
orderByField = null;
}
if (this.props.setSorting) {
this.props.setSorting(orderByField, orderByReverse);
} else {
this.props.toggleSorting(orderByField);
}
}
};
const { showHeader } = this.props; const { showHeader } = this.props;
if (this.props.loading) { if (this.props.loading) {
if (isEmpty(tableDataProps.dataSource)) { if (isEmpty(tableDataProps.dataSource)) {
tableDataProps.columns = tableDataProps.columns.map(column => ({ tableDataProps.columns = tableDataProps.columns.map((column) => ({
...column, ...column,
sorter: false, sorter: false,
render: () => <Skeleton active paragraph={false} />, render: () => <Skeleton active paragraph={false} />,
})); }));
tableDataProps.dataSource = range(10).map(key => ({ key: `${key}` })); tableDataProps.dataSource = range(10).map((key) => ({ key: `${key}` }));
} else { } else {
tableDataProps.loading = { indicator: null }; tableDataProps.loading = { indicator: null };
} }
@@ -200,6 +218,7 @@ export default class ItemsTable extends React.Component {
rowKey={this.getRowKey} rowKey={this.getRowKey}
pagination={false} pagination={false}
onRow={onTableRow} onRow={onTableRow}
onChange={onChange}
data-test={this.props["data-test"]} data-test={this.props["data-test"]}
{...tableDataProps} {...tableDataProps}
/> />

View File

@@ -47,20 +47,30 @@ function SchemaItem({ item, expanded, onToggle, onSelect, ...props }) {
return ( return (
<div {...props}> <div {...props}>
<div className="schema-list-item"> <div className="schema-list-item">
<PlainButton className="table-name" onClick={onToggle}> <Tooltip
<i className="fa fa-table m-r-5" aria-hidden="true" /> title={item.description}
<strong> mouseEnterDelay={0}
<span title={item.name}>{tableDisplayName}</span> mouseLeaveDelay={0}
{!isNil(item.size) && <span> ({item.size})</span>} placement="rightTop"
</strong> trigger={item.description ? "hover" : ""}
</PlainButton> overlayStyle={{ whiteSpace: "pre-line" }}
>
<PlainButton className="table-name" onClick={onToggle}>
<i className="fa fa-table m-r-5" aria-hidden="true" />
<strong>
<span title={item.name}>{tableDisplayName}</span>
{!isNil(item.size) && <span> ({item.size})</span>}
</strong>
</PlainButton>
</Tooltip>
<Tooltip <Tooltip
title="Insert table name into query text" title="Insert table name into query text"
mouseEnterDelay={0} mouseEnterDelay={0}
mouseLeaveDelay={0} mouseLeaveDelay={0}
placement="topRight" placement="topRight"
arrowPointAtCenter> arrowPointAtCenter
<PlainButton className="copy-to-editor" onClick={e => handleSelect(e, item.name)}> >
<PlainButton className="copy-to-editor" onClick={(e) => handleSelect(e, item.name)}>
<i className="fa fa-angle-double-right" aria-hidden="true" /> <i className="fa fa-angle-double-right" aria-hidden="true" />
</PlainButton> </PlainButton>
</Tooltip> </Tooltip>
@@ -70,16 +80,23 @@ function SchemaItem({ item, expanded, onToggle, onSelect, ...props }) {
{item.loading ? ( {item.loading ? (
<div className="table-open">Loading...</div> <div className="table-open">Loading...</div>
) : ( ) : (
map(item.columns, column => { map(item.columns, (column) => {
const columnName = get(column, "name"); const columnName = get(column, "name");
const columnType = get(column, "type"); const columnType = get(column, "type");
const columnDescription = get(column, "description");
return ( return (
<Tooltip <Tooltip
title="Insert column name into query text" title={"Insert column name into query text" + (columnDescription ? "\n" + columnDescription : "")}
mouseEnterDelay={0} mouseEnterDelay={0}
mouseLeaveDelay={0} mouseLeaveDelay={0}
placement="rightTop"> placement="rightTop"
<PlainButton key={columnName} className="table-open-item" onClick={e => handleSelect(e, columnName)}> overlayStyle={{ whiteSpace: "pre-line" }}
>
<PlainButton
key={columnName}
className="table-open-item"
onClick={(e) => handleSelect(e, columnName)}
>
<div> <div>
{columnName} {columnType && <span className="column-type">{columnType}</span>} {columnName} {columnType && <span className="column-type">{columnType}</span>}
</div> </div>
@@ -168,7 +185,7 @@ export function SchemaList({ loading, schema, expandedFlags, onTableExpand, onIt
} }
export function applyFilterOnSchema(schema, filterString) { export function applyFilterOnSchema(schema, filterString) {
const filters = filter(filterString.toLowerCase().split(/\s+/), s => s.length > 0); const filters = filter(filterString.toLowerCase().split(/\s+/), (s) => s.length > 0);
// Empty string: return original schema // Empty string: return original schema
if (filters.length === 0) { if (filters.length === 0) {
@@ -181,9 +198,9 @@ export function applyFilterOnSchema(schema, filterString) {
const columnFilter = filters[0]; const columnFilter = filters[0];
return filter( return filter(
schema, schema,
item => (item) =>
includes(item.name.toLowerCase(), nameFilter) || includes(item.name.toLowerCase(), nameFilter) ||
some(item.columns, column => includes(get(column, "name").toLowerCase(), columnFilter)) some(item.columns, (column) => includes(get(column, "name").toLowerCase(), columnFilter))
); );
} }
@@ -191,11 +208,11 @@ export function applyFilterOnSchema(schema, filterString) {
const nameFilter = filters[0]; const nameFilter = filters[0];
const columnFilter = filters[1]; const columnFilter = filters[1];
return filter( return filter(
map(schema, item => { map(schema, (item) => {
if (includes(item.name.toLowerCase(), nameFilter)) { if (includes(item.name.toLowerCase(), nameFilter)) {
item = { item = {
...item, ...item,
columns: filter(item.columns, column => includes(get(column, "name").toLowerCase(), columnFilter)), columns: filter(item.columns, (column) => includes(get(column, "name").toLowerCase(), columnFilter)),
}; };
return item.columns.length > 0 ? item : null; return item.columns.length > 0 ? item : null;
} }
@@ -243,7 +260,7 @@ export default function SchemaBrowser({
placeholder="Search schema..." placeholder="Search schema..."
aria-label="Search schema" aria-label="Search schema"
disabled={schema.length === 0} disabled={schema.length === 0}
onChange={event => handleFilterChange(event.target.value)} onChange={(event) => handleFilterChange(event.target.value)}
/> />
<Tooltip title="Refresh Schema"> <Tooltip title="Refresh Schema">

View File

@@ -59,6 +59,7 @@ function wrapComponentWithSettings(WrappedComponent) {
"dateTimeFormat", "dateTimeFormat",
"integerFormat", "integerFormat",
"floatFormat", "floatFormat",
"nullValue",
"booleanValues", "booleanValues",
"tableCellMaxJSONSize", "tableCellMaxJSONSize",
"allowCustomJSVisualizations", "allowCustomJSVisualizations",

View File

@@ -1,13 +1,13 @@
export default { export default {
columns: 6, // grid columns count columns: 12, // grid columns count
rowHeight: 50, // grid row height (incl. bottom padding) rowHeight: 50, // grid row height (incl. bottom padding)
margins: 15, // widget margins margins: 15, // widget margins
mobileBreakPoint: 800, mobileBreakPoint: 800,
// defaults for widgets // defaults for widgets
defaultSizeX: 3, defaultSizeX: 6,
defaultSizeY: 3, defaultSizeY: 3,
minSizeX: 1, minSizeX: 2,
maxSizeX: 6, maxSizeX: 12,
minSizeY: 1, minSizeY: 2,
maxSizeY: 1000, maxSizeY: 1000,
}; };

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" translate="no">
<head> <head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta charset="UTF-8" /> <meta charset="UTF-8" />

View File

@@ -81,12 +81,19 @@ function DashboardListExtraActions(props) {
} }
function DashboardList({ controller }) { function DashboardList({ controller }) {
let usedListColumns = listColumns;
if (controller.params.currentPage === "favorites") {
usedListColumns = [
...usedListColumns,
Columns.dateTime.sortable({ title: "Starred At", field: "starred_at", width: "1%" }),
];
}
const { const {
areExtraActionsAvailable, areExtraActionsAvailable,
listColumns: tableColumns, listColumns: tableColumns,
Component: ExtraActionsComponent, Component: ExtraActionsComponent,
selectedItems, selectedItems,
} = useItemsListExtraActions(controller, listColumns, DashboardListExtraActions); } = useItemsListExtraActions(controller, usedListColumns, DashboardListExtraActions);
return ( return (
<div className="page-dashboard-list"> <div className="page-dashboard-list">
@@ -139,9 +146,9 @@ function DashboardList({ controller }) {
showPageSizeSelect showPageSizeSelect
totalCount={controller.totalItemsCount} totalCount={controller.totalItemsCount}
pageSize={controller.itemsPerPage} pageSize={controller.itemsPerPage}
onPageSizeChange={itemsPerPage => controller.updatePagination({ itemsPerPage })} onPageSizeChange={(itemsPerPage) => controller.updatePagination({ itemsPerPage })}
page={controller.page} page={controller.page}
onChange={page => controller.updatePagination({ page })} onChange={(page) => controller.updatePagination({ page })}
/> />
</div> </div>
</React.Fragment> </React.Fragment>
@@ -170,10 +177,10 @@ const DashboardListPage = itemsList(
}[currentPage]; }[currentPage];
}, },
getItemProcessor() { getItemProcessor() {
return item => new Dashboard(item); return (item) => new Dashboard(item);
}, },
}), }),
() => new UrlStateStorage({ orderByField: "created_at", orderByReverse: true }) ({ ...props }) => new UrlStateStorage({ orderByField: props.orderByField ?? "created_at", orderByReverse: true })
); );
routes.register( routes.register(
@@ -181,7 +188,7 @@ routes.register(
routeWithUserSession({ routeWithUserSession({
path: "/dashboards", path: "/dashboards",
title: "Dashboards", title: "Dashboards",
render: pageProps => <DashboardListPage {...pageProps} currentPage="all" />, render: (pageProps) => <DashboardListPage {...pageProps} currentPage="all" />,
}) })
); );
routes.register( routes.register(
@@ -189,7 +196,7 @@ routes.register(
routeWithUserSession({ routeWithUserSession({
path: "/dashboards/favorites", path: "/dashboards/favorites",
title: "Favorite Dashboards", title: "Favorite Dashboards",
render: pageProps => <DashboardListPage {...pageProps} currentPage="favorites" />, render: (pageProps) => <DashboardListPage {...pageProps} currentPage="favorites" orderByField="starred_at" />,
}) })
); );
routes.register( routes.register(
@@ -197,6 +204,6 @@ routes.register(
routeWithUserSession({ routeWithUserSession({
path: "/dashboards/my", path: "/dashboards/my",
title: "My Dashboards", title: "My Dashboards",
render: pageProps => <DashboardListPage {...pageProps} currentPage="my" />, render: (pageProps) => <DashboardListPage {...pageProps} currentPage="my" />,
}) })
); );

View File

@@ -31,7 +31,8 @@ function DashboardSettings({ dashboardConfiguration }) {
<Checkbox <Checkbox
checked={!!dashboard.dashboard_filters_enabled} checked={!!dashboard.dashboard_filters_enabled}
onChange={({ target }) => updateDashboard({ dashboard_filters_enabled: target.checked })} onChange={({ target }) => updateDashboard({ dashboard_filters_enabled: target.checked })}
data-test="DashboardFiltersCheckbox"> data-test="DashboardFiltersCheckbox"
>
Use Dashboard Level Filters Use Dashboard Level Filters
</Checkbox> </Checkbox>
</div> </div>
@@ -90,9 +91,9 @@ function DashboardComponent(props) {
const [pageContainer, setPageContainer] = useState(null); const [pageContainer, setPageContainer] = useState(null);
const [bottomPanelStyles, setBottomPanelStyles] = useState({}); const [bottomPanelStyles, setBottomPanelStyles] = useState({});
const onParametersEdit = parameters => { const onParametersEdit = (parameters) => {
const paramOrder = map(parameters, "name"); const paramOrder = map(parameters, "name");
updateDashboard({ options: { globalParamOrder: paramOrder } }); updateDashboard({ options: { ...dashboard.options, globalParamOrder: paramOrder } });
}; };
useEffect(() => { useEffect(() => {
@@ -175,7 +176,7 @@ function DashboardPage({ dashboardSlug, dashboardId, onError }) {
useEffect(() => { useEffect(() => {
Dashboard.get({ id: dashboardId, slug: dashboardSlug }) Dashboard.get({ id: dashboardId, slug: dashboardSlug })
.then(dashboardData => { .then((dashboardData) => {
recordEvent("view", "dashboard", dashboardData.id); recordEvent("view", "dashboard", dashboardData.id);
setDashboard(dashboardData); setDashboard(dashboardData);
@@ -207,7 +208,7 @@ routes.register(
"Dashboards.LegacyViewOrEdit", "Dashboards.LegacyViewOrEdit",
routeWithUserSession({ routeWithUserSession({
path: "/dashboard/:dashboardSlug", path: "/dashboard/:dashboardSlug",
render: pageProps => <DashboardPage {...pageProps} />, render: (pageProps) => <DashboardPage {...pageProps} />,
}) })
); );
@@ -215,6 +216,6 @@ routes.register(
"Dashboards.ViewOrEdit", "Dashboards.ViewOrEdit",
routeWithUserSession({ routeWithUserSession({
path: "/dashboards/:dashboardId([^-]+)(-.*)?", path: "/dashboards/:dashboardId([^-]+)(-.*)?",
render: pageProps => <DashboardPage {...pageProps} />, render: (pageProps) => <DashboardPage {...pageProps} />,
}) })
); );

View File

@@ -8,7 +8,7 @@
} }
> .container { > .container {
min-height: calc(100vh - 95px); min-height: calc(100% - 95px);
} }
.loading-message { .loading-message {

View File

@@ -22,7 +22,7 @@ import { DashboardStatusEnum } from "../hooks/useDashboard";
import "./DashboardHeader.less"; import "./DashboardHeader.less";
function getDashboardTags() { function getDashboardTags() {
return getTags("api/dashboards/tags").then(tags => map(tags, t => t.name)); return getTags("api/dashboards/tags").then((tags) => map(tags, (t) => t.name));
} }
function buttonType(value) { function buttonType(value) {
@@ -38,7 +38,7 @@ function DashboardPageTitle({ dashboardConfiguration }) {
<h3> <h3>
<EditInPlace <EditInPlace
isEditable={editingLayout} isEditable={editingLayout}
onDone={name => updateDashboard({ name })} onDone={(name) => updateDashboard({ name })}
value={dashboard.name} value={dashboard.name}
ignoreBlanks ignoreBlanks
/> />
@@ -53,7 +53,7 @@ function DashboardPageTitle({ dashboardConfiguration }) {
isArchived={dashboard.is_archived} isArchived={dashboard.is_archived}
canEdit={canEditDashboard} canEdit={canEditDashboard}
getAvailableTags={getDashboardTags} getAvailableTags={getDashboardTags}
onEdit={tags => updateDashboard({ tags })} onEdit={(tags) => updateDashboard({ tags })}
/> />
</div> </div>
); );
@@ -89,14 +89,15 @@ function RefreshButton({ dashboardConfiguration }) {
placement="bottomRight" placement="bottomRight"
overlay={ overlay={
<Menu onClick={onRefreshRateSelected} selectedKeys={[`${refreshRate}`]}> <Menu onClick={onRefreshRateSelected} selectedKeys={[`${refreshRate}`]}>
{refreshRateOptions.map(option => ( {refreshRateOptions.map((option) => (
<Menu.Item key={`${option}`} disabled={!includes(allowedIntervals, option)}> <Menu.Item key={`${option}`} disabled={!includes(allowedIntervals, option)}>
{durationHumanize(option)} {durationHumanize(option)}
</Menu.Item> </Menu.Item>
))} ))}
{refreshRate && <Menu.Item key={null}>Disable auto refresh</Menu.Item>} {refreshRate && <Menu.Item key={null}>Disable auto refresh</Menu.Item>}
</Menu> </Menu>
}> }
>
<Button className="icon-button hidden-xs" type={buttonType(refreshRate)}> <Button className="icon-button hidden-xs" type={buttonType(refreshRate)}>
<i className="fa fa-angle-down" aria-hidden="true" /> <i className="fa fa-angle-down" aria-hidden="true" />
<span className="sr-only">Split button!</span> <span className="sr-only">Split button!</span>
@@ -166,7 +167,8 @@ function DashboardMoreOptionsButton({ dashboardConfiguration }) {
<PlainButton onClick={archive}>Archive</PlainButton> <PlainButton onClick={archive}>Archive</PlainButton>
</Menu.Item> </Menu.Item>
</Menu> </Menu>
}> }
>
<Button className="icon-button m-l-5" data-test="DashboardMoreButton" aria-label="More actions"> <Button className="icon-button m-l-5" data-test="DashboardMoreButton" aria-label="More actions">
<EllipsisOutlinedIcon rotate={90} aria-hidden="true" /> <EllipsisOutlinedIcon rotate={90} aria-hidden="true" />
</Button> </Button>
@@ -216,7 +218,8 @@ function DashboardControl({ dashboardConfiguration, headerExtra }) {
type={buttonType(fullscreen)} type={buttonType(fullscreen)}
className="icon-button m-l-5" className="icon-button m-l-5"
onClick={toggleFullscreen} onClick={toggleFullscreen}
aria-label="Toggle fullscreen display"> aria-label="Toggle fullscreen display"
>
<i className="zmdi zmdi-fullscreen" aria-hidden="true" /> <i className="zmdi zmdi-fullscreen" aria-hidden="true" />
</Button> </Button>
</Tooltip> </Tooltip>
@@ -229,7 +232,8 @@ function DashboardControl({ dashboardConfiguration, headerExtra }) {
type={buttonType(dashboard.publicAccessEnabled)} type={buttonType(dashboard.publicAccessEnabled)}
onClick={showShareDashboardDialog} onClick={showShareDashboardDialog}
data-test="OpenShareForm" data-test="OpenShareForm"
aria-label="Share"> aria-label="Share"
>
<i className="zmdi zmdi-share" aria-hidden="true" /> <i className="zmdi zmdi-share" aria-hidden="true" />
</Button> </Button>
</Tooltip> </Tooltip>
@@ -252,7 +256,11 @@ function DashboardEditControl({ dashboardConfiguration, headerExtra }) {
doneBtnClickedWhileSaving, doneBtnClickedWhileSaving,
dashboardStatus, dashboardStatus,
retrySaveDashboardLayout, retrySaveDashboardLayout,
saveDashboardParameters,
} = dashboardConfiguration; } = dashboardConfiguration;
const handleDoneEditing = () => {
saveDashboardParameters().then(() => setEditingLayout(false));
};
let status; let status;
if (dashboardStatus === DashboardStatusEnum.SAVED) { if (dashboardStatus === DashboardStatusEnum.SAVED) {
status = <span className="save-status">Saved</span>; status = <span className="save-status">Saved</span>;
@@ -277,7 +285,7 @@ function DashboardEditControl({ dashboardConfiguration, headerExtra }) {
Retry Retry
</Button> </Button>
) : ( ) : (
<Button loading={doneBtnClickedWhileSaving} type="primary" onClick={() => setEditingLayout(false)}> <Button loading={doneBtnClickedWhileSaving} type="primary" onClick={handleDoneEditing}>
{!doneBtnClickedWhileSaving && <i className="fa fa-check m-r-5" aria-hidden="true" />} Done Editing {!doneBtnClickedWhileSaving && <i className="fa fa-check m-r-5" aria-hidden="true" />} Done Editing
</Button> </Button>
)} )}

View File

@@ -22,12 +22,12 @@ export { DashboardStatusEnum } from "./useEditModeHandler";
function getAffectedWidgets(widgets, updatedParameters = []) { function getAffectedWidgets(widgets, updatedParameters = []) {
return !isEmpty(updatedParameters) return !isEmpty(updatedParameters)
? widgets.filter(widget => ? widgets.filter((widget) =>
Object.values(widget.getParameterMappings()) Object.values(widget.getParameterMappings())
.filter(({ type }) => type === "dashboard-level") .filter(({ type }) => type === "dashboard-level")
.some(({ mapTo }) => .some(({ mapTo }) =>
includes( includes(
updatedParameters.map(p => p.name), updatedParameters.map((p) => p.name),
mapTo mapTo
) )
) )
@@ -50,7 +50,7 @@ function useDashboard(dashboardData) {
[dashboard] [dashboard]
); );
const hasOnlySafeQueries = useMemo( const hasOnlySafeQueries = useMemo(
() => every(dashboard.widgets, w => (w.getQuery() ? w.getQuery().is_safe : true)), () => every(dashboard.widgets, (w) => (w.getQuery() ? w.getQuery().is_safe : true)),
[dashboard] [dashboard]
); );
@@ -67,19 +67,19 @@ function useDashboard(dashboardData) {
const updateDashboard = useCallback( const updateDashboard = useCallback(
(data, includeVersion = true) => { (data, includeVersion = true) => {
setDashboard(currentDashboard => extend({}, currentDashboard, data)); setDashboard((currentDashboard) => extend({}, currentDashboard, data));
data = { ...data, id: dashboard.id }; data = { ...data, id: dashboard.id };
if (includeVersion) { if (includeVersion) {
data = { ...data, version: dashboard.version }; data = { ...data, version: dashboard.version };
} }
return Dashboard.save(data) return Dashboard.save(data)
.then(updatedDashboard => { .then((updatedDashboard) => {
setDashboard(currentDashboard => extend({}, currentDashboard, pick(updatedDashboard, keys(data)))); setDashboard((currentDashboard) => extend({}, currentDashboard, pick(updatedDashboard, keys(data))));
if (has(data, "name")) { if (has(data, "name")) {
location.setPath(url.parse(updatedDashboard.url).pathname, true); location.setPath(url.parse(updatedDashboard.url).pathname, true);
} }
}) })
.catch(error => { .catch((error) => {
const status = get(error, "response.status"); const status = get(error, "response.status");
if (status === 403) { if (status === 403) {
notification.error("Dashboard update failed", "Permission Denied."); notification.error("Dashboard update failed", "Permission Denied.");
@@ -102,25 +102,25 @@ function useDashboard(dashboardData) {
const loadWidget = useCallback((widget, forceRefresh = false) => { const loadWidget = useCallback((widget, forceRefresh = false) => {
widget.getParametersDefs(); // Force widget to read parameters values from URL widget.getParametersDefs(); // Force widget to read parameters values from URL
setDashboard(currentDashboard => extend({}, currentDashboard)); setDashboard((currentDashboard) => extend({}, currentDashboard));
return widget return widget
.load(forceRefresh) .load(forceRefresh)
.catch(error => { .catch((error) => {
// QueryResultErrors are expected // QueryResultErrors are expected
if (error instanceof QueryResultError) { if (error instanceof QueryResultError) {
return; return;
} }
return Promise.reject(error); return Promise.reject(error);
}) })
.finally(() => setDashboard(currentDashboard => extend({}, currentDashboard))); .finally(() => setDashboard((currentDashboard) => extend({}, currentDashboard)));
}, []); }, []);
const refreshWidget = useCallback(widget => loadWidget(widget, true), [loadWidget]); const refreshWidget = useCallback((widget) => loadWidget(widget, true), [loadWidget]);
const removeWidget = useCallback(widgetId => { const removeWidget = useCallback((widgetId) => {
setDashboard(currentDashboard => setDashboard((currentDashboard) =>
extend({}, currentDashboard, { extend({}, currentDashboard, {
widgets: currentDashboard.widgets.filter(widget => widget.id !== undefined && widget.id !== widgetId), widgets: currentDashboard.widgets.filter((widget) => widget.id !== undefined && widget.id !== widgetId),
}) })
); );
}, []); }, []);
@@ -132,11 +132,11 @@ function useDashboard(dashboardData) {
(forceRefresh = false, updatedParameters = []) => { (forceRefresh = false, updatedParameters = []) => {
const affectedWidgets = getAffectedWidgets(dashboardRef.current.widgets, updatedParameters); const affectedWidgets = getAffectedWidgets(dashboardRef.current.widgets, updatedParameters);
const loadWidgetPromises = compact( const loadWidgetPromises = compact(
affectedWidgets.map(widget => loadWidget(widget, forceRefresh).catch(error => error)) affectedWidgets.map((widget) => loadWidget(widget, forceRefresh).catch((error) => error))
); );
return Promise.all(loadWidgetPromises).then(() => { return Promise.all(loadWidgetPromises).then(() => {
const queryResults = compact(map(dashboardRef.current.widgets, widget => widget.getQueryResult())); const queryResults = compact(map(dashboardRef.current.widgets, (widget) => widget.getQueryResult()));
const updatedFilters = collectDashboardFilters(dashboardRef.current, queryResults, location.search); const updatedFilters = collectDashboardFilters(dashboardRef.current, queryResults, location.search);
setFilters(updatedFilters); setFilters(updatedFilters);
}); });
@@ -145,7 +145,7 @@ function useDashboard(dashboardData) {
); );
const refreshDashboard = useCallback( const refreshDashboard = useCallback(
updatedParameters => { (updatedParameters) => {
if (!refreshing) { if (!refreshing) {
setRefreshing(true); setRefreshing(true);
loadDashboard(true, updatedParameters).finally(() => setRefreshing(false)); loadDashboard(true, updatedParameters).finally(() => setRefreshing(false));
@@ -154,15 +154,30 @@ function useDashboard(dashboardData) {
[refreshing, loadDashboard] [refreshing, loadDashboard]
); );
const saveDashboardParameters = useCallback(() => {
const currentDashboard = dashboardRef.current;
return updateDashboard({
options: {
...currentDashboard.options,
parameters: map(globalParameters, (p) => p.toSaveableObject()),
},
}).catch((error) => {
console.error("Failed to persist parameter values:", error);
notification.error("Parameter values could not be saved. Your changes may not be persisted.");
throw error;
});
}, [globalParameters, updateDashboard]);
const archiveDashboard = useCallback(() => { const archiveDashboard = useCallback(() => {
recordEvent("archive", "dashboard", dashboard.id); recordEvent("archive", "dashboard", dashboard.id);
Dashboard.delete(dashboard).then(updatedDashboard => Dashboard.delete(dashboard).then((updatedDashboard) =>
setDashboard(currentDashboard => extend({}, currentDashboard, pick(updatedDashboard, ["is_archived"]))) setDashboard((currentDashboard) => extend({}, currentDashboard, pick(updatedDashboard, ["is_archived"])))
); );
}, [dashboard]); // eslint-disable-line react-hooks/exhaustive-deps }, [dashboard]); // eslint-disable-line react-hooks/exhaustive-deps
const showShareDashboardDialog = useCallback(() => { const showShareDashboardDialog = useCallback(() => {
const handleDialogClose = () => setDashboard(currentDashboard => extend({}, currentDashboard)); const handleDialogClose = () => setDashboard((currentDashboard) => extend({}, currentDashboard));
ShareDashboardDialog.showModal({ ShareDashboardDialog.showModal({
dashboard, dashboard,
@@ -175,8 +190,8 @@ function useDashboard(dashboardData) {
const showAddTextboxDialog = useCallback(() => { const showAddTextboxDialog = useCallback(() => {
TextboxDialog.showModal({ TextboxDialog.showModal({
isNew: true, isNew: true,
}).onClose(text => }).onClose((text) =>
dashboard.addWidget(text).then(() => setDashboard(currentDashboard => extend({}, currentDashboard))) dashboard.addWidget(text).then(() => setDashboard((currentDashboard) => extend({}, currentDashboard)))
); );
}, [dashboard]); }, [dashboard]);
@@ -188,13 +203,13 @@ function useDashboard(dashboardData) {
.addWidget(visualization, { .addWidget(visualization, {
parameterMappings: editableMappingsToParameterMappings(parameterMappings), parameterMappings: editableMappingsToParameterMappings(parameterMappings),
}) })
.then(widget => { .then((widget) => {
const widgetsToSave = [ const widgetsToSave = [
widget, widget,
...synchronizeWidgetTitles(widget.options.parameterMappings, dashboard.widgets), ...synchronizeWidgetTitles(widget.options.parameterMappings, dashboard.widgets),
]; ];
return Promise.all(widgetsToSave.map(w => w.save())).then(() => return Promise.all(widgetsToSave.map((w) => w.save())).then(() =>
setDashboard(currentDashboard => extend({}, currentDashboard)) setDashboard((currentDashboard) => extend({}, currentDashboard))
); );
}) })
); );
@@ -238,6 +253,7 @@ function useDashboard(dashboardData) {
setRefreshRate, setRefreshRate,
disableRefreshRate, disableRefreshRate,
...editModeHandler, ...editModeHandler,
saveDashboardParameters,
gridDisabled, gridDisabled,
setGridDisabled, setGridDisabled,
fullscreen, fullscreen,

View File

@@ -6,6 +6,7 @@ import Link from "@/components/Link";
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession"; import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
import EmptyState, { EmptyStateHelpMessage } from "@/components/empty-state/EmptyState"; import EmptyState, { EmptyStateHelpMessage } from "@/components/empty-state/EmptyState";
import DynamicComponent from "@/components/DynamicComponent"; import DynamicComponent from "@/components/DynamicComponent";
import BeaconConsent from "@/components/BeaconConsent";
import PlainButton from "@/components/PlainButton"; import PlainButton from "@/components/PlainButton";
import { axios } from "@/services/axios"; import { axios } from "@/services/axios";
@@ -30,7 +31,8 @@ function DeprecatedEmbedFeatureAlert() {
<Link <Link
href="https://discuss.redash.io/t/support-for-parameters-in-embedded-visualizations/3337" href="https://discuss.redash.io/t/support-for-parameters-in-embedded-visualizations/3337"
target="_blank" target="_blank"
rel="noopener noreferrer"> rel="noopener noreferrer"
>
Read more Read more
</Link> </Link>
. .
@@ -42,7 +44,7 @@ function DeprecatedEmbedFeatureAlert() {
function EmailNotVerifiedAlert() { function EmailNotVerifiedAlert() {
const verifyEmail = () => { const verifyEmail = () => {
axios.post("verification_email/").then(data => { axios.post("verification_email/").then((data) => {
notification.success(data.message); notification.success(data.message);
}); });
}; };
@@ -88,6 +90,7 @@ export default function Home() {
</DynamicComponent> </DynamicComponent>
<DynamicComponent name="HomeExtra" /> <DynamicComponent name="HomeExtra" />
<DashboardAndQueryFavoritesList /> <DashboardAndQueryFavoritesList />
<BeaconConsent />
</div> </div>
</div> </div>
); );
@@ -98,6 +101,6 @@ routes.register(
routeWithUserSession({ routeWithUserSession({
path: "/", path: "/",
title: "Redash", title: "Redash",
render: pageProps => <Home {...pageProps} />, render: (pageProps) => <Home {...pageProps} />,
}) })
); );

View File

@@ -15,7 +15,7 @@ export function FavoriteList({ title, resource, itemUrl, emptyState }) {
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
resource resource
.favorites() .favorites({ order: "-starred_at" })
.then(({ results }) => setItems(results)) .then(({ results }) => setItems(results))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [resource]); }, [resource]);
@@ -28,7 +28,7 @@ export function FavoriteList({ title, resource, itemUrl, emptyState }) {
</div> </div>
{!isEmpty(items) && ( {!isEmpty(items) && (
<div role="list" className="list-group"> <div role="list" className="list-group">
{items.map(item => ( {items.map((item) => (
<Link key={itemUrl(item)} role="listitem" className="list-group-item" href={itemUrl(item)}> <Link key={itemUrl(item)} role="listitem" className="list-group-item" href={itemUrl(item)}>
<span className="btn-favorite m-r-5"> <span className="btn-favorite m-r-5">
<i className="fa fa-star" aria-hidden="true" /> <i className="fa fa-star" aria-hidden="true" />
@@ -61,7 +61,7 @@ export function DashboardAndQueryFavoritesList() {
<FavoriteList <FavoriteList
title="Favorite Dashboards" title="Favorite Dashboards"
resource={Dashboard} resource={Dashboard}
itemUrl={dashboard => dashboard.url} itemUrl={(dashboard) => dashboard.url}
emptyState={ emptyState={
<p> <p>
<span className="btn-favorite m-r-5"> <span className="btn-favorite m-r-5">
@@ -76,7 +76,7 @@ export function DashboardAndQueryFavoritesList() {
<FavoriteList <FavoriteList
title="Favorite Queries" title="Favorite Queries"
resource={Query} resource={Query}
itemUrl={query => `queries/${query.id}`} itemUrl={(query) => `queries/${query.id}`}
emptyState={ emptyState={
<p> <p>
<span className="btn-favorite m-r-5"> <span className="btn-favorite m-r-5">

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useRef } from "react"; import React, { useCallback, useEffect, useRef } from "react";
import cx from "classnames"; import cx from "classnames";
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession"; import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
@@ -20,7 +20,7 @@ import ItemsTable, { Columns } from "@/components/items-list/components/ItemsTab
import Layout from "@/components/layouts/ContentWithSidebar"; import Layout from "@/components/layouts/ContentWithSidebar";
import { Query } from "@/services/query"; import { Query } from "@/services/query";
import { currentUser } from "@/services/auth"; import { clientConfig, currentUser } from "@/services/auth";
import location from "@/services/location"; import location from "@/services/location";
import routes from "@/services/routes"; import routes from "@/services/routes";
@@ -95,25 +95,39 @@ function QueriesList({ controller }) {
const controllerRef = useRef(); const controllerRef = useRef();
controllerRef.current = controller; controllerRef.current = controller;
const updateSearch = useCallback(
(searchTemm) => {
controller.updateSearch(searchTemm, { isServerSideFTS: !clientConfig.multiByteSearchEnabled });
},
[controller]
);
useEffect(() => { useEffect(() => {
const unlistenLocationChanges = location.listen((unused, action) => { const unlistenLocationChanges = location.listen((unused, action) => {
const searchTerm = location.search.q || ""; const searchTerm = location.search.q || "";
if (action === "PUSH" && searchTerm !== controllerRef.current.searchTerm) { if (action === "PUSH" && searchTerm !== controllerRef.current.searchTerm) {
controllerRef.current.updateSearch(searchTerm); updateSearch(searchTerm);
} }
}); });
return () => { return () => {
unlistenLocationChanges(); unlistenLocationChanges();
}; };
}, []); }, [updateSearch]);
let usedListColumns = listColumns;
if (controller.params.currentPage === "favorites") {
usedListColumns = [
...usedListColumns,
Columns.dateTime.sortable({ title: "Starred At", field: "starred_at", width: "1%" }),
];
}
const { const {
areExtraActionsAvailable, areExtraActionsAvailable,
listColumns: tableColumns, listColumns: tableColumns,
Component: ExtraActionsComponent, Component: ExtraActionsComponent,
selectedItems, selectedItems,
} = useItemsListExtraActions(controller, listColumns, QueriesListExtraActions); } = useItemsListExtraActions(controller, usedListColumns, QueriesListExtraActions);
return ( return (
<div className="page-queries-list"> <div className="page-queries-list">
@@ -135,7 +149,7 @@ function QueriesList({ controller }) {
placeholder="Search Queries..." placeholder="Search Queries..."
label="Search queries" label="Search queries"
value={controller.searchTerm} value={controller.searchTerm}
onChange={controller.updateSearch} onChange={updateSearch}
/> />
<Sidebar.Menu items={sidebarMenu} selected={controller.params.currentPage} /> <Sidebar.Menu items={sidebarMenu} selected={controller.params.currentPage} />
<Sidebar.Tags url="api/queries/tags" onChange={controller.updateSelectedTags} showUnselectAll /> <Sidebar.Tags url="api/queries/tags" onChange={controller.updateSelectedTags} showUnselectAll />
@@ -160,14 +174,15 @@ function QueriesList({ controller }) {
orderByField={controller.orderByField} orderByField={controller.orderByField}
orderByReverse={controller.orderByReverse} orderByReverse={controller.orderByReverse}
toggleSorting={controller.toggleSorting} toggleSorting={controller.toggleSorting}
setSorting={controller.setSorting}
/> />
<Paginator <Paginator
showPageSizeSelect showPageSizeSelect
totalCount={controller.totalItemsCount} totalCount={controller.totalItemsCount}
pageSize={controller.itemsPerPage} pageSize={controller.itemsPerPage}
onPageSizeChange={itemsPerPage => controller.updatePagination({ itemsPerPage })} onPageSizeChange={(itemsPerPage) => controller.updatePagination({ itemsPerPage })}
page={controller.page} page={controller.page}
onChange={page => controller.updatePagination({ page })} onChange={(page) => controller.updatePagination({ page })}
/> />
</div> </div>
</React.Fragment> </React.Fragment>
@@ -196,10 +211,10 @@ const QueriesListPage = itemsList(
}[currentPage]; }[currentPage];
}, },
getItemProcessor() { getItemProcessor() {
return item => new Query(item); return (item) => new Query(item);
}, },
}), }),
() => new UrlStateStorage({ orderByField: "created_at", orderByReverse: true }) ({ ...props }) => new UrlStateStorage({ orderByField: props.orderByField ?? "created_at", orderByReverse: true })
); );
routes.register( routes.register(
@@ -207,7 +222,7 @@ routes.register(
routeWithUserSession({ routeWithUserSession({
path: "/queries", path: "/queries",
title: "Queries", title: "Queries",
render: pageProps => <QueriesListPage {...pageProps} currentPage="all" />, render: (pageProps) => <QueriesListPage {...pageProps} currentPage="all" />,
}) })
); );
routes.register( routes.register(
@@ -215,7 +230,7 @@ routes.register(
routeWithUserSession({ routeWithUserSession({
path: "/queries/favorites", path: "/queries/favorites",
title: "Favorite Queries", title: "Favorite Queries",
render: pageProps => <QueriesListPage {...pageProps} currentPage="favorites" />, render: (pageProps) => <QueriesListPage {...pageProps} currentPage="favorites" orderByField="starred_at" />,
}) })
); );
routes.register( routes.register(
@@ -223,7 +238,7 @@ routes.register(
routeWithUserSession({ routeWithUserSession({
path: "/queries/archive", path: "/queries/archive",
title: "Archived Queries", title: "Archived Queries",
render: pageProps => <QueriesListPage {...pageProps} currentPage="archive" />, render: (pageProps) => <QueriesListPage {...pageProps} currentPage="archive" />,
}) })
); );
routes.register( routes.register(
@@ -231,6 +246,6 @@ routes.register(
routeWithUserSession({ routeWithUserSession({
path: "/queries/my", path: "/queries/my",
title: "My Queries", title: "My Queries",
render: pageProps => <QueriesListPage {...pageProps} currentPage="my" />, render: (pageProps) => <QueriesListPage {...pageProps} currentPage="my" />,
}) })
); );

View File

@@ -2,7 +2,7 @@ import PropTypes from "prop-types";
import React from "react"; import React from "react";
export function QuerySourceTypeIcon(props) { export function QuerySourceTypeIcon(props) {
return <img src={`static/images/db-logos/${props.type}.png`} width="20" alt={props.alt} />; return <img src={`/static/images/db-logos/${props.type}.png`} width="20" alt={props.alt} />;
} }
QuerySourceTypeIcon.propTypes = { QuerySourceTypeIcon.propTypes = {

View File

@@ -18,7 +18,7 @@ function EmptyState({ title, message, refreshButton }) {
<div className="query-results-empty-state"> <div className="query-results-empty-state">
<div className="empty-state-content"> <div className="empty-state-content">
<div> <div>
<img src="static/images/illustrations/no-query-results.svg" alt="No Query Results Illustration" /> <img src="/static/images/illustrations/no-query-results.svg" alt="No Query Results Illustration" />
</div> </div>
<h3>{title}</h3> <h3>{title}</h3>
<div className="m-b-20">{message}</div> <div className="m-b-20">{message}</div>
@@ -40,7 +40,7 @@ EmptyState.defaultProps = {
function TabWithDeleteButton({ visualizationName, canDelete, onDelete, ...props }) { function TabWithDeleteButton({ visualizationName, canDelete, onDelete, ...props }) {
const handleDelete = useCallback( const handleDelete = useCallback(
e => { (e) => {
e.stopPropagation(); e.stopPropagation();
Modal.confirm({ Modal.confirm({
title: "Delete Visualization", title: "Delete Visualization",
@@ -111,7 +111,8 @@ export default function QueryVisualizationTabs({
className="add-visualization-button" className="add-visualization-button"
data-test="NewVisualization" data-test="NewVisualization"
type="link" type="link"
onClick={() => onAddVisualization()}> onClick={() => onAddVisualization()}
>
<i className="fa fa-plus" aria-hidden="true" /> <i className="fa fa-plus" aria-hidden="true" />
<span className="m-l-5 hidden-xs">Add Visualization</span> <span className="m-l-5 hidden-xs">Add Visualization</span>
</Button> </Button>
@@ -119,7 +120,7 @@ export default function QueryVisualizationTabs({
} }
const orderedVisualizations = useMemo(() => orderBy(visualizations, ["id"]), [visualizations]); const orderedVisualizations = useMemo(() => orderBy(visualizations, ["id"]), [visualizations]);
const isFirstVisualization = useCallback(visId => visId === orderedVisualizations[0].id, [orderedVisualizations]); const isFirstVisualization = useCallback((visId) => visId === orderedVisualizations[0].id, [orderedVisualizations]);
const isMobile = useMedia({ maxWidth: 768 }); const isMobile = useMedia({ maxWidth: 768 });
const [filters, setFilters] = useState([]); const [filters, setFilters] = useState([]);
@@ -132,9 +133,10 @@ export default function QueryVisualizationTabs({
data-test="QueryPageVisualizationTabs" data-test="QueryPageVisualizationTabs"
animated={false} animated={false}
tabBarGutter={0} tabBarGutter={0}
onChange={activeKey => onChangeTab(+activeKey)} onChange={(activeKey) => onChangeTab(+activeKey)}
destroyInactiveTabPane> destroyInactiveTabPane
{orderedVisualizations.map(visualization => ( >
{orderedVisualizations.map((visualization) => (
<TabPane <TabPane
key={`${visualization.id}`} key={`${visualization.id}`}
tab={ tab={
@@ -144,7 +146,8 @@ export default function QueryVisualizationTabs({
visualizationName={visualization.name} visualizationName={visualization.name}
onDelete={() => onDeleteVisualization(visualization.id)} onDelete={() => onDeleteVisualization(visualization.id)}
/> />
}> }
>
{queryResult ? ( {queryResult ? (
<VisualizationRenderer <VisualizationRenderer
visualization={visualization} visualization={visualization}

View File

@@ -1,16 +1,11 @@
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { reduce } from "lodash";
import localOptions from "@/lib/localOptions"; import localOptions from "@/lib/localOptions";
function calculateTokensCount(schema) {
return reduce(schema, (totalLength, table) => totalLength + table.columns.length, 0);
}
export default function useAutocompleteFlags(schema) { export default function useAutocompleteFlags(schema) {
const isAvailable = useMemo(() => calculateTokensCount(schema) <= 5000, [schema]); const isAvailable = true;
const [isEnabled, setIsEnabled] = useState(localOptions.get("liveAutocomplete", true)); const [isEnabled, setIsEnabled] = useState(localOptions.get("liveAutocomplete", true));
const toggleAutocomplete = useCallback(state => { const toggleAutocomplete = useCallback((state) => {
setIsEnabled(state); setIsEnabled(state);
localOptions.set("liveAutocomplete", state); localOptions.set("liveAutocomplete", state);
}, []); }, []);

View File

@@ -0,0 +1,40 @@
import React from "react";
import Form from "antd/lib/form";
import Checkbox from "antd/lib/checkbox";
import Skeleton from "antd/lib/skeleton";
import HelpTrigger from "@/components/HelpTrigger";
import DynamicComponent from "@/components/DynamicComponent";
import { SettingsEditorPropTypes, SettingsEditorDefaultProps } from "../prop-types";
export default function BeaconConsentSettings(props) {
const { values, onChange, loading } = props;
return (
<DynamicComponent name="OrganizationSettings.BeaconConsentSettings" {...props}>
<Form.Item
label={
<span>
Anonymous Usage Data Sharing
<HelpTrigger className="m-l-5 m-r-5" type="USAGE_DATA_SHARING" />
</span>
}
>
{loading ? (
<Skeleton title={{ width: 300 }} paragraph={false} active />
) : (
<Checkbox
name="beacon_consent"
checked={values.beacon_consent}
onChange={(e) => onChange({ beacon_consent: e.target.checked })}
>
Help Redash improve by automatically sending anonymous usage data
</Checkbox>
)}
</Form.Item>
</DynamicComponent>
);
}
BeaconConsentSettings.propTypes = SettingsEditorPropTypes;
BeaconConsentSettings.defaultProps = SettingsEditorDefaultProps;

View File

@@ -4,6 +4,7 @@ import DynamicComponent from "@/components/DynamicComponent";
import FormatSettings from "./FormatSettings"; import FormatSettings from "./FormatSettings";
import PlotlySettings from "./PlotlySettings"; import PlotlySettings from "./PlotlySettings";
import FeatureFlagsSettings from "./FeatureFlagsSettings"; import FeatureFlagsSettings from "./FeatureFlagsSettings";
import BeaconConsentSettings from "./BeaconConsentSettings";
export default function GeneralSettings(props) { export default function GeneralSettings(props) {
return ( return (
@@ -13,6 +14,7 @@ export default function GeneralSettings(props) {
<FormatSettings {...props} /> <FormatSettings {...props} />
<PlotlySettings {...props} /> <PlotlySettings {...props} />
<FeatureFlagsSettings {...props} /> <FeatureFlagsSettings {...props} />
<BeaconConsentSettings {...props} />
</DynamicComponent> </DynamicComponent>
); );
} }

View File

@@ -10,9 +10,9 @@ export const urlForDashboard = ({ id, slug }) => `dashboards/${id}-${slug}`;
export function collectDashboardFilters(dashboard, queryResults, urlParams) { export function collectDashboardFilters(dashboard, queryResults, urlParams) {
const filters = {}; const filters = {};
_.each(queryResults, queryResult => { _.each(queryResults, (queryResult) => {
const queryFilters = queryResult && queryResult.getFilters ? queryResult.getFilters() : []; const queryFilters = queryResult && queryResult.getFilters ? queryResult.getFilters() : [];
_.each(queryFilters, queryFilter => { _.each(queryFilters, (queryFilter) => {
const hasQueryStringValue = _.has(urlParams, queryFilter.name); const hasQueryStringValue = _.has(urlParams, queryFilter.name);
if (!(hasQueryStringValue || dashboard.dashboard_filters_enabled)) { if (!(hasQueryStringValue || dashboard.dashboard_filters_enabled)) {
@@ -44,7 +44,7 @@ function prepareWidgetsForDashboard(widgets) {
const defaultWidgetSizeY = const defaultWidgetSizeY =
Math.max( Math.max(
_.chain(widgets) _.chain(widgets)
.map(w => w.options.position.sizeY) .map((w) => w.options.position.sizeY)
.max() .max()
.value(), .value(),
20 20
@@ -55,11 +55,11 @@ function prepareWidgetsForDashboard(widgets) {
// 2. update position of widgets in each row - place it right below // 2. update position of widgets in each row - place it right below
// biggest widget from previous row // biggest widget from previous row
_.chain(widgets) _.chain(widgets)
.sortBy(widget => widget.options.position.row) .sortBy((widget) => widget.options.position.row)
.groupBy(widget => widget.options.position.row) .groupBy((widget) => widget.options.position.row)
.reduce((row, widgetsAtRow) => { .reduce((row, widgetsAtRow) => {
let height = 1; let height = 1;
_.each(widgetsAtRow, widget => { _.each(widgetsAtRow, (widget) => {
height = Math.max( height = Math.max(
height, height,
widget.options.position.autoHeight ? defaultWidgetSizeY : widget.options.position.sizeY widget.options.position.autoHeight ? defaultWidgetSizeY : widget.options.position.sizeY
@@ -74,8 +74,8 @@ function prepareWidgetsForDashboard(widgets) {
.value(); .value();
// Sort widgets by updated column and row value // Sort widgets by updated column and row value
widgets = _.sortBy(widgets, widget => widget.options.position.col); widgets = _.sortBy(widgets, (widget) => widget.options.position.col);
widgets = _.sortBy(widgets, widget => widget.options.position.row); widgets = _.sortBy(widgets, (widget) => widget.options.position.row);
return widgets; return widgets;
} }
@@ -85,7 +85,7 @@ function calculateNewWidgetPosition(existingWidgets, newWidget) {
// Find first free row for each column // Find first free row for each column
const bottomLine = _.chain(existingWidgets) const bottomLine = _.chain(existingWidgets)
.map(w => { .map((w) => {
const options = _.extend({}, w.options); const options = _.extend({}, w.options);
const position = _.extend({ row: 0, sizeY: 0 }, options.position); const position = _.extend({ row: 0, sizeY: 0 }, options.position);
return { return {
@@ -97,21 +97,24 @@ function calculateNewWidgetPosition(existingWidgets, newWidget) {
height: position.sizeY, height: position.sizeY,
}; };
}) })
.reduce((result, item) => { .reduce(
const from = Math.max(item.left, 0); (result, item) => {
const to = Math.min(item.right, result.length + 1); const from = Math.max(item.left, 0);
for (let i = from; i < to; i += 1) { const to = Math.min(item.right, result.length + 1);
result[i] = Math.max(result[i], item.bottom); for (let i = from; i < to; i += 1) {
} result[i] = Math.max(result[i], item.bottom);
return result; }
}, _.map(new Array(dashboardGridOptions.columns), _.constant(0))) return result;
},
_.map(new Array(dashboardGridOptions.columns), _.constant(0))
)
.value(); .value();
// Go through columns, pick them by count necessary to hold new block, // Go through columns, pick them by count necessary to hold new block,
// and calculate bottom-most free row per group. // and calculate bottom-most free row per group.
// Choose group with the top-most free row (comparing to other groups) // Choose group with the top-most free row (comparing to other groups)
return _.chain(_.range(0, dashboardGridOptions.columns - width + 1)) return _.chain(_.range(0, dashboardGridOptions.columns - width + 1))
.map(col => ({ .map((col) => ({
col, col,
row: _.chain(bottomLine) row: _.chain(bottomLine)
.slice(col, col + width) .slice(col, col + width)
@@ -126,14 +129,14 @@ function calculateNewWidgetPosition(existingWidgets, newWidget) {
export function Dashboard(dashboard) { export function Dashboard(dashboard) {
_.extend(this, dashboard); _.extend(this, dashboard);
Object.defineProperty(this, "url", { Object.defineProperty(this, "url", {
get: function() { get: function () {
return urlForDashboard(this); return urlForDashboard(this);
}, },
}); });
} }
function prepareDashboardWidgets(widgets) { function prepareDashboardWidgets(widgets) {
return prepareWidgetsForDashboard(_.map(widgets, widget => new Widget(widget))); return prepareWidgetsForDashboard(_.map(widgets, (widget) => new Widget(widget)));
} }
function transformSingle(dashboard) { function transformSingle(dashboard) {
@@ -154,7 +157,7 @@ function transformResponse(data) {
return data; return data;
} }
const saveOrCreateUrl = data => (data.id ? `api/dashboards/${data.id}` : "api/dashboards"); const saveOrCreateUrl = (data) => (data.id ? `api/dashboards/${data.id}` : "api/dashboards");
const DashboardService = { const DashboardService = {
get: ({ id, slug }) => { get: ({ id, slug }) => {
const params = {}; const params = {};
@@ -164,12 +167,12 @@ const DashboardService = {
return axios.get(`api/dashboards/${id || slug}`, { params }).then(transformResponse); return axios.get(`api/dashboards/${id || slug}`, { params }).then(transformResponse);
}, },
getByToken: ({ token }) => axios.get(`api/dashboards/public/${token}`).then(transformResponse), getByToken: ({ token }) => axios.get(`api/dashboards/public/${token}`).then(transformResponse),
save: data => axios.post(saveOrCreateUrl(data), data).then(transformResponse), save: (data) => axios.post(saveOrCreateUrl(data), data).then(transformResponse),
delete: ({ id }) => axios.delete(`api/dashboards/${id}`).then(transformResponse), delete: ({ id }) => axios.delete(`api/dashboards/${id}`).then(transformResponse),
query: params => axios.get("api/dashboards", { params }).then(transformResponse), query: (params) => axios.get("api/dashboards", { params }).then(transformResponse),
recent: params => axios.get("api/dashboards/recent", { params }).then(transformResponse), recent: (params) => axios.get("api/dashboards/recent", { params }).then(transformResponse),
myDashboards: params => axios.get("api/dashboards/my", { params }).then(transformResponse), myDashboards: (params) => axios.get("api/dashboards/my", { params }).then(transformResponse),
favorites: params => axios.get("api/dashboards/favorites", { params }).then(transformResponse), favorites: (params) => axios.get("api/dashboards/favorites", { params }).then(transformResponse),
favorite: ({ id }) => axios.post(`api/dashboards/${id}/favorite`), favorite: ({ id }) => axios.post(`api/dashboards/${id}/favorite`),
unfavorite: ({ id }) => axios.delete(`api/dashboards/${id}/favorite`), unfavorite: ({ id }) => axios.delete(`api/dashboards/${id}/favorite`),
fork: ({ id }) => axios.post(`api/dashboards/${id}/fork`, { id }).then(transformResponse), fork: ({ id }) => axios.post(`api/dashboards/${id}/fork`, { id }).then(transformResponse),
@@ -187,13 +190,13 @@ Dashboard.prototype.canEdit = function canEdit() {
Dashboard.prototype.getParametersDefs = function getParametersDefs() { Dashboard.prototype.getParametersDefs = function getParametersDefs() {
const globalParams = {}; const globalParams = {};
const queryParams = location.search; const queryParams = location.search;
_.each(this.widgets, widget => { _.each(this.widgets, (widget) => {
if (widget.getQuery()) { if (widget.getQuery()) {
const mappings = widget.getParameterMappings(); const mappings = widget.getParameterMappings();
widget widget
.getQuery() .getQuery()
.getParametersDefs(false) .getParametersDefs(false)
.forEach(param => { .forEach((param) => {
const mapping = mappings[param.name]; const mapping = mappings[param.name];
if (mapping.type === Widget.MappingType.DashboardLevel) { if (mapping.type === Widget.MappingType.DashboardLevel) {
// create global param // create global param
@@ -210,15 +213,19 @@ Dashboard.prototype.getParametersDefs = function getParametersDefs() {
}); });
} }
}); });
const mergedValues = {
..._.mapValues(globalParams, (p) => p.value),
...Object.fromEntries((this.options.parameters || []).map((param) => [param.name, param.value])),
};
const resultingGlobalParams = _.values( const resultingGlobalParams = _.values(
_.each(globalParams, param => { _.each(globalParams, (param) => {
param.setValue(param.value); // apply global param value to all locals param.setValue(mergedValues[param.name]); // apply merged value
param.fromUrlParams(queryParams); // try to initialize from url (may do nothing) param.fromUrlParams(queryParams); // allow param-specific parsing logic
}) })
); );
// order dashboard params using paramOrder // order dashboard params using paramOrder
return _.sortBy(resultingGlobalParams, param => return _.sortBy(resultingGlobalParams, (param) =>
_.includes(this.options.globalParamOrder, param.name) _.includes(this.options.globalParamOrder, param.name)
? _.indexOf(this.options.globalParamOrder, param.name) ? _.indexOf(this.options.globalParamOrder, param.name)
: _.size(this.options.globalParamOrder) : _.size(this.options.globalParamOrder)

View File

@@ -4,19 +4,19 @@ import { fetchDataFromJob } from "@/services/query-result";
export const SCHEMA_NOT_SUPPORTED = 1; export const SCHEMA_NOT_SUPPORTED = 1;
export const SCHEMA_LOAD_ERROR = 2; export const SCHEMA_LOAD_ERROR = 2;
export const IMG_ROOT = "static/images/db-logos"; export const IMG_ROOT = "/static/images/db-logos";
function mapSchemaColumnsToObject(columns) { function mapSchemaColumnsToObject(columns) {
return map(columns, column => (isObject(column) ? column : { name: column })); return map(columns, (column) => (isObject(column) ? column : { name: column }));
} }
const DataSource = { const DataSource = {
query: () => axios.get("api/data_sources"), query: () => axios.get("api/data_sources"),
get: ({ id }) => axios.get(`api/data_sources/${id}`), get: ({ id }) => axios.get(`api/data_sources/${id}`),
types: () => axios.get("api/data_sources/types"), types: () => axios.get("api/data_sources/types"),
create: data => axios.post(`api/data_sources`, data), create: (data) => axios.post(`api/data_sources`, data),
save: data => axios.post(`api/data_sources/${data.id}`, data), save: (data) => axios.post(`api/data_sources/${data.id}`, data),
test: data => axios.post(`api/data_sources/${data.id}/test`), test: (data) => axios.post(`api/data_sources/${data.id}/test`),
delete: ({ id }) => axios.delete(`api/data_sources/${id}`), delete: ({ id }) => axios.delete(`api/data_sources/${id}`),
fetchSchema: (data, refresh = false) => { fetchSchema: (data, refresh = false) => {
const params = {}; const params = {};
@@ -27,15 +27,15 @@ const DataSource = {
return axios return axios
.get(`api/data_sources/${data.id}/schema`, { params }) .get(`api/data_sources/${data.id}/schema`, { params })
.then(data => { .then((data) => {
if (has(data, "job")) { if (has(data, "job")) {
return fetchDataFromJob(data.job.id).catch(error => return fetchDataFromJob(data.job.id).catch((error) =>
error.code === SCHEMA_NOT_SUPPORTED ? [] : Promise.reject(new Error(data.job.error)) error.code === SCHEMA_NOT_SUPPORTED ? [] : Promise.reject(new Error(data.job.error))
); );
} }
return has(data, "schema") ? data.schema : Promise.reject(); return has(data, "schema") ? data.schema : Promise.reject();
}) })
.then(tables => map(tables, table => ({ ...table, columns: mapSchemaColumnsToObject(table.columns) }))); .then((tables) => map(tables, (table) => ({ ...table, columns: mapSchemaColumnsToObject(table.columns) })));
}, },
}; };

View File

@@ -9,7 +9,7 @@ function normalizeLocation(rawLocation) {
const result = {}; const result = {};
result.path = pathname; result.path = pathname;
result.search = mapValues(qs.parse(search), value => (isNil(value) ? true : value)); result.search = mapValues(qs.parse(search), (value) => (isNil(value) ? true : value));
result.hash = trimStart(hash, "#"); result.hash = trimStart(hash, "#");
result.url = `${pathname}${search}${hash}`; result.url = `${pathname}${search}${hash}`;
@@ -27,7 +27,7 @@ const location = {
confirmChange(handler) { confirmChange(handler) {
if (isFunction(handler)) { if (isFunction(handler)) {
return history.block(nextLocation => { return history.block((nextLocation) => {
return handler(normalizeLocation(nextLocation), location); return handler(normalizeLocation(nextLocation), location);
}); });
} else { } else {
@@ -60,12 +60,18 @@ const location = {
// serialize search and keep existing search parameters (!) // serialize search and keep existing search parameters (!)
if (isObject(newLocation.search)) { if (isObject(newLocation.search)) {
newLocation.search = omitBy(extend({}, location.search, newLocation.search), isNil); newLocation.search = omitBy(extend({}, location.search, newLocation.search), isNil);
newLocation.search = mapValues(newLocation.search, value => (value === true ? null : value)); newLocation.search = mapValues(newLocation.search, (value) => (value === true ? null : value));
newLocation.search = qs.stringify(newLocation.search); newLocation.search = qs.stringify(newLocation.search);
} }
} }
if (replace) { if (replace) {
history.replace(newLocation); if (
newLocation.pathname !== location.path ||
newLocation.search !== qs.stringify(location.search) ||
newLocation.hash !== location.hash
) {
history.replace(newLocation);
}
} else { } else {
history.push(newLocation); history.push(newLocation);
} }

View File

@@ -17,7 +17,9 @@ const DYNAMIC_PREFIX = "d_";
* @param now {function(): moment.Moment=} moment - defaults to now * @param now {function(): moment.Moment=} moment - defaults to now
* @returns {function(withNow: boolean): [moment.Moment, moment.Moment|undefined]} * @returns {function(withNow: boolean): [moment.Moment, moment.Moment|undefined]}
*/ */
const untilNow = (from, now = () => moment()) => (withNow = true) => [from(), withNow ? now() : undefined]; const untilNow =
(from, now = () => moment()) =>
(withNow = true) => [from(), withNow ? now() : undefined];
const DYNAMIC_DATE_RANGES = { const DYNAMIC_DATE_RANGES = {
today: { today: {
@@ -26,14 +28,7 @@ const DYNAMIC_DATE_RANGES = {
}, },
yesterday: { yesterday: {
name: "Yesterday", name: "Yesterday",
value: () => [ value: () => [moment().subtract(1, "day").startOf("day"), moment().subtract(1, "day").endOf("day")],
moment()
.subtract(1, "day")
.startOf("day"),
moment()
.subtract(1, "day")
.endOf("day"),
],
}, },
this_week: { this_week: {
name: "This week", name: "This week",
@@ -49,36 +44,15 @@ const DYNAMIC_DATE_RANGES = {
}, },
last_week: { last_week: {
name: "Last week", name: "Last week",
value: () => [ value: () => [moment().subtract(1, "week").startOf("week"), moment().subtract(1, "week").endOf("week")],
moment()
.subtract(1, "week")
.startOf("week"),
moment()
.subtract(1, "week")
.endOf("week"),
],
}, },
last_month: { last_month: {
name: "Last month", name: "Last month",
value: () => [ value: () => [moment().subtract(1, "month").startOf("month"), moment().subtract(1, "month").endOf("month")],
moment()
.subtract(1, "month")
.startOf("month"),
moment()
.subtract(1, "month")
.endOf("month"),
],
}, },
last_year: { last_year: {
name: "Last year", name: "Last year",
value: () => [ value: () => [moment().subtract(1, "year").startOf("year"), moment().subtract(1, "year").endOf("year")],
moment()
.subtract(1, "year")
.startOf("year"),
moment()
.subtract(1, "year")
.endOf("year"),
],
}, },
last_hour: { last_hour: {
name: "Last hour", name: "Last hour",
@@ -94,63 +68,31 @@ const DYNAMIC_DATE_RANGES = {
}, },
last_7_days: { last_7_days: {
name: "Last 7 days", name: "Last 7 days",
value: untilNow( value: untilNow(() => moment().subtract(7, "days").startOf("day")),
() =>
moment()
.subtract(7, "days")
.startOf("day"),
() => moment().endOf("day")
),
}, },
last_14_days: { last_14_days: {
name: "Last 14 days", name: "Last 14 days",
value: untilNow( value: untilNow(() => moment().subtract(14, "days").startOf("day")),
() =>
moment()
.subtract(14, "days")
.startOf("day"),
() => moment().endOf("day")
),
}, },
last_30_days: { last_30_days: {
name: "Last 30 days", name: "Last 30 days",
value: untilNow( value: untilNow(() => moment().subtract(30, "days").startOf("day")),
() =>
moment()
.subtract(30, "days")
.startOf("day"),
() => moment().endOf("day")
),
}, },
last_60_days: { last_60_days: {
name: "Last 60 days", name: "Last 60 days",
value: untilNow( value: untilNow(() => moment().subtract(60, "days").startOf("day")),
() =>
moment()
.subtract(60, "days")
.startOf("day"),
() => moment().endOf("day")
),
}, },
last_90_days: { last_90_days: {
name: "Last 90 days", name: "Last 90 days",
value: untilNow( value: untilNow(() => moment().subtract(90, "days").startOf("day")),
() =>
moment()
.subtract(90, "days")
.startOf("day"),
() => moment().endOf("day")
),
}, },
last_12_months: { last_12_months: {
name: "Last 12 months", name: "Last 12 months",
value: untilNow( value: untilNow(() => moment().subtract(12, "months").startOf("day")),
() => },
moment() last_10_years: {
.subtract(12, "months") name: "Last 10 years",
.startOf("day"), value: untilNow(() => moment().subtract(10, "years").startOf("day")),
() => moment().endOf("day")
),
}, },
}; };
@@ -164,7 +106,7 @@ export function isDynamicDateRangeString(value) {
} }
export function getDynamicDateRangeStringFromName(dynamicRangeName) { export function getDynamicDateRangeStringFromName(dynamicRangeName) {
const key = findKey(DYNAMIC_DATE_RANGES, range => range.name === dynamicRangeName); const key = findKey(DYNAMIC_DATE_RANGES, (range) => range.name === dynamicRangeName);
return key ? DYNAMIC_PREFIX + key : undefined; return key ? DYNAMIC_PREFIX + key : undefined;
} }
@@ -233,7 +175,7 @@ class DateRangeParameter extends Parameter {
getExecutionValue() { getExecutionValue() {
if (this.hasDynamicValue) { if (this.hasDynamicValue) {
const format = date => date.format(DATETIME_FORMATS[this.type]); const format = (date) => date.format(DATETIME_FORMATS[this.type]);
const [start, end] = this.normalizedValue.value().map(format); const [start, end] = this.normalizedValue.value().map(format);
return { start, end }; return { start, end };
} }

View File

@@ -58,7 +58,7 @@ class Parameter {
updateLocals() { updateLocals() {
if (isArray(this.locals)) { if (isArray(this.locals)) {
each(this.locals, local => { each(this.locals, (local) => {
local.setValue(this.value); local.setValue(this.value);
}); });
} }
@@ -117,7 +117,7 @@ class Parameter {
/** Get a saveable version of the Parameter by omitting unnecessary props */ /** Get a saveable version of the Parameter by omitting unnecessary props */
toSaveableObject() { toSaveableObject() {
return omit(this, ["$$value", "urlPrefix", "pendingValue", "parentQueryId"]); return omit(this, ["$$value", "urlPrefix", "pendingValue", "parentQueryId", "locals"]);
} }
} }

View File

@@ -9,7 +9,7 @@ const logger = debug("redash:services:QueryResult");
const filterTypes = ["filter", "multi-filter", "multiFilter"]; const filterTypes = ["filter", "multi-filter", "multiFilter"];
function defer() { function defer() {
const result = { onStatusChange: status => {} }; const result = { onStatusChange: (status) => {} };
result.promise = new Promise((resolve, reject) => { result.promise = new Promise((resolve, reject) => {
result.resolve = resolve; result.resolve = resolve;
result.reject = reject; result.reject = reject;
@@ -40,13 +40,13 @@ function getColumnNameWithoutType(column) {
} }
function getColumnFriendlyName(column) { function getColumnFriendlyName(column) {
return getColumnNameWithoutType(column).replace(/(?:^|\s)\S/g, a => a.toUpperCase()); return getColumnNameWithoutType(column).replace(/(?:^|\s)\S/g, (a) => a.toUpperCase());
} }
const createOrSaveUrl = data => (data.id ? `api/query_results/${data.id}` : "api/query_results"); const createOrSaveUrl = (data) => (data.id ? `api/query_results/${data.id}` : "api/query_results");
const QueryResultResource = { const QueryResultResource = {
get: ({ id }) => axios.get(`api/query_results/${id}`), get: ({ id }) => axios.get(`api/query_results/${id}`),
post: data => axios.post(createOrSaveUrl(data), data), post: (data) => axios.post(createOrSaveUrl(data), data),
}; };
export const ExecutionStatus = { export const ExecutionStatus = {
@@ -97,11 +97,11 @@ function handleErrorResponse(queryResult, error) {
} }
function sleep(ms) { function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
export function fetchDataFromJob(jobId, interval = 1000) { export function fetchDataFromJob(jobId, interval = 1000) {
return axios.get(`api/jobs/${jobId}`).then(data => { return axios.get(`api/jobs/${jobId}`).then((data) => {
const status = statuses[data.job.status]; const status = statuses[data.job.status];
if (status === ExecutionStatus.WAITING || status === ExecutionStatus.PROCESSING) { if (status === ExecutionStatus.WAITING || status === ExecutionStatus.PROCESSING) {
return sleep(interval).then(() => fetchDataFromJob(data.job.id)); return sleep(interval).then(() => fetchDataFromJob(data.job.id));
@@ -146,7 +146,7 @@ class QueryResult {
// TODO: we should stop manipulating incoming data, and switch to relaying // TODO: we should stop manipulating incoming data, and switch to relaying
// on the column type set by the backend. This logic is prone to errors, // on the column type set by the backend. This logic is prone to errors,
// and better be removed. Kept for now, for backward compatability. // and better be removed. Kept for now, for backward compatability.
each(this.query_result.data.rows, row => { each(this.query_result.data.rows, (row) => {
forOwn(row, (v, k) => { forOwn(row, (v, k) => {
let newType = null; let newType = null;
if (isNumber(v)) { if (isNumber(v)) {
@@ -173,7 +173,7 @@ class QueryResult {
}); });
}); });
each(this.query_result.data.columns, column => { each(this.query_result.data.columns, (column) => {
column.name = "" + column.name; column.name = "" + column.name;
if (columnTypes[column.name]) { if (columnTypes[column.name]) {
if (column.type == null || column.type === "string") { if (column.type == null || column.type === "string") {
@@ -265,14 +265,14 @@ class QueryResult {
getColumnNames() { getColumnNames() {
if (this.columnNames === undefined && this.query_result.data) { if (this.columnNames === undefined && this.query_result.data) {
this.columnNames = this.query_result.data.columns.map(v => v.name); this.columnNames = this.query_result.data.columns.map((v) => v.name);
} }
return this.columnNames; return this.columnNames;
} }
getColumnFriendlyNames() { getColumnFriendlyNames() {
return this.getColumnNames().map(col => getColumnFriendlyName(col)); return this.getColumnNames().map((col) => getColumnFriendlyName(col));
} }
getTruncated() { getTruncated() {
@@ -286,7 +286,7 @@ class QueryResult {
const filters = []; const filters = [];
this.getColumns().forEach(col => { this.getColumns().forEach((col) => {
const name = col.name; const name = col.name;
const type = name.split("::")[1] || name.split("__")[1]; const type = name.split("::")[1] || name.split("__")[1];
if (includes(filterTypes, type)) { if (includes(filterTypes, type)) {
@@ -302,8 +302,8 @@ class QueryResult {
} }
}, this); }, this);
this.getRawData().forEach(row => { this.getRawData().forEach((row) => {
filters.forEach(filter => { filters.forEach((filter) => {
filter.values.push(row[filter.name]); filter.values.push(row[filter.name]);
if (filter.values.length === 1) { if (filter.values.length === 1) {
if (filter.multiple) { if (filter.multiple) {
@@ -315,8 +315,8 @@ class QueryResult {
}); });
}); });
filters.forEach(filter => { filters.forEach((filter) => {
filter.values = uniqBy(filter.values, v => { filter.values = uniqBy(filter.values, (v) => {
if (moment.isMoment(v)) { if (moment.isMoment(v)) {
return v.unix(); return v.unix();
} }
@@ -345,12 +345,12 @@ class QueryResult {
axios axios
.get(`api/queries/${queryId}/results/${id}.json`) .get(`api/queries/${queryId}/results/${id}.json`)
.then(response => { .then((response) => {
// Success handler // Success handler
queryResult.isLoadingResult = false; queryResult.isLoadingResult = false;
queryResult.update(response); queryResult.update(response);
}) })
.catch(error => { .catch((error) => {
// Error handler // Error handler
queryResult.isLoadingResult = false; queryResult.isLoadingResult = false;
handleErrorResponse(queryResult, error); handleErrorResponse(queryResult, error);
@@ -362,10 +362,10 @@ class QueryResult {
loadLatestCachedResult(queryId, parameters) { loadLatestCachedResult(queryId, parameters) {
axios axios
.post(`api/queries/${queryId}/results`, { queryId, parameters }) .post(`api/queries/${queryId}/results`, { queryId, parameters })
.then(response => { .then((response) => {
this.update(response); this.update(response);
}) })
.catch(error => { .catch((error) => {
handleErrorResponse(this, error); handleErrorResponse(this, error);
}); });
} }
@@ -375,11 +375,11 @@ class QueryResult {
this.deferred.onStatusChange(ExecutionStatus.LOADING_RESULT); this.deferred.onStatusChange(ExecutionStatus.LOADING_RESULT);
QueryResultResource.get({ id: this.job.query_result_id }) QueryResultResource.get({ id: this.job.query_result_id })
.then(response => { .then((response) => {
this.update(response); this.update(response);
this.isLoadingResult = false; this.isLoadingResult = false;
}) })
.catch(error => { .catch((error) => {
if (tryCount === undefined) { if (tryCount === undefined) {
tryCount = 0; tryCount = 0;
} }
@@ -394,9 +394,12 @@ class QueryResult {
}); });
this.isLoadingResult = false; this.isLoadingResult = false;
} else { } else {
setTimeout(() => { setTimeout(
this.loadResult(tryCount + 1); () => {
}, 1000 * Math.pow(2, tryCount)); this.loadResult(tryCount + 1);
},
1000 * Math.pow(2, tryCount)
);
} }
}); });
} }
@@ -410,19 +413,26 @@ class QueryResult {
: axios.get(`api/queries/${query}/jobs/${this.job.id}`); : axios.get(`api/queries/${query}/jobs/${this.job.id}`);
request request
.then(jobResponse => { .then((jobResponse) => {
this.update(jobResponse); this.update(jobResponse);
if (this.getStatus() === "processing" && this.job.query_result_id && this.job.query_result_id !== "None") { if (this.getStatus() === "processing" && this.job.query_result_id && this.job.query_result_id !== "None") {
loadResult(); loadResult();
} else if (this.getStatus() !== "failed") { } else if (this.getStatus() !== "failed") {
const waitTime = tryNumber > 10 ? 3000 : 500; let waitTime;
if (tryNumber <= 10) {
waitTime = 500;
} else if (tryNumber <= 50) {
waitTime = 1000;
} else {
waitTime = 3000;
}
setTimeout(() => { setTimeout(() => {
this.refreshStatus(query, parameters, tryNumber + 1); this.refreshStatus(query, parameters, tryNumber + 1);
}, waitTime); }, waitTime);
} }
}) })
.catch(error => { .catch((error) => {
logger("Connection error", error); logger("Connection error", error);
// TODO: use QueryResultError, or better yet: exception/reject of promise. // TODO: use QueryResultError, or better yet: exception/reject of promise.
this.update({ this.update({
@@ -451,14 +461,14 @@ class QueryResult {
axios axios
.post(`api/queries/${id}/results`, { id, parameters, apply_auto_limit: applyAutoLimit, max_age: maxAge }) .post(`api/queries/${id}/results`, { id, parameters, apply_auto_limit: applyAutoLimit, max_age: maxAge })
.then(response => { .then((response) => {
queryResult.update(response); queryResult.update(response);
if ("job" in response) { if ("job" in response) {
queryResult.refreshStatus(id, parameters); queryResult.refreshStatus(id, parameters);
} }
}) })
.catch(error => { .catch((error) => {
handleErrorResponse(queryResult, error); handleErrorResponse(queryResult, error);
}); });
@@ -481,14 +491,14 @@ class QueryResult {
} }
QueryResultResource.post(params) QueryResultResource.post(params)
.then(response => { .then((response) => {
queryResult.update(response); queryResult.update(response);
if ("job" in response) { if ("job" in response) {
queryResult.refreshStatus(query, parameters); queryResult.refreshStatus(query, parameters);
} }
}) })
.catch(error => { .catch((error) => {
handleErrorResponse(queryResult, error); handleErrorResponse(queryResult, error);
}); });

View File

@@ -63,7 +63,7 @@ function runCypressCI() {
CYPRESS_OPTIONS, // eslint-disable-line no-unused-vars CYPRESS_OPTIONS, // eslint-disable-line no-unused-vars
} = process.env; } = process.env;
if (GITHUB_REPOSITORY === "getredash/redash") { if (GITHUB_REPOSITORY === "getredash/redash" && process.env.CYPRESS_RECORD_KEY) {
process.env.CYPRESS_OPTIONS = "--record"; process.env.CYPRESS_OPTIONS = "--record";
} }

View File

@@ -23,7 +23,7 @@ describe("Dashboard", () => {
cy.getByTestId("DashboardSaveButton").click(); cy.getByTestId("DashboardSaveButton").click();
}); });
cy.wait("@NewDashboard").then(xhr => { cy.wait("@NewDashboard").then((xhr) => {
const id = Cypress._.get(xhr, "response.body.id"); const id = Cypress._.get(xhr, "response.body.id");
assert.isDefined(id, "Dashboard api call returns id"); assert.isDefined(id, "Dashboard api call returns id");
@@ -40,13 +40,9 @@ describe("Dashboard", () => {
cy.getByTestId("DashboardMoreButton").click(); cy.getByTestId("DashboardMoreButton").click();
cy.getByTestId("DashboardMoreButtonMenu") cy.getByTestId("DashboardMoreButtonMenu").contains("Archive").click();
.contains("Archive")
.click();
cy.get(".ant-modal .ant-btn") cy.get(".ant-modal .ant-btn").contains("Archive").click({ force: true });
.contains("Archive")
.click({ force: true });
cy.get(".label-tag-archived").should("exist"); cy.get(".label-tag-archived").should("exist");
cy.visit("/dashboards"); cy.visit("/dashboards");
@@ -60,7 +56,7 @@ describe("Dashboard", () => {
cy.server(); cy.server();
cy.route("GET", "**/api/dashboards/*").as("LoadDashboard"); cy.route("GET", "**/api/dashboards/*").as("LoadDashboard");
cy.createDashboard("Dashboard multiple urls").then(({ id, slug }) => { cy.createDashboard("Dashboard multiple urls").then(({ id, slug }) => {
[`/dashboards/${id}`, `/dashboards/${id}-anything-here`, `/dashboard/${slug}`].forEach(url => { [`/dashboards/${id}`, `/dashboards/${id}-anything-here`, `/dashboard/${slug}`].forEach((url) => {
cy.visit(url); cy.visit(url);
cy.wait("@LoadDashboard"); cy.wait("@LoadDashboard");
cy.getByTestId(`DashboardId${id}Container`).should("exist"); cy.getByTestId(`DashboardId${id}Container`).should("exist");
@@ -72,7 +68,7 @@ describe("Dashboard", () => {
}); });
context("viewport width is at 800px", () => { context("viewport width is at 800px", () => {
before(function() { before(function () {
cy.login(); cy.login();
cy.createDashboard("Foo Bar") cy.createDashboard("Foo Bar")
.then(({ id }) => { .then(({ id }) => {
@@ -80,49 +76,42 @@ describe("Dashboard", () => {
this.dashboardEditUrl = `/dashboards/${id}?edit`; this.dashboardEditUrl = `/dashboards/${id}?edit`;
return cy.addTextbox(id, "Hello World!").then(getWidgetTestId); return cy.addTextbox(id, "Hello World!").then(getWidgetTestId);
}) })
.then(elTestId => { .then((elTestId) => {
cy.visit(this.dashboardUrl); cy.visit(this.dashboardUrl);
cy.getByTestId(elTestId).as("textboxEl"); cy.getByTestId(elTestId).as("textboxEl");
}); });
}); });
beforeEach(function() { beforeEach(function () {
cy.login(); cy.login();
cy.visit(this.dashboardUrl); cy.visit(this.dashboardUrl);
cy.viewport(800 + menuWidth, 800); cy.viewport(800 + menuWidth, 800);
}); });
it("shows widgets with full width", () => { it("shows widgets with full width", () => {
cy.get("@textboxEl").should($el => { cy.get("@textboxEl").should(($el) => {
expect($el.width()).to.eq(770); expect($el.width()).to.eq(770);
}); });
cy.viewport(801 + menuWidth, 800); cy.viewport(801 + menuWidth, 800);
cy.get("@textboxEl").should($el => { cy.get("@textboxEl").should(($el) => {
expect($el.width()).to.eq(378); expect($el.width()).to.eq(182);
}); });
}); });
it("hides edit option", () => { it("hides edit option", () => {
cy.getByTestId("DashboardMoreButton") cy.getByTestId("DashboardMoreButton").click().should("be.visible");
.click()
.should("be.visible");
cy.getByTestId("DashboardMoreButtonMenu") cy.getByTestId("DashboardMoreButtonMenu").contains("Edit").as("editButton").should("not.be.visible");
.contains("Edit")
.as("editButton")
.should("not.be.visible");
cy.viewport(801 + menuWidth, 800); cy.viewport(801 + menuWidth, 800);
cy.get("@editButton").should("be.visible"); cy.get("@editButton").should("be.visible");
}); });
it("disables edit mode", function() { it("disables edit mode", function () {
cy.viewport(801 + menuWidth, 800); cy.viewport(801 + menuWidth, 800);
cy.visit(this.dashboardEditUrl); cy.visit(this.dashboardEditUrl);
cy.contains("button", "Done Editing") cy.contains("button", "Done Editing").as("saveButton").should("exist");
.as("saveButton")
.should("exist");
cy.viewport(800 + menuWidth, 800); cy.viewport(800 + menuWidth, 800);
cy.contains("button", "Done Editing").should("not.exist"); cy.contains("button", "Done Editing").should("not.exist");
@@ -130,14 +119,14 @@ describe("Dashboard", () => {
}); });
context("viewport width is at 767px", () => { context("viewport width is at 767px", () => {
before(function() { before(function () {
cy.login(); cy.login();
cy.createDashboard("Foo Bar").then(({ id }) => { cy.createDashboard("Foo Bar").then(({ id }) => {
this.dashboardUrl = `/dashboards/${id}`; this.dashboardUrl = `/dashboards/${id}`;
}); });
}); });
beforeEach(function() { beforeEach(function () {
cy.visit(this.dashboardUrl); cy.visit(this.dashboardUrl);
cy.viewport(767, 800); cy.viewport(767, 800);
}); });

View File

@@ -23,7 +23,7 @@ describe("Dashboard Filters", () => {
name: "Query Filters", name: "Query Filters",
query: `SELECT stage1 AS "stage1::filter", stage2, value FROM (${SQL}) q`, query: `SELECT stage1 AS "stage1::filter", stage2, value FROM (${SQL}) q`,
}; };
cy.createDashboard("Dashboard Filters").then(dashboard => { cy.createDashboard("Dashboard Filters").then((dashboard) => {
createQueryAndAddWidget(dashboard.id, queryData) createQueryAndAddWidget(dashboard.id, queryData)
.as("widget1TestId") .as("widget1TestId")
.then(() => createQueryAndAddWidget(dashboard.id, queryData, { position: { col: 4 } })) .then(() => createQueryAndAddWidget(dashboard.id, queryData, { position: { col: 4 } }))
@@ -32,26 +32,23 @@ describe("Dashboard Filters", () => {
}); });
}); });
it("filters rows in a Table Visualization", function() { it("filters rows in a Table Visualization", function () {
editDashboard(); editDashboard();
cy.getByTestId("DashboardFilters").should("not.exist"); cy.getByTestId("DashboardFilters").should("not.exist");
cy.getByTestId("DashboardFiltersCheckbox").click(); cy.getByTestId("DashboardFiltersCheckbox").click();
cy.getByTestId("DashboardFilters").within(() => { cy.getByTestId("DashboardFilters").within(() => {
cy.getByTestId("FilterName-stage1::filter") cy.getByTestId("FilterName-stage1::filter").find(".ant-select-selection-item").should("have.text", "a");
.find(".ant-select-selection-item")
.should("have.text", "a");
}); });
cy.getByTestId(this.widget1TestId).within(() => { cy.getByTestId(this.widget1TestId).within(() => {
expectTableToHaveLength(4); expectTableToHaveLength(4);
expectFirstColumnToHaveMembers(["a", "a", "a", "a"]); expectFirstColumnToHaveMembers(["a", "a", "a", "a"]);
cy.getByTestId("FilterName-stage1::filter") cy.getByTestId("FilterName-stage1::filter").find(".ant-select").click();
.find(".ant-select")
.click();
}); });
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.contains(".ant-select-item-option-content:visible", "b").click(); cy.contains(".ant-select-item-option-content:visible", "b").click();
cy.getByTestId(this.widget1TestId).within(() => { cy.getByTestId(this.widget1TestId).within(() => {
@@ -69,14 +66,13 @@ describe("Dashboard Filters", () => {
// assert that changing a global filter affects all widgets // assert that changing a global filter affects all widgets
cy.getByTestId("DashboardFilters").within(() => { cy.getByTestId("DashboardFilters").within(() => {
cy.getByTestId("FilterName-stage1::filter") cy.getByTestId("FilterName-stage1::filter").find(".ant-select").click();
.find(".ant-select")
.click();
}); });
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.contains(".ant-select-item-option-content:visible", "c").click(); cy.contains(".ant-select-item-option-content:visible", "c").click();
[this.widget1TestId, this.widget2TestId].forEach(widgetTestId => [this.widget1TestId, this.widget2TestId].forEach((widgetTestId) =>
cy.getByTestId(widgetTestId).within(() => { cy.getByTestId(widgetTestId).within(() => {
expectTableToHaveLength(4); expectTableToHaveLength(4);
expectFirstColumnToHaveMembers(["c", "c", "c", "c"]); expectFirstColumnToHaveMembers(["c", "c", "c", "c"]);

View File

@@ -5,7 +5,7 @@ import { getWidgetTestId, editDashboard, resizeBy } from "../../support/dashboar
const menuWidth = 80; const menuWidth = 80;
describe("Grid compliant widgets", () => { describe("Grid compliant widgets", () => {
beforeEach(function() { beforeEach(function () {
cy.login(); cy.login();
cy.viewport(1215 + menuWidth, 800); cy.viewport(1215 + menuWidth, 800);
cy.createDashboard("Foo Bar") cy.createDashboard("Foo Bar")
@@ -13,7 +13,7 @@ describe("Grid compliant widgets", () => {
this.dashboardUrl = `/dashboards/${id}`; this.dashboardUrl = `/dashboards/${id}`;
return cy.addTextbox(id, "Hello World!").then(getWidgetTestId); return cy.addTextbox(id, "Hello World!").then(getWidgetTestId);
}) })
.then(elTestId => { .then((elTestId) => {
cy.visit(this.dashboardUrl); cy.visit(this.dashboardUrl);
cy.getByTestId(elTestId).as("textboxEl"); cy.getByTestId(elTestId).as("textboxEl");
}); });
@@ -27,7 +27,7 @@ describe("Grid compliant widgets", () => {
it("stays put when dragged under snap threshold", () => { it("stays put when dragged under snap threshold", () => {
cy.get("@textboxEl") cy.get("@textboxEl")
.dragBy(90) .dragBy(30)
.invoke("offset") .invoke("offset")
.should("have.property", "left", 15 + menuWidth); // no change, 15 -> 15 .should("have.property", "left", 15 + menuWidth); // no change, 15 -> 15
}); });
@@ -36,14 +36,14 @@ describe("Grid compliant widgets", () => {
cy.get("@textboxEl") cy.get("@textboxEl")
.dragBy(110) .dragBy(110)
.invoke("offset") .invoke("offset")
.should("have.property", "left", 215 + menuWidth); // moved by 200, 15 -> 215 .should("have.property", "left", 115 + menuWidth); // moved by 100, 15 -> 115
}); });
it("moves two columns when dragged over snap threshold", () => { it("moves two columns when dragged over snap threshold", () => {
cy.get("@textboxEl") cy.get("@textboxEl")
.dragBy(330) .dragBy(200)
.invoke("offset") .invoke("offset")
.should("have.property", "left", 415 + menuWidth); // moved by 400, 15 -> 415 .should("have.property", "left", 215 + menuWidth); // moved by 200, 15 -> 215
}); });
}); });
@@ -52,7 +52,7 @@ describe("Grid compliant widgets", () => {
cy.route("POST", "**/api/widgets/*").as("WidgetSave"); cy.route("POST", "**/api/widgets/*").as("WidgetSave");
editDashboard(); editDashboard();
cy.get("@textboxEl").dragBy(330); cy.get("@textboxEl").dragBy(100);
cy.wait("@WidgetSave"); cy.wait("@WidgetSave");
}); });
}); });
@@ -64,24 +64,24 @@ describe("Grid compliant widgets", () => {
}); });
it("stays put when dragged under snap threshold", () => { it("stays put when dragged under snap threshold", () => {
resizeBy(cy.get("@textboxEl"), 90) resizeBy(cy.get("@textboxEl"), 30)
.then(() => cy.get("@textboxEl")) .then(() => cy.get("@textboxEl"))
.invoke("width") .invoke("width")
.should("eq", 585); // no change, 585 -> 585 .should("eq", 285); // no change, 285 -> 285
}); });
it("moves one column when dragged over snap threshold", () => { it("moves one column when dragged over snap threshold", () => {
resizeBy(cy.get("@textboxEl"), 110) resizeBy(cy.get("@textboxEl"), 110)
.then(() => cy.get("@textboxEl")) .then(() => cy.get("@textboxEl"))
.invoke("width") .invoke("width")
.should("eq", 785); // resized by 200, 585 -> 785 .should("eq", 385); // resized by 200, 185 -> 385
}); });
it("moves two columns when dragged over snap threshold", () => { it("moves two columns when dragged over snap threshold", () => {
resizeBy(cy.get("@textboxEl"), 400) resizeBy(cy.get("@textboxEl"), 400)
.then(() => cy.get("@textboxEl")) .then(() => cy.get("@textboxEl"))
.invoke("width") .invoke("width")
.should("eq", 985); // resized by 400, 585 -> 985 .should("eq", 685); // resized by 400, 285 -> 685
}); });
}); });
@@ -101,16 +101,16 @@ describe("Grid compliant widgets", () => {
resizeBy(cy.get("@textboxEl"), 0, 30) resizeBy(cy.get("@textboxEl"), 0, 30)
.then(() => cy.get("@textboxEl")) .then(() => cy.get("@textboxEl"))
.invoke("height") .invoke("height")
.should("eq", 185); // resized by 50, , 135 -> 185 .should("eq", 185);
}); });
it("shrinks to minimum", () => { it("shrinks to minimum", () => {
cy.get("@textboxEl") cy.get("@textboxEl")
.then($el => resizeBy(cy.get("@textboxEl"), -$el.width(), -$el.height())) // resize to 0,0 .then(($el) => resizeBy(cy.get("@textboxEl"), -$el.width(), -$el.height())) // resize to 0,0
.then(() => cy.get("@textboxEl")) .then(() => cy.get("@textboxEl"))
.should($el => { .should(($el) => {
expect($el.width()).to.eq(185); // min textbox width expect($el.width()).to.eq(185); // min textbox width
expect($el.height()).to.eq(35); // min textbox height expect($el.height()).to.eq(85); // min textbox height
}); });
}); });
}); });

View File

@@ -3,7 +3,7 @@
import { getWidgetTestId, editDashboard } from "../../support/dashboard"; import { getWidgetTestId, editDashboard } from "../../support/dashboard";
describe("Textbox", () => { describe("Textbox", () => {
beforeEach(function() { beforeEach(function () {
cy.login(); cy.login();
cy.createDashboard("Foo Bar").then(({ id }) => { cy.createDashboard("Foo Bar").then(({ id }) => {
this.dashboardId = id; this.dashboardId = id;
@@ -12,12 +12,10 @@ describe("Textbox", () => {
}); });
const confirmDeletionInModal = () => { const confirmDeletionInModal = () => {
cy.get(".ant-modal .ant-btn") cy.get(".ant-modal .ant-btn").contains("Delete").click({ force: true });
.contains("Delete")
.click({ force: true });
}; };
it("adds textbox", function() { it("adds textbox", function () {
cy.visit(this.dashboardUrl); cy.visit(this.dashboardUrl);
editDashboard(); editDashboard();
cy.getByTestId("AddTextboxButton").click(); cy.getByTestId("AddTextboxButton").click();
@@ -29,10 +27,10 @@ describe("Textbox", () => {
cy.get(".widget-text").should("exist"); cy.get(".widget-text").should("exist");
}); });
it("removes textbox by X button", function() { it("removes textbox by X button", function () {
cy.addTextbox(this.dashboardId, "Hello World!") cy.addTextbox(this.dashboardId, "Hello World!")
.then(getWidgetTestId) .then(getWidgetTestId)
.then(elTestId => { .then((elTestId) => {
cy.visit(this.dashboardUrl); cy.visit(this.dashboardUrl);
editDashboard(); editDashboard();
@@ -45,32 +43,30 @@ describe("Textbox", () => {
}); });
}); });
it("removes textbox by menu", function() { it("removes textbox by menu", function () {
cy.addTextbox(this.dashboardId, "Hello World!") cy.addTextbox(this.dashboardId, "Hello World!")
.then(getWidgetTestId) .then(getWidgetTestId)
.then(elTestId => { .then((elTestId) => {
cy.visit(this.dashboardUrl); cy.visit(this.dashboardUrl);
cy.getByTestId(elTestId).within(() => { cy.getByTestId(elTestId).within(() => {
cy.getByTestId("WidgetDropdownButton").click(); cy.getByTestId("WidgetDropdownButton").click();
}); });
cy.getByTestId("WidgetDropdownButtonMenu") cy.getByTestId("WidgetDropdownButtonMenu").contains("Remove from Dashboard").click();
.contains("Remove from Dashboard")
.click();
confirmDeletionInModal(); confirmDeletionInModal();
cy.getByTestId(elTestId).should("not.exist"); cy.getByTestId(elTestId).should("not.exist");
}); });
}); });
it("allows opening menu after removal", function() { it("allows opening menu after removal", function () {
let elTestId1; let elTestId1;
cy.addTextbox(this.dashboardId, "txb 1") cy.addTextbox(this.dashboardId, "txb 1")
.then(getWidgetTestId) .then(getWidgetTestId)
.then(elTestId => { .then((elTestId) => {
elTestId1 = elTestId; elTestId1 = elTestId;
return cy.addTextbox(this.dashboardId, "txb 2").then(getWidgetTestId); return cy.addTextbox(this.dashboardId, "txb 2").then(getWidgetTestId);
}) })
.then(elTestId2 => { .then((elTestId2) => {
cy.visit(this.dashboardUrl); cy.visit(this.dashboardUrl);
editDashboard(); editDashboard();
@@ -97,10 +93,10 @@ describe("Textbox", () => {
}); });
}); });
it("edits textbox", function() { it("edits textbox", function () {
cy.addTextbox(this.dashboardId, "Hello World!") cy.addTextbox(this.dashboardId, "Hello World!")
.then(getWidgetTestId) .then(getWidgetTestId)
.then(elTestId => { .then((elTestId) => {
cy.visit(this.dashboardUrl); cy.visit(this.dashboardUrl);
cy.getByTestId(elTestId) cy.getByTestId(elTestId)
.as("textboxEl") .as("textboxEl")
@@ -108,17 +104,13 @@ describe("Textbox", () => {
cy.getByTestId("WidgetDropdownButton").click(); cy.getByTestId("WidgetDropdownButton").click();
}); });
cy.getByTestId("WidgetDropdownButtonMenu") cy.getByTestId("WidgetDropdownButtonMenu").contains("Edit").click();
.contains("Edit")
.click();
const newContent = "[edited]"; const newContent = "[edited]";
cy.getByTestId("TextboxDialog") cy.getByTestId("TextboxDialog")
.should("exist") .should("exist")
.within(() => { .within(() => {
cy.get("textarea") cy.get("textarea").clear().type(newContent);
.clear()
.type(newContent);
cy.contains("button", "Save").click(); cy.contains("button", "Save").click();
}); });
@@ -126,7 +118,7 @@ describe("Textbox", () => {
}); });
}); });
it("renders textbox according to position configuration", function() { it("renders textbox according to position configuration", function () {
const id = this.dashboardId; const id = this.dashboardId;
const txb1Pos = { col: 0, row: 0, sizeX: 3, sizeY: 2 }; const txb1Pos = { col: 0, row: 0, sizeX: 3, sizeY: 2 };
const txb2Pos = { col: 1, row: 1, sizeX: 3, sizeY: 4 }; const txb2Pos = { col: 1, row: 1, sizeX: 3, sizeY: 4 };
@@ -135,15 +127,15 @@ describe("Textbox", () => {
cy.addTextbox(id, "x", { position: txb1Pos }) cy.addTextbox(id, "x", { position: txb1Pos })
.then(() => cy.addTextbox(id, "x", { position: txb2Pos })) .then(() => cy.addTextbox(id, "x", { position: txb2Pos }))
.then(getWidgetTestId) .then(getWidgetTestId)
.then(elTestId => { .then((elTestId) => {
cy.visit(this.dashboardUrl); cy.visit(this.dashboardUrl);
return cy.getByTestId(elTestId); return cy.getByTestId(elTestId);
}) })
.should($el => { .should(($el) => {
const { top, left } = $el.offset(); const { top, left } = $el.offset();
expect(top).to.be.oneOf([162, 162.015625]); expect(top).to.be.oneOf([162, 162.015625]);
expect(left).to.eq(282); expect(left).to.eq(188);
expect($el.width()).to.eq(545); expect($el.width()).to.eq(265);
expect($el.height()).to.eq(185); expect($el.height()).to.eq(185);
}); });
}); });

View File

@@ -5,8 +5,9 @@ describe("Embedded Queries", () => {
}); });
it("is unavailable when public urls feature is disabled", () => { it("is unavailable when public urls feature is disabled", () => {
cy.createQuery({ query: "select name from users order by name" }).then(query => { cy.createQuery({ query: "select name from users order by name" }).then((query) => {
cy.visit(`/queries/${query.id}/source`); cy.visit(`/queries/${query.id}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click(); cy.getByTestId("ExecuteButton").click();
cy.getByTestId("QueryPageVisualizationTabs", { timeout: 10000 }).should("exist"); cy.getByTestId("QueryPageVisualizationTabs", { timeout: 10000 }).should("exist");
cy.clickThrough(` cy.clickThrough(`
@@ -15,7 +16,7 @@ describe("Embedded Queries", () => {
`); `);
cy.getByTestId("EmbedIframe") cy.getByTestId("EmbedIframe")
.invoke("text") .invoke("text")
.then(embedUrl => { .then((embedUrl) => {
// disable the feature // disable the feature
cy.updateOrgSettings({ disable_public_urls: true }); cy.updateOrgSettings({ disable_public_urls: true });
@@ -23,9 +24,7 @@ describe("Embedded Queries", () => {
cy.visit(`/queries/${query.id}/source`); cy.visit(`/queries/${query.id}/source`);
cy.getByTestId("QueryPageVisualizationTabs", { timeout: 10000 }).should("exist"); cy.getByTestId("QueryPageVisualizationTabs", { timeout: 10000 }).should("exist");
cy.getByTestId("QueryPageHeaderMoreButton").click(); cy.getByTestId("QueryPageHeaderMoreButton").click();
cy.get(".ant-dropdown-menu-item") cy.get(".ant-dropdown-menu-item").should("exist").should("not.contain", "Show API Key");
.should("exist")
.should("not.contain", "Show API Key");
cy.getByTestId("QueryControlDropdownButton").click(); cy.getByTestId("QueryControlDropdownButton").click();
cy.get(".ant-dropdown-menu-item").should("exist"); cy.get(".ant-dropdown-menu-item").should("exist");
cy.getByTestId("ShowEmbedDialogButton").should("not.exist"); cy.getByTestId("ShowEmbedDialogButton").should("not.exist");
@@ -42,8 +41,9 @@ describe("Embedded Queries", () => {
}); });
it("can be shared without parameters", () => { it("can be shared without parameters", () => {
cy.createQuery({ query: "select name from users order by name" }).then(query => { cy.createQuery({ query: "select name from users order by name" }).then((query) => {
cy.visit(`/queries/${query.id}/source`); cy.visit(`/queries/${query.id}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click(); cy.getByTestId("ExecuteButton").click();
cy.getByTestId("QueryPageVisualizationTabs", { timeout: 10000 }).should("exist"); cy.getByTestId("QueryPageVisualizationTabs", { timeout: 10000 }).should("exist");
cy.clickThrough(` cy.clickThrough(`
@@ -52,7 +52,7 @@ describe("Embedded Queries", () => {
`); `);
cy.getByTestId("EmbedIframe") cy.getByTestId("EmbedIframe")
.invoke("text") .invoke("text")
.then(embedUrl => { .then((embedUrl) => {
cy.logout(); cy.logout();
cy.visit(embedUrl); cy.visit(embedUrl);
cy.getByTestId("VisualizationEmbed", { timeout: 10000 }).should("exist"); cy.getByTestId("VisualizationEmbed", { timeout: 10000 }).should("exist");
@@ -90,7 +90,7 @@ describe("Embedded Queries", () => {
cy.getByTestId("EmbedIframe") cy.getByTestId("EmbedIframe")
.invoke("text") .invoke("text")
.then(embedUrl => { .then((embedUrl) => {
cy.logout(); cy.logout();
cy.visit(embedUrl); cy.visit(embedUrl);
cy.getByTestId("VisualizationEmbed", { timeout: 10000 }).should("exist"); cy.getByTestId("VisualizationEmbed", { timeout: 10000 }).should("exist");

View File

@@ -44,6 +44,7 @@ describe("Box Plot", () => {
.then(({ id }) => cy.createVisualization(id, "BOXPLOT", "Boxplot (Deprecated)", {})) .then(({ id }) => cy.createVisualization(id, "BOXPLOT", "Boxplot (Deprecated)", {}))
.then(({ id: visualizationId, query_id: queryId }) => { .then(({ id: visualizationId, query_id: queryId }) => {
cy.visit(`queries/${queryId}/source#${visualizationId}`); cy.visit(`queries/${queryId}/source#${visualizationId}`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click(); cy.getByTestId("ExecuteButton").click();
}); });
}); });
@@ -61,9 +62,7 @@ describe("Box Plot", () => {
// Wait for proper initialization of visualization // Wait for proper initialization of visualization
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find("svg").should("exist");
.find("svg")
.should("exist");
cy.percySnapshot("Visualizations - Box Plot", { widths: [viewportWidth] }); cy.percySnapshot("Visualizations - Box Plot", { widths: [viewportWidth] });
}); });

View File

@@ -31,6 +31,7 @@ describe("Chart", () => {
it("creates Bar charts", function () { it("creates Bar charts", function () {
cy.visit(`queries/${this.queryId}/source`); cy.visit(`queries/${this.queryId}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click(); cy.getByTestId("ExecuteButton").click();
const getBarChartAssertionFunction = const getBarChartAssertionFunction =
@@ -109,6 +110,7 @@ describe("Chart", () => {
}); });
it("colors Bar charts", function () { it("colors Bar charts", function () {
cy.visit(`queries/${this.queryId}/source`); cy.visit(`queries/${this.queryId}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click(); cy.getByTestId("ExecuteButton").click();
cy.getByTestId("NewVisualization").click(); cy.getByTestId("NewVisualization").click();
cy.getByTestId("Chart.ColumnMapping.x").selectAntdOption("Chart.ColumnMapping.x.stage"); cy.getByTestId("Chart.ColumnMapping.x").selectAntdOption("Chart.ColumnMapping.x.stage");
@@ -123,6 +125,7 @@ describe("Chart", () => {
}); });
it("colors Pie charts", function () { it("colors Pie charts", function () {
cy.visit(`queries/${this.queryId}/source`); cy.visit(`queries/${this.queryId}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click(); cy.getByTestId("ExecuteButton").click();
cy.getByTestId("NewVisualization").click(); cy.getByTestId("NewVisualization").click();
cy.getByTestId("Chart.GlobalSeriesType").click(); cy.getByTestId("Chart.GlobalSeriesType").click();

View File

@@ -34,6 +34,7 @@ describe("Choropleth", () => {
cy.login(); cy.login();
cy.createQuery({ query: SQL }).then(({ id }) => { cy.createQuery({ query: SQL }).then(({ id }) => {
cy.visit(`queries/${id}/source`); cy.visit(`queries/${id}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click(); cy.getByTestId("ExecuteButton").click();
}); });
cy.getByTestId("NewVisualization").click(); cy.getByTestId("NewVisualization").click();
@@ -76,9 +77,7 @@ describe("Choropleth", () => {
// Wait for proper initialization of visualization // Wait for proper initialization of visualization
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find(".map-visualization-container.leaflet-container").should("exist");
.find(".map-visualization-container.leaflet-container")
.should("exist");
cy.percySnapshot("Visualizations - Choropleth", { widths: [viewportWidth] }); cy.percySnapshot("Visualizations - Choropleth", { widths: [viewportWidth] });
}); });

View File

@@ -24,6 +24,7 @@ describe("Cohort", () => {
cy.login(); cy.login();
cy.createQuery({ query: SQL }).then(({ id }) => { cy.createQuery({ query: SQL }).then(({ id }) => {
cy.visit(`queries/${id}/source`); cy.visit(`queries/${id}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click(); cy.getByTestId("ExecuteButton").click();
}); });
cy.getByTestId("NewVisualization").click(); cy.getByTestId("NewVisualization").click();
@@ -51,9 +52,7 @@ describe("Cohort", () => {
// Wait for proper initialization of visualization // Wait for proper initialization of visualization
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find("table").should("exist");
.find("table")
.should("exist");
cy.percySnapshot("Visualizations - Cohort (simple)", { widths: [viewportWidth] }); cy.percySnapshot("Visualizations - Cohort (simple)", { widths: [viewportWidth] });
cy.clickThrough(` cy.clickThrough(`
@@ -64,9 +63,7 @@ describe("Cohort", () => {
// Wait for proper initialization of visualization // Wait for proper initialization of visualization
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find("table").should("exist");
.find("table")
.should("exist");
cy.percySnapshot("Visualizations - Cohort (diagonal)", { widths: [viewportWidth] }); cy.percySnapshot("Visualizations - Cohort (diagonal)", { widths: [viewportWidth] });
}); });
}); });

View File

@@ -12,6 +12,7 @@ describe("Counter", () => {
cy.login(); cy.login();
cy.createQuery({ query: SQL }).then(({ id }) => { cy.createQuery({ query: SQL }).then(({ id }) => {
cy.visit(`queries/${id}/source`); cy.visit(`queries/${id}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click(); cy.getByTestId("ExecuteButton").click();
}); });
cy.getByTestId("NewVisualization").click(); cy.getByTestId("NewVisualization").click();
@@ -24,9 +25,7 @@ describe("Counter", () => {
Counter.General.ValueColumn.a Counter.General.ValueColumn.a
`); `);
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find(".counter-visualization-container").should("exist");
.find(".counter-visualization-container")
.should("exist");
// wait a bit before taking snapshot // wait a bit before taking snapshot
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
@@ -43,9 +42,7 @@ describe("Counter", () => {
"Counter.General.Label": "Custom Label", "Counter.General.Label": "Custom Label",
}); });
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find(".counter-visualization-container").should("exist");
.find(".counter-visualization-container")
.should("exist");
// wait a bit before taking snapshot // wait a bit before taking snapshot
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
@@ -65,9 +62,7 @@ describe("Counter", () => {
"Counter.General.TargetValueRowNumber": "2", "Counter.General.TargetValueRowNumber": "2",
}); });
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find(".counter-visualization-container").should("exist");
.find(".counter-visualization-container")
.should("exist");
// wait a bit before taking snapshot // wait a bit before taking snapshot
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
@@ -83,9 +78,7 @@ describe("Counter", () => {
Counter.General.TargetValueColumn.b Counter.General.TargetValueColumn.b
`); `);
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find(".counter-visualization-container").should("exist");
.find(".counter-visualization-container")
.should("exist");
// wait a bit before taking snapshot // wait a bit before taking snapshot
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
@@ -106,9 +99,7 @@ describe("Counter", () => {
"Counter.General.TargetValueRowNumber": "2", "Counter.General.TargetValueRowNumber": "2",
}); });
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find(".counter-visualization-container").should("exist");
.find(".counter-visualization-container")
.should("exist");
// wait a bit before taking snapshot // wait a bit before taking snapshot
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
@@ -123,9 +114,7 @@ describe("Counter", () => {
Counter.General.CountRows Counter.General.CountRows
`); `);
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find(".counter-visualization-container").should("exist");
.find(".counter-visualization-container")
.should("exist");
// wait a bit before taking snapshot // wait a bit before taking snapshot
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
@@ -151,9 +140,7 @@ describe("Counter", () => {
"Counter.Formatting.StringSuffix": "%", "Counter.Formatting.StringSuffix": "%",
}); });
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find(".counter-visualization-container").should("exist");
.find(".counter-visualization-container")
.should("exist");
// wait a bit before taking snapshot // wait a bit before taking snapshot
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
@@ -180,9 +167,7 @@ describe("Counter", () => {
"Counter.Formatting.StringSuffix": "%", "Counter.Formatting.StringSuffix": "%",
}); });
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find(".counter-visualization-container").should("exist");
.find(".counter-visualization-container")
.should("exist");
// wait a bit before taking snapshot // wait a bit before taking snapshot
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting

View File

@@ -5,34 +5,25 @@ describe("Edit visualization dialog", () => {
cy.login(); cy.login();
cy.createQuery().then(({ id }) => { cy.createQuery().then(({ id }) => {
cy.visit(`queries/${id}/source`); cy.visit(`queries/${id}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click(); cy.getByTestId("ExecuteButton").click();
}); });
}); });
it("opens New Visualization dialog", () => { it("opens New Visualization dialog", () => {
cy.getByTestId("NewVisualization") cy.getByTestId("NewVisualization").should("exist").click();
.should("exist")
.click();
cy.getByTestId("EditVisualizationDialog").should("exist"); cy.getByTestId("EditVisualizationDialog").should("exist");
// Default visualization should be selected // Default visualization should be selected
cy.getByTestId("VisualizationType") cy.getByTestId("VisualizationType").should("exist").should("contain", "Chart");
.should("exist") cy.getByTestId("VisualizationName").should("exist").should("have.value", "Chart");
.should("contain", "Chart");
cy.getByTestId("VisualizationName")
.should("exist")
.should("have.value", "Chart");
}); });
it("opens Edit Visualization dialog", () => { it("opens Edit Visualization dialog", () => {
cy.getByTestId("EditVisualization").click(); cy.getByTestId("EditVisualization").click();
cy.getByTestId("EditVisualizationDialog").should("exist"); cy.getByTestId("EditVisualizationDialog").should("exist");
// Default `Table` visualization should be selected // Default `Table` visualization should be selected
cy.getByTestId("VisualizationType") cy.getByTestId("VisualizationType").should("exist").should("contain", "Table");
.should("exist") cy.getByTestId("VisualizationName").should("exist").should("have.value", "Table");
.should("contain", "Table");
cy.getByTestId("VisualizationName")
.should("exist")
.should("have.value", "Table");
}); });
it("creates visualization with custom name", () => { it("creates visualization with custom name", () => {
@@ -44,15 +35,9 @@ describe("Edit visualization dialog", () => {
VisualizationType.TABLE VisualizationType.TABLE
`); `);
cy.getByTestId("VisualizationName") cy.getByTestId("VisualizationName").clear().type(visualizationName);
.clear()
.type(visualizationName);
cy.getByTestId("EditVisualizationDialog") cy.getByTestId("EditVisualizationDialog").contains("button", "Save").click();
.contains("button", "Save") cy.getByTestId("QueryPageVisualizationTabs").contains("span", visualizationName).should("exist");
.click();
cy.getByTestId("QueryPageVisualizationTabs")
.contains("span", visualizationName)
.should("exist");
}); });
}); });

View File

@@ -25,6 +25,7 @@ describe("Funnel", () => {
cy.login(); cy.login();
cy.createQuery({ query: SQL }).then(({ id }) => { cy.createQuery({ query: SQL }).then(({ id }) => {
cy.visit(`queries/${id}/source`); cy.visit(`queries/${id}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click(); cy.getByTestId("ExecuteButton").click();
}); });
}); });
@@ -59,9 +60,7 @@ describe("Funnel", () => {
// Wait for proper initialization of visualization // Wait for proper initialization of visualization
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find("table").should("exist");
.find("table")
.should("exist");
cy.percySnapshot("Visualizations - Funnel (basic)", { widths: [viewportWidth] }); cy.percySnapshot("Visualizations - Funnel (basic)", { widths: [viewportWidth] });
cy.clickThrough(` cy.clickThrough(`
@@ -81,9 +80,7 @@ describe("Funnel", () => {
// Wait for proper initialization of visualization // Wait for proper initialization of visualization
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find("table").should("exist");
.find("table")
.should("exist");
cy.percySnapshot("Visualizations - Funnel (extra options)", { widths: [viewportWidth] }); cy.percySnapshot("Visualizations - Funnel (extra options)", { widths: [viewportWidth] });
}); });
}); });

View File

@@ -24,6 +24,7 @@ describe("Map (Markers)", () => {
.then(({ id }) => cy.createVisualization(id, "MAP", "Map (Markers)", { mapTileUrl })) .then(({ id }) => cy.createVisualization(id, "MAP", "Map (Markers)", { mapTileUrl }))
.then(({ id: visualizationId, query_id: queryId }) => { .then(({ id: visualizationId, query_id: queryId }) => {
cy.visit(`queries/${queryId}/source#${visualizationId}`); cy.visit(`queries/${queryId}/source#${visualizationId}`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click(); cy.getByTestId("ExecuteButton").click();
}); });
}); });
@@ -51,9 +52,7 @@ describe("Map (Markers)", () => {
cy.fillInputs({ "ColorPicker.CustomColor": "blue{enter}" }); cy.fillInputs({ "ColorPicker.CustomColor": "blue{enter}" });
cy.getByTestId("ColorPicker.CustomColor").should("not.be.visible"); cy.getByTestId("ColorPicker.CustomColor").should("not.be.visible");
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find(".leaflet-control-zoom-in").click();
.find(".leaflet-control-zoom-in")
.click();
// Wait for proper initialization of visualization // Wait for proper initialization of visualization
cy.wait(1000); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(1000); // eslint-disable-line cypress/no-unnecessary-waiting
@@ -85,9 +84,7 @@ describe("Map (Markers)", () => {
cy.fillInputs({ "ColorPicker.CustomColor": "maroon{enter}" }); cy.fillInputs({ "ColorPicker.CustomColor": "maroon{enter}" });
cy.getByTestId("ColorPicker.CustomColor").should("not.be.visible"); cy.getByTestId("ColorPicker.CustomColor").should("not.be.visible");
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find(".leaflet-control-zoom-in").click();
.find(".leaflet-control-zoom-in")
.click();
// Wait for proper initialization of visualization // Wait for proper initialization of visualization
cy.wait(1000); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(1000); // eslint-disable-line cypress/no-unnecessary-waiting

View File

@@ -19,9 +19,7 @@ const SQL = `
function createPivotThroughUI(visualizationName, options = {}) { function createPivotThroughUI(visualizationName, options = {}) {
cy.getByTestId("NewVisualization").click(); cy.getByTestId("NewVisualization").click();
cy.getByTestId("VisualizationType").selectAntdOption("VisualizationType.PIVOT"); cy.getByTestId("VisualizationType").selectAntdOption("VisualizationType.PIVOT");
cy.getByTestId("VisualizationName") cy.getByTestId("VisualizationName").clear().type(visualizationName);
.clear()
.type(visualizationName);
if (options.hideControls) { if (options.hideControls) {
cy.getByTestId("PivotEditor.HideControls").click(); cy.getByTestId("PivotEditor.HideControls").click();
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview")
@@ -29,36 +27,30 @@ function createPivotThroughUI(visualizationName, options = {}) {
.find(".pvtAxisContainer, .pvtRenderer, .pvtVals") .find(".pvtAxisContainer, .pvtRenderer, .pvtVals")
.should("be.not.visible"); .should("be.not.visible");
} }
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find("table").should("exist");
.find("table") cy.getByTestId("EditVisualizationDialog").contains("button", "Save").click();
.should("exist");
cy.getByTestId("EditVisualizationDialog")
.contains("button", "Save")
.click();
} }
describe("Pivot", () => { describe("Pivot", () => {
beforeEach(() => { beforeEach(() => {
cy.login(); cy.login();
cy.createQuery({ name: "Pivot Visualization", query: SQL }) cy.createQuery({ name: "Pivot Visualization", query: SQL }).its("id").as("queryId");
.its("id")
.as("queryId");
}); });
it("creates Pivot with controls", function() { it("creates Pivot with controls", function () {
cy.visit(`queries/${this.queryId}/source`); cy.visit(`queries/${this.queryId}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click(); cy.getByTestId("ExecuteButton").click();
const visualizationName = "Pivot"; const visualizationName = "Pivot";
createPivotThroughUI(visualizationName); createPivotThroughUI(visualizationName);
cy.getByTestId("QueryPageVisualizationTabs") cy.getByTestId("QueryPageVisualizationTabs").contains("span", visualizationName).should("exist");
.contains("span", visualizationName)
.should("exist");
}); });
it("creates Pivot without controls", function() { it("creates Pivot without controls", function () {
cy.visit(`queries/${this.queryId}/source`); cy.visit(`queries/${this.queryId}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click(); cy.getByTestId("ExecuteButton").click();
const visualizationName = "Pivot"; const visualizationName = "Pivot";
@@ -76,7 +68,7 @@ describe("Pivot", () => {
.should("be.not.visible"); .should("be.not.visible");
}); });
it("updates the visualization when results change", function() { it("updates the visualization when results change", function () {
const options = { const options = {
aggregatorName: "Count", aggregatorName: "Count",
data: [], // force it to have a data object, although it shouldn't data: [], // force it to have a data object, although it shouldn't
@@ -86,8 +78,9 @@ describe("Pivot", () => {
vals: ["value"], vals: ["value"],
}; };
cy.createVisualization(this.queryId, "PIVOT", "Pivot", options).then(visualization => { cy.createVisualization(this.queryId, "PIVOT", "Pivot", options).then((visualization) => {
cy.visit(`queries/${this.queryId}/source#${visualization.id}`); cy.visit(`queries/${this.queryId}/source#${visualization.id}`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click(); cy.getByTestId("ExecuteButton").click();
// assert number of rows is 11 // assert number of rows is 11
@@ -104,16 +97,14 @@ describe("Pivot", () => {
cy.wait(200); cy.wait(200);
cy.getByTestId("SaveButton").click(); cy.getByTestId("SaveButton").click();
cy.getByTestId("ExecuteButton") cy.getByTestId("ExecuteButton").should("be.enabled").click();
.should("be.enabled")
.click();
// assert number of rows is 12 // assert number of rows is 12
cy.getByTestId("PivotTableVisualization").contains(".pvtGrandTotal", "12"); cy.getByTestId("PivotTableVisualization").contains(".pvtGrandTotal", "12");
}); });
}); });
it("takes a snapshot with different configured Pivots", function() { it("takes a snapshot with different configured Pivots", function () {
const options = { const options = {
aggregatorName: "Sum", aggregatorName: "Sum",
controls: { enabled: true }, controls: { enabled: true },
@@ -142,19 +133,20 @@ describe("Pivot", () => {
]; ];
cy.createDashboard("Pivot Visualization") cy.createDashboard("Pivot Visualization")
.then(dashboard => { .then((dashboard) => {
this.dashboardUrl = `/dashboards/${dashboard.id}`; this.dashboardUrl = `/dashboards/${dashboard.id}`;
return cy.all( return cy.all(
pivotTables.map(pivot => () => pivotTables.map(
cy (pivot) => () =>
.createVisualization(this.queryId, "PIVOT", pivot.name, pivot.options) cy
.then(visualization => cy.addWidget(dashboard.id, visualization.id, { position: pivot.position })) .createVisualization(this.queryId, "PIVOT", pivot.name, pivot.options)
.then((visualization) => cy.addWidget(dashboard.id, visualization.id, { position: pivot.position }))
) )
); );
}) })
.then(widgets => { .then((widgets) => {
cy.visit(this.dashboardUrl); cy.visit(this.dashboardUrl);
widgets.forEach(widget => { widgets.forEach((widget) => {
cy.getByTestId(getWidgetTestId(widget)).within(() => cy.getByTestId(getWidgetTestId(widget)).within(() =>
cy.getByTestId("PivotTableVisualization").should("exist") cy.getByTestId("PivotTableVisualization").should("exist")
); );

View File

@@ -25,6 +25,7 @@ describe("Sankey and Sunburst", () => {
beforeEach(() => { beforeEach(() => {
cy.createQuery({ query: SQL }).then(({ id }) => { cy.createQuery({ query: SQL }).then(({ id }) => {
cy.visit(`queries/${id}/source`); cy.visit(`queries/${id}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click(); cy.getByTestId("ExecuteButton").click();
cy.getByTestId("NewVisualization").click(); cy.getByTestId("NewVisualization").click();
cy.getByTestId("VisualizationType").selectAntdOption("VisualizationType.SUNBURST_SEQUENCE"); cy.getByTestId("VisualizationType").selectAntdOption("VisualizationType.SUNBURST_SEQUENCE");
@@ -34,37 +35,21 @@ describe("Sankey and Sunburst", () => {
it("creates Sunburst", () => { it("creates Sunburst", () => {
const visualizationName = "Sunburst"; const visualizationName = "Sunburst";
cy.getByTestId("VisualizationName") cy.getByTestId("VisualizationName").clear().type(visualizationName);
.clear() cy.getByTestId("VisualizationPreview").find("svg").should("exist");
.type(visualizationName);
cy.getByTestId("VisualizationPreview")
.find("svg")
.should("exist");
cy.getByTestId("EditVisualizationDialog") cy.getByTestId("EditVisualizationDialog").contains("button", "Save").click();
.contains("button", "Save") cy.getByTestId("QueryPageVisualizationTabs").contains("span", visualizationName).should("exist");
.click();
cy.getByTestId("QueryPageVisualizationTabs")
.contains("span", visualizationName)
.should("exist");
}); });
it("creates Sankey", () => { it("creates Sankey", () => {
const visualizationName = "Sankey"; const visualizationName = "Sankey";
cy.getByTestId("VisualizationName") cy.getByTestId("VisualizationName").clear().type(visualizationName);
.clear() cy.getByTestId("VisualizationPreview").find("svg").should("exist");
.type(visualizationName);
cy.getByTestId("VisualizationPreview")
.find("svg")
.should("exist");
cy.getByTestId("EditVisualizationDialog") cy.getByTestId("EditVisualizationDialog").contains("button", "Save").click();
.contains("button", "Save") cy.getByTestId("QueryPageVisualizationTabs").contains("span", visualizationName).should("exist");
.click();
cy.getByTestId("QueryPageVisualizationTabs")
.contains("span", visualizationName)
.should("exist");
}); });
}); });
@@ -92,21 +77,22 @@ describe("Sankey and Sunburst", () => {
}, },
]; ];
it("takes a snapshot with Sunburst (1 - 5 stages)", function() { it("takes a snapshot with Sunburst (1 - 5 stages)", function () {
cy.createDashboard("Sunburst Visualization").then(dashboard => { cy.createDashboard("Sunburst Visualization").then((dashboard) => {
this.dashboardUrl = `/dashboards/${dashboard.id}`; this.dashboardUrl = `/dashboards/${dashboard.id}`;
return cy return cy
.all( .all(
STAGES_WIDGETS.map(sunburst => () => STAGES_WIDGETS.map(
cy (sunburst) => () =>
.createQuery({ name: `Sunburst with ${sunburst.name}`, query: sunburst.query }) cy
.then(queryData => cy.createVisualization(queryData.id, "SUNBURST_SEQUENCE", "Sunburst", {})) .createQuery({ name: `Sunburst with ${sunburst.name}`, query: sunburst.query })
.then(visualization => cy.addWidget(dashboard.id, visualization.id, { position: sunburst.position })) .then((queryData) => cy.createVisualization(queryData.id, "SUNBURST_SEQUENCE", "Sunburst", {}))
.then((visualization) => cy.addWidget(dashboard.id, visualization.id, { position: sunburst.position }))
) )
) )
.then(widgets => { .then((widgets) => {
cy.visit(this.dashboardUrl); cy.visit(this.dashboardUrl);
widgets.forEach(widget => { widgets.forEach((widget) => {
cy.getByTestId(getWidgetTestId(widget)).within(() => cy.get("svg").should("exist")); cy.getByTestId(getWidgetTestId(widget)).within(() => cy.get("svg").should("exist"));
}); });
@@ -117,21 +103,22 @@ describe("Sankey and Sunburst", () => {
}); });
}); });
it("takes a snapshot with Sankey (1 - 5 stages)", function() { it("takes a snapshot with Sankey (1 - 5 stages)", function () {
cy.createDashboard("Sankey Visualization").then(dashboard => { cy.createDashboard("Sankey Visualization").then((dashboard) => {
this.dashboardUrl = `/dashboards/${dashboard.id}`; this.dashboardUrl = `/dashboards/${dashboard.id}`;
return cy return cy
.all( .all(
STAGES_WIDGETS.map(sankey => () => STAGES_WIDGETS.map(
cy (sankey) => () =>
.createQuery({ name: `Sankey with ${sankey.name}`, query: sankey.query }) cy
.then(queryData => cy.createVisualization(queryData.id, "SANKEY", "Sankey", {})) .createQuery({ name: `Sankey with ${sankey.name}`, query: sankey.query })
.then(visualization => cy.addWidget(dashboard.id, visualization.id, { position: sankey.position })) .then((queryData) => cy.createVisualization(queryData.id, "SANKEY", "Sankey", {}))
.then((visualization) => cy.addWidget(dashboard.id, visualization.id, { position: sankey.position }))
) )
) )
.then(widgets => { .then((widgets) => {
cy.visit(this.dashboardUrl); cy.visit(this.dashboardUrl);
widgets.forEach(widget => { widgets.forEach((widget) => {
cy.getByTestId(getWidgetTestId(widget)).within(() => cy.get("svg").should("exist")); cy.getByTestId(getWidgetTestId(widget)).within(() => cy.get("svg").should("exist"));
}); });

View File

@@ -64,6 +64,7 @@ describe("Word Cloud", () => {
cy.login(); cy.login();
cy.createQuery({ query: SQL }).then(({ id }) => { cy.createQuery({ query: SQL }).then(({ id }) => {
cy.visit(`queries/${id}/source`); cy.visit(`queries/${id}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click(); cy.getByTestId("ExecuteButton").click();
}); });
cy.document().then(injectFont); cy.document().then(injectFont);
@@ -80,9 +81,7 @@ describe("Word Cloud", () => {
// Wait for proper initialization of visualization // Wait for proper initialization of visualization
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find("svg text").should("have.length", 11);
.find("svg text")
.should("have.length", 11);
cy.percySnapshot("Visualizations - Word Cloud (Automatic word frequencies)", { widths: [viewportWidth] }); cy.percySnapshot("Visualizations - Word Cloud (Automatic word frequencies)", { widths: [viewportWidth] });
}); });
@@ -99,9 +98,7 @@ describe("Word Cloud", () => {
// Wait for proper initialization of visualization // Wait for proper initialization of visualization
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find("svg text").should("have.length", 5);
.find("svg text")
.should("have.length", 5);
cy.percySnapshot("Visualizations - Word Cloud (Frequencies from another column)", { widths: [viewportWidth] }); cy.percySnapshot("Visualizations - Word Cloud (Frequencies from another column)", { widths: [viewportWidth] });
}); });
@@ -125,9 +122,7 @@ describe("Word Cloud", () => {
// Wait for proper initialization of visualization // Wait for proper initialization of visualization
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find("svg text").should("have.length", 2);
.find("svg text")
.should("have.length", 2);
cy.percySnapshot("Visualizations - Word Cloud (With filters)", { widths: [viewportWidth] }); cy.percySnapshot("Visualizations - Word Cloud (With filters)", { widths: [viewportWidth] });
}); });

View File

@@ -3,36 +3,26 @@
* @param should Passed to should expression after plot points are captured * @param should Passed to should expression after plot points are captured
*/ */
export function assertPlotPreview(should = "exist") { export function assertPlotPreview(should = "exist") {
cy.getByTestId("VisualizationPreview") cy.getByTestId("VisualizationPreview").find("g.overplot").should("exist").find("g.points").should(should);
.find("g.plot")
.should("exist")
.find("g.points")
.should(should);
} }
export function createChartThroughUI(chartName, chartSpecificAssertionFn = () => {}) { export function createChartThroughUI(chartName, chartSpecificAssertionFn = () => {}) {
cy.getByTestId("NewVisualization").click(); cy.getByTestId("NewVisualization").click();
cy.getByTestId("VisualizationType").selectAntdOption("VisualizationType.CHART"); cy.getByTestId("VisualizationType").selectAntdOption("VisualizationType.CHART");
cy.getByTestId("VisualizationName") cy.getByTestId("VisualizationName").clear().type(chartName);
.clear()
.type(chartName);
chartSpecificAssertionFn(); chartSpecificAssertionFn();
cy.server(); cy.server();
cy.route("POST", "**/api/visualizations").as("SaveVisualization"); cy.route("POST", "**/api/visualizations").as("SaveVisualization");
cy.getByTestId("EditVisualizationDialog") cy.getByTestId("EditVisualizationDialog").contains("button", "Save").click();
.contains("button", "Save")
.click();
cy.getByTestId("QueryPageVisualizationTabs") cy.getByTestId("QueryPageVisualizationTabs").contains("span", chartName).should("exist");
.contains("span", chartName)
.should("exist");
cy.wait("@SaveVisualization").should("have.property", "status", 200); cy.wait("@SaveVisualization").should("have.property", "status", 200);
return cy.get("@SaveVisualization").then(xhr => { return cy.get("@SaveVisualization").then((xhr) => {
const { id, name, options } = xhr.response.body; const { id, name, options } = xhr.response.body;
return cy.wrap({ id, name, options }); return cy.wrap({ id, name, options });
}); });
@@ -42,19 +32,13 @@ export function assertTabbedEditor(chartSpecificTabbedEditorAssertionFn = () =>
cy.getByTestId("Chart.GlobalSeriesType").should("exist"); cy.getByTestId("Chart.GlobalSeriesType").should("exist");
cy.getByTestId("VisualizationEditor.Tabs.Series").click(); cy.getByTestId("VisualizationEditor.Tabs.Series").click();
cy.getByTestId("VisualizationEditor") cy.getByTestId("VisualizationEditor").find("table").should("exist");
.find("table")
.should("exist");
cy.getByTestId("VisualizationEditor.Tabs.Colors").click(); cy.getByTestId("VisualizationEditor.Tabs.Colors").click();
cy.getByTestId("VisualizationEditor") cy.getByTestId("VisualizationEditor").find("table").should("exist");
.find("table")
.should("exist");
cy.getByTestId("VisualizationEditor.Tabs.DataLabels").click(); cy.getByTestId("VisualizationEditor.Tabs.DataLabels").click();
cy.getByTestId("VisualizationEditor") cy.getByTestId("VisualizationEditor").getByTestId("Chart.DataLabels.ShowDataLabels").should("exist");
.getByTestId("Chart.DataLabels.ShowDataLabels")
.should("exist");
chartSpecificTabbedEditorAssertionFn(); chartSpecificTabbedEditorAssertionFn();
@@ -63,39 +47,29 @@ export function assertTabbedEditor(chartSpecificTabbedEditorAssertionFn = () =>
export function assertAxesAndAddLabels(xaxisLabel, yaxisLabel) { export function assertAxesAndAddLabels(xaxisLabel, yaxisLabel) {
cy.getByTestId("VisualizationEditor.Tabs.XAxis").click(); cy.getByTestId("VisualizationEditor.Tabs.XAxis").click();
cy.getByTestId("Chart.XAxis.Type") cy.getByTestId("Chart.XAxis.Type").contains(".ant-select-selection-item", "Auto Detect").should("exist");
.contains(".ant-select-selection-item", "Auto Detect")
.should("exist");
cy.getByTestId("Chart.XAxis.Name") cy.getByTestId("Chart.XAxis.Name").clear().type(xaxisLabel);
.clear()
.type(xaxisLabel);
cy.getByTestId("VisualizationEditor.Tabs.YAxis").click(); cy.getByTestId("VisualizationEditor.Tabs.YAxis").click();
cy.getByTestId("Chart.LeftYAxis.Type") cy.getByTestId("Chart.LeftYAxis.Type").contains(".ant-select-selection-item", "Linear").should("exist");
.contains(".ant-select-selection-item", "Linear")
.should("exist");
cy.getByTestId("Chart.LeftYAxis.Name") cy.getByTestId("Chart.LeftYAxis.Name").clear().type(yaxisLabel);
.clear()
.type(yaxisLabel);
cy.getByTestId("Chart.LeftYAxis.TickFormat") cy.getByTestId("Chart.LeftYAxis.TickFormat").clear().type("+");
.clear()
.type("+");
cy.getByTestId("VisualizationEditor.Tabs.General").click(); cy.getByTestId("VisualizationEditor.Tabs.General").click();
} }
export function createDashboardWithCharts(title, chartGetters, widgetsAssertionFn = () => {}) { export function createDashboardWithCharts(title, chartGetters, widgetsAssertionFn = () => {}) {
cy.createDashboard(title).then(dashboard => { cy.createDashboard(title).then((dashboard) => {
const dashboardUrl = `/dashboards/${dashboard.id}`; const dashboardUrl = `/dashboards/${dashboard.id}`;
const widgetGetters = chartGetters.map(chartGetter => `${chartGetter}Widget`); const widgetGetters = chartGetters.map((chartGetter) => `${chartGetter}Widget`);
chartGetters.forEach((chartGetter, i) => { chartGetters.forEach((chartGetter, i) => {
const position = { autoHeight: false, sizeY: 8, sizeX: 3, col: (i % 2) * 3 }; const position = { autoHeight: false, sizeY: 8, sizeX: 3, col: (i % 2) * 3 };
cy.get(`@${chartGetter}`) cy.get(`@${chartGetter}`)
.then(chart => cy.addWidget(dashboard.id, chart.id, { position })) .then((chart) => cy.addWidget(dashboard.id, chart.id, { position }))
.as(widgetGetters[i]); .as(widgetGetters[i]);
}); });

View File

@@ -53,7 +53,7 @@ services:
image: redis:7-alpine image: redis:7-alpine
restart: unless-stopped restart: unless-stopped
postgres: postgres:
image: pgautoupgrade/pgautoupgrade:latest image: postgres:18-alpine
ports: ports:
- "15432:5432" - "15432:5432"
# The following turns the DB into less durable, but gains significant performance improvements for the tests run (x3 # The following turns the DB into less durable, but gains significant performance improvements for the tests run (x3

View File

@@ -0,0 +1,26 @@
"""set default alert selector
Revision ID: 1655999df5e3
Revises: 9e8c841d1a30
Create Date: 2025-07-09 14:44:00
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = '1655999df5e3'
down_revision = '9e8c841d1a30'
branch_labels = None
depends_on = None
def upgrade():
op.execute("""
UPDATE alerts
SET options = jsonb_set(options, '{selector}', '"first"')
WHERE options->>'selector' IS NULL;
""")
def downgrade():
pass

View File

@@ -0,0 +1,34 @@
"""12-column dashboard layout
Revision ID: db0aca1ebd32
Revises: 1655999df5e3
Create Date: 2025-03-31 13:45:43.160893
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'db0aca1ebd32'
down_revision = '1655999df5e3'
branch_labels = None
depends_on = None
def upgrade():
op.execute("""
UPDATE widgets
SET options = jsonb_set(options, '{position,col}', to_json((options->'position'->>'col')::int * 2)::jsonb);
UPDATE widgets
SET options = jsonb_set(options, '{position,sizeX}', to_json((options->'position'->>'sizeX')::int * 2)::jsonb);
""")
def downgrade():
op.execute("""
UPDATE widgets
SET options = jsonb_set(options, '{position,col}', to_json((options->'position'->>'col')::int / 2)::jsonb);
UPDATE widgets
SET options = jsonb_set(options, '{position,sizeX}', to_json((options->'position'->>'sizeX')::int / 2)::jsonb);
""")

View File

@@ -1,6 +1,6 @@
{ {
"name": "redash-client", "name": "redash-client",
"version": "24.11.0-dev", "version": "25.12.0-dev",
"description": "The frontend part of Redash.", "description": "The frontend part of Redash.",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
@@ -46,8 +46,8 @@
"dependencies": { "dependencies": {
"@ant-design/icons": "^4.2.1", "@ant-design/icons": "^4.2.1",
"@redash/viz": "file:viz-lib", "@redash/viz": "file:viz-lib",
"ace-builds": "^1.4.12", "ace-builds": "^1.43.3",
"antd": "^4.4.3", "antd": "4.4.3",
"axios": "0.27.2", "axios": "0.27.2",
"axios-auth-refresh": "3.3.6", "axios-auth-refresh": "3.3.6",
"bootstrap": "^3.4.1", "bootstrap": "^3.4.1",
@@ -68,7 +68,7 @@
"prop-types": "^15.6.1", "prop-types": "^15.6.1",
"query-string": "^6.9.0", "query-string": "^6.9.0",
"react": "16.14.0", "react": "16.14.0",
"react-ace": "^9.1.1", "react-ace": "^14.0.1",
"react-dom": "^16.14.0", "react-dom": "^16.14.0",
"react-grid-layout": "^0.18.2", "react-grid-layout": "^0.18.2",
"react-resizable": "^1.10.1", "react-resizable": "^1.10.1",
@@ -100,6 +100,7 @@
"@types/sql-formatter": "^2.3.0", "@types/sql-formatter": "^2.3.0",
"@typescript-eslint/eslint-plugin": "^2.10.0", "@typescript-eslint/eslint-plugin": "^2.10.0",
"@typescript-eslint/parser": "^2.10.0", "@typescript-eslint/parser": "^2.10.0",
"assert": "^2.1.0",
"atob": "^2.1.2", "atob": "^2.1.2",
"babel-eslint": "^10.0.3", "babel-eslint": "^10.0.3",
"babel-jest": "^24.1.0", "babel-jest": "^24.1.0",
@@ -138,20 +139,24 @@
"mini-css-extract-plugin": "^1.6.2", "mini-css-extract-plugin": "^1.6.2",
"mockdate": "^2.0.2", "mockdate": "^2.0.2",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"prettier": "^1.19.1", "prettier": "3.3.2",
"process": "^0.11.10",
"raw-loader": "^0.5.1", "raw-loader": "^0.5.1",
"react-refresh": "^0.14.0", "react-refresh": "^0.14.0",
"react-test-renderer": "^16.14.0", "react-test-renderer": "^16.14.0",
"request-cookies": "^1.1.0", "request-cookies": "^1.1.0",
"source-map-loader": "^1.1.3",
"stream-browserify": "^3.0.0",
"style-loader": "^2.0.0", "style-loader": "^2.0.0",
"typescript": "^4.1.2", "typescript": "4.1.2",
"url": "^0.11.4",
"url-loader": "^4.1.1", "url-loader": "^4.1.1",
"webpack": "^4.46.0", "webpack": "^5.101.3",
"webpack-build-notifier": "^2.3.0", "webpack-build-notifier": "^3.0.1",
"webpack-bundle-analyzer": "^4.9.0", "webpack-bundle-analyzer": "^4.9.0",
"webpack-cli": "^4.10.0", "webpack-cli": "^4.10.0",
"webpack-dev-server": "^4.15.1", "webpack-dev-server": "^4.15.1",
"webpack-manifest-plugin": "^2.0.4" "webpack-manifest-plugin": "^5.0.1"
}, },
"optionalDependencies": { "optionalDependencies": {
"fsevents": "^2.3.2" "fsevents": "^2.3.2"

3287
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,17 @@
[project] [project]
name = "redash"
version = "25.12.0-dev"
requires-python = ">=3.8" requires-python = ">=3.8"
description = "Make Your Company Data Driven. Connect to any data source, easily visualize, dashboard and share your data."
authors = [
{ name = "Arik Fraimovich", email = "<arik@redash.io>" }
]
# to be added to/removed from the mailing list, please reach out to Arik via the above email or Discord
maintainers = [
{ name = "Redash maintainers and contributors", email = "<maintainers@redash.io>" }
]
readme = "README.md"
dependencies = []
[tool.black] [tool.black]
target-version = ['py38'] target-version = ['py38']
@@ -10,17 +22,6 @@ force-exclude = '''
)/ )/
''' '''
[tool.poetry]
name = "redash"
version = "24.11.0-dev"
description = "Make Your Company Data Driven. Connect to any data source, easily visualize, dashboard and share your data."
authors = ["Arik Fraimovich <arik@redash.io>"]
# to be added to/removed from the mailing list, please reach out to Arik via the above email or Discord
maintainers = [
"Redash maintainers and contributors <maintainers@redash.io>",
]
readme = "README.md"
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = ">=3.8,<3.11" python = ">=3.8,<3.11"
advocate = "1.0.0" advocate = "1.0.0"
@@ -46,7 +47,7 @@ greenlet = "2.0.2"
gunicorn = "22.0.0" gunicorn = "22.0.0"
httplib2 = "0.19.0" httplib2 = "0.19.0"
itsdangerous = "2.1.2" itsdangerous = "2.1.2"
jinja2 = "3.1.4" jinja2 = "3.1.5"
jsonschema = "3.1.1" jsonschema = "3.1.1"
markupsafe = "2.1.1" markupsafe = "2.1.1"
maxminddb-geolite2 = "2018.703" maxminddb-geolite2 = "2018.703"
@@ -86,13 +87,17 @@ wtforms = "2.2.1"
xlsxwriter = "1.2.2" xlsxwriter = "1.2.2"
tzlocal = "4.3.1" tzlocal = "4.3.1"
pyodbc = "5.1.0" pyodbc = "5.1.0"
debugpy = "^1.8.9"
paramiko = "3.4.1"
oracledb = "2.5.1"
ibm-db = { version = "^3.2.7", markers = "platform_machine == 'x86_64' or platform_machine == 'AMD64'" }
[tool.poetry.group.all_ds] [tool.poetry.group.all_ds]
optional = true optional = true
[tool.poetry.group.all_ds.dependencies] [tool.poetry.group.all_ds.dependencies]
atsd-client = "3.0.5" atsd-client = "3.0.5"
azure-kusto-data = "0.0.35" azure-kusto-data = "5.0.1"
boto3 = "1.28.8" boto3 = "1.28.8"
botocore = "1.31.8" botocore = "1.31.8"
cassandra-driver = "3.21.0" cassandra-driver = "3.21.0"
@@ -100,6 +105,7 @@ certifi = ">=2019.9.11"
cmem-cmempy = "21.2.3" cmem-cmempy = "21.2.3"
databend-py = "0.4.6" databend-py = "0.4.6"
databend-sqlalchemy = "0.2.4" databend-sqlalchemy = "0.2.4"
duckdb = "1.3.2"
google-api-python-client = "1.7.11" google-api-python-client = "1.7.11"
gspread = "5.11.2" gspread = "5.11.2"
impyla = "0.16.0" impyla = "0.16.0"
@@ -107,16 +113,16 @@ influxdb = "5.2.3"
influxdb-client = "1.38.0" influxdb-client = "1.38.0"
memsql = "3.2.0" memsql = "3.2.0"
mysqlclient = "2.1.1" mysqlclient = "2.1.1"
numpy = "1.24.4"
nzalchemy = "^11.0.2" nzalchemy = "^11.0.2"
nzpy = ">=1.15" nzpy = ">=1.15"
oauth2client = "4.1.3" oauth2client = "4.1.3"
openpyxl = "3.0.7" openpyxl = "3.0.7"
oracledb = "2.1.2"
pandas = "1.3.4" pandas = "1.3.4"
phoenixdb = "0.7" phoenixdb = "0.7"
pinotdb = ">=0.4.5" pinotdb = ">=0.4.5"
protobuf = "3.20.2" protobuf = "3.20.2"
pyathena = ">=1.5.0,<=1.11.5" pyathena = "2.25.2"
pydgraph = "2.0.2" pydgraph = "2.0.2"
pydruid = "0.5.7" pydruid = "0.5.7"
pyexasol = "0.12.0" pyexasol = "0.12.0"
@@ -156,7 +162,6 @@ jwcrypto = "1.5.6"
mock = "5.0.2" mock = "5.0.2"
pre-commit = "3.3.3" pre-commit = "3.3.3"
ptpython = "3.0.23" ptpython = "3.0.23"
ptvsd = "4.3.2"
pytest-cov = "4.1.0" pytest-cov = "4.1.0"
watchdog = "3.0.0" watchdog = "3.0.0"
ruff = "0.0.289" ruff = "0.0.289"

View File

@@ -14,13 +14,14 @@ from redash.app import create_app # noqa
from redash.destinations import import_destinations from redash.destinations import import_destinations
from redash.query_runner import import_query_runners from redash.query_runner import import_query_runners
__version__ = "24.11.0-dev" __version__ = "25.12.0-dev"
if os.environ.get("REMOTE_DEBUG"): if os.environ.get("REMOTE_DEBUG"):
import ptvsd import debugpy
ptvsd.enable_attach(address=("0.0.0.0", 5678)) debugpy.listen(("0.0.0.0", 5678))
debugpy.wait_for_client()
def setup_logging(): def setup_logging():

View File

@@ -36,10 +36,14 @@ def create_app():
from .metrics import request as request_metrics from .metrics import request as request_metrics
from .models import db, users from .models import db, users
from .utils import sentry from .utils import sentry
from .version_check import reset_new_version_status
sentry.init() sentry.init()
app = Redash() app = Redash()
# Check and update the cached version for use by the client
reset_new_version_status()
security.init_app(app) security.init_app(app)
request_metrics.init_app(app) request_metrics.init_app(app)
db.init_app(app) db.init_app(app)

View File

@@ -4,7 +4,7 @@ import requests
from authlib.integrations.flask_client import OAuth from authlib.integrations.flask_client import OAuth
from flask import Blueprint, flash, redirect, request, session, url_for from flask import Blueprint, flash, redirect, request, session, url_for
from redash import models from redash import models, settings
from redash.authentication import ( from redash.authentication import (
create_and_login_user, create_and_login_user,
get_next_path, get_next_path,
@@ -29,6 +29,41 @@ def verify_profile(org, profile):
return False return False
def get_user_profile(access_token, logger):
headers = {"Authorization": f"OAuth {access_token}"}
response = requests.get("https://www.googleapis.com/oauth2/v1/userinfo", headers=headers)
if response.status_code == 401:
logger.warning("Failed getting user profile (response code 401).")
return None
return response.json()
def build_redirect_uri():
scheme = settings.GOOGLE_OAUTH_SCHEME_OVERRIDE or None
return url_for(".callback", _external=True, _scheme=scheme)
def build_next_path(org_slug=None):
next_path = request.args.get("next")
if not next_path:
if org_slug is None:
org_slug = session.get("org_slug")
scheme = None
if settings.GOOGLE_OAUTH_SCHEME_OVERRIDE:
scheme = settings.GOOGLE_OAUTH_SCHEME_OVERRIDE
next_path = url_for(
"redash.index",
org_slug=org_slug,
_external=True,
_scheme=scheme,
)
return next_path
def create_google_oauth_blueprint(app): def create_google_oauth_blueprint(app):
oauth = OAuth(app) oauth = OAuth(app)
@@ -36,23 +71,12 @@ def create_google_oauth_blueprint(app):
blueprint = Blueprint("google_oauth", __name__) blueprint = Blueprint("google_oauth", __name__)
CONF_URL = "https://accounts.google.com/.well-known/openid-configuration" CONF_URL = "https://accounts.google.com/.well-known/openid-configuration"
oauth = OAuth(app)
oauth.register( oauth.register(
name="google", name="google",
server_metadata_url=CONF_URL, server_metadata_url=CONF_URL,
client_kwargs={"scope": "openid email profile"}, client_kwargs={"scope": "openid email profile"},
) )
def get_user_profile(access_token):
headers = {"Authorization": "OAuth {}".format(access_token)}
response = requests.get("https://www.googleapis.com/oauth2/v1/userinfo", headers=headers)
if response.status_code == 401:
logger.warning("Failed getting user profile (response code 401).")
return None
return response.json()
@blueprint.route("/<org_slug>/oauth/google", endpoint="authorize_org") @blueprint.route("/<org_slug>/oauth/google", endpoint="authorize_org")
def org_login(org_slug): def org_login(org_slug):
session["org_slug"] = current_org.slug session["org_slug"] = current_org.slug
@@ -60,9 +84,9 @@ def create_google_oauth_blueprint(app):
@blueprint.route("/oauth/google", endpoint="authorize") @blueprint.route("/oauth/google", endpoint="authorize")
def login(): def login():
redirect_uri = url_for(".callback", _external=True) redirect_uri = build_redirect_uri()
next_path = request.args.get("next", url_for("redash.index", org_slug=session.get("org_slug"))) next_path = build_next_path()
logger.debug("Callback url: %s", redirect_uri) logger.debug("Callback url: %s", redirect_uri)
logger.debug("Next is: %s", next_path) logger.debug("Next is: %s", next_path)
@@ -86,7 +110,7 @@ def create_google_oauth_blueprint(app):
flash("Validation error. Please retry.") flash("Validation error. Please retry.")
return redirect(url_for("redash.login")) return redirect(url_for("redash.login"))
profile = get_user_profile(access_token) profile = get_user_profile(access_token, logger)
if profile is None: if profile is None:
flash("Validation error. Please retry.") flash("Validation error. Please retry.")
return redirect(url_for("redash.login")) return redirect(url_for("redash.login"))
@@ -110,7 +134,9 @@ def create_google_oauth_blueprint(app):
if user is None: if user is None:
return logout_and_redirect_to_index() return logout_and_redirect_to_index()
unsafe_next_path = session.get("next_url") or url_for("redash.index", org_slug=org.slug) unsafe_next_path = session.get("next_url")
if not unsafe_next_path:
unsafe_next_path = build_next_path(org.slug)
next_path = get_next_path(unsafe_next_path) next_path = get_next_path(unsafe_next_path)
return redirect(next_path) return redirect(next_path)

View File

@@ -1,3 +1,5 @@
import html
import json
import logging import logging
from copy import deepcopy from copy import deepcopy
@@ -37,6 +39,129 @@ class Webex(BaseDestination):
@staticmethod @staticmethod
def formatted_attachments_template(subject, description, query_link, alert_link): def formatted_attachments_template(subject, description, query_link, alert_link):
# Attempt to parse the description to find a 2D array
try:
# Extract the part of the description that looks like a JSON array
start_index = description.find("[")
end_index = description.rfind("]") + 1
json_array_str = description[start_index:end_index]
# Decode HTML entities
json_array_str = html.unescape(json_array_str)
# Replace single quotes with double quotes for valid JSON
json_array_str = json_array_str.replace("'", '"')
# Load the JSON array
data_array = json.loads(json_array_str)
# Check if it's a 2D array
if isinstance(data_array, list) and all(isinstance(i, list) for i in data_array):
# Create a table for the Adaptive Card
table_rows = []
for row in data_array:
table_rows.append(
{
"type": "ColumnSet",
"columns": [
{"type": "Column", "items": [{"type": "TextBlock", "text": str(item), "wrap": True}]}
for item in row
],
}
)
# Create the body of the card with the table
body = (
[
{
"type": "TextBlock",
"text": f"{subject}",
"weight": "bolder",
"size": "medium",
"wrap": True,
},
{
"type": "TextBlock",
"text": f"{description[:start_index]}",
"isSubtle": True,
"wrap": True,
},
]
+ table_rows
+ [
{
"type": "TextBlock",
"text": f"Click [here]({query_link}) to check your query!",
"wrap": True,
"isSubtle": True,
},
{
"type": "TextBlock",
"text": f"Click [here]({alert_link}) to check your alert!",
"wrap": True,
"isSubtle": True,
},
]
)
else:
# Fallback to the original description if no valid 2D array is found
body = [
{
"type": "TextBlock",
"text": f"{subject}",
"weight": "bolder",
"size": "medium",
"wrap": True,
},
{
"type": "TextBlock",
"text": f"{description}",
"isSubtle": True,
"wrap": True,
},
{
"type": "TextBlock",
"text": f"Click [here]({query_link}) to check your query!",
"wrap": True,
"isSubtle": True,
},
{
"type": "TextBlock",
"text": f"Click [here]({alert_link}) to check your alert!",
"wrap": True,
"isSubtle": True,
},
]
except json.JSONDecodeError:
# If parsing fails, fallback to the original description
body = [
{
"type": "TextBlock",
"text": f"{subject}",
"weight": "bolder",
"size": "medium",
"wrap": True,
},
{
"type": "TextBlock",
"text": f"{description}",
"isSubtle": True,
"wrap": True,
},
{
"type": "TextBlock",
"text": f"Click [here]({query_link}) to check your query!",
"wrap": True,
"isSubtle": True,
},
{
"type": "TextBlock",
"text": f"Click [here]({alert_link}) to check your alert!",
"wrap": True,
"isSubtle": True,
},
]
return [ return [
{ {
"contentType": "application/vnd.microsoft.card.adaptive", "contentType": "application/vnd.microsoft.card.adaptive",
@@ -44,44 +169,7 @@ class Webex(BaseDestination):
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json", "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard", "type": "AdaptiveCard",
"version": "1.0", "version": "1.0",
"body": [ "body": body,
{
"type": "ColumnSet",
"columns": [
{
"type": "Column",
"width": 4,
"items": [
{
"type": "TextBlock",
"text": {subject},
"weight": "bolder",
"size": "medium",
"wrap": True,
},
{
"type": "TextBlock",
"text": {description},
"isSubtle": True,
"wrap": True,
},
{
"type": "TextBlock",
"text": f"Click [here]({query_link}) to check your query!",
"wrap": True,
"isSubtle": True,
},
{
"type": "TextBlock",
"text": f"Click [here]({alert_link}) to check your alert!",
"wrap": True,
"isSubtle": True,
},
],
},
],
}
],
}, },
} }
] ]
@@ -116,6 +204,10 @@ class Webex(BaseDestination):
# destinations is guaranteed to be a comma-separated string # destinations is guaranteed to be a comma-separated string
for destination_id in destinations.split(","): for destination_id in destinations.split(","):
destination_id = destination_id.strip() # Remove any leading or trailing whitespace
if not destination_id: # Check if the destination_id is empty or blank
continue # Skip to the next iteration if it's empty or blank
payload = deepcopy(template_payload) payload = deepcopy(template_payload)
payload[payload_tag] = destination_id payload[payload_tag] = destination_id
self.post_message(payload, headers) self.post_message(payload, headers)

View File

@@ -42,7 +42,7 @@ class Webhook(BaseDestination):
auth = HTTPBasicAuth(options.get("username"), options.get("password")) if options.get("username") else None auth = HTTPBasicAuth(options.get("username"), options.get("password")) if options.get("username") else None
resp = requests.post( resp = requests.post(
options.get("url"), options.get("url"),
data=json_dumps(data), data=json_dumps(data).encode("utf-8"),
auth=auth, auth=auth,
headers=headers, headers=headers,
timeout=5.0, timeout=5.0,

View File

@@ -15,6 +15,7 @@ from redash.authentication.account import (
) )
from redash.handlers import routes from redash.handlers import routes
from redash.handlers.base import json_response, org_scoped_rule from redash.handlers.base import json_response, org_scoped_rule
from redash.version_check import get_latest_version
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -254,19 +255,30 @@ def number_format_config():
} }
def null_value_config():
return {
"nullValue": current_org.get_setting("null_value"),
}
def client_config(): def client_config():
if not current_user.is_api_user() and current_user.is_authenticated: if not current_user.is_api_user() and current_user.is_authenticated:
client_config_inner = { client_config = {
"newVersionAvailable": bool(get_latest_version()),
"version": __version__, "version": __version__,
} }
else: else:
client_config_inner = {} client_config = {}
if current_user.has_permission("admin") and current_org.get_setting("beacon_consent") is None:
client_config["showBeaconConsentMessage"] = True
defaults = { defaults = {
"allowScriptsInUserInput": settings.ALLOW_SCRIPTS_IN_USER_INPUT, "allowScriptsInUserInput": settings.ALLOW_SCRIPTS_IN_USER_INPUT,
"showPermissionsControl": current_org.get_setting("feature_show_permissions_control"), "showPermissionsControl": current_org.get_setting("feature_show_permissions_control"),
"hidePlotlyModeBar": current_org.get_setting("hide_plotly_mode_bar"), "hidePlotlyModeBar": current_org.get_setting("hide_plotly_mode_bar"),
"disablePublicUrls": current_org.get_setting("disable_public_urls"), "disablePublicUrls": current_org.get_setting("disable_public_urls"),
"multiByteSearchEnabled": current_org.get_setting("multi_byte_search_enabled"),
"allowCustomJSVisualizations": settings.FEATURE_ALLOW_CUSTOM_JS_VISUALIZATIONS, "allowCustomJSVisualizations": settings.FEATURE_ALLOW_CUSTOM_JS_VISUALIZATIONS,
"autoPublishNamedQueries": settings.FEATURE_AUTO_PUBLISH_NAMED_QUERIES, "autoPublishNamedQueries": settings.FEATURE_AUTO_PUBLISH_NAMED_QUERIES,
"extendedAlertOptions": settings.FEATURE_EXTENDED_ALERT_OPTIONS, "extendedAlertOptions": settings.FEATURE_EXTENDED_ALERT_OPTIONS,
@@ -280,12 +292,13 @@ def client_config():
"tableCellMaxJSONSize": settings.TABLE_CELL_MAX_JSON_SIZE, "tableCellMaxJSONSize": settings.TABLE_CELL_MAX_JSON_SIZE,
} }
client_config_inner.update(defaults) client_config.update(defaults)
client_config_inner.update({"basePath": base_href()}) client_config.update({"basePath": base_href()})
client_config_inner.update(date_time_format_config()) client_config.update(date_time_format_config())
client_config_inner.update(number_format_config()) client_config.update(number_format_config())
client_config.update(null_value_config())
return client_config_inner return client_config
def messages(): def messages():

View File

@@ -26,6 +26,8 @@ order_map = {
"-name": "-lowercase_name", "-name": "-lowercase_name",
"created_at": "created_at", "created_at": "created_at",
"-created_at": "-created_at", "-created_at": "-created_at",
"starred_at": "favorites-created_at",
"-starred_at": "-favorites-created_at",
} }
order_results = partial(_order_results, default_order="-created_at", allowed_orders=order_map) order_results = partial(_order_results, default_order="-created_at", allowed_orders=order_map)

View File

@@ -44,6 +44,8 @@ order_map = {
"-executed_at": "-query_results-retrieved_at", "-executed_at": "-query_results-retrieved_at",
"created_by": "users-name", "created_by": "users-name",
"-created_by": "-users-name", "-created_by": "-users-name",
"starred_at": "favorites-created_at",
"-starred_at": "-favorites-created_at",
} }
order_results = partial(_order_results, default_order="-created_at", allowed_orders=order_map) order_results = partial(_order_results, default_order="-created_at", allowed_orders=order_map)
@@ -239,6 +241,8 @@ class QueryListResource(BaseQueryListResource):
query = models.Query.create(**query_def) query = models.Query.create(**query_def)
models.db.session.add(query) models.db.session.add(query)
models.db.session.commit() models.db.session.commit()
query.update_latest_result_by_query_hash()
models.db.session.commit()
self.record_event({"action": "create", "object_id": query.id, "object_type": "query"}) self.record_event({"action": "create", "object_id": query.id, "object_type": "query"})
@@ -362,6 +366,8 @@ class QueryResource(BaseResource):
try: try:
self.update_model(query, query_def) self.update_model(query, query_def)
models.db.session.commit() models.db.session.commit()
query.update_latest_result_by_query_hash()
models.db.session.commit()
except StaleDataError: except StaleDataError:
abort(409) abort(409)

View File

@@ -1,12 +1,13 @@
from flask import g, redirect, render_template, request, url_for from flask import g, redirect, render_template, request, url_for
from flask_login import login_user from flask_login import login_user
from wtforms import Form, PasswordField, StringField, validators from wtforms import BooleanField, Form, PasswordField, StringField, validators
from wtforms.fields.html5 import EmailField from wtforms.fields.html5 import EmailField
from redash import settings from redash import settings
from redash.authentication.org_resolving import current_org from redash.authentication.org_resolving import current_org
from redash.handlers.base import routes from redash.handlers.base import routes
from redash.models import Group, Organization, User, db from redash.models import Group, Organization, User, db
from redash.tasks.general import subscribe
class SetupForm(Form): class SetupForm(Form):
@@ -14,6 +15,8 @@ class SetupForm(Form):
email = EmailField("Email Address", validators=[validators.Email()]) email = EmailField("Email Address", validators=[validators.Email()])
password = PasswordField("Password", validators=[validators.Length(6)]) password = PasswordField("Password", validators=[validators.Length(6)])
org_name = StringField("Organization Name", validators=[validators.InputRequired()]) org_name = StringField("Organization Name", validators=[validators.InputRequired()])
security_notifications = BooleanField()
newsletter = BooleanField()
def create_org(org_name, user_name, email, password): def create_org(org_name, user_name, email, password):
@@ -54,6 +57,8 @@ def setup():
return redirect("/") return redirect("/")
form = SetupForm(request.form) form = SetupForm(request.form)
form.newsletter.data = True
form.security_notifications.data = True
if request.method == "POST" and form.validate(): if request.method == "POST" and form.validate():
default_org, user = create_org(form.org_name.data, form.name.data, form.email.data, form.password.data) default_org, user = create_org(form.org_name.data, form.name.data, form.email.data, form.password.data)
@@ -61,6 +66,10 @@ def setup():
g.org = default_org g.org = default_org
login_user(user) login_user(user)
# signup to newsletter if needed
if form.newsletter.data or form.security_notifications:
subscribe.delay(form.data)
return redirect(url_for("redash.index", org_slug=None)) return redirect(url_for("redash.index", org_slug=None))
return render_template("setup.html", form=form) return render_template("setup.html", form=form)

View File

@@ -2,6 +2,7 @@ import calendar
import datetime import datetime
import logging import logging
import numbers import numbers
import re
import time import time
import pytz import pytz
@@ -228,7 +229,7 @@ class DataSource(BelongsToOrgMixin, db.Model):
def _sort_schema(self, schema): def _sort_schema(self, schema):
return [ return [
{"name": i["name"], "columns": sorted(i["columns"], key=lambda x: x["name"] if isinstance(x, dict) else x)} {**i, "columns": sorted(i["columns"], key=lambda x: x["name"] if isinstance(x, dict) else x)}
for i in sorted(schema, key=lambda x: x["name"]) for i in sorted(schema, key=lambda x: x["name"])
] ]
@@ -564,7 +565,7 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
db.session.query(tag_column, usage_count) db.session.query(tag_column, usage_count)
.group_by(tag_column) .group_by(tag_column)
.filter(Query.id.in_(queries.options(load_only("id")))) .filter(Query.id.in_(queries.options(load_only("id"))))
.order_by(usage_count.desc()) .order_by(tag_column)
) )
return query return query
@@ -644,6 +645,43 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
return list(outdated_queries.values()) return list(outdated_queries.values())
@classmethod
def _do_multi_byte_search(cls, all_queries, term, limit=None):
# term examples:
# - word
# - name:word
# - query:word
# - "multiple words"
# - name:"multiple words"
# - word1 word2 word3
# - word1 "multiple word" query:"select foo"
tokens = re.findall(r'(?:([^:\s]+):)?(?:"([^"]+)"|(\S+))', term)
conditions = []
for token in tokens:
key = None
if token[0]:
key = token[0]
if token[1]:
value = token[1]
else:
value = token[2]
pattern = f"%{value}%"
if key == "id" and value.isdigit():
conditions.append(cls.id.equal(int(value)))
elif key == "name":
conditions.append(cls.name.ilike(pattern))
elif key == "query":
conditions.append(cls.query_text.ilike(pattern))
elif key == "description":
conditions.append(cls.description.ilike(pattern))
else:
conditions.append(or_(cls.name.ilike(pattern), cls.description.ilike(pattern)))
return all_queries.filter(and_(*conditions)).order_by(Query.id).limit(limit)
@classmethod @classmethod
def search( def search(
cls, cls,
@@ -664,12 +702,7 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
if multi_byte_search: if multi_byte_search:
# Since tsvector doesn't work well with CJK languages, use `ilike` too # Since tsvector doesn't work well with CJK languages, use `ilike` too
pattern = "%{}%".format(term) return cls._do_multi_byte_search(all_queries, term, limit)
return (
all_queries.filter(or_(cls.name.ilike(pattern), cls.description.ilike(pattern)))
.order_by(Query.id)
.limit(limit)
)
# sort the result using the weight as defined in the search vector column # sort the result using the weight as defined in the search vector column
return all_queries.search(term, sort=True).limit(limit) return all_queries.search(term, sort=True).limit(limit)
@@ -678,13 +711,7 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
def search_by_user(cls, term, user, limit=None, multi_byte_search=False): def search_by_user(cls, term, user, limit=None, multi_byte_search=False):
if multi_byte_search: if multi_byte_search:
# Since tsvector doesn't work well with CJK languages, use `ilike` too # Since tsvector doesn't work well with CJK languages, use `ilike` too
pattern = "%{}%".format(term) return cls._do_multi_byte_search(cls.by_user(user), term, limit)
return (
cls.by_user(user)
.filter(or_(cls.name.ilike(pattern), cls.description.ilike(pattern)))
.order_by(Query.id)
.limit(limit)
)
return cls.by_user(user).search(term, sort=True).limit(limit) return cls.by_user(user).search(term, sort=True).limit(limit)
@@ -726,6 +753,23 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
return db.session.execute(query, {"ids": tuple(query_ids)}).fetchall() return db.session.execute(query, {"ids": tuple(query_ids)}).fetchall()
def update_latest_result_by_query_hash(self):
query_hash = self.query_hash
data_source_id = self.data_source_id
query_result = (
QueryResult.query.options(load_only("id"))
.filter(
QueryResult.query_hash == query_hash,
QueryResult.data_source_id == data_source_id,
)
.order_by(QueryResult.retrieved_at.desc())
.first()
)
if query_result:
latest_query_data_id = query_result.id
self.latest_query_data_id = latest_query_data_id
db.session.add(self)
@classmethod @classmethod
def update_latest_result(cls, query_result): def update_latest_result(cls, query_result):
# TODO: Investigate how big an impact this select-before-update makes. # TODO: Investigate how big an impact this select-before-update makes.
@@ -908,6 +952,7 @@ def next_state(op, value, threshold):
# boolean value is Python specific and most likely will be confusing to # boolean value is Python specific and most likely will be confusing to
# users. # users.
value = str(value).lower() value = str(value).lower()
value_is_number = False
else: else:
try: try:
value = float(value) value = float(value)
@@ -969,6 +1014,7 @@ class Alert(TimestampMixin, BelongsToOrgMixin, db.Model):
def evaluate(self): def evaluate(self):
data = self.query_rel.latest_query_data.data if self.query_rel.latest_query_data else None data = self.query_rel.latest_query_data.data if self.query_rel.latest_query_data else None
new_state = self.UNKNOWN_STATE
if data and data["rows"] and self.options["column"] in data["rows"][0]: if data and data["rows"] and self.options["column"] in data["rows"][0]:
op = OPERATORS.get(self.options["op"], lambda v, t: False) op = OPERATORS.get(self.options["op"], lambda v, t: False)
@@ -997,9 +1043,8 @@ class Alert(TimestampMixin, BelongsToOrgMixin, db.Model):
threshold = self.options["value"] threshold = self.options["value"]
new_state = next_state(op, value, threshold) if value is not None:
else: new_state = next_state(op, value, threshold)
new_state = self.UNKNOWN_STATE
return new_state return new_state
@@ -1136,7 +1181,7 @@ class Dashboard(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model
db.session.query(tag_column, usage_count) db.session.query(tag_column, usage_count)
.group_by(tag_column) .group_by(tag_column)
.filter(Dashboard.id.in_(dashboards.options(load_only("id")))) .filter(Dashboard.id.in_(dashboards.options(load_only("id"))))
.order_by(usage_count.desc()) .order_by(tag_column)
) )
return query return query
@@ -1144,15 +1189,19 @@ class Dashboard(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model
def favorites(cls, user, base_query=None): def favorites(cls, user, base_query=None):
if base_query is None: if base_query is None:
base_query = cls.all(user.org, user.group_ids, user.id) base_query = cls.all(user.org, user.group_ids, user.id)
return base_query.join( return (
( base_query.distinct(cls.lowercase_name, Dashboard.created_at, Dashboard.slug, Favorite.created_at)
Favorite, .join(
and_( (
Favorite.object_type == "Dashboard", Favorite,
Favorite.object_id == Dashboard.id, and_(
), Favorite.object_type == "Dashboard",
Favorite.object_id == Dashboard.id,
),
)
) )
).filter(Favorite.user_id == user.id) .filter(Favorite.user_id == user.id)
)
@classmethod @classmethod
def by_user(cls, user): def by_user(cls, user):

View File

@@ -288,7 +288,10 @@ class BaseSQLQueryRunner(BaseQueryRunner):
return True return True
def query_is_select_no_limit(self, query): def query_is_select_no_limit(self, query):
parsed_query = sqlparse.parse(query)[0] parsed_query_list = sqlparse.parse(query)
if len(parsed_query_list) == 0:
return False
parsed_query = parsed_query_list[0]
last_keyword_idx = find_last_keyword_idx(parsed_query) last_keyword_idx = find_last_keyword_idx(parsed_query)
# Either invalid query or query that is not select # Either invalid query or query that is not select
if last_keyword_idx == -1 or parsed_query.tokens[0].value.upper() != "SELECT": if last_keyword_idx == -1 or parsed_query.tokens[0].value.upper() != "SELECT":

View File

@@ -90,15 +90,26 @@ class Athena(BaseQueryRunner):
"title": "Athena cost per Tb scanned (USD)", "title": "Athena cost per Tb scanned (USD)",
"default": 5, "default": 5,
}, },
"result_reuse_enable": {
"type": "boolean",
"title": "Reuse Athena query results",
},
"result_reuse_minutes": {
"type": "number",
"title": "Minutes to reuse Athena query results",
"default": 60,
},
}, },
"required": ["region", "s3_staging_dir"], "required": ["region", "s3_staging_dir"],
"extra_options": ["glue", "catalog_ids", "cost_per_tb"], "extra_options": ["glue", "catalog_ids", "cost_per_tb", "result_reuse_enable", "result_reuse_minutes"],
"order": [ "order": [
"region", "region",
"s3_staging_dir", "s3_staging_dir",
"schema", "schema",
"work_group", "work_group",
"cost_per_tb", "cost_per_tb",
"result_reuse_enable",
"result_reuse_minutes",
], ],
"secret": ["aws_secret_key"], "secret": ["aws_secret_key"],
} }
@@ -247,6 +258,8 @@ class Athena(BaseQueryRunner):
kms_key=self.configuration.get("kms_key", None), kms_key=self.configuration.get("kms_key", None),
work_group=self.configuration.get("work_group", "primary"), work_group=self.configuration.get("work_group", "primary"),
formatter=SimpleFormatter(), formatter=SimpleFormatter(),
result_reuse_enable=self.configuration.get("result_reuse_enable", False),
result_reuse_minutes=self.configuration.get("result_reuse_minutes", 60),
**self._get_iam_credentials(user=user), **self._get_iam_credentials(user=user),
).cursor() ).cursor()

View File

@@ -11,12 +11,12 @@ from redash.query_runner import (
from redash.utils import json_loads from redash.utils import json_loads
try: try:
from azure.kusto.data.exceptions import KustoServiceError from azure.kusto.data import (
from azure.kusto.data.request import (
ClientRequestProperties, ClientRequestProperties,
KustoClient, KustoClient,
KustoConnectionStringBuilder, KustoConnectionStringBuilder,
) )
from azure.kusto.data.exceptions import KustoServiceError
enabled = True enabled = True
except ImportError: except ImportError:
@@ -37,6 +37,34 @@ TYPES_MAP = {
} }
def _get_data_scanned(kusto_response):
try:
metadata_table = next(
(table for table in kusto_response.tables if table.table_name == "QueryCompletionInformation"),
None,
)
if metadata_table:
resource_usage_json = next(
(row["Payload"] for row in metadata_table.rows if row["EventTypeName"] == "QueryResourceConsumption"),
"{}",
)
resource_usage = json_loads(resource_usage_json).get("resource_usage", {})
data_scanned = (
resource_usage["cache"]["shards"]["cold"]["hitbytes"]
+ resource_usage["cache"]["shards"]["cold"]["missbytes"]
+ resource_usage["cache"]["shards"]["hot"]["hitbytes"]
+ resource_usage["cache"]["shards"]["hot"]["missbytes"]
+ resource_usage["cache"]["shards"]["bypassbytes"]
)
except Exception:
data_scanned = 0
return int(data_scanned)
class AzureKusto(BaseQueryRunner): class AzureKusto(BaseQueryRunner):
should_annotate_query = False should_annotate_query = False
noop_query = "let noop = datatable (Noop:string)[1]; noop" noop_query = "let noop = datatable (Noop:string)[1]; noop"
@@ -44,8 +72,6 @@ class AzureKusto(BaseQueryRunner):
def __init__(self, configuration): def __init__(self, configuration):
super(AzureKusto, self).__init__(configuration) super(AzureKusto, self).__init__(configuration)
self.syntax = "custom" self.syntax = "custom"
self.client_request_properties = ClientRequestProperties()
self.client_request_properties.application = "redash"
@classmethod @classmethod
def configuration_schema(cls): def configuration_schema(cls):
@@ -60,12 +86,14 @@ class AzureKusto(BaseQueryRunner):
}, },
"azure_ad_tenant_id": {"type": "string", "title": "Azure AD Tenant Id"}, "azure_ad_tenant_id": {"type": "string", "title": "Azure AD Tenant Id"},
"database": {"type": "string"}, "database": {"type": "string"},
"msi": {"type": "boolean", "title": "Use Managed Service Identity"},
"user_msi": {
"type": "string",
"title": "User-assigned managed identity client ID",
},
}, },
"required": [ "required": [
"cluster", "cluster",
"azure_ad_client_id",
"azure_ad_client_secret",
"azure_ad_tenant_id",
"database", "database",
], ],
"order": [ "order": [
@@ -91,18 +119,48 @@ class AzureKusto(BaseQueryRunner):
return "Azure Data Explorer (Kusto)" return "Azure Data Explorer (Kusto)"
def run_query(self, query, user): def run_query(self, query, user):
kcsb = KustoConnectionStringBuilder.with_aad_application_key_authentication( cluster = self.configuration["cluster"]
connection_string=self.configuration["cluster"], msi = self.configuration.get("msi", False)
aad_app_id=self.configuration["azure_ad_client_id"], # Managed Service Identity(MSI)
app_key=self.configuration["azure_ad_client_secret"], if msi:
authority_id=self.configuration["azure_ad_tenant_id"], # If user-assigned managed identity is used, the client ID must be provided
) if self.configuration.get("user_msi"):
kcsb = KustoConnectionStringBuilder.with_aad_managed_service_identity_authentication(
cluster,
client_id=self.configuration["user_msi"],
)
else:
kcsb = KustoConnectionStringBuilder.with_aad_managed_service_identity_authentication(cluster)
# Service Principal auth
else:
aad_app_id = self.configuration.get("azure_ad_client_id")
app_key = self.configuration.get("azure_ad_client_secret")
authority_id = self.configuration.get("azure_ad_tenant_id")
if not (aad_app_id and app_key and authority_id):
raise ValueError(
"Azure AD Client ID, Client Secret, and Tenant ID are required for Service Principal authentication."
)
kcsb = KustoConnectionStringBuilder.with_aad_application_key_authentication(
connection_string=cluster,
aad_app_id=aad_app_id,
app_key=app_key,
authority_id=authority_id,
)
client = KustoClient(kcsb) client = KustoClient(kcsb)
request_properties = ClientRequestProperties()
request_properties.application = "redash"
if user:
request_properties.user = user.email
request_properties.set_option("request_description", user.email)
db = self.configuration["database"] db = self.configuration["database"]
try: try:
response = client.execute(db, query, self.client_request_properties) response = client.execute(db, query, request_properties)
result_cols = response.primary_results[0].columns result_cols = response.primary_results[0].columns
result_rows = response.primary_results[0].rows result_rows = response.primary_results[0].rows
@@ -123,14 +181,15 @@ class AzureKusto(BaseQueryRunner):
rows.append(row.to_dict()) rows.append(row.to_dict())
error = None error = None
data = {"columns": columns, "rows": rows} data = {
"columns": columns,
"rows": rows,
"metadata": {"data_scanned": _get_data_scanned(response)},
}
except KustoServiceError as err: except KustoServiceError as err:
data = None data = None
try: error = str(err)
error = err.args[1][0]["error"]["@message"]
except (IndexError, KeyError):
error = err.args[1]
return data, error return data, error
@@ -143,7 +202,10 @@ class AzureKusto(BaseQueryRunner):
self._handle_run_query_error(error) self._handle_run_query_error(error)
schema_as_json = json_loads(results["rows"][0]["DatabaseSchema"]) schema_as_json = json_loads(results["rows"][0]["DatabaseSchema"])
tables_list = schema_as_json["Databases"][self.configuration["database"]]["Tables"].values() tables_list = [
*(schema_as_json["Databases"][self.configuration["database"]]["Tables"].values()),
*(schema_as_json["Databases"][self.configuration["database"]]["MaterializedViews"].values()),
]
schema = {} schema = {}
@@ -154,7 +216,9 @@ class AzureKusto(BaseQueryRunner):
schema[table_name] = {"name": table_name, "columns": []} schema[table_name] = {"name": table_name, "columns": []}
for column in table["OrderedColumns"]: for column in table["OrderedColumns"]:
schema[table_name]["columns"].append(column["Name"]) schema[table_name]["columns"].append(
{"name": column["Name"], "type": TYPES_MAP.get(column["CslType"], None)}
)
return list(schema.values()) return list(schema.values())

View File

@@ -7,11 +7,12 @@ from base64 import b64decode
from redash import settings from redash import settings
from redash.query_runner import ( from redash.query_runner import (
TYPE_BOOLEAN, TYPE_BOOLEAN,
TYPE_DATE,
TYPE_DATETIME, TYPE_DATETIME,
TYPE_FLOAT, TYPE_FLOAT,
TYPE_INTEGER, TYPE_INTEGER,
TYPE_STRING, TYPE_STRING,
BaseQueryRunner, BaseSQLQueryRunner,
InterruptException, InterruptException,
JobTimeoutException, JobTimeoutException,
register, register,
@@ -37,6 +38,8 @@ types_map = {
"BOOLEAN": TYPE_BOOLEAN, "BOOLEAN": TYPE_BOOLEAN,
"STRING": TYPE_STRING, "STRING": TYPE_STRING,
"TIMESTAMP": TYPE_DATETIME, "TIMESTAMP": TYPE_DATETIME,
"DATETIME": TYPE_DATETIME,
"DATE": TYPE_DATE,
} }
@@ -83,7 +86,7 @@ def _get_query_results(jobs, project_id, location, job_id, start_index):
).execute() ).execute()
logging.debug("query_reply %s", query_reply) logging.debug("query_reply %s", query_reply)
if not query_reply["jobComplete"]: if not query_reply["jobComplete"]:
time.sleep(10) time.sleep(1)
return _get_query_results(jobs, project_id, location, job_id, start_index) return _get_query_results(jobs, project_id, location, job_id, start_index)
return query_reply return query_reply
@@ -95,7 +98,7 @@ def _get_total_bytes_processed_for_resp(bq_response):
return int(bq_response.get("totalBytesProcessed", "0")) return int(bq_response.get("totalBytesProcessed", "0"))
class BigQuery(BaseQueryRunner): class BigQuery(BaseSQLQueryRunner):
noop_query = "SELECT 1" noop_query = "SELECT 1"
def __init__(self, configuration): def __init__(self, configuration):
@@ -153,6 +156,11 @@ class BigQuery(BaseQueryRunner):
"secret": ["jsonKeyFile"], "secret": ["jsonKeyFile"],
} }
def annotate_query(self, query, metadata):
# Remove "Job ID" before annotating the query to avoid cache misses
metadata = {k: v for k, v in metadata.items() if k != "Job ID"}
return super().annotate_query(query, metadata)
def _get_bigquery_service(self): def _get_bigquery_service(self):
socket.setdefaulttimeout(settings.BIGQUERY_HTTP_TIMEOUT) socket.setdefaulttimeout(settings.BIGQUERY_HTTP_TIMEOUT)
@@ -212,11 +220,12 @@ class BigQuery(BaseQueryRunner):
job_data = self._get_job_data(query) job_data = self._get_job_data(query)
insert_response = jobs.insert(projectId=project_id, body=job_data).execute() insert_response = jobs.insert(projectId=project_id, body=job_data).execute()
self.current_job_id = insert_response["jobReference"]["jobId"] self.current_job_id = insert_response["jobReference"]["jobId"]
self.current_job_location = insert_response["jobReference"]["location"]
current_row = 0 current_row = 0
query_reply = _get_query_results( query_reply = _get_query_results(
jobs, jobs,
project_id=project_id, project_id=project_id,
location=self._get_location(), location=self.current_job_location,
job_id=self.current_job_id, job_id=self.current_job_id,
start_index=current_row, start_index=current_row,
) )
@@ -233,13 +242,11 @@ class BigQuery(BaseQueryRunner):
query_result_request = { query_result_request = {
"projectId": project_id, "projectId": project_id,
"jobId": query_reply["jobReference"]["jobId"], "jobId": self.current_job_id,
"startIndex": current_row, "startIndex": current_row,
"location": self.current_job_location,
} }
if self._get_location():
query_result_request["location"] = self._get_location()
query_reply = jobs.getQueryResults(**query_result_request).execute() query_reply = jobs.getQueryResults(**query_result_request).execute()
columns = [ columns = [
@@ -301,28 +308,70 @@ class BigQuery(BaseQueryRunner):
datasets = self._get_project_datasets(project_id) datasets = self._get_project_datasets(project_id)
query_base = """ query_base = """
SELECT table_schema, table_name, field_path SELECT table_schema, table_name, field_path, data_type, description
FROM `{dataset_id}`.INFORMATION_SCHEMA.COLUMN_FIELD_PATHS FROM `{dataset_id}`.INFORMATION_SCHEMA.COLUMN_FIELD_PATHS
WHERE table_schema NOT IN ('information_schema') WHERE table_schema NOT IN ('information_schema')
""" """
table_query_base = """
SELECT table_schema, table_name, JSON_VALUE(option_value) as table_description
FROM `{dataset_id}`.INFORMATION_SCHEMA.TABLE_OPTIONS
WHERE table_schema NOT IN ('information_schema')
AND option_name = 'description'
"""
location_dataset_ids = {}
schema = {} schema = {}
queries = []
for dataset in datasets: for dataset in datasets:
dataset_id = dataset["datasetReference"]["datasetId"] dataset_id = dataset["datasetReference"]["datasetId"]
query = query_base.format(dataset_id=dataset_id) location = dataset["location"]
queries.append(query) if self._get_location() and location != self._get_location():
logger.debug("dataset location is different: %s", location)
continue
query = "\nUNION ALL\n".join(queries) if location not in location_dataset_ids:
results, error = self.run_query(query, None) location_dataset_ids[location] = []
if error is not None: location_dataset_ids[location].append(dataset_id)
self._handle_run_query_error(error)
for row in results["rows"]: for location, datasets in location_dataset_ids.items():
table_name = "{0}.{1}".format(row["table_schema"], row["table_name"]) queries = []
if table_name not in schema: for dataset_id in datasets:
schema[table_name] = {"name": table_name, "columns": []} query = query_base.format(dataset_id=dataset_id)
schema[table_name]["columns"].append(row["field_path"]) queries.append(query)
query = "\nUNION ALL\n".join(queries)
results, error = self.run_query(query, None)
if error is not None:
self._handle_run_query_error(error)
for row in results["rows"]:
table_name = "{0}.{1}".format(row["table_schema"], row["table_name"])
if table_name not in schema:
schema[table_name] = {"name": table_name, "columns": []}
schema[table_name]["columns"].append(
{
"name": row["field_path"],
"type": row["data_type"],
"description": row["description"],
}
)
table_queries = []
for dataset_id in datasets:
table_query = table_query_base.format(dataset_id=dataset_id)
table_queries.append(table_query)
table_query = "\nUNION ALL\n".join(table_queries)
results, error = self.run_query(table_query, None)
if error is not None:
self._handle_run_query_error(error)
for row in results["rows"]:
table_name = "{0}.{1}".format(row["table_schema"], row["table_name"])
if table_name not in schema:
schema[table_name] = {"name": table_name, "columns": []}
if "table_description" in row:
schema[table_name]["description"] = row["table_description"]
return list(schema.values()) return list(schema.values())
@@ -356,7 +405,7 @@ class BigQuery(BaseQueryRunner):
self._get_bigquery_service().jobs().cancel( self._get_bigquery_service().jobs().cancel(
projectId=self._get_project_id(), projectId=self._get_project_id(),
jobId=self.current_job_id, jobId=self.current_job_id,
location=self._get_location(), location=self.current_job_location,
).execute() ).execute()
raise raise

View File

@@ -77,7 +77,11 @@ class ClickHouse(BaseSQLQueryRunner):
self._url = self._url._replace(netloc="{}:{}".format(self._url.hostname, port)) self._url = self._url._replace(netloc="{}:{}".format(self._url.hostname, port))
def _get_tables(self, schema): def _get_tables(self, schema):
query = "SELECT database, table, name FROM system.columns WHERE database NOT IN ('system')" query = """
SELECT database, table, name, type as data_type
FROM system.columns
WHERE database NOT IN ('system', 'information_schema', 'INFORMATION_SCHEMA')
"""
results, error = self.run_query(query, None) results, error = self.run_query(query, None)
@@ -90,7 +94,7 @@ class ClickHouse(BaseSQLQueryRunner):
if table_name not in schema: if table_name not in schema:
schema[table_name] = {"name": table_name, "columns": []} schema[table_name] = {"name": table_name, "columns": []}
schema[table_name]["columns"].append(row["name"]) schema[table_name]["columns"].append({"name": row["name"], "type": row["data_type"]})
return list(schema.values()) return list(schema.values())

View File

@@ -0,0 +1,174 @@
import logging
from redash.query_runner import (
TYPE_BOOLEAN,
TYPE_DATE,
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseSQLQueryRunner,
InterruptException,
register,
)
logger = logging.getLogger(__name__)
try:
import duckdb
enabled = True
except ImportError:
enabled = False
# Map DuckDB types to Redash column types
TYPES_MAP = {
"BOOLEAN": TYPE_BOOLEAN,
"TINYINT": TYPE_INTEGER,
"SMALLINT": TYPE_INTEGER,
"INTEGER": TYPE_INTEGER,
"BIGINT": TYPE_INTEGER,
"HUGEINT": TYPE_INTEGER,
"REAL": TYPE_FLOAT,
"DOUBLE": TYPE_FLOAT,
"DECIMAL": TYPE_FLOAT,
"VARCHAR": TYPE_STRING,
"BLOB": TYPE_STRING,
"DATE": TYPE_DATE,
"TIMESTAMP": TYPE_DATETIME,
"TIMESTAMP WITH TIME ZONE": TYPE_DATETIME,
"TIME": TYPE_DATETIME,
"INTERVAL": TYPE_STRING,
"UUID": TYPE_STRING,
"JSON": TYPE_STRING,
"STRUCT": TYPE_STRING,
"MAP": TYPE_STRING,
"UNION": TYPE_STRING,
}
class DuckDB(BaseSQLQueryRunner):
noop_query = "SELECT 1"
def __init__(self, configuration):
super().__init__(configuration)
self.dbpath = configuration.get("dbpath", ":memory:")
exts = configuration.get("extensions", "")
self.extensions = [e.strip() for e in exts.split(",") if e.strip()]
self._connect()
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"dbpath": {
"type": "string",
"title": "Database Path",
"default": ":memory:",
},
"extensions": {"type": "string", "title": "Extensions (comma separated)"},
},
"order": ["dbpath", "extensions"],
"required": ["dbpath"],
}
@classmethod
def enabled(cls) -> bool:
return enabled
def _connect(self) -> None:
self.con = duckdb.connect(self.dbpath)
for ext in self.extensions:
try:
if "." in ext:
prefix, name = ext.split(".", 1)
if prefix == "community":
self.con.execute(f"INSTALL {name} FROM community")
self.con.execute(f"LOAD {name}")
else:
raise Exception("Unknown extension prefix.")
else:
self.con.execute(f"INSTALL {ext}")
self.con.execute(f"LOAD {ext}")
except Exception as e:
logger.warning("Failed to load extension %s: %s", ext, e)
def run_query(self, query, user) -> tuple:
try:
cursor = self.con.cursor()
cursor.execute(query)
columns = self.fetch_columns(
[(d[0], TYPES_MAP.get(d[1].upper(), TYPE_STRING)) for d in cursor.description]
)
rows = [dict(zip((col["name"] for col in columns), row)) for row in cursor.fetchall()]
data = {"columns": columns, "rows": rows}
return data, None
except duckdb.InterruptException:
raise InterruptException("Query cancelled by user.")
except Exception as e:
logger.exception("Error running query: %s", e)
return None, str(e)
def get_schema(self, get_stats=False) -> list:
tables_query = """
SELECT table_schema, table_name FROM information_schema.tables
WHERE table_schema NOT IN ('information_schema', 'pg_catalog');
"""
tables_results, error = self.run_query(tables_query, None)
if error:
raise Exception(f"Failed to get tables: {error}")
schema = {}
for table_row in tables_results["rows"]:
full_table_name = f"{table_row['table_schema']}.{table_row['table_name']}"
schema[full_table_name] = {"name": full_table_name, "columns": []}
describe_query = f'DESCRIBE "{table_row["table_schema"]}"."{table_row["table_name"]}";'
columns_results, error = self.run_query(describe_query, None)
if error:
logger.warning("Failed to describe table %s: %s", full_table_name, error)
continue
for col_row in columns_results["rows"]:
col = {"name": col_row["column_name"], "type": col_row["column_type"]}
schema[full_table_name]["columns"].append(col)
if col_row["column_type"].startswith("STRUCT("):
schema[full_table_name]["columns"].extend(
self._expand_struct_fields(col["name"], col_row["column_type"])
)
return list(schema.values())
def _expand_struct_fields(self, base_name: str, struct_type: str) -> list:
"""Recursively expand STRUCT(...) definitions into pseudo-columns."""
fields = []
# strip STRUCT( ... )
inner = struct_type[len("STRUCT(") : -1].strip()
# careful: nested structs, so parse comma-separated parts properly
depth, current, parts = 0, [], []
for c in inner:
if c == "(":
depth += 1
elif c == ")":
depth -= 1
if c == "," and depth == 0:
parts.append("".join(current).strip())
current = []
else:
current.append(c)
if current:
parts.append("".join(current).strip())
for part in parts:
# each part looks like: "fieldname TYPE"
fname, ftype = part.split(" ", 1)
colname = f"{base_name}.{fname}"
fields.append({"name": colname, "type": ftype})
if ftype.startswith("STRUCT("):
fields.extend(self._expand_struct_fields(colname, ftype))
return fields
register(DuckDB)

View File

@@ -91,8 +91,8 @@ class BaseElasticSearch(BaseQueryRunner):
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG)
self.server_url = self.configuration["server"] self.server_url = self.configuration.get("server", "")
if self.server_url[-1] == "/": if self.server_url and self.server_url[-1] == "/":
self.server_url = self.server_url[:-1] self.server_url = self.server_url[:-1]
basic_auth_user = self.configuration.get("basic_auth_user", None) basic_auth_user = self.configuration.get("basic_auth_user", None)

View File

@@ -34,9 +34,13 @@ class ResultSet:
def parse_issue(issue, field_mapping): # noqa: C901 def parse_issue(issue, field_mapping): # noqa: C901
result = OrderedDict() result = OrderedDict()
result["key"] = issue["key"]
for k, v in issue["fields"].items(): # # Handle API v3 response format: key field may be missing, use id as fallback
result["key"] = issue.get("key", issue.get("id", "unknown"))
# Handle API v3 response format: fields may be missing
fields = issue.get("fields", {})
for k, v in fields.items(): #
output_name = field_mapping.get_output_field_name(k) output_name = field_mapping.get_output_field_name(k)
member_names = field_mapping.get_dict_members(k) member_names = field_mapping.get_dict_members(k)
@@ -98,7 +102,9 @@ def parse_issues(data, field_mapping):
def parse_count(data): def parse_count(data):
results = ResultSet() results = ResultSet()
results.add_row({"count": data["total"]}) # API v3 may not return 'total' field, fallback to counting issues
count = data.get("total", len(data.get("issues", [])))
results.add_row({"count": count})
return results return results
@@ -160,18 +166,26 @@ class JiraJQL(BaseHTTPQueryRunner):
self.syntax = "json" self.syntax = "json"
def run_query(self, query, user): def run_query(self, query, user):
jql_url = "{}/rest/api/2/search".format(self.configuration["url"]) # Updated to API v3 endpoint, fix double slash issue
jql_url = "{}/rest/api/3/search/jql".format(self.configuration["url"].rstrip("/"))
query = json_loads(query) query = json_loads(query)
query_type = query.pop("queryType", "select") query_type = query.pop("queryType", "select")
field_mapping = FieldMapping(query.pop("fieldMapping", {})) field_mapping = FieldMapping(query.pop("fieldMapping", {}))
# API v3 requires mandatory jql parameter with restrictions
if "jql" not in query or not query["jql"]:
query["jql"] = "created >= -30d order by created DESC"
if query_type == "count": if query_type == "count":
query["maxResults"] = 1 query["maxResults"] = 1
query["fields"] = "" query["fields"] = ""
else: else:
query["maxResults"] = query.get("maxResults", 1000) query["maxResults"] = query.get("maxResults", 1000)
if "fields" not in query:
query["fields"] = "*all"
response, error = self.get_response(jql_url, params=query) response, error = self.get_response(jql_url, params=query)
if error is not None: if error is not None:
return None, error return None, error
@@ -182,17 +196,15 @@ class JiraJQL(BaseHTTPQueryRunner):
results = parse_count(data) results = parse_count(data)
else: else:
results = parse_issues(data, field_mapping) results = parse_issues(data, field_mapping)
index = data["startAt"] + data["maxResults"]
while data["total"] > index: # API v3 uses token-based pagination instead of startAt/total
query["startAt"] = index while not data.get("isLast", True) and "nextPageToken" in data:
query["nextPageToken"] = data["nextPageToken"]
response, error = self.get_response(jql_url, params=query) response, error = self.get_response(jql_url, params=query)
if error is not None: if error is not None:
return None, error return None, error
data = response.json() data = response.json()
index = data["startAt"] + data["maxResults"]
addl_results = parse_issues(data, field_mapping) addl_results = parse_issues(data, field_mapping)
results.merge(addl_results) results.merge(addl_results)

View File

@@ -188,7 +188,7 @@ class MongoDB(BaseQueryRunner):
self.syntax = "json" self.syntax = "json"
self.db_name = self.configuration["dbName"] self.db_name = self.configuration.get("dbName", "")
self.is_replica_set = ( self.is_replica_set = (
True if "replicaSetName" in self.configuration and self.configuration["replicaSetName"] else False True if "replicaSetName" in self.configuration and self.configuration["replicaSetName"] else False
@@ -215,10 +215,10 @@ class MongoDB(BaseQueryRunner):
if readPreference: if readPreference:
kwargs["readPreference"] = readPreference kwargs["readPreference"] = readPreference
if "username" in self.configuration: if self.configuration.get("username"):
kwargs["username"] = self.configuration["username"] kwargs["username"] = self.configuration["username"]
if "password" in self.configuration: if self.configuration.get("password"):
kwargs["password"] = self.configuration["password"] kwargs["password"] = self.configuration["password"]
db_connection = pymongo.MongoClient(self.configuration["connectionString"], **kwargs) db_connection = pymongo.MongoClient(self.configuration["connectionString"], **kwargs)

View File

@@ -150,9 +150,11 @@ class Mysql(BaseSQLQueryRunner):
query = """ query = """
SELECT col.table_schema as table_schema, SELECT col.table_schema as table_schema,
col.table_name as table_name, col.table_name as table_name,
col.column_name as column_name col.column_name as column_name,
col.data_type as data_type,
col.column_comment as column_comment
FROM `information_schema`.`columns` col FROM `information_schema`.`columns` col
WHERE col.table_schema NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys'); WHERE LOWER(col.table_schema) NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys');
""" """
results, error = self.run_query(query, None) results, error = self.run_query(query, None)
@@ -169,7 +171,38 @@ class Mysql(BaseSQLQueryRunner):
if table_name not in schema: if table_name not in schema:
schema[table_name] = {"name": table_name, "columns": []} schema[table_name] = {"name": table_name, "columns": []}
schema[table_name]["columns"].append(row["column_name"]) schema[table_name]["columns"].append(
{
"name": row["column_name"],
"type": row["data_type"],
"description": row["column_comment"],
}
)
table_query = """
SELECT col.table_schema as table_schema,
col.table_name as table_name,
col.table_comment as table_comment
FROM `information_schema`.`tables` col
WHERE LOWER(col.table_schema) NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys'); \
"""
results, error = self.run_query(table_query, None)
if error is not None:
self._handle_run_query_error(error)
for row in results["rows"]:
if row["table_schema"] != self.configuration["db"]:
table_name = "{}.{}".format(row["table_schema"], row["table_name"])
else:
table_name = row["table_name"]
if table_name not in schema:
schema[table_name] = {"name": table_name, "columns": []}
if "table_comment" in row and row["table_comment"]:
schema[table_name]["description"] = row["table_comment"]
return list(schema.values()) return list(schema.values())

View File

@@ -138,6 +138,15 @@ def _get_ssl_config(configuration):
return ssl_config return ssl_config
def _parse_dsn(configuration):
standard_params = {"user", "password", "host", "port", "dbname"}
params = psycopg2.extensions.parse_dsn(configuration.get("dsn", ""))
overlap = standard_params.intersection(params.keys())
if overlap:
raise ValueError("Extra parameters may not contain {}".format(overlap))
return params
class PostgreSQL(BaseSQLQueryRunner): class PostgreSQL(BaseSQLQueryRunner):
noop_query = "SELECT 1" noop_query = "SELECT 1"
@@ -151,6 +160,7 @@ class PostgreSQL(BaseSQLQueryRunner):
"host": {"type": "string", "default": "127.0.0.1"}, "host": {"type": "string", "default": "127.0.0.1"},
"port": {"type": "number", "default": 5432}, "port": {"type": "number", "default": 5432},
"dbname": {"type": "string", "title": "Database Name"}, "dbname": {"type": "string", "title": "Database Name"},
"dsn": {"type": "string", "default": "application_name=redash", "title": "Parameters"},
"sslmode": { "sslmode": {
"type": "string", "type": "string",
"title": "SSL Mode", "title": "SSL Mode",
@@ -205,24 +215,15 @@ class PostgreSQL(BaseSQLQueryRunner):
def _get_tables(self, schema): def _get_tables(self, schema):
""" """
relkind constants per https://www.postgresql.org/docs/10/static/catalog-pg-class.html relkind constants from https://www.postgresql.org/docs/current/catalog-pg-class.html
r = regular table
v = view
m = materialized view m = materialized view
f = foreign table
p = partitioned table (new in 10)
---
i = index
S = sequence
t = TOAST table
c = composite type
""" """
query = """ query = """
SELECT s.nspname as table_schema, SELECT s.nspname AS table_schema,
c.relname as table_name, c.relname AS table_name,
a.attname as column_name, a.attname AS column_name,
null as data_type NULL AS data_type
FROM pg_class c FROM pg_class c
JOIN pg_namespace s JOIN pg_namespace s
ON c.relnamespace = s.oid ON c.relnamespace = s.oid
@@ -231,8 +232,8 @@ class PostgreSQL(BaseSQLQueryRunner):
ON a.attrelid = c.oid ON a.attrelid = c.oid
AND a.attnum > 0 AND a.attnum > 0
AND NOT a.attisdropped AND NOT a.attisdropped
WHERE c.relkind IN ('m', 'f', 'p') WHERE c.relkind = 'm'
AND has_table_privilege(s.nspname || '.' || c.relname, 'select') AND has_table_privilege(quote_ident(s.nspname) || '.' || quote_ident(c.relname), 'select')
AND has_schema_privilege(s.nspname, 'usage') AND has_schema_privilege(s.nspname, 'usage')
UNION UNION
@@ -243,6 +244,8 @@ class PostgreSQL(BaseSQLQueryRunner):
data_type data_type
FROM information_schema.columns FROM information_schema.columns
WHERE table_schema NOT IN ('pg_catalog', 'information_schema') WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
AND has_table_privilege(quote_ident(table_schema) || '.' || quote_ident(table_name), 'select')
AND has_schema_privilege(table_schema, 'usage')
""" """
self._get_definitions(schema, query) self._get_definitions(schema, query)
@@ -251,6 +254,7 @@ class PostgreSQL(BaseSQLQueryRunner):
def _get_connection(self): def _get_connection(self):
self.ssl_config = _get_ssl_config(self.configuration) self.ssl_config = _get_ssl_config(self.configuration)
self.dsn = _parse_dsn(self.configuration)
connection = psycopg2.connect( connection = psycopg2.connect(
user=self.configuration.get("user"), user=self.configuration.get("user"),
password=self.configuration.get("password"), password=self.configuration.get("password"),
@@ -259,6 +263,7 @@ class PostgreSQL(BaseSQLQueryRunner):
dbname=self.configuration.get("dbname"), dbname=self.configuration.get("dbname"),
async_=True, async_=True,
**self.ssl_config, **self.ssl_config,
**self.dsn,
) )
return connection return connection

View File

@@ -55,12 +55,13 @@ class Script(BaseQueryRunner):
def __init__(self, configuration): def __init__(self, configuration):
super(Script, self).__init__(configuration) super(Script, self).__init__(configuration)
path = self.configuration.get("path", "")
# If path is * allow any execution path # If path is * allow any execution path
if self.configuration["path"] == "*": if path == "*":
return return
# Poor man's protection against running scripts from outside the scripts directory # Poor man's protection against running scripts from outside the scripts directory
if self.configuration["path"].find("../") > -1: if path.find("../") > -1:
raise ValueError("Scripts can only be run from the configured scripts directory") raise ValueError("Scripts can only be run from the configured scripts directory")
def test_connection(self): def test_connection(self):

View File

@@ -1,11 +1,14 @@
try: try:
import snowflake.connector import snowflake.connector
from cryptography.hazmat.primitives.serialization import load_pem_private_key
enabled = True enabled = True
except ImportError: except ImportError:
enabled = False enabled = False
from base64 import b64decode
from redash import __version__ from redash import __version__
from redash.query_runner import ( from redash.query_runner import (
TYPE_BOOLEAN, TYPE_BOOLEAN,
@@ -43,6 +46,8 @@ class Snowflake(BaseSQLQueryRunner):
"account": {"type": "string"}, "account": {"type": "string"},
"user": {"type": "string"}, "user": {"type": "string"},
"password": {"type": "string"}, "password": {"type": "string"},
"private_key_File": {"type": "string"},
"private_key_pwd": {"type": "string"},
"warehouse": {"type": "string"}, "warehouse": {"type": "string"},
"database": {"type": "string"}, "database": {"type": "string"},
"region": {"type": "string", "default": "us-west"}, "region": {"type": "string", "default": "us-west"},
@@ -57,13 +62,15 @@ class Snowflake(BaseSQLQueryRunner):
"account", "account",
"user", "user",
"password", "password",
"private_key_File",
"private_key_pwd",
"warehouse", "warehouse",
"database", "database",
"region", "region",
"host", "host",
], ],
"required": ["user", "password", "account", "database", "warehouse"], "required": ["user", "account", "database", "warehouse"],
"secret": ["password"], "secret": ["password", "private_key_File", "private_key_pwd"],
"extra_options": [ "extra_options": [
"host", "host",
], ],
@@ -88,7 +95,7 @@ class Snowflake(BaseSQLQueryRunner):
if region == "us-west": if region == "us-west":
region = None region = None
if self.configuration.__contains__("host"): if self.configuration.get("host"):
host = self.configuration.get("host") host = self.configuration.get("host")
else: else:
if region: if region:
@@ -96,14 +103,29 @@ class Snowflake(BaseSQLQueryRunner):
else: else:
host = "{}.snowflakecomputing.com".format(account) host = "{}.snowflakecomputing.com".format(account)
connection = snowflake.connector.connect( params = {
user=self.configuration["user"], "user": self.configuration["user"],
password=self.configuration["password"], "account": account,
account=account, "region": region,
region=region, "host": host,
host=host, "application": "Redash/{} (Snowflake)".format(__version__.split("-")[0]),
application="Redash/{} (Snowflake)".format(__version__.split("-")[0]), }
)
if self.configuration.get("password"):
params["password"] = self.configuration["password"]
elif self.configuration.get("private_key_File"):
private_key_b64 = self.configuration.get("private_key_File")
private_key_bytes = b64decode(private_key_b64)
if self.configuration.get("private_key_pwd"):
private_key_pwd = self.configuration.get("private_key_pwd").encode()
else:
private_key_pwd = None
private_key_pem = load_pem_private_key(private_key_bytes, private_key_pwd)
params["private_key"] = private_key_pem
else:
raise Exception("Neither password nor private_key_b64 is set.")
connection = snowflake.connector.connect(**params)
return connection return connection

View File

@@ -28,7 +28,7 @@ class Sqlite(BaseSQLQueryRunner):
def __init__(self, configuration): def __init__(self, configuration):
super(Sqlite, self).__init__(configuration) super(Sqlite, self).__init__(configuration)
self._dbpath = self.configuration["dbpath"] self._dbpath = self.configuration.get("dbpath", "")
def _get_tables(self, schema): def _get_tables(self, schema):
query_table = "select tbl_name from sqlite_master where type='table'" query_table = "select tbl_name from sqlite_master where type='table'"

View File

@@ -1,6 +1,6 @@
import functools import functools
from flask import session from flask import request, session
from flask_login import current_user from flask_login import current_user
from flask_talisman import talisman from flask_talisman import talisman
from flask_wtf.csrf import CSRFProtect, generate_csrf from flask_wtf.csrf import CSRFProtect, generate_csrf
@@ -25,6 +25,7 @@ def init_app(app):
app.config["WTF_CSRF_CHECK_DEFAULT"] = False app.config["WTF_CSRF_CHECK_DEFAULT"] = False
app.config["WTF_CSRF_SSL_STRICT"] = False app.config["WTF_CSRF_SSL_STRICT"] = False
app.config["WTF_CSRF_TIME_LIMIT"] = settings.CSRF_TIME_LIMIT app.config["WTF_CSRF_TIME_LIMIT"] = settings.CSRF_TIME_LIMIT
app.config["SESSION_COOKIE_NAME"] = settings.SESSION_COOKIE_NAME
@app.after_request @app.after_request
def inject_csrf_token(response): def inject_csrf_token(response):
@@ -35,6 +36,15 @@ def init_app(app):
@app.before_request @app.before_request
def check_csrf(): def check_csrf():
# BEGIN workaround until https://github.com/lepture/flask-wtf/pull/419 is merged
if request.blueprint in csrf._exempt_blueprints:
return
view = app.view_functions.get(request.endpoint)
if view is not None and f"{view.__module__}.{view.__name__}" in csrf._exempt_views:
return
# END workaround
if not current_user.is_authenticated or "user_id" in session: if not current_user.is_authenticated or "user_id" in session:
csrf.protect() csrf.protect()

View File

@@ -82,9 +82,19 @@ class QuerySerializer(Serializer):
else: else:
result = [serialize_query(query, **self.options) for query in self.object_or_list] result = [serialize_query(query, **self.options) for query in self.object_or_list]
if self.options.get("with_favorite_state", True): if self.options.get("with_favorite_state", True):
favorite_ids = models.Favorite.are_favorites(current_user.id, self.object_or_list) queries = list(self.object_or_list)
favorites = models.Favorite.query.filter(
models.Favorite.object_id.in_([o.id for o in queries]),
models.Favorite.object_type == "Query",
models.Favorite.user_id == current_user.id,
)
favorites_dict = {fav.object_id: fav for fav in favorites}
for query in result: for query in result:
query["is_favorite"] = query["id"] in favorite_ids favorite = favorites_dict.get(query["id"])
query["is_favorite"] = favorite is not None
if favorite:
query["starred_at"] = favorite.created_at
return result return result
@@ -263,9 +273,19 @@ class DashboardSerializer(Serializer):
else: else:
result = [serialize_dashboard(obj, **self.options) for obj in self.object_or_list] result = [serialize_dashboard(obj, **self.options) for obj in self.object_or_list]
if self.options.get("with_favorite_state", True): if self.options.get("with_favorite_state", True):
favorite_ids = models.Favorite.are_favorites(current_user.id, self.object_or_list) dashboards = list(self.object_or_list)
for obj in result: favorites = models.Favorite.query.filter(
obj["is_favorite"] = obj["id"] in favorite_ids models.Favorite.object_id.in_([o.id for o in dashboards]),
models.Favorite.object_type == "Dashboard",
models.Favorite.user_id == current_user.id,
)
favorites_dict = {fav.object_id: fav for fav in favorites}
for query in result:
favorite = favorites_dict.get(query["id"])
query["is_favorite"] = favorite is not None
if favorite:
query["starred_at"] = favorite.created_at
return result return result

View File

@@ -82,6 +82,7 @@ SESSION_COOKIE_SECURE = parse_boolean(os.environ.get("REDASH_SESSION_COOKIE_SECU
# Whether the session cookie is set HttpOnly. # Whether the session cookie is set HttpOnly.
SESSION_COOKIE_HTTPONLY = parse_boolean(os.environ.get("REDASH_SESSION_COOKIE_HTTPONLY", "true")) SESSION_COOKIE_HTTPONLY = parse_boolean(os.environ.get("REDASH_SESSION_COOKIE_HTTPONLY", "true"))
SESSION_EXPIRY_TIME = int(os.environ.get("REDASH_SESSION_EXPIRY_TIME", 60 * 60 * 6)) SESSION_EXPIRY_TIME = int(os.environ.get("REDASH_SESSION_EXPIRY_TIME", 60 * 60 * 6))
SESSION_COOKIE_NAME = os.environ.get("REDASH_SESSION_COOKIE_NAME", "session")
# Whether the session cookie is set to secure. # Whether the session cookie is set to secure.
REMEMBER_COOKIE_SECURE = parse_boolean(os.environ.get("REDASH_REMEMBER_COOKIE_SECURE") or str(COOKIES_SECURE)) REMEMBER_COOKIE_SECURE = parse_boolean(os.environ.get("REDASH_REMEMBER_COOKIE_SECURE") or str(COOKIES_SECURE))
@@ -135,6 +136,13 @@ FEATURE_POLICY = os.environ.get("REDASH_FEATURE_POLICY", "")
MULTI_ORG = parse_boolean(os.environ.get("REDASH_MULTI_ORG", "false")) MULTI_ORG = parse_boolean(os.environ.get("REDASH_MULTI_ORG", "false"))
# If Redash is behind a proxy it might sometimes receive a X-Forwarded-Proto of HTTP
# even if your actual Redash URL scheme is HTTPS. This will cause Flask to build
# the OAuth redirect URL incorrectly thus failing auth. This is especially common if
# you're behind a SSL/TCP configured AWS ELB or similar.
# This setting will force the URL scheme.
GOOGLE_OAUTH_SCHEME_OVERRIDE = os.environ.get("REDASH_GOOGLE_OAUTH_SCHEME_OVERRIDE", "")
GOOGLE_CLIENT_ID = os.environ.get("REDASH_GOOGLE_CLIENT_ID", "") GOOGLE_CLIENT_ID = os.environ.get("REDASH_GOOGLE_CLIENT_ID", "")
GOOGLE_CLIENT_SECRET = os.environ.get("REDASH_GOOGLE_CLIENT_SECRET", "") GOOGLE_CLIENT_SECRET = os.environ.get("REDASH_GOOGLE_CLIENT_SECRET", "")
GOOGLE_OAUTH_ENABLED = bool(GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET) GOOGLE_OAUTH_ENABLED = bool(GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET)
@@ -340,6 +348,7 @@ default_query_runners = [
"redash.query_runner.oracle", "redash.query_runner.oracle",
"redash.query_runner.e6data", "redash.query_runner.e6data",
"redash.query_runner.risingwave", "redash.query_runner.risingwave",
"redash.query_runner.duckdb",
] ]
enabled_query_runners = array_from_string( enabled_query_runners = array_from_string(
@@ -413,6 +422,7 @@ PAGE_SIZE_OPTIONS = list(
TABLE_CELL_MAX_JSON_SIZE = int(os.environ.get("REDASH_TABLE_CELL_MAX_JSON_SIZE", 50000)) TABLE_CELL_MAX_JSON_SIZE = int(os.environ.get("REDASH_TABLE_CELL_MAX_JSON_SIZE", 50000))
# Features: # Features:
VERSION_CHECK = parse_boolean(os.environ.get("REDASH_VERSION_CHECK", "true"))
FEATURE_DISABLE_REFRESH_QUERIES = parse_boolean(os.environ.get("REDASH_FEATURE_DISABLE_REFRESH_QUERIES", "false")) FEATURE_DISABLE_REFRESH_QUERIES = parse_boolean(os.environ.get("REDASH_FEATURE_DISABLE_REFRESH_QUERIES", "false"))
FEATURE_SHOW_QUERY_RESULTS_COUNT = parse_boolean(os.environ.get("REDASH_FEATURE_SHOW_QUERY_RESULTS_COUNT", "true")) FEATURE_SHOW_QUERY_RESULTS_COUNT = parse_boolean(os.environ.get("REDASH_FEATURE_SHOW_QUERY_RESULTS_COUNT", "true"))
FEATURE_ALLOW_CUSTOM_JS_VISUALIZATIONS = parse_boolean( FEATURE_ALLOW_CUSTOM_JS_VISUALIZATIONS = parse_boolean(

View File

@@ -27,6 +27,7 @@ DATE_FORMAT = os.environ.get("REDASH_DATE_FORMAT", "DD/MM/YY")
TIME_FORMAT = os.environ.get("REDASH_TIME_FORMAT", "HH:mm") TIME_FORMAT = os.environ.get("REDASH_TIME_FORMAT", "HH:mm")
INTEGER_FORMAT = os.environ.get("REDASH_INTEGER_FORMAT", "0,0") INTEGER_FORMAT = os.environ.get("REDASH_INTEGER_FORMAT", "0,0")
FLOAT_FORMAT = os.environ.get("REDASH_FLOAT_FORMAT", "0,0.00") FLOAT_FORMAT = os.environ.get("REDASH_FLOAT_FORMAT", "0,0.00")
NULL_VALUE = os.environ.get("REDASH_NULL_VALUE", "null")
MULTI_BYTE_SEARCH_ENABLED = parse_boolean(os.environ.get("MULTI_BYTE_SEARCH_ENABLED", "false")) MULTI_BYTE_SEARCH_ENABLED = parse_boolean(os.environ.get("MULTI_BYTE_SEARCH_ENABLED", "false"))
JWT_LOGIN_ENABLED = parse_boolean(os.environ.get("REDASH_JWT_LOGIN_ENABLED", "false")) JWT_LOGIN_ENABLED = parse_boolean(os.environ.get("REDASH_JWT_LOGIN_ENABLED", "false"))
@@ -45,6 +46,7 @@ HIDE_PLOTLY_MODE_BAR = parse_boolean(os.environ.get("HIDE_PLOTLY_MODE_BAR", "fal
DISABLE_PUBLIC_URLS = parse_boolean(os.environ.get("REDASH_DISABLE_PUBLIC_URLS", "false")) DISABLE_PUBLIC_URLS = parse_boolean(os.environ.get("REDASH_DISABLE_PUBLIC_URLS", "false"))
settings = { settings = {
"beacon_consent": None,
"auth_password_login_enabled": PASSWORD_LOGIN_ENABLED, "auth_password_login_enabled": PASSWORD_LOGIN_ENABLED,
"auth_saml_enabled": SAML_LOGIN_ENABLED, "auth_saml_enabled": SAML_LOGIN_ENABLED,
"auth_saml_type": SAML_LOGIN_TYPE, "auth_saml_type": SAML_LOGIN_TYPE,
@@ -58,6 +60,7 @@ settings = {
"time_format": TIME_FORMAT, "time_format": TIME_FORMAT,
"integer_format": INTEGER_FORMAT, "integer_format": INTEGER_FORMAT,
"float_format": FLOAT_FORMAT, "float_format": FLOAT_FORMAT,
"null_value": NULL_VALUE,
"multi_byte_search_enabled": MULTI_BYTE_SEARCH_ENABLED, "multi_byte_search_enabled": MULTI_BYTE_SEARCH_ENABLED,
"auth_jwt_login_enabled": JWT_LOGIN_ENABLED, "auth_jwt_login_enabled": JWT_LOGIN_ENABLED,
"auth_jwt_auth_issuer": JWT_AUTH_ISSUER, "auth_jwt_auth_issuer": JWT_AUTH_ISSUER,

View File

@@ -7,6 +7,7 @@ from redash.tasks.general import (
record_event, record_event,
send_mail, send_mail,
sync_user_details, sync_user_details,
version_check,
) )
from redash.tasks.queries import ( from redash.tasks.queries import (
cleanup_query_results, cleanup_query_results,

Some files were not shown because too many files have changed in this diff Show More