Compare commits

...

220 Commits

Author SHA1 Message Date
dependabot[bot]
af71e0ec13 Bump serialize-javascript from 6.0.1 to 6.0.2 in /viz-lib
Bumps [serialize-javascript](https://github.com/yahoo/serialize-javascript) from 6.0.1 to 6.0.2.
- [Release notes](https://github.com/yahoo/serialize-javascript/releases)
- [Commits](https://github.com/yahoo/serialize-javascript/compare/v6.0.1...v6.0.2)

---
updated-dependencies:
- dependency-name: serialize-javascript
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-05 14:32:00 +00: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
github-actions[bot]
49277d27f8 Snapshot: 24.11.0-dev 2024-11-01 00:35:04 +00:00
Yeger
2aae5705c9 don't crash when there is no data (#7208)
* don't crash when there is no data

* Add test
2024-10-31 08:49:57 +00:00
dependabot[bot]
38d0579660 Bump elliptic from 6.5.7 to 6.6.0 (#7214)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-31 02:41:33 +00:00
Ezra Odio
673ba769c7 Fix issue with scheduled queries (#7111)
Co-authored-by: Ezra Odio <eodio@starfishstorage.com>
Co-authored-by: Arik Fraimovich <arik@arikfr.com>
2024-10-29 10:36:05 +00:00
Eric Radman
b922730482 Docker build: use heredoc for multi-line actions (#7210) 2024-10-29 10:23:15 +10:00
Arik Fraimovich
ba973eb1fe Fixes #6767: correctly rehash queries in a migration (#7184) 2024-10-25 01:00:29 +00:00
dependabot[bot]
d8dde6c544 Bump cryptography from 42.0.8 to 43.0.1 (#7205)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-25 00:27:23 +00:00
dependabot[bot]
d359a716a7 Bump http-proxy-middleware from 2.0.6 to 2.0.7 (#7204)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-24 23:56:09 +00:00
dependabot[bot]
ba4293912b Bump snowflake-connector-python from 3.12.0 to 3.12.3 (#7203)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-24 23:23:28 +00:00
thiagogds
ee359120ee Use correct redis connection (#7077) 2024-10-24 17:54:09 +10:00
thiagogds
04a25f4327 Fix RQ wrongly moving jobs to FailedJobRegistry (#7186)
Something changed in python-rq and the old code was behaving in a way that if a job ran for longer than 2 min it would be automatically set as failed, but it would continue running.

This causes a problem in the UI because it is as if the job stopped, but it actually didn't
2024-10-17 13:30:02 -04:00
Eric Radman
7c22756e66 Move restyled to a github action (#7191) 2024-10-16 09:45:25 +03:00
dependabot[bot]
a03668f5b2 Bump restrictedpython from 6.2 to 7.3 (#7181)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-01 13:22:44 +10:00
github-actions[bot]
e4a841a0c5 Snapshot: 24.10.0-dev 2024-10-01 00:34:37 +00:00
Zach Liu
38dc31a49b Get rid of the strange looking 0 following "Running..." and "runtime" (#7099)
* Snapshot: 24.08.0-dev

* no more Running...0 or runtime0

* also missing a space

* Restyled by prettier

* check if data_scanned is defined

otherwise we could get "Data Scanned ?" if it's not supported
by some data sources

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Restyled.io <commits@restyled.io>
2024-09-19 13:25:05 +03:00
Justin Clift
c42b15125c Automatically remove orphans when running make up (#7164) 2024-09-17 05:11:51 +00:00
dependabot[bot]
590d39bc8d Bump dompurify from 2.0.17 to 2.5.4 in /viz-lib (#7163)
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 2.0.17 to 2.5.4.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/2.0.17...2.5.4)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-17 13:54:32 +10:00
Justin Clift
79bbb248bb Update make up to automatically initialise the db (#7161)
It does this by (very quickly) checking if the organization table
is present, running `make create_database` if not.
2024-09-14 16:29:04 +08:00
Zach Liu
5cf0b7b038 Better error msg for token validation (#7159)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-09-14 12:03:20 +10:00
Justin Clift
fb1a056561 Add REDASH_HOST to the docker compose file (#7157)
This ensures emails generated in the development environment have the port number included in their urls.
2024-09-12 10:06:52 +00:00
dependabot[bot]
75e1ce4c9c Bump body-parser from 1.20.1 to 1.20.3 (#7156)
Bumps [body-parser](https://github.com/expressjs/body-parser) from 1.20.1 to 1.20.3.
- [Release notes](https://github.com/expressjs/body-parser/releases)
- [Changelog](https://github.com/expressjs/body-parser/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/body-parser/compare/1.20.1...1.20.3)

---
updated-dependencies:
- dependency-name: body-parser
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-12 15:33:59 +10:00
dependabot[bot]
d6c6e3bb7a Bump express from 4.19.2 to 4.21.0 (#7155)
Bumps [express](https://github.com/expressjs/express) from 4.19.2 to 4.21.0.
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/4.21.0/History.md)
- [Commits](https://github.com/expressjs/express/compare/4.19.2...4.21.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-12 14:46:25 +10:00
dependabot[bot]
821c1a9488 Bump path-to-regexp from 3.2.0 to 3.3.0 (#7154)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-12 12:42:27 +10:00
Zach Liu
76eeea1f64 Make schema refresh timeout configurable via env var (#7114)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-09-09 01:40:35 +00:00
Justin Clift
2ab07f9fc3 Remove left over compose.base.yaml file (#7142) 2024-09-06 17:47:56 +10:00
Justin Clift
a85b9d7801 Update pymssql to fix some problems with macOS ARM64 (2.3.1) (#7140)
Related: https://github.com/pymssql/pymssql/blob/master/ChangeLog.rst
2024-09-04 17:27:02 +09:00
github-actions[bot]
3330815081 Snapshot: 24.09.0-dev 2024-09-01 00:35:07 +00:00
dependabot[bot]
c25c65bc04 Bump webpack from 5.88.2 to 5.94.0 in /viz-lib (#7135)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-31 13:48:45 +10:00
Eric Radman
79a4c4c9c9 Revert "Adding ability to fix table columns in place (#7019)" (#7131) 2024-08-26 22:57:47 +10:00
Justin Clift
58a7438cc8 Bump python-rapidjson to 1.20 (#7126) 2024-08-20 08:35:54 +00:00
Zach Liu
c073c1e154 Fix mismatched poetry version (#7122)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-08-16 16:01:53 +10:00
Justin Clift
159a329e26 Bump elliptic to version 6.5.7 to fix a Dependabot warning (#7120) 2024-08-14 14:11:38 +10:00
Ezra Odio
9de135c0bd Add option to choose color scheme for charts (#7062) 2024-08-08 13:08:49 -04:00
Zach Liu
285c2b6e56 Add data type to athena query runner (#7112)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-08-07 03:36:58 +00:00
dependabot[bot]
b1fe2d4162 Bump sentry-sdk from 1.28.1 to 2.8.0 (#7069)
The Dependabot alert for sentry-sdk says that the security fix has
been backported to the 1.x series as well, in version 1.45.1.

So, lets use that as it should be more compatible that jumping to
a new major series version.
2024-08-06 10:05:21 +10:00
Zach Liu
a4f92a8fb5 Add data type to redshift query runner (#7109)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-08-06 08:43:13 +10:00
Ezra Odio
51ef625a30 Fix alert evaluation logic and issue with calculating min and max of columns without numbers (#7103) 2024-08-05 15:20:26 +00:00
Masayuki Takahashi
a2611b89a3 Fix a display order bug in MongoDB Query Runner (#7106) 2024-08-04 04:22:59 +10:00
SeongTae Jeong
a531597016 Add the option to take new custom version for Snapshot (#7096) 2024-08-02 06:08:16 +00:00
Justin Clift
e59c02f497 Bump bootstrap to 3.4.1
Related:
- https://blog.getbootstrap.com/2018/12/13/bootstrap-3-4-0/
- https://blog.getbootstrap.com/2019/02/13/bootstrap-4-3-1-and-3-4-1/
2024-08-02 13:37:17 +09:00
github-actions[bot]
c1a60bf6d2 Snapshot: 24.08.1-dev 2024-08-02 02:49:08 +00:00
Ariel Richtman
72203655ec update rds trust (#7100) 2024-08-02 11:09:11 +10:00
Eric Radman
5257e39282 Revert "Removed unused configuration class (#6682)" (#7071) 2024-08-01 23:08:09 +00:00
Justin Clift
ec70ff4408 Bump cryptography to 42.0.x & snowflake-connector-python to 3.12.0 (#7097) 2024-08-02 08:35:36 +10:00
Masayuki Takahashi
ed8c05f634 Fix columns duplication on MongoDB Query Runner #6640 (#6641)
Co-authored-by: Konstantin Smirnov <46676677+konnectr@users.noreply.github.com>
2024-08-01 22:34:50 +00:00
Zach Liu
86b75db82e get data size in memory for better logs (#7090) 2024-08-01 12:17:54 -04:00
Ezra Odio
660d04b0f1 Adding Evaluate button for alerts to test them (#7032) 2024-08-01 15:02:12 +00:00
Ezra Odio
fc1e1f7a01 Add min/max/first selector for alerts (#7076) 2024-08-01 10:30:57 -04:00
SeongTae Jeong
8725fa4737 Add support for 'linux/arm64' platforms (#7094)
Co-authored-by: Justin Clift <justin@postgresql.org>
2024-08-01 08:52:45 +00:00
Justin Clift
ea0b3cbe3a Add the asdf .tool-versions file to .gitignore (#7095) 2024-08-01 08:19:11 +00:00
Justin Clift
714b950fde Match FROM and AS capitalisation in Dockerfile (#7093) 2024-08-01 17:48:57 +10:00
github-actions[bot]
a9c9f085af Snapshot: 24.08.0-dev 2024-08-01 00:30:32 +00:00
Ezra Odio
a69f7fb2fe Add new text pattern parameter (#7025)
Co-authored-by: Ezra Odio <eodio@starfishstorage.com>
Co-authored-by: Restyled.io <commits@restyled.io>
2024-07-24 13:20:33 -04:00
Daisuke Taniwaki
c244e75352 Support Arbitrary Catalog IDs on Athena Data Source (#7059)
Co-authored-by: SeongTae Jeong <seongtaejg@gmail.com>
2024-07-24 16:57:27 +10:00
Ezra Odio
80f7ba1b91 Added option to toggle sort on pie charts (#7055)
Co-authored-by: Ezra Odio <eodio@starfishstorage.com>
Co-authored-by: Eric Radman <eradman@starfishstorage.com>
2024-07-22 15:13:00 +00:00
SeongTae Jeong
d2745e5acc Add a label for Restyler's PR and Bump component version (#7037) 2024-07-18 22:00:29 +00:00
Ezra Odio
4114227471 Remove defaults set during schema upgrade/downgrade (#7068) 2024-07-18 16:05:34 -04:00
Ezra Odio
8fc4ce1494 Conditionally render tooltip for Edit alert button (#7054)
* Made Edit alert tooltip render conditionally
2024-07-18 14:31:31 +00:00
Ezra Odio
ebb0e2c9ad Adding ability to fix table columns in place (#7019)
This change involved adding an extra option to the GridSettings editor,
adding the "fixed" option to columns, and adding styling for the fixed
columns. In order to change the number of fixed columns, which will
default to 0, one has to go to Edit visualization -> Grid -> Choose
number of columns to fix -> Save.
2024-07-17 13:59:47 +00:00
dependabot[bot]
57a79bc96b Bump setuptools from 69.0.3 to 70.0.0 (#7060)
Bumps [setuptools](https://github.com/pypa/setuptools) from 69.0.3 to 70.0.0.
- [Release notes](https://github.com/pypa/setuptools/releases)
- [Changelog](https://github.com/pypa/setuptools/blob/main/NEWS.rst)
- [Commits](https://github.com/pypa/setuptools/compare/v69.0.3...v70.0.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-16 05:45:28 +10:00
Justin Clift
77f108dd09 Bump requests to 2.32.3 (#7057) 2024-07-15 09:39:14 +09:00
dependabot[bot]
dd1a9b96da Bump zipp from 3.17.0 to 3.19.1 (#7051)
Bumps [zipp](https://github.com/jaraco/zipp) from 3.17.0 to 3.19.1.
- [Release notes](https://github.com/jaraco/zipp/releases)
- [Changelog](https://github.com/jaraco/zipp/blob/main/NEWS.rst)
- [Commits](https://github.com/jaraco/zipp/compare/v3.17.0...v3.19.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Justin Clift <justin@postgresql.org>
2024-07-12 09:25:12 +00:00
Ezra Odio
d9282b2688 Add usedforsecurity=False flag to md5 hashes (#7049)
Co-authored-by: Ezra Odio <eodio@starfishstorage.com>
Co-authored-by: Justin Clift <justin@postgresql.org>
2024-07-12 03:34:53 +10:00
Eric Radman
28c39219af Update requests module to 2.32.2 (#7053)
2.32.0 was yanked
2024-07-11 16:24:18 +00:00
Ezra Odio
a37ef3b235 Fixed frontend test deprecation warnings (#7013)
Created Moment in ISO 8601 format instead of using
the default Date() constructor.

Co-authored-by: Ezra Odio <eodio@starfishstorage.com>
2024-07-08 14:29:41 +00:00
dependabot[bot]
0056aa68f8 Bump certifi from 2023.11.17 to 2024.7.4 (#7047)
Bumps [certifi](https://github.com/certifi/python-certifi) from 2023.11.17 to 2024.7.4.
- [Commits](https://github.com/certifi/python-certifi/compare/2023.11.17...2024.07.04)

---
updated-dependencies:
- dependency-name: certifi
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-08 22:57:01 +10:00
dependabot[bot]
76b5a30fd9 Bump ws from 5.2.3 to 5.2.4 in /viz-lib (#7040)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-01 01:19:54 +00:00
github-actions[bot]
db4fdd003e Snapshot: 24.07.0-dev 2024-07-01 00:31:06 +00:00
Ezra Odio
4cb32fc1c3 Map() implementation fix for chart labels (#7022)
Co-authored-by: Ezra Odio <eodio@starfishstorage.com>
Co-authored-by: Eric Radman <eradman@starfishstorage.com>
2024-06-18 18:05:37 +00:00
dependabot[bot]
a6c728b99c Bump ws from 5.2.3 to 5.2.4 (#7021)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-18 02:25:45 +00:00
dependabot[bot]
01e036d0a9 Bump urllib3 from 1.26.18 to 1.26.19 (#7020)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-18 01:55:21 +00:00
Eric Radman
17fe69f551 PG: Only list tables where schema has USAGE permission (#7000)
This covers cases where partitioned tables are part of a schema that is
not accessible by the current user.

CREATE SCHEMA xyz;

CREATE TABLE xyz.tab (
   id bigint GENERATED ALWAYS AS IDENTITY,
   ts timestamp NOT NULL
) PARTITION BY LIST ((ts::date));

CREATE TABLE xyz.tab_default PARTITION OF xyz.tab DEFAULT;
2024-06-06 10:49:00 +10:00
Ezra Odio
bceaab0496 Update to Python 3.10 (#6991)
Updated from Python 3.8 to 3.10. Python 3.10 is the default for Ubuntu 22. This change necessitated upgrading to
SQLAlchemy_Utils 0.38.3, and importing the sort_query function from an older version of SQLAlchemy_Utils because it was dropped in newer versions.

Co-authored-by: Ezra Odio <eodio@starfishstorage.com>
2024-06-05 17:41:49 +10:00
Lucas Fernando Cardoso Nunes
70dd05916f ci: bot identity correction (#6997)
Signed-off-by: Lucas Fernando Cardoso Nunes <lucasfc.nunes@gmail.com>
2024-06-02 06:54:08 +10:00
github-actions
60a12e906e Snapshot: 24.06.0-dev 2024-06-01 00:27:53 +00:00
dependabot[bot]
ec051a8939 --- (#6981)
updated-dependencies:
- dependency-name: requests
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-21 06:50:52 +00:00
Arik Fraimovich
60d3c66a8b Merge pull request from GHSA-32fw-wc7f-7qg9 2024-05-18 07:36:29 -07:00
Justin Clift
bd4ba96c43 Typo fix in message (#6979) 2024-05-18 08:18:37 +00:00
Eric Radman
10a46fd33c Revert "show pg and athena column comments and table descriptions as antd tooltip if they are defined (#6582)" (#6971)
This reverts commit c12d45077a.

This commit did not sort tables properly by schema, then name
2024-05-16 11:28:42 +08:00
Eric Radman
c874eb6b11 Revert changes to job status (#6969)
"Query in queue" should switch to "Executing query", but does not.

Commands:

git revert --no-commit bd17662005
git revert --no-commit 5ac5d86f5e
vim tests/handlers/test_query_results.py
git add tests/handlers/test_query_results.py

Co-authored-by: Justin Clift <justin@postgresql.org>
2024-05-14 22:06:45 -04:00
Taehyung Lim
f3a323695f Bump pyodbc from 4.0.28 to 5.1.0 (#6962) 2024-05-14 16:26:28 +00:00
Eric Radman
408ba78bd0 Update MSSQL OBDC driver to v18 (#6968) 2024-05-15 01:55:32 +10:00
Eric Radman
58cc49bc88 Revert build (2 of 2) (#6967) 2024-05-14 12:30:48 +00:00
Eric Radman
753ea846ff Revert CI workflow (1 of 2) (#6965) 2024-05-14 21:54:51 +10:00
Taehyung Lim
1b946b59ec sync .nvmrc with workflow (#6958) 2024-05-10 21:32:52 +10:00
dependabot[bot]
4569191113 Bump jinja2 from 3.1.3 to 3.1.4 (#6951)
Bumps [jinja2](https://github.com/pallets/jinja) from 3.1.3 to 3.1.4.
- [Release notes](https://github.com/pallets/jinja/releases)
- [Changelog](https://github.com/pallets/jinja/blob/main/CHANGES.rst)
- [Commits](https://github.com/pallets/jinja/compare/3.1.3...3.1.4)

---
updated-dependencies:
- dependency-name: jinja2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-07 07:14:20 +10:00
Justin Clift
62890c3ec4 Revert "Remove deprecated advocate package (#6944)"
This reverts commit bd115e7f5f, as
it turns out to be a useful security feature.

In order to remove this in a better way, we'll need to replace it
with something that provides equivalent functionality.
2024-05-07 03:20:05 +10:00
Andrii Chubatiuk
bd115e7f5f Remove deprecated advocate package (#6944) 2024-05-06 23:14:13 +10:00
Andrii Chubatiuk
bd17662005 Fixed error serialization (#6937)
* serialize errors

* lint fix

* cover successful case
2024-05-02 13:52:35 +00:00
Jason Cowley
b7f22b1896 Fix 'str' object has no attribute 'pop' error when parsing query (#6941) 2024-05-02 21:31:23 +10:00
Justin Clift
897c683980 pgautoupgrade now does multi-arch builds (#6939)
Thanks to substantial efforts by @andyundso, the Docker Hub
images for pgautoupgrade are now multi-arch (x86_64 and ARM64). :)
2024-05-01 23:03:06 +10:00
github-actions
2b974e12ed Snapshot: 24.05.0-dev 2024-05-01 00:26:34 +00:00
SeongTae Jeong
372adfed6b Downgrade 'codecov-action' version from v4 to v3 (#6930)
The 'codecov-action@v4' requires an organization-level upload token, not
a single repo upload token, so we're temporarily downgrading it until we
can generate an organization-level upload token.

Reference: https://github.com/codecov/codecov-action/issues/1273
2024-04-26 08:28:20 +00:00
Eric Radman
dbab9cadb4 Source .env when running docker containers (#6927)
Restore previous functionality.

Ensure .env exists before building server.

Co-authored-by: github-actions <github-actions@github.com>
2024-04-25 11:36:03 -04:00
Kim Yann
06244716e6 Flatten all level for MongoDB data source (#6844) 2024-04-25 11:37:35 +00:00
Luciano Vitti
f09760389a aggregate Y column values rather than displaying last Y value (#6908) 2024-04-25 06:21:31 +00:00
Eric Radman
84e6d3cad5 Use staticPath var to fetch unsupportedRedirect.js (#6923)
Use Webpack configuration for locating this asset in the same way that
client/app/index.html does.

This code path is when REDASH_MULTI_ORG=true.

Co-authored-by: github-actions <github-actions@github.com>
2024-04-24 10:57:45 +00:00
Andrii Chubatiuk
3399e3761e mssql-odbc-arm64 (#6924)
Co-authored-by: Peter Lee <yankeeguyu@gmail.com>
2024-04-24 10:05:07 +00:00
Peter Lee
1c48b2218b Update widgets.py (#6926) 2024-04-24 19:37:35 +10:00
Andrii Chubatiuk
5ac5d86f5e consistent rq status naming and handling (#6913)
* consistent rq status naming and handling

* test fix

* make scheduled and deferred statuses cancelable
2024-04-24 13:15:04 +10:00
Marko Stankovic
5e4764af9c bugfix: unable to parse elasticsearch index mappings (#6918) 2024-04-23 18:13:05 +08:00
Eric Radman
e2a39de7d1 Remove workaround from check_csrf() (#6919)
This code was supposed to be temporary, and raises an exception if REDASH_MULTI_ORG=true is set.
2024-04-23 13:14:45 +10:00
Justin Clift
6c68b48917 Add pydeps Makefile target for installing Python dependencies (#6890)
This combines the manual steps needed for installing the Python dependencies into a single Makefile target.
2024-04-18 22:35:01 +10:00
Andrii Chubatiuk
7e8a61c73d Rq upgrade (#6902)
* fix(aws-es): fixed es auth

* fixed lock

* rq v1.16
2024-04-17 17:46:32 +10:00
dependabot[bot]
991e94dd6a Bump gunicorn from 21.2.0 to 22.0.0 (#6900)
Bumps [gunicorn](https://github.com/benoitc/gunicorn) from 21.2.0 to 22.0.0.
- [Release notes](https://github.com/benoitc/gunicorn/releases)
- [Commits](https://github.com/benoitc/gunicorn/compare/21.2.0...22.0.0)

---
updated-dependencies:
- dependency-name: gunicorn
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-17 14:00:59 +10:00
Andrii Chubatiuk
2ffeecb813 fix: aws elasticsearch typo (#6899)
Co-authored-by: Peter Lee <yankeeguyu@gmail.com>
2024-04-17 02:49:39 +00:00
dependabot[bot]
3dd855aef1 Bump sqlparse from 0.4.4 to 0.5.0 (#6895)
Bumps [sqlparse](https://github.com/andialbrecht/sqlparse) from 0.4.4 to 0.5.0.
- [Changelog](https://github.com/andialbrecht/sqlparse/blob/master/CHANGELOG)
- [Commits](https://github.com/andialbrecht/sqlparse/compare/0.4.4...0.5.0)

---
updated-dependencies:
- dependency-name: sqlparse
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-16 13:06:57 +10:00
Justin Clift
713aca440a Extend make up to automatically initialise the database (#6855) 2024-04-13 14:47:41 +10:00
dependabot[bot]
70bb684d9e Bump dnspython from 2.4.2 to 2.6.1 (#6886)
Bumps [dnspython](https://github.com/rthalley/dnspython) from 2.4.2 to 2.6.1.
- [Release notes](https://github.com/rthalley/dnspython/releases)
- [Changelog](https://github.com/rthalley/dnspython/blob/main/doc/whatsnew.rst)
- [Commits](https://github.com/rthalley/dnspython/compare/v2.4.2...v2.6.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-13 13:46:38 +10:00
dependabot[bot]
4034f791c3 Bump pymongo from 4.3.3 to 4.6.3 (#6863)
Bumps [pymongo](https://github.com/mongodb/mongo-python-driver) from 4.3.3 to 4.6.3.
- [Release notes](https://github.com/mongodb/mongo-python-driver/releases)
- [Changelog](https://github.com/mongodb/mongo-python-driver/blob/master/doc/changelog.rst)
- [Commits](https://github.com/mongodb/mongo-python-driver/compare/4.3.3...4.6.3)

---
updated-dependencies:
- dependency-name: pymongo
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-13 03:14:35 +00:00
Justin Clift
b9875a231b Improve the text displayed when using the command line (#6884)
This removes some debugging output, and makes an unexpected text
string useful by explaining what's happening.
2024-04-13 11:51:15 +10:00
Justin Clift
062a70cf20 Change default webUI port back to 5001 (#6883)
This PR changes the default (tcp) port for the web user interface back to port 5001.

The recent change to port 5000 (to match an old default) turned out to be more painful than it's worth.

So, lets keep using port 5001 after all.
2024-04-13 02:02:55 +10:00
Andrii Chubatiuk
c12d45077a show pg and athena column comments and table descriptions as antd tooltip if they are defined (#6582)
* show column comments by default for athena and postgres

* Restyled by prettier

* fixed typo

* fmt fix

* ordered imports

* fixed unit tests

* fixed tests for athena

---------

Co-authored-by: Andrew Chubatiuk <andrew.chubatiuk@motional.com>
Co-authored-by: Restyled.io <commits@restyled.io>
Co-authored-by: Andrii Chubatiuk <wachy@Andriis-MBP-2.lan>
2024-04-12 21:02:15 +10:00
snickerjp
6d6412753d Bump python-oracledb from 2.0.1 to 2.1.2 (#6881) 2024-04-12 10:33:16 +00:00
Andrii Chubatiuk
275e12e7c1 fix: unquote values in compose (#6882) 2024-04-12 20:05:24 +10:00
Andrii Chubatiuk
77d7508cee fixed local setup to run on ARM64 (#6877)
* fixed local setup to run on ARM64

* set local profile in makefile by default

* reverted compose comment for postgres command
2024-04-12 08:10:34 +00:00
Justin Clift
9601660751 Update Node image in Dockerfile to 18-bookworm 2024-04-12 15:08:56 +10:00
dependabot[bot]
45c6fa0591 Bump idna from 3.6 to 3.7 (#6878)
Bumps [idna](https://github.com/kjd/idna) from 3.6 to 3.7.
- [Release notes](https://github.com/kjd/idna/releases)
- [Changelog](https://github.com/kjd/idna/blob/master/HISTORY.rst)
- [Commits](https://github.com/kjd/idna/compare/v3.6...v3.7)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-12 14:58:04 +10:00
Andrii Chubatiuk
95ecb8e229 fix for coverage (#6872)
Co-authored-by: Andrii Chubatiuk <wachy@Andriis-MBP-2.lan>
2024-04-11 05:35:14 +00:00
dependabot[bot]
cb0707176c Bump tar from 6.1.15 to 6.2.1 (#6866)
Bumps [tar](https://github.com/isaacs/node-tar) from 6.1.15 to 6.2.1.
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v6.1.15...v6.2.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-11 06:47:08 +10:00
Andrii Chubatiuk
d7247f8b84 use default docker repo name if variable is not defined (#6870)
Co-authored-by: Andrii Chubatiuk <wachy@Andriis-MBP-2.lan>
2024-04-11 06:15:43 +10:00
Andrii Chubatiuk
776703fab7 filter widget results to fix tests during repeatable execution (#6693)
* filter widged results to fix tests during repeatable execution

* minor fix

* made queryName variable

---------

Co-authored-by: Andrew Chubatiuk <andrew.chubatiuk@motional.com>
2024-04-10 23:14:47 +10:00
Andrii Chubatiuk
34cde71238 fix percy for a branch (#6868)
Co-authored-by: Andrii Chubatiuk <wachy@Andriis-MBP-2.lan>
2024-04-10 21:10:41 +10:00
Andrii Chubatiuk
f631075be3 reverted e2e secrets (#6867)
Co-authored-by: Andrii Chubatiuk <wachy@Andriis-MBP-2.lan>
2024-04-10 20:16:31 +10:00
Andrii Chubatiuk
3f19534301 reuse built frontend in ci, merge compose files (#6674)
* reuse built frontend in ci, merge compose files

* pr comments

* added make create_db alias to create_database

* fixed lint

---------

Co-authored-by: Andrii Chubatiuk <wachy@Andriis-MBP-2.lan>
2024-04-10 19:53:14 +10:00
Justin Clift
24dec192ee Update yarn to current latest in 1.22.x series (#6858)
* Update yarn to current latest in 1.22.x series

* Use an environment variable for the yarn version

As suggested by @lucydodo:

  https://github.com/getredash/redash/pull/6858#discussion_r1555131358

Thanks heaps. :)
2024-04-08 01:52:45 +00:00
Ran Benita
82d88ed4eb Bump gunicorn from 20.0.4 to 21.2.0 (#6856)
The version 20.0.4 has a security issue:
https://grenfeldt.dev/2021/04/01/gunicorn-20.0.4-request-smuggling/

Changelog:
https://docs.gunicorn.org/en/stable/news.html
2024-04-08 06:26:02 +10:00
Justin Clift
af0773c58a Update "make clean" to remove Redash dev Docker images (#6847)
Also added a "make clean-all" target to remove the related containers
2024-04-07 12:14:38 +10:00
Justin Clift
15e6583d72 Automatically use the latest version of PostgreSQL (#6851) 2024-04-05 10:46:12 -04:00
Justin Clift
4eb5f4e47f Remove version check and all of the data sharing (#6852) 2024-04-06 00:02:31 +10:00
Eric Radman
a0f5c706ff Remove Qubole query runner (#6848)
The qds-sdk-py package along with the rest of the Qubole project is no longer
maintained:

3c6a34ce33

Removing this eliminates these warnings when running Redash management commands:

./qds_sdk/commands.py:1124: SyntaxWarning: "is" with a literal. Did you mean "=="?
  if options.mode is "1":
./qds_sdk/commands.py:1137: SyntaxWarning: "is" with a literal. Did you mean "=="?
  if options.db_update_mode is "updateonly":
./qds_sdk/commands.py:1424: SyntaxWarning: "is" with a literal. Did you mean "=="?
  if (total is 0) or (downloaded == total):

Co-authored-by: github-actions <github-actions@github.com>
2024-04-04 07:52:18 +10:00
Will Lachance
702a550659 Handle timedelta in query results (#6846) 2024-04-03 15:44:08 +00:00
Eric Radman
38a06c7ab9 Autoformat hyperlinks in Slack alerts (#6845)
Format the Slack message using the "mrkdwn" type, which will make
hyperlinks clickable.

New test for Slack destination.

Co-authored-by: github-actions <github-actions@github.com>
2024-04-03 01:31:55 +00:00
Eric Radman
a6074878bb Use setup-python@v5, setup-node@v4 (#6842)
To avoid warnings in the CI pipeline

> Node.js 16 actions are deprecated. Please update the following actions to use Node.js 20

Co-authored-by: github-actions <github-actions@github.com>
2024-04-01 23:37:37 +00:00
github-actions
fb348c7116 Snapshot: 24.04.0-dev 2024-04-01 00:27:13 +00:00
Will Lachance
24419863ec Handle decimal types in query results (#6837)
Since #6687, we don't serialize query results as JSON
before returning them. This is fine, except for the
query results data source which needs to pass the
data directly to sqlite3, and doesn't know how to
do that with the decimal types that are occasionally
returned by (at least) the PostgreSQL query runner:

https://www.psycopg.org/docs/faq.html#problems-with-type-conversions
2024-03-29 17:51:14 +10:00
dependabot[bot]
c4d3d9c683 Bump express from 4.18.2 to 4.19.2 (#6838)
Bumps [express](https://github.com/expressjs/express) from 4.18.2 to 4.19.2.
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/master/History.md)
- [Commits](https://github.com/expressjs/express/compare/4.18.2...4.19.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-29 17:14:56 +10:00
dependabot[bot]
1672cd9280 Bump webpack-dev-middleware from 5.3.3 to 5.3.4 (#6829)
Bumps [webpack-dev-middleware](https://github.com/webpack/webpack-dev-middleware) from 5.3.3 to 5.3.4.
- [Release notes](https://github.com/webpack/webpack-dev-middleware/releases)
- [Changelog](https://github.com/webpack/webpack-dev-middleware/blob/v5.3.4/CHANGELOG.md)
- [Commits](https://github.com/webpack/webpack-dev-middleware/compare/v5.3.3...v5.3.4)

---
updated-dependencies:
- dependency-name: webpack-dev-middleware
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-24 10:20:14 +10:00
Eric Radman
6575a6499a BigQuery: use default for useQueryAnnotation option (#6824)
This option may not be set after an upgrade

Co-authored-by: github-actions <github-actions@github.com>
2024-03-21 20:36:09 +00:00
dependabot[bot]
e360e4658e Bump jwcrypto from 1.5.1 to 1.5.6 (#6816)
Bumps [jwcrypto](https://github.com/latchset/jwcrypto) from 1.5.1 to 1.5.6.
- [Release notes](https://github.com/latchset/jwcrypto/releases)
- [Commits](https://github.com/latchset/jwcrypto/compare/v1.5.1...v1.5.6)

---
updated-dependencies:
- dependency-name: jwcrypto
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-20 07:40:23 +10:00
dependabot[bot]
107933c363 Bump follow-redirects from 1.15.5 to 1.15.6 in /viz-lib (#6813)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.5 to 1.15.6.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.5...v1.15.6)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Peter Lee <yankeeguyu@gmail.com>
2024-03-19 23:38:47 +10:00
Vladislav Denisov
667a696ca5 ClickHouse query runner: fixed error message (#6764)
* Snapshot: 23.11.0-dev

* Snapshot: 23.12.0-dev

* Snapshot: 24.01.0-dev

* Snapshot: 24.02.0-dev

* clickhouse: check for `exception` field in response

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Vladislav Denisov <denisov@sports.ru>
Co-authored-by: Guido Petri <18634426+guidopetri@users.noreply.github.com>
Co-authored-by: Peter Lee <yankeeguyu@gmail.com>
2024-03-18 02:15:49 +10:00
Robin Edwards
7d0d242072 schedule may not contain an until key (#6771) 2024-03-15 09:00:19 +10:00
Arun Govind M
d554136f70 fix: Uncaught rejection promise error in Edit Visualization Dialog Modal (#6794) 2024-03-07 10:01:51 +10:00
Stefan Negele
34723e2f3e Add RisingWave support (#6776) 2024-03-05 17:29:47 +08:00
dependabot[bot]
11794b3fe3 Bump es5-ext from 0.10.53 to 0.10.63 in /viz-lib (#6782)
Bumps [es5-ext](https://github.com/medikoo/es5-ext) from 0.10.53 to 0.10.63.
- [Release notes](https://github.com/medikoo/es5-ext/releases)
- [Changelog](https://github.com/medikoo/es5-ext/blob/main/CHANGELOG.md)
- [Commits](https://github.com/medikoo/es5-ext/compare/v0.10.53...v0.10.63)

---
updated-dependencies:
- dependency-name: es5-ext
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-02 04:46:01 +00:00
Eric Radman
3997916d77 Always push images to hub.docker.com/u/redash (#6792)
Using github.repository_owner name was convenient for testing this
action, but is not correct since account names do not match.

Git Hub: getredash/
Docker Hub: redash/

Co-authored-by: github-actions <github-actions@github.com>
2024-03-01 21:42:15 +00:00
dependabot[bot]
b09a2256dc Bump es5-ext from 0.10.53 to 0.10.63 (#6784)
Bumps [es5-ext](https://github.com/medikoo/es5-ext) from 0.10.53 to 0.10.63.
- [Release notes](https://github.com/medikoo/es5-ext/releases)
- [Changelog](https://github.com/medikoo/es5-ext/blob/main/CHANGELOG.md)
- [Commits](https://github.com/medikoo/es5-ext/compare/v0.10.53...v0.10.63)

---
updated-dependencies:
- dependency-name: es5-ext
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-01 18:51:54 +00:00
Eric Radman
95a45bb4dc Snapshot: 24.03.0-dev (#6791)
Co-authored-by: github-actions <github-actions@github.com>
2024-03-02 04:21:22 +10:00
Eric Radman
7cd03c797c Publish preview Docker image when release candidate is tagged (#6787)
* Only respond to new tags ending with -dev
* Use github account name to allow easier testing in a fork
* Allow preview image to be referenced by a specific tag, or by latest tag

redash/preview:24.02.0-dev
redash/redash:preview

Co-authored-by: github-actions <github-actions@github.com>
2024-03-01 16:05:12 +00:00
Eric Radman
1200f9887a Use SSH deployment key to bump version and tag release candiate (#6789)
To allow this workflow to run even though normal contributors
are required to create a pull request.

Steps:

1. Generate SSH key pair: ssh-keygen -t ed25519. No need for passphrases etc.
2. Add public key (.pub one) as a deploy key at Your repo -> Settings ->
   Security -> Deploy keys, check "Allow write access".
3. Add private key as a secret at Your repo -> Settings -> Security -> Secrets
   and variables -> Actions

https://stackoverflow.com/a/76135647/1809872

Co-authored-by: github-actions <github-actions@github.com>
2024-03-02 00:51:51 +10:00
Dirk van Donkelaar
81d22f1eb2 Add limit option for MSSQL query runner (#6704)
* Add limit option for MSSQL query runner

* Fixed linting errors
2024-02-27 06:16:54 +10:00
Eric Radman
2fe0326280 Allow WebPack configuration when building in Docker (#6780)
Co-authored-by: github-actions <github-actions@github.com>
2024-02-27 05:28:39 +10:00
Andrii Chubatiuk
094984f564 Node 18 (#6752)
* Snapshot: 24.02.0-dev

* node-18

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Andrew Chubatiuk <andrew.chubatiuk@motional.com>
2024-02-22 22:10:20 +00:00
dependabot[bot]
52cd6ff006 Bump axios from 0.27.2 to 0.28.0 in /viz-lib (#6775)
Bumps [axios](https://github.com/axios/axios) from 0.27.2 to 0.28.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v0.28.0/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v0.27.2...v0.28.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-23 04:01:43 +10:00
Andrii Chubatiuk
939bec2114 Load custom encoder only when runner enabled (#6748)
* Snapshot: 24.02.0-dev

* load encoders only for enabled runners

* try importing within init

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Andrew Chubatiuk <andrew.chubatiuk@motional.com>
Co-authored-by: Guido Petri <18634426+guidopetri@users.noreply.github.com>
2024-02-09 21:49:59 -05:00
Andrii Chubatiuk
320fddfd52 Changed checkout commit (#6749)
* Snapshot: 24.02.0-dev

* changed checkout commit

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Andrew Chubatiuk <andrew.chubatiuk@motional.com>
2024-02-07 03:15:13 +10:00
Guido Petri
ab39283ae6 fix linting (#6745) 2024-02-05 19:47:25 -06:00
Eric Radman
6386905616 Revert example message for "Custom rule for hiding filter" (#6709)
Partialy reverts
 Hide filter components on shared pages
 https://github.com/getredash/redash/pull/6115

The "hide_filter" feature is incomplete, and the example text is
confusing, adds clutter to the share dashboard dialog.

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Guido Petri <18634426+guidopetri@users.noreply.github.com>
2024-02-05 20:38:17 -05:00
Andrii Chubatiuk
d986b976e5 fixed custom json encoders (#6741)
Co-authored-by: Andrew Chubatiuk <andrew.chubatiuk@motional.com>
2024-02-05 20:34:45 -05:00
Lucas Fernando Cardoso Nunes
a600921c0b feat: avoid npm usage (#6742)
Signed-off-by: Lucas Fernando Cardoso Nunes <lucasfc.nunes@gmail.com>
2024-02-05 20:31:26 -05:00
Lucas Fernando Cardoso Nunes
af2f4af8a2 refactor: use docker compose recommendations (#5854)
Signed-off-by: Lucas Fernando Cardoso Nunes <lucasfc.nunes@gmail.com>
2024-02-04 07:22:40 -06:00
Eric Radman
49a5e74283 Snapshot: 24.02.0-dev (#6740)
Co-authored-by: github-actions <github-actions@github.com>
2024-02-03 01:03:46 +00:00
Andrii Chubatiuk
b98b5f2ba4 Switch to pull_request_target events to hide cypress secrets (#6716)
Co-authored-by: Andrew Chubatiuk <andrew.chubatiuk@motional.com>
Co-authored-by: Justin Clift <justin@postgresql.org>
2024-01-30 14:51:46 +10:00
Dirk van Donkelaar
d245ff7bb1 Fixed notification template (#6721)
* Fixed notification template

* Made if-clause equal to append

Like Slack and email notification

* Add custom_body attribute to discord test

* Add missing attribute
2024-01-29 22:15:42 -05:00
Andrii Chubatiuk
97db492531 Removed unused configuration class (#6682)
Co-authored-by: Andrew Chubatiuk <andrew.chubatiuk@motional.com>
2024-01-18 21:50:27 +10:00
Dirk van Donkelaar
30e7392933 Fix UnicodeEncodeError for non-Latin characters (#6715) 2024-01-18 08:15:11 +00:00
Andrii Chubatiuk
a54171f2c2 cast query_results data back to text (#6713)
Co-authored-by: Andrew Chubatiuk <andrew.chubatiuk@motional.com>
2024-01-18 11:13:47 +10:00
Robin Edwards
cd03da3260 Cast to JSONB type to match altered column type (#6707) 2024-01-16 02:23:35 +10:00
dependabot[bot]
4c47bef582 Bump jinja2 from 3.1.2 to 3.1.3 (#6700)
Bumps [jinja2](https://github.com/pallets/jinja) from 3.1.2 to 3.1.3.
- [Release notes](https://github.com/pallets/jinja/releases)
- [Changelog](https://github.com/pallets/jinja/blob/main/CHANGES.rst)
- [Commits](https://github.com/pallets/jinja/compare/3.1.2...3.1.3)

---
updated-dependencies:
- dependency-name: jinja2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Justin Clift <justin@postgresql.org>
2024-01-12 10:08:25 +10:00
Andrii Chubatiuk
ec1c4d07de Removed pseudojson class, converted all options and other json columns to jsonb ones (#6687)
Co-authored-by: Andrew Chubatiuk <andrew.chubatiuk@motional.com>
2024-01-12 09:02:00 +10:00
Andrii Chubatiuk
4d5103978b Removed simplejson (#6685)
* removed simplejson

* minor fix

* fixed lint

---------

Co-authored-by: Andrii Chubatiuk <wachy@Andriis-MBP-2.lan>
Co-authored-by: Andrew Chubatiuk <andrew.chubatiuk@motional.com>
2024-01-11 17:47:33 +10:00
dependabot[bot]
3c2c2786ed Bump follow-redirects from 1.15.2 to 1.15.4 in /viz-lib (#6699)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.2 to 1.15.4.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.2...v1.15.4)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-10 21:15:45 +00:00
snickerjp
cd482e780a Bump python-oracledb from 1.4.0 to 2.0.1 (#6698) 2024-01-10 20:44:18 +00:00
Eric Radman
4d81c3148d Update query hash with parameters applied (#6683)
This allows queries with parameters to run on a schedule since the hash
used to update the query_result will match.

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Guido Petri <18634426+guidopetri@users.noreply.github.com>
2024-01-10 03:46:31 +00:00
Eric Radman
1b1b9bd98d Import all Plotly visualizations (#6673)
- Accessible using the Custom chart type
- Disable 'fs' and 'path' modules which are available in node, but not
  on the frontend.

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Guido Petri <18634426+guidopetri@users.noreply.github.com>
2024-01-07 20:19:58 +00:00
dependabot[bot]
473cf29c9f Bump pycryptodome from 3.19.0 to 3.19.1 (#6694)
Bumps [pycryptodome](https://github.com/Legrandin/pycryptodome) from 3.19.0 to 3.19.1.
- [Release notes](https://github.com/Legrandin/pycryptodome/releases)
- [Changelog](https://github.com/Legrandin/pycryptodome/blob/master/Changelog.rst)
- [Commits](https://github.com/Legrandin/pycryptodome/compare/v3.19.0...v3.19.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-06 10:33:30 +10:00
Andrii Chubatiuk
cbde237b12 removed explicit object inheritance (#6686)
* removed explicit object inheritance

* minor fix

* pr comments

---------

Co-authored-by: Andrew Chubatiuk <andrew.chubatiuk@motional.com>
2024-01-05 19:52:02 +09:00
Eric Radman
998dc31eb0 Snapshot: 24.01.0-dev (#6681)
Co-authored-by: github-actions <github-actions@github.com>
2024-01-03 00:43:01 +10:00
dependabot[bot]
2505e8ab3b Bump jwcrypto from 1.5.0 to 1.5.1 (#6679)
Bumps [jwcrypto](https://github.com/latchset/jwcrypto) from 1.5.0 to 1.5.1.
- [Release notes](https://github.com/latchset/jwcrypto/releases)
- [Commits](https://github.com/latchset/jwcrypto/compare/v1.5.0...v1.5.1)

---
updated-dependencies:
- dependency-name: jwcrypto
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-29 11:32:41 +09:00
fabrei
858fc4d78f Adds influxdb v2 query runner as a default query runner. (#6669)
* Adds influxdb v2 query runner as a default query runner.

* Deletes TableList object for typing, because it is possible, that the import of influxdb client fails.

---------

Co-authored-by: Masayuki Takahashi <masayuki038@gmail.com>
2023-12-20 21:46:12 +09:00
dependabot[bot]
3e500ea18e Bump paramiko from 3.3.1 to 3.4.0 (#6670)
Bumps [paramiko](https://github.com/paramiko/paramiko) from 3.3.1 to 3.4.0.
- [Commits](https://github.com/paramiko/paramiko/compare/3.3.1...3.4.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-20 19:21:40 +09:00
fabrei
58bf96c298 Adds ssl support for prometheus query runner. (#6657)
* Adds ssl support for prometheus query runner.

- Adds possibilty to upload and use of ssl cert, key and ca file in redash ui

* Extends test cases for prometheus query runner.

- Adds secret attribute to configuration schema.

* Fixes wrong timestamps in different timezones in prometheus' testcases.

- Dynamically calculates timestamps in testcases to be robust in
  different timezones.
- Adds now datetime function to make it more testable.

* Fixes timestamp in prometheus' testcases which can be wrong depending on timezone.

---------

Co-authored-by: Masayuki Takahashi <masayuki038@gmail.com>
2023-12-17 21:58:16 +09:00
fabrei
66ef942572 Adds influxdb v2 query runner. (#6646)
* Adds influxdb v2 query runner.

- Adds test cases
- Adds influxdb v2 icon
- Updates python dependencies

* Fixes import order.

* Fixes code formatting for black tool.

* Adds influxdb version 2 support in readme.

---------

Co-authored-by: Fabian Reiber <reiber@dfn-cert.de>
Co-authored-by: Masayuki Takahashi <masayuki038@gmail.com>
2023-12-12 23:01:50 +09:00
Masayuki Takahashi
9bbdb4b765 Show an error message on "Test Connection" failure for Google Spreadsheet Query Runner (#6652) 2023-12-10 00:42:46 +09:00
239 changed files with 10106 additions and 6210 deletions

View File

@@ -1,11 +1,11 @@
FROM cypress/browsers:node16.18.0-chrome90-ff88
FROM cypress/browsers:node18.12.0-chrome106-ff106
ENV APP /usr/src/app
WORKDIR $APP
COPY package.json yarn.lock .yarnrc $APP/
COPY viz-lib $APP/viz-lib
RUN npm install yarn@1.22.19 -g && yarn --frozen-lockfile --network-concurrency 1 > /dev/null
RUN npm install yarn@1.22.22 -g && yarn --frozen-lockfile --network-concurrency 1 > /dev/null
COPY . $APP

View File

@@ -1,4 +1,3 @@
version: '2.2'
services:
redash:
build: ../
@@ -19,7 +18,7 @@ services:
image: redis:7-alpine
restart: unless-stopped
postgres:
image: pgautoupgrade/pgautoupgrade:15-alpine3.8
image: pgautoupgrade/pgautoupgrade:latest
command: "postgres -c fsync=off -c full_page_writes=off -c synchronous_commit=OFF"
restart: unless-stopped
environment:

View File

@@ -1,4 +1,3 @@
version: "2.2"
x-redash-service: &redash-service
build:
context: ../
@@ -67,7 +66,7 @@ services:
image: redis:7-alpine
restart: unless-stopped
postgres:
image: pgautoupgrade/pgautoupgrade:15-alpine3.8
image: pgautoupgrade/pgautoupgrade:latest
command: "postgres -c fsync=off -c full_page_writes=off -c synchronous_commit=OFF"
restart: unless-stopped
environment:

View File

@@ -1,5 +1,4 @@
client/.tmp/
client/dist/
node_modules/
viz-lib/node_modules/
.tmp/

View File

@@ -4,16 +4,23 @@ on:
branches:
- master
pull_request:
branches:
- master
env:
NODE_VERSION: 16.20.1
NODE_VERSION: 18
YARN_VERSION: 1.22.22
jobs:
backend-lint:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
- if: github.event.pull_request.mergeable == 'false'
name: Exit if PR is not mergeable
run: exit 1
- uses: actions/checkout@v4
with:
fetch-depth: 1
- uses: actions/setup-python@v4
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-python@v5
with:
python-version: '3.8'
- run: sudo pip install black==23.1.0 ruff==0.0.287
@@ -24,14 +31,18 @@ jobs:
runs-on: ubuntu-22.04
needs: backend-lint
env:
COMPOSE_FILE: .ci/docker-compose.ci.yml
COMPOSE_FILE: .ci/compose.ci.yaml
COMPOSE_PROJECT_NAME: redash
COMPOSE_DOCKER_CLI_BUILD: 1
DOCKER_BUILDKIT: 1
steps:
- uses: actions/checkout@v3
- if: github.event.pull_request.mergeable == 'false'
name: Exit if PR is not mergeable
run: exit 1
- uses: actions/checkout@v4
with:
fetch-depth: 1
ref: ${{ github.event.pull_request.head.sha }}
- name: Build Docker Images
run: |
set -x
@@ -49,15 +60,17 @@ jobs:
mkdir -p /tmp/test-results/unit-tests
docker cp tests:/app/coverage.xml ./coverage.xml
docker cp tests:/app/junit.xml /tmp/test-results/unit-tests/results.xml
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v3
# - name: Upload coverage reports to Codecov
# uses: codecov/codecov-action@v3
# with:
# token: ${{ secrets.CODECOV_TOKEN }}
- name: Store Test Results
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: test-results
name: backend-test-results
path: /tmp/test-results
- name: Store Coverage Results
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage.xml
@@ -65,39 +78,47 @@ jobs:
frontend-lint:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
- if: github.event.pull_request.mergeable == 'false'
name: Exit if PR is not mergeable
run: exit 1
- uses: actions/checkout@v4
with:
fetch-depth: 1
- uses: actions/setup-node@v3
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'yarn'
- name: Install Dependencies
run: |
npm install --global --force yarn@1.22.19
npm install --global --force yarn@$YARN_VERSION
yarn cache clean && yarn --frozen-lockfile --network-concurrency 1
- name: Run Lint
run: yarn lint:ci
- name: Store Test Results
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: test-results
name: frontend-test-results
path: /tmp/test-results
frontend-unit-tests:
runs-on: ubuntu-22.04
needs: frontend-lint
steps:
- uses: actions/checkout@v3
- if: github.event.pull_request.mergeable == 'false'
name: Exit if PR is not mergeable
run: exit 1
- uses: actions/checkout@v4
with:
fetch-depth: 1
- uses: actions/setup-node@v3
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'yarn'
- name: Install Dependencies
run: |
npm install --global --force yarn@1.22.19
npm install --global --force yarn@$YARN_VERSION
yarn cache clean && yarn --frozen-lockfile --network-concurrency 1
- name: Run App Tests
run: yarn test
@@ -109,18 +130,22 @@ jobs:
runs-on: ubuntu-22.04
needs: frontend-lint
env:
COMPOSE_FILE: .ci/docker-compose.cypress.yml
COMPOSE_FILE: .ci/compose.cypress.yaml
COMPOSE_PROJECT_NAME: cypress
PERCY_TOKEN_ENCODED: ZGRiY2ZmZDQ0OTdjMzM5ZWE0ZGQzNTZiOWNkMDRjOTk4Zjg0ZjMxMWRmMDZiM2RjOTYxNDZhOGExMjI4ZDE3MA==
CYPRESS_PROJECT_ID_ENCODED: OTI0Y2th
CYPRESS_RECORD_KEY_ENCODED: YzA1OTIxMTUtYTA1Yy00NzQ2LWEyMDMtZmZjMDgwZGI2ODgx
CYPRESS_INSTALL_BINARY: 0
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: 1
# PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }}
# CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }}
# CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
steps:
- uses: actions/checkout@v3
- if: github.event.pull_request.mergeable == 'false'
name: Exit if PR is not mergeable
run: exit 1
- uses: actions/checkout@v4
with:
fetch-depth: 1
- uses: actions/setup-node@v3
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'yarn'
@@ -130,7 +155,7 @@ jobs:
echo "CODE_COVERAGE=true" >> "$GITHUB_ENV"
- name: Install Dependencies
run: |
npm install --global --force yarn@1.22.19
npm install --global --force yarn@$YARN_VERSION
yarn cache clean && yarn --frozen-lockfile --network-concurrency 1
- name: Setup Redash Server
run: |
@@ -146,93 +171,7 @@ jobs:
- name: Copy Code Coverage Results
run: docker cp cypress:/usr/src/app/coverage ./coverage || true
- name: Store Coverage Results
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage
build-skip-check:
runs-on: ubuntu-22.04
outputs:
skip: ${{ steps.skip-check.outputs.skip }}
steps:
- name: Skip?
id: skip-check
run: |
if [[ "${{ vars.DOCKER_USER }}" == '' ]]; then
echo 'Docker user is empty. Skipping build+push'
echo skip=true >> "$GITHUB_OUTPUT"
elif [[ "${{ secrets.DOCKER_PASS }}" == '' ]]; then
echo 'Docker password is empty. Skipping build+push'
echo skip=true >> "$GITHUB_OUTPUT"
elif [[ "${{ github.ref_name }}" != 'master' ]]; then
echo 'Ref name is not `master`. Skipping build+push'
echo skip=true >> "$GITHUB_OUTPUT"
else
echo 'Docker user and password are set and branch is `master`.'
echo 'Building + pushing `preview` image.'
echo skip=false >> "$GITHUB_OUTPUT"
fi
build-docker-image:
runs-on: ubuntu-22.04
needs:
- backend-unit-tests
- frontend-unit-tests
- frontend-e2e-tests
- build-skip-check
if: needs.build-skip-check.outputs.skip == 'false'
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 1
- uses: actions/setup-node@v3
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'yarn'
- name: Install Dependencies
run: |
npm install --global --force yarn@1.22.19
yarn cache clean && yarn --frozen-lockfile --network-concurrency 1
- name: Set up QEMU
timeout-minutes: 1
uses: docker/setup-qemu-action@v2.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ vars.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASS }}
- name: Bump version
id: version
run: |
set -x
.ci/update_version
VERSION=$(jq -r .version package.json)
VERSION_TAG="${VERSION}.b${GITHUB_RUN_ID}.${GITHUB_RUN_NUMBER}"
echo "VERSION_TAG=$VERSION_TAG" >> "$GITHUB_OUTPUT"
- name: Build and push preview image to Docker Hub
uses: docker/build-push-action@v4
with:
push: true
tags: |
redash/redash:preview
redash/preview:${{ steps.version.outputs.VERSION_TAG }}
context: .
build-args: |
test_all_deps=true
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64
env:
DOCKER_CONTENT_TRUST: true
- name: "Failure: output container logs to console"
if: failure()
run: docker compose logs

View File

@@ -1,11 +1,24 @@
name: Periodic Snapshot
# 10 minutes after midnight on the first of every month
on:
schedule:
- cron: "10 0 1 * *"
- cron: '10 0 1 * *' # 10 minutes after midnight on the first of every month
workflow_dispatch:
inputs:
bump:
description: 'Bump the last digit of the version'
required: false
type: boolean
version:
description: 'Specific version to set'
required: false
default: ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
permissions:
actions: write
contents: write
jobs:
@@ -13,14 +26,60 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ssh-key: ${{ secrets.ACTION_PUSH_KEY }}
- run: |
date="$(date +%y.%m).0-dev"
gawk -i inplace -F: -v q=\" -v tag=$date '/^ "version": / { print $1 FS, q tag q ","; next} { print }' package.json
gawk -i inplace -F= -v q=\" -v tag=$date '/^__version__ =/ { print $1 FS, q tag q; next} { print }' redash/__init__.py
gawk -i inplace -F= -v q=\" -v tag=$date '/^version =/ { print $1 FS, q tag q; next} { print }' pyproject.toml
git config user.name github-actions
git config user.email github-actions@github.com
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
# Function to bump the version
bump_version() {
local version="$1"
local IFS=.
read -r major minor patch <<< "$version"
patch=$((patch + 1))
echo "$major.$minor.$patch-dev"
}
# Determine the new version tag
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
BUMP_INPUT="${{ github.event.inputs.bump }}"
SPECIFIC_VERSION="${{ github.event.inputs.version }}"
# Check if both bump and specific version are provided
if [ "$BUMP_INPUT" = "true" ] && [ -n "$SPECIFIC_VERSION" ]; then
echo "::error::Error: Cannot specify both bump and specific version."
exit 1
fi
if [ -n "$SPECIFIC_VERSION" ]; then
TAG_NAME="$SPECIFIC_VERSION-dev"
elif [ "$BUMP_INPUT" = "true" ]; then
CURRENT_VERSION=$(grep '"version":' package.json | awk -F\" '{print $4}')
TAG_NAME=$(bump_version "$CURRENT_VERSION")
else
echo "No version bump or specific version provided for manual dispatch."
exit 1
fi
else
TAG_NAME="$(date +%y.%m).0-dev"
fi
echo "New version tag: $TAG_NAME"
# Update version in files
gawk -i inplace -F: -v q=\" -v tag=${TAG_NAME} '/^ "version": / { print $1 FS, q tag q ","; next} { print }' package.json
gawk -i inplace -F= -v q=\" -v tag=${TAG_NAME} '/^__version__ =/ { print $1 FS, q tag q; next} { print }' redash/__init__.py
gawk -i inplace -F= -v q=\" -v tag=${TAG_NAME} '/^version =/ { print $1 FS, q tag q; next} { print }' pyproject.toml
git add package.json redash/__init__.py pyproject.toml
git commit -m "Snapshot: ${date}"
git tag $date
git push --atomic origin master refs/tags/$date
git commit -m "Snapshot: ${TAG_NAME}"
git tag ${TAG_NAME}
git push --atomic origin master refs/tags/${TAG_NAME}
# Run the 'preview-image' workflow if run this workflow manually
# For more information, please see the: https://docs.github.com/en/actions/security-guides/automatic-token-authentication
if [ "$BUMP_INPUT" = "true" ] || [ -n "$SPECIFIC_VERSION" ]; then
gh workflow run preview-image.yml --ref $TAG_NAME
fi

182
.github/workflows/preview-image.yml vendored Normal file
View File

@@ -0,0 +1,182 @@
name: Preview Image
on:
push:
tags:
- '*-dev'
workflow_dispatch:
inputs:
dockerRepository:
description: 'Docker repository'
required: true
default: 'preview'
type: choice
options:
- preview
- redash
env:
NODE_VERSION: 18
jobs:
build-skip-check:
runs-on: ubuntu-22.04
outputs:
skip: ${{ steps.skip-check.outputs.skip }}
steps:
- name: Skip?
id: skip-check
run: |
if [[ "${{ vars.DOCKER_USER }}" == '' ]]; then
echo 'Docker user is empty. Skipping build+push'
echo skip=true >> "$GITHUB_OUTPUT"
elif [[ "${{ secrets.DOCKER_PASS }}" == '' ]]; then
echo 'Docker password is empty. Skipping build+push'
echo skip=true >> "$GITHUB_OUTPUT"
else
echo 'Docker user and password are set and branch is `master`.'
echo 'Building + pushing `preview` image.'
echo skip=false >> "$GITHUB_OUTPUT"
fi
build-docker-image:
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:
- build-skip-check
if: needs.build-skip-check.outputs.skip == 'false'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
ref: ${{ github.event.push.after }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'yarn'
- 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: Install Dependencies
env:
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: true
run: |
npm install --global --force yarn@1.22.22
yarn cache clean && yarn --frozen-lockfile --network-concurrency 1
- name: Set version
id: version
run: |
set -x
.ci/update_version
VERSION_TAG=$(jq -r .version package.json)
echo "VERSION_TAG=$VERSION_TAG" >> "$GITHUB_OUTPUT"
- name: Build and push preview image to Docker Hub
id: build-preview
uses: docker/build-push-action@v4
if: ${{ github.event.inputs.dockerRepository == 'preview' || !github.event.workflow_run }}
with:
tags: |
${{ vars.DOCKER_USER }}/redash
${{ vars.DOCKER_USER }}/preview
context: .
build-args: |
test_all_deps=true
outputs: type=image,push-by-digest=true,push=true
cache-from: type=gha,scope=${{ matrix.arch }}
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_USER }}/redash:${{ steps.version.outputs.VERSION_TAG }}
context: .
build-args: |
test_all_deps=true
outputs: type=image,push-by-digest=true,push=true
cache-from: type=gha,scope=${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
env:
DOCKER_CONTENT_TRUST: true
- name: "Failure: output container logs to console"
if: failure()
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_USER }}/redash:preview \
$(printf '${{ vars.DOCKER_USER }}/redash:preview@sha256:%s ' *)
docker buildx imagetools create -t ${{ vars.DOCKER_USER }}/preview:${{ needs.build-docker-image.outputs.VERSION_TAG }} \
$(printf '${{ vars.DOCKER_USER }}/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_USER }}/redash:${{ needs.build-docker-image.outputs.VERSION_TAG }} \
$(printf '${{ vars.DOCKER_USER }}/redash:${{ needs.build-docker-image.outputs.VERSION_TAG }}@sha256:%s ' *)

36
.github/workflows/restyled.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: Restyled
on:
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
restyled:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- uses: restyled-io/actions/setup@v4
- id: restyler
uses: restyled-io/actions/run@v4
with:
fail-on-differences: true
- if: |
!cancelled() &&
steps.restyler.outputs.success == 'true' &&
github.event.pull_request.head.repo.full_name == github.repository
uses: peter-evans/create-pull-request@v6
with:
base: ${{ steps.restyler.outputs.restyled-base }}
branch: ${{ steps.restyler.outputs.restyled-head }}
title: ${{ steps.restyler.outputs.restyled-title }}
body: ${{ steps.restyler.outputs.restyled-body }}
labels: "restyled"
reviewers: ${{ github.event.pull_request.user.login }}
delete-branch: true

1
.gitignore vendored
View File

@@ -17,6 +17,7 @@ client/dist
_build
.vscode
.env
.tool-versions
dump.rdb

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict = true

2
.nvmrc
View File

@@ -1 +1 @@
v16.20.1
v18

View File

@@ -38,7 +38,9 @@ request_review: author
#
# These can be used to tell other automation to avoid our PRs.
#
labels: ["Skip CI"]
labels:
- restyled
- "Skip CI"
# Labels to ignore
#
@@ -50,13 +52,13 @@ labels: ["Skip CI"]
# Restylers to run, and how
restylers:
- name: black
image: restyled/restyler-black:v19.10b0
image: restyled/restyler-black:v24.4.2
include:
- redash
- tests
- migrations/versions
- name: prettier
image: restyled/restyler-prettier:v1.19.1-2
image: restyled/restyler-prettier:v3.3.2-2
command:
- prettier
- --write

View File

@@ -1,6 +1,6 @@
FROM node:16.20.1-bookworm as frontend-builder
FROM node:18-bookworm AS frontend-builder
RUN npm install --global --force yarn@1.22.19
RUN npm install --global --force yarn@1.22.22
# Controls whether to build the frontend assets
ARG skip_frontend_build
@@ -14,18 +14,30 @@ USER redash
WORKDIR /frontend
COPY --chown=redash package.json yarn.lock .yarnrc /frontend/
COPY --chown=redash viz-lib /frontend/viz-lib
COPY --chown=redash scripts /frontend/scripts
# Controls whether to instrument code for coverage information
ARG code_coverage
ENV BABEL_ENV=${code_coverage:+test}
# Avoid issues caused by lags in disk and network I/O speeds when working on top of QEMU emulation for multi-platform image building.
RUN yarn config set network-timeout 300000
RUN if [ "x$skip_frontend_build" = "x" ] ; then yarn --frozen-lockfile --network-concurrency 1; fi
COPY --chown=redash client /frontend/client
COPY --chown=redash webpack.config.js /frontend/
RUN if [ "x$skip_frontend_build" = "x" ] ; then yarn build; else mkdir -p /frontend/client/dist && touch /frontend/client/dist/multi_org.html && touch /frontend/client/dist/index.html; fi
RUN <<EOF
if [ "x$skip_frontend_build" = "x" ]; then
yarn build
else
mkdir -p /frontend/client/dist
touch /frontend/client/dist/multi_org.html
touch /frontend/client/dist/index.html
fi
EOF
FROM python:3.8-slim-bookworm
FROM python:3.10-slim-bookworm
EXPOSE 5000
@@ -63,28 +75,34 @@ RUN apt-get update && \
ARG TARGETPLATFORM
ARG databricks_odbc_driver_url=https://databricks-bi-artifacts.s3.us-east-2.amazonaws.com/simbaspark-drivers/odbc/2.6.26/SimbaSparkODBC-2.6.26.1045-Debian-64bit.zip
RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg \
&& curl https://packages.microsoft.com/config/debian/12/prod.list > /etc/apt/sources.list.d/mssql-release.list \
&& apt-get update \
&& ACCEPT_EULA=Y apt-get install -y --no-install-recommends msodbcsql17 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* \
&& curl "$databricks_odbc_driver_url" --location --output /tmp/simba_odbc.zip \
&& chmod 600 /tmp/simba_odbc.zip \
&& unzip /tmp/simba_odbc.zip -d /tmp/simba \
&& dpkg -i /tmp/simba/*.deb \
&& printf "[Simba]\nDriver = /opt/simba/spark/lib/64/libsparkodbc_sb64.so" >> /etc/odbcinst.ini \
&& rm /tmp/simba_odbc.zip \
&& rm -rf /tmp/simba; fi
RUN <<EOF
if [ "$TARGETPLATFORM" = "linux/amd64" ]; then
curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg
curl https://packages.microsoft.com/config/debian/12/prod.list > /etc/apt/sources.list.d/mssql-release.list
apt-get update
ACCEPT_EULA=Y apt-get install -y --no-install-recommends msodbcsql18
apt-get clean
rm -rf /var/lib/apt/lists/*
curl "$databricks_odbc_driver_url" --location --output /tmp/simba_odbc.zip
chmod 600 /tmp/simba_odbc.zip
unzip /tmp/simba_odbc.zip -d /tmp/simba
dpkg -i /tmp/simba/*.deb
printf "[Simba]\nDriver = /opt/simba/spark/lib/64/libsparkodbc_sb64.so" >> /etc/odbcinst.ini
rm /tmp/simba_odbc.zip
rm -rf /tmp/simba
fi
EOF
WORKDIR /app
ENV POETRY_VERSION=1.6.1
ENV POETRY_VERSION=1.8.3
ENV POETRY_HOME=/etc/poetry
ENV POETRY_VIRTUALENVS_CREATE=false
RUN curl -sSL https://install.python-poetry.org | python3 -
# Avoid crashes, including corrupted cache artifacts, when building multi-platform images with GitHub Actions.
RUN /etc/poetry/bin/poetry cache clear pypi --all
COPY pyproject.toml poetry.lock ./
ARG POETRY_OPTIONS="--no-root --no-interaction --no-ansi"

View File

@@ -1,10 +1,14 @@
.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
.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
compose_build: .env
COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose build
up:
COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose up -d --build
docker compose up -d redis postgres --remove-orphans
docker compose exec -u postgres postgres psql postgres --csv \
-1tqc "SELECT table_name FROM information_schema.tables WHERE table_name = 'organizations'" 2> /dev/null \
| grep -q "organizations" || make create_database
COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose up -d --build --remove-orphans
test_db:
@for i in `seq 1 5`; do \
@@ -17,7 +21,21 @@ create_database: .env
docker compose run server create_db
clean:
docker compose down && docker compose rm
docker compose down
docker compose --project-name cypress down
docker compose rm --stop --force
docker compose --project-name cypress rm --stop --force
docker image rm --force \
cypress-server:latest cypress-worker:latest cypress-scheduler:latest \
redash-server:latest redash-worker:latest redash-scheduler:latest
docker container prune --force
docker image prune --force
docker volume prune --force
clean-all: clean
docker image rm --force \
redash/redash:latest redis:7-alpine maildev/maildev:latest \
pgautoupgrade/pgautoupgrade:15-alpine3.8 pgautoupgrade/pgautoupgrade:latest
down:
docker compose down

View File

@@ -61,6 +61,7 @@ Redash supports more than 35 SQL and NoSQL [data sources](https://redash.io/help
- Apache Hive
- Apache Impala
- InfluxDB
- InfluxDBv2
- IBM Netezza Performance Server
- JIRA (JQL)
- JSON
@@ -83,6 +84,7 @@ Redash supports more than 35 SQL and NoSQL [data sources](https://redash.io/help
- Python
- Qubole
- Rockset
- RisingWave
- Salesforce
- ScyllaDB
- Shell Scripts

View File

@@ -67,7 +67,7 @@ help() {
echo ""
echo "shell -- open shell"
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 "manage -- CLI to manage redash"
echo "tests -- run tests"

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

View File

@@ -22,7 +22,7 @@ function BeaconConsent() {
setHide(true);
};
const confirmConsent = confirm => {
const confirmConsent = (confirm) => {
let message = "🙏 Thank you.";
if (!confirm) {
@@ -47,7 +47,8 @@ function BeaconConsent() {
<HelpTrigger type="USAGE_DATA_SHARING" />
</>
}
bordered={false}>
bordered={false}
>
<Text>Help Redash improve by automatically sending anonymous usage data:</Text>
<div className="m-t-5">
<ul>
@@ -66,8 +67,7 @@ function BeaconConsent() {
</div>
<div className="m-t-15">
<Text type="secondary">
You can change this setting anytime from the{" "}
<Link href="settings/organization">Organization Settings</Link> page.
You can change this setting anytime from the <Link href="settings/general">Settings</Link> page.
</Text>
</div>
</Card>

View File

@@ -12,6 +12,7 @@ import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import QuerySelector from "@/components/QuerySelector";
import { Query } from "@/services/query";
import { useUniqueId } from "@/lib/hooks/useUniqueId";
import "./EditParameterSettingsDialog.less";
const { Option } = Select;
const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } };
@@ -26,7 +27,7 @@ function isTypeDateRange(type) {
function joinExampleList(multiValuesOptions) {
const { prefix, suffix } = multiValuesOptions;
return ["value1", "value2", "value3"].map(value => `${prefix}${value}${suffix}`).join(",");
return ["value1", "value2", "value3"].map((value) => `${prefix}${value}${suffix}`).join(",");
}
function NameInput({ name, type, onChange, existingNames, setValidation }) {
@@ -54,7 +55,7 @@ function NameInput({ name, type, onChange, existingNames, setValidation }) {
return (
<Form.Item required label="Keyword" help={helpText} validateStatus={validateStatus} {...formItemProps}>
<Input onChange={e => onChange(e.target.value)} autoFocus />
<Input onChange={(e) => onChange(e.target.value)} autoFocus />
</Form.Item>
);
}
@@ -71,6 +72,8 @@ function EditParameterSettingsDialog(props) {
const [param, setParam] = useState(clone(props.parameter));
const [isNameValid, setIsNameValid] = useState(true);
const [initialQuery, setInitialQuery] = useState();
const [userInput, setUserInput] = useState(param.regex || "");
const [isValidRegex, setIsValidRegex] = useState(true);
const isNew = !props.parameter.name;
@@ -114,6 +117,17 @@ function EditParameterSettingsDialog(props) {
const paramFormId = useUniqueId("paramForm");
const handleRegexChange = (e) => {
setUserInput(e.target.value);
try {
new RegExp(e.target.value);
setParam({ ...param, regex: e.target.value });
setIsValidRegex(true);
} catch (error) {
setIsValidRegex(false);
}
};
return (
<Modal
{...props.dialog.props}
@@ -129,15 +143,17 @@ function EditParameterSettingsDialog(props) {
disabled={!isFulfilled()}
type="primary"
form={paramFormId}
data-test="SaveParameterSettings">
data-test="SaveParameterSettings"
>
{isNew ? "Add Parameter" : "OK"}
</Button>,
]}>
]}
>
<Form layout="horizontal" onFinish={onConfirm} id={paramFormId}>
{isNew && (
<NameInput
name={param.name}
onChange={name => setParam({ ...param, name })}
onChange={(name) => setParam({ ...param, name })}
setValidation={setIsNameValid}
existingNames={props.existingParams}
type={param.type}
@@ -146,15 +162,16 @@ function EditParameterSettingsDialog(props) {
<Form.Item required label="Title" {...formItemProps}>
<Input
value={isNull(param.title) ? getDefaultTitle(param.name) : param.title}
onChange={e => setParam({ ...param, title: e.target.value })}
onChange={(e) => setParam({ ...param, title: e.target.value })}
data-test="ParameterTitleInput"
/>
</Form.Item>
<Form.Item label="Type" {...formItemProps}>
<Select value={param.type} onChange={type => setParam({ ...param, type })} data-test="ParameterTypeSelect">
<Select value={param.type} onChange={(type) => setParam({ ...param, type })} data-test="ParameterTypeSelect">
<Option value="text" data-test="TextParameterTypeOption">
Text
</Option>
<Option value="text-pattern">Text Pattern</Option>
<Option value="number" data-test="NumberParameterTypeOption">
Number
</Option>
@@ -180,12 +197,26 @@ function EditParameterSettingsDialog(props) {
<Option value="datetime-range-with-seconds">Date and Time Range (with seconds)</Option>
</Select>
</Form.Item>
{param.type === "text-pattern" && (
<Form.Item
label="Regex"
help={!isValidRegex ? "Invalid Regex Pattern" : "Valid Regex Pattern"}
{...formItemProps}
>
<Input
value={userInput}
onChange={handleRegexChange}
className={!isValidRegex ? "input-error" : ""}
data-test="RegexPatternInput"
/>
</Form.Item>
)}
{param.type === "enum" && (
<Form.Item label="Values" help="Dropdown list values (newline delimited)" {...formItemProps}>
<Input.TextArea
rows={3}
value={param.enumOptions}
onChange={e => setParam({ ...param, enumOptions: e.target.value })}
onChange={(e) => setParam({ ...param, enumOptions: e.target.value })}
/>
</Form.Item>
)}
@@ -193,7 +224,7 @@ function EditParameterSettingsDialog(props) {
<Form.Item label="Query" help="Select query to load dropdown values from" {...formItemProps}>
<QuerySelector
selectedQuery={initialQuery}
onChange={q => setParam({ ...param, queryId: q && q.id })}
onChange={(q) => setParam({ ...param, queryId: q && q.id })}
type="select"
/>
</Form.Item>
@@ -202,7 +233,7 @@ function EditParameterSettingsDialog(props) {
<Form.Item className="m-b-0" label=" " colon={false} {...formItemProps}>
<Checkbox
defaultChecked={!!param.multiValuesOptions}
onChange={e =>
onChange={(e) =>
setParam({
...param,
multiValuesOptions: e.target.checked
@@ -214,7 +245,8 @@ function EditParameterSettingsDialog(props) {
: null,
})
}
data-test="AllowMultipleValuesCheckbox">
data-test="AllowMultipleValuesCheckbox"
>
Allow multiple values
</Checkbox>
</Form.Item>
@@ -227,10 +259,11 @@ function EditParameterSettingsDialog(props) {
Placed in query as: <code>{joinExampleList(param.multiValuesOptions)}</code>
</React.Fragment>
}
{...formItemProps}>
{...formItemProps}
>
<Select
value={param.multiValuesOptions.prefix}
onChange={quoteOption =>
onChange={(quoteOption) =>
setParam({
...param,
multiValuesOptions: {
@@ -240,7 +273,8 @@ function EditParameterSettingsDialog(props) {
},
})
}
data-test="QuotationSelect">
data-test="QuotationSelect"
>
<Option value="">None (default)</Option>
<Option value="'">Single Quotation Mark</Option>
<Option value={'"'} data-test="DoubleQuotationMarkOption">

View File

@@ -0,0 +1,3 @@
.input-error {
border-color: red !important;
}

View File

@@ -101,7 +101,7 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
clearTimeout(this.iframeLoadingTimeout);
}
loadIframe = url => {
loadIframe = (url) => {
clearTimeout(this.iframeLoadingTimeout);
this.setState({ loading: true, error: false });
@@ -116,8 +116,8 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
clearTimeout(this.iframeLoadingTimeout);
};
onPostMessageReceived = event => {
if (!some(allowedDomains, domain => startsWith(event.origin, domain))) {
onPostMessageReceived = (event) => {
if (!some(allowedDomains, (domain) => startsWith(event.origin, domain))) {
return;
}
@@ -134,7 +134,7 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
return helpTriggerType ? helpTriggerType[0] : this.props.href;
};
openDrawer = e => {
openDrawer = (e) => {
// keep "open in new tab" behavior
if (!e.shiftKey && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
@@ -144,7 +144,7 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
}
};
closeDrawer = event => {
closeDrawer = (event) => {
if (event) {
event.preventDefault();
}
@@ -161,7 +161,7 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
const tooltip = get(types, `${this.props.type}[1]`, this.props.title);
const className = cx("help-trigger", this.props.className);
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;
return (
@@ -180,13 +180,15 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
)}
</>
) : null
}>
}
>
<Link
href={url || this.getUrl()}
className={className}
rel="noopener noreferrer"
target="_blank"
onClick={shouldRenderAsLink ? () => {} : this.openDrawer}>
onClick={shouldRenderAsLink ? () => {} : this.openDrawer}
>
{this.props.children}
</Link>
</Tooltip>
@@ -197,7 +199,8 @@ export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName
visible={this.state.visible}
className={cx("help-drawer", drawerClassName)}
destroyOnClose
width={400}>
width={400}
>
<div className="drawer-wrapper">
<div className="drawer-menu">
{url && (

View File

@@ -33,10 +33,10 @@ export const MappingType = {
};
export function parameterMappingsToEditableMappings(mappings, parameters, existingParameterNames = []) {
return map(mappings, mapping => {
return map(mappings, (mapping) => {
const result = extend({}, mapping);
const alreadyExists = includes(existingParameterNames, mapping.mapTo);
result.param = find(parameters, p => p.name === mapping.name);
result.param = find(parameters, (p) => p.name === mapping.name);
switch (mapping.type) {
case ParameterMappingType.DashboardLevel:
result.type = alreadyExists ? MappingType.DashboardMapToExisting : MappingType.DashboardAddNew;
@@ -62,7 +62,7 @@ export function editableMappingsToParameterMappings(mappings) {
map(
// convert to map
mappings,
mapping => {
(mapping) => {
const result = extend({}, mapping);
switch (mapping.type) {
case MappingType.DashboardAddNew:
@@ -95,11 +95,11 @@ export function editableMappingsToParameterMappings(mappings) {
export function synchronizeWidgetTitles(sourceMappings, widgets) {
const affectedWidgets = [];
each(sourceMappings, sourceMapping => {
each(sourceMappings, (sourceMapping) => {
if (sourceMapping.type === ParameterMappingType.DashboardLevel) {
each(widgets, widget => {
each(widgets, (widget) => {
const widgetMappings = widget.options.parameterMappings;
each(widgetMappings, widgetMapping => {
each(widgetMappings, (widgetMapping) => {
// check if mapped to the same dashboard-level parameter
if (
widgetMapping.type === ParameterMappingType.DashboardLevel &&
@@ -140,7 +140,7 @@ export class ParameterMappingInput extends React.Component {
className: "form-item",
};
updateSourceType = type => {
updateSourceType = (type) => {
let {
mapping: { mapTo },
} = this.props;
@@ -155,7 +155,7 @@ export class ParameterMappingInput extends React.Component {
this.updateParamMapping({ type, mapTo });
};
updateParamMapping = update => {
updateParamMapping = (update) => {
const { onChange, mapping } = this.props;
const newMapping = extend({}, mapping, update);
if (newMapping.value !== mapping.value) {
@@ -175,7 +175,7 @@ export class ParameterMappingInput extends React.Component {
renderMappingTypeSelector() {
const noExisting = isEmpty(this.props.existingParamNames);
return (
<Radio.Group value={this.props.mapping.type} onChange={e => this.updateSourceType(e.target.value)}>
<Radio.Group value={this.props.mapping.type} onChange={(e) => this.updateSourceType(e.target.value)}>
<Radio className="radio" value={MappingType.DashboardAddNew} data-test="NewDashboardParameterOption">
New dashboard parameter
</Radio>
@@ -205,16 +205,16 @@ export class ParameterMappingInput extends React.Component {
<Input
value={mapTo}
aria-label="Parameter name (key)"
onChange={e => this.updateParamMapping({ mapTo: e.target.value })}
onChange={(e) => this.updateParamMapping({ mapTo: e.target.value })}
/>
);
}
renderDashboardMapToExisting() {
const { mapping, existingParamNames } = this.props;
const options = map(existingParamNames, paramName => ({ label: paramName, value: paramName }));
const options = map(existingParamNames, (paramName) => ({ label: paramName, value: paramName }));
return <Select value={mapping.mapTo} onChange={mapTo => this.updateParamMapping({ mapTo })} options={options} />;
return <Select value={mapping.mapTo} onChange={(mapTo) => this.updateParamMapping({ mapTo })} options={options} />;
}
renderStaticValue() {
@@ -226,7 +226,8 @@ export class ParameterMappingInput extends React.Component {
enumOptions={mapping.param.enumOptions}
queryId={mapping.param.queryId}
parameter={mapping.param}
onSelect={value => this.updateParamMapping({ value })}
onSelect={(value) => this.updateParamMapping({ value })}
regex={mapping.param.regex}
/>
);
}
@@ -284,12 +285,12 @@ class MappingEditor extends React.Component {
};
}
onVisibleChange = visible => {
onVisibleChange = (visible) => {
if (visible) this.show();
else this.hide();
};
onChange = mapping => {
onChange = (mapping) => {
let inputError = null;
if (mapping.type === MappingType.DashboardAddNew) {
@@ -351,7 +352,8 @@ class MappingEditor extends React.Component {
trigger="click"
content={this.renderContent()}
visible={visible}
onVisibleChange={this.onVisibleChange}>
onVisibleChange={this.onVisibleChange}
>
<Button size="small" type="dashed" data-test={`EditParamMappingButton-${mapping.param.name}`}>
<EditOutlinedIcon />
</Button>
@@ -376,14 +378,14 @@ class TitleEditor extends React.Component {
title: "", // will be set on editing
};
onPopupVisibleChange = showPopup => {
onPopupVisibleChange = (showPopup) => {
this.setState({
showPopup,
title: showPopup ? this.getMappingTitle() : "",
});
};
onEditingTitleChange = event => {
onEditingTitleChange = (event) => {
this.setState({ title: event.target.value });
};
@@ -460,7 +462,8 @@ class TitleEditor extends React.Component {
trigger="click"
content={this.renderPopover()}
visible={this.state.showPopup}
onVisibleChange={this.onPopupVisibleChange}>
onVisibleChange={this.onPopupVisibleChange}
>
<Button size="small" type="dashed">
<EditOutlinedIcon />
</Button>
@@ -508,7 +511,7 @@ export class ParameterMappingListInput extends React.Component {
// just to be safe, array or object
if (typeof value === "object") {
return map(value, v => this.getStringValue(v)).join(", ");
return map(value, (v) => this.getStringValue(v)).join(", ");
}
// rest
@@ -574,7 +577,7 @@ export class ParameterMappingListInput extends React.Component {
render() {
const { existingParams } = this.props; // eslint-disable-line react/prop-types
const dataSource = this.props.mappings.map(mapping => ({ mapping }));
const dataSource = this.props.mappings.map((mapping) => ({ mapping }));
return (
<div className="parameters-mapping-list">
@@ -583,11 +586,11 @@ export class ParameterMappingListInput extends React.Component {
title="Title"
dataIndex="mapping"
key="title"
render={mapping => (
render={(mapping) => (
<TitleEditor
existingParams={existingParams}
mapping={mapping}
onChange={newMapping => this.updateParamMapping(mapping, newMapping)}
onChange={(newMapping) => this.updateParamMapping(mapping, newMapping)}
/>
)}
/>
@@ -596,19 +599,19 @@ export class ParameterMappingListInput extends React.Component {
dataIndex="mapping"
key="keyword"
className="keyword"
render={mapping => <code>{`{{ ${mapping.name} }}`}</code>}
render={(mapping) => <code>{`{{ ${mapping.name} }}`}</code>}
/>
<Table.Column
title="Default Value"
dataIndex="mapping"
key="value"
render={mapping => this.constructor.getDefaultValue(mapping, this.props.existingParams)}
render={(mapping) => this.constructor.getDefaultValue(mapping, this.props.existingParams)}
/>
<Table.Column
title="Value Source"
dataIndex="mapping"
key="source"
render={mapping => {
render={(mapping) => {
const existingParamsNames = existingParams
.filter(({ type }) => type === mapping.param.type) // exclude mismatching param types
.map(({ name }) => name); // keep names only

View File

@@ -9,11 +9,12 @@ import DateRangeParameter from "@/components/dynamic-parameters/DateRangeParamet
import QueryBasedParameterInput from "./QueryBasedParameterInput";
import "./ParameterValueInput.less";
import Tooltip from "./Tooltip";
const multipleValuesProps = {
maxTagCount: 3,
maxTagTextLength: 10,
maxTagPlaceholder: num => `+${num.length} more`,
maxTagPlaceholder: (num) => `+${num.length} more`,
};
class ParameterValueInput extends React.Component {
@@ -25,6 +26,7 @@ class ParameterValueInput extends React.Component {
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
onSelect: PropTypes.func,
className: PropTypes.string,
regex: PropTypes.string,
};
static defaultProps = {
@@ -35,6 +37,7 @@ class ParameterValueInput extends React.Component {
parameter: null,
onSelect: () => {},
className: "",
regex: "",
};
constructor(props) {
@@ -45,7 +48,7 @@ class ParameterValueInput extends React.Component {
};
}
componentDidUpdate = prevProps => {
componentDidUpdate = (prevProps) => {
const { value, parameter } = this.props;
// if value prop updated, reset dirty state
if (prevProps.value !== value || prevProps.parameter !== parameter) {
@@ -56,7 +59,7 @@ class ParameterValueInput extends React.Component {
}
};
onSelect = value => {
onSelect = (value) => {
const isDirty = !isEqual(value, this.props.value);
this.setState({ value, isDirty });
this.props.onSelect(value, isDirty);
@@ -93,9 +96,9 @@ class ParameterValueInput extends React.Component {
renderEnumInput() {
const { enumOptions, parameter } = this.props;
const { value } = this.state;
const enumOptionsArray = enumOptions.split("\n").filter(v => v !== "");
const enumOptionsArray = enumOptions.split("\n").filter((v) => v !== "");
// Antd Select doesn't handle null in multiple mode
const normalize = val => (parameter.multiValuesOptions && val === null ? [] : val);
const normalize = (val) => (parameter.multiValuesOptions && val === null ? [] : val);
return (
<SelectWithVirtualScroll
@@ -103,7 +106,7 @@ class ParameterValueInput extends React.Component {
mode={parameter.multiValuesOptions ? "multiple" : "default"}
value={normalize(value)}
onChange={this.onSelect}
options={map(enumOptionsArray, opt => ({ label: String(opt), value: opt }))}
options={map(enumOptionsArray, (opt) => ({ label: String(opt), value: opt }))}
showSearch
showArrow
notFoundContent={isEmpty(enumOptionsArray) ? "No options available" : null}
@@ -133,18 +136,36 @@ class ParameterValueInput extends React.Component {
const { className } = this.props;
const { value } = this.state;
const normalize = val => (isNaN(val) ? undefined : val);
const normalize = (val) => (isNaN(val) ? undefined : val);
return (
<InputNumber
className={className}
value={normalize(value)}
aria-label="Parameter number value"
onChange={val => this.onSelect(normalize(val))}
onChange={(val) => this.onSelect(normalize(val))}
/>
);
}
renderTextPatternInput() {
const { className } = this.props;
const { value } = this.state;
return (
<React.Fragment>
<Tooltip title={`Regex to match: ${this.props.regex}`} placement="right">
<Input
className={className}
value={value}
aria-label="Parameter text pattern value"
onChange={(e) => this.onSelect(e.target.value)}
/>
</Tooltip>
</React.Fragment>
);
}
renderTextInput() {
const { className } = this.props;
const { value } = this.state;
@@ -155,7 +176,7 @@ class ParameterValueInput extends React.Component {
value={value}
aria-label="Parameter text value"
data-test="TextParamInput"
onChange={e => this.onSelect(e.target.value)}
onChange={(e) => this.onSelect(e.target.value)}
/>
);
}
@@ -177,6 +198,8 @@ class ParameterValueInput extends React.Component {
return this.renderQueryBasedInput();
case "number":
return this.renderNumberInput();
case "text-pattern":
return this.renderTextPatternInput();
default:
return this.renderTextInput();
}

View File

@@ -14,7 +14,7 @@ import "./Parameters.less";
function updateUrl(parameters) {
const params = extend({}, location.search);
parameters.forEach(param => {
parameters.forEach((param) => {
extend(params, param.toUrlParams());
});
location.setSearch(params, true);
@@ -43,7 +43,7 @@ export default class Parameters extends React.Component {
appendSortableToParent: true,
};
toCamelCase = str => {
toCamelCase = (str) => {
if (isEmpty(str)) {
return "";
}
@@ -59,10 +59,10 @@ export default class Parameters extends React.Component {
}
const hideRegex = /hide_filter=([^&]+)/g;
const matches = window.location.search.matchAll(hideRegex);
this.hideValues = Array.from(matches, match => match[1]);
this.hideValues = Array.from(matches, (match) => match[1]);
}
componentDidUpdate = prevProps => {
componentDidUpdate = (prevProps) => {
const { parameters, disableUrlUpdate } = this.props;
const parametersChanged = prevProps.parameters !== parameters;
const disableUrlUpdateChanged = prevProps.disableUrlUpdate !== disableUrlUpdate;
@@ -74,7 +74,7 @@ export default class Parameters extends React.Component {
}
};
handleKeyDown = e => {
handleKeyDown = (e) => {
// Cmd/Ctrl/Alt + Enter
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey || e.altKey)) {
e.stopPropagation();
@@ -109,8 +109,8 @@ export default class Parameters extends React.Component {
applyChanges = () => {
const { onValuesChange, disableUrlUpdate } = this.props;
this.setState(({ parameters }) => {
const parametersWithPendingValues = parameters.filter(p => p.hasPendingValue);
forEach(parameters, p => p.applyPendingValue());
const parametersWithPendingValues = parameters.filter((p) => p.hasPendingValue);
forEach(parameters, (p) => p.applyPendingValue());
if (!disableUrlUpdate) {
updateUrl(parameters);
}
@@ -121,7 +121,7 @@ export default class Parameters extends React.Component {
showParameterSettings = (parameter, index) => {
const { onParametersEdit } = this.props;
EditParameterSettingsDialog.showModal({ parameter }).onClose(updated => {
EditParameterSettingsDialog.showModal({ parameter }).onClose((updated) => {
this.setState(({ parameters }) => {
const updatedParameter = extend(parameter, updated);
parameters[index] = createParameter(updatedParameter, updatedParameter.parentQueryId);
@@ -132,7 +132,7 @@ export default class Parameters extends React.Component {
};
renderParameter(param, index) {
if (this.hideValues.some(value => this.toCamelCase(value) === this.toCamelCase(param.name))) {
if (this.hideValues.some((value) => this.toCamelCase(value) === this.toCamelCase(param.name))) {
return null;
}
const { editable } = this.props;
@@ -149,7 +149,8 @@ export default class Parameters extends React.Component {
aria-label="Edit"
onClick={() => this.showParameterSettings(param, index)}
data-test={`ParameterSettings-${param.name}`}
type="button">
type="button"
>
<i className="fa fa-cog" aria-hidden="true" />
</PlainButton>
)}
@@ -162,6 +163,7 @@ export default class Parameters extends React.Component {
enumOptions={param.enumOptions}
queryId={param.queryId}
onSelect={(value, isDirty) => this.setPendingValue(param, value, isDirty)}
regex={param.regex}
/>
</div>
);
@@ -178,20 +180,22 @@ export default class Parameters extends React.Component {
useDragHandle
lockToContainerEdges
helperClass="parameter-dragged"
helperContainer={containerEl => (appendSortableToParent ? containerEl : document.body)}
helperContainer={(containerEl) => (appendSortableToParent ? containerEl : document.body)}
updateBeforeSortStart={this.onBeforeSortStart}
onSortEnd={this.moveParameter}
containerProps={{
className: "parameter-container",
onKeyDown: dirtyParamCount ? this.handleKeyDown : null,
}}>
}}
>
{parameters &&
parameters.map((param, index) => (
<SortableElement key={param.name} index={index}>
<div
className="parameter-block"
data-editable={sortable || null}
data-test={`ParameterBlock-${param.name}`}>
data-test={`ParameterBlock-${param.name}`}
>
{sortable && <DragHandle data-test={`DragHandle-${param.name}`} />}
{this.renderParameter(param, index)}
</div>

View File

@@ -69,7 +69,7 @@ UserPreviewCard.defaultProps = {
// DataSourcePreviewCard
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;
return (
<PreviewCard {...props} imageUrl={imageUrl} title={title}>

View File

@@ -96,7 +96,7 @@ function EmptyState({
}, []);
// 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) {
return null;
@@ -181,7 +181,7 @@ function EmptyState({
];
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 (
<div className="empty-state-wrapper">
@@ -196,7 +196,7 @@ function EmptyState({
</div>
<div className="empty-state__steps">
<h4>Let&apos;s get started</h4>
<ol>{stepsItems.map(item => item.node)}</ol>
<ol>{stepsItems.map((item) => item.node)}</ol>
{helpMessage}
</div>
</div>

View File

@@ -65,6 +65,7 @@ export const Query = PropTypes.shape({
export const AlertOptions = PropTypes.shape({
column: PropTypes.string,
selector: PropTypes.oneOf(["first", "min", "max"]),
op: PropTypes.oneOf([">", ">=", "<", "<=", "==", "!="]),
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
custom_subject: PropTypes.string,
@@ -83,6 +84,7 @@ export const Alert = PropTypes.shape({
query: Query,
options: PropTypes.shape({
column: PropTypes.string,
selector: PropTypes.string,
op: PropTypes.string,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
}).isRequired,

View File

@@ -148,7 +148,9 @@ function EditVisualizationDialog({ dialog, visualization, query, queryResult })
function dismiss() {
const optionsChanged = !isEqual(options, defaultState.originalOptions);
confirmDialogClose(nameChanged || optionsChanged).then(dialog.dismiss);
confirmDialogClose(nameChanged || optionsChanged)
.then(dialog.dismiss)
.catch(() => {});
}
// When editing existing visualization chart type selector is disabled, so add only existing visualization's

View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8" />
<base href="{{base_href}}" />
<title><%= htmlWebpackPlugin.options.title %></title>
<script src="/static/unsupportedRedirect.js" async></script>
<script src="<%= htmlWebpackPlugin.options.staticPath %>unsupportedRedirect.js" async></script>
<link rel="icon" type="image/png" sizes="32x32" href="/static/images/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="96x96" href="/static/images/favicon-96x96.png" />

View File

@@ -16,6 +16,7 @@ import MenuButton from "./components/MenuButton";
import AlertView from "./AlertView";
import AlertEdit from "./AlertEdit";
import AlertNew from "./AlertNew";
import notifications from "@/services/notifications";
const MODES = {
NEW: 0,
@@ -64,6 +65,7 @@ class Alert extends React.Component {
this.setState({
alert: {
options: {
selector: "first",
op: ">",
value: 1,
muted: false,
@@ -75,7 +77,7 @@ class Alert extends React.Component {
} else {
const { alertId } = this.props;
AlertService.get({ id: alertId })
.then(alert => {
.then((alert) => {
if (this._isMounted) {
const canEdit = currentUser.canEdit(alert);
@@ -93,7 +95,7 @@ class Alert extends React.Component {
this.onQuerySelected(alert.query);
}
})
.catch(error => {
.catch((error) => {
if (this._isMounted) {
this.props.onError(error);
}
@@ -112,7 +114,7 @@ class Alert extends React.Component {
alert.rearm = pendingRearm || null;
return AlertService.save(alert)
.then(alert => {
.then((alert) => {
notification.success("Saved.");
navigateTo(`alerts/${alert.id}`, true);
this.setState({ alert, mode: MODES.VIEW });
@@ -122,7 +124,7 @@ class Alert extends React.Component {
});
};
onQuerySelected = query => {
onQuerySelected = (query) => {
this.setState(({ alert }) => ({
alert: Object.assign(alert, { query }),
queryResult: null,
@@ -130,7 +132,7 @@ class Alert extends React.Component {
if (query) {
// get cached result for column names and values
new QueryService(query).getQueryResultPromise().then(queryResult => {
new QueryService(query).getQueryResultPromise().then((queryResult) => {
if (this._isMounted) {
this.setState({ queryResult });
let { column } = this.state.alert.options;
@@ -146,18 +148,18 @@ class Alert extends React.Component {
}
};
onNameChange = name => {
onNameChange = (name) => {
const { alert } = this.state;
this.setState({
alert: Object.assign(alert, { name }),
});
};
onRearmChange = pendingRearm => {
onRearmChange = (pendingRearm) => {
this.setState({ pendingRearm });
};
setAlertOptions = obj => {
setAlertOptions = (obj) => {
const { alert } = this.state;
const options = { ...alert.options, ...obj };
this.setState({
@@ -177,6 +179,17 @@ class Alert extends React.Component {
});
};
evaluate = () => {
const { alert } = this.state;
return AlertService.evaluate(alert)
.then(() => {
notification.success("Alert evaluated. Refresh page for updated status.");
})
.catch(() => {
notifications.error("Failed to evaluate alert.");
});
};
mute = () => {
const { alert } = this.state;
return AlertService.mute(alert)
@@ -223,7 +236,14 @@ class Alert extends React.Component {
const { queryResult, mode, canEdit, pendingRearm } = this.state;
const menuButton = (
<MenuButton doDelete={this.delete} muted={muted} mute={this.mute} unmute={this.unmute} canEdit={canEdit} />
<MenuButton
doDelete={this.delete}
muted={muted}
mute={this.mute}
unmute={this.unmute}
canEdit={canEdit}
evaluate={this.evaluate}
/>
);
const commonProps = {
@@ -258,7 +278,7 @@ routes.register(
routeWithUserSession({
path: "/alerts/new",
title: "New Alert",
render: pageProps => <Alert {...pageProps} mode={MODES.NEW} />,
render: (pageProps) => <Alert {...pageProps} mode={MODES.NEW} />,
})
);
routes.register(
@@ -266,7 +286,7 @@ routes.register(
routeWithUserSession({
path: "/alerts/:alertId",
title: "Alert",
render: pageProps => <Alert {...pageProps} mode={MODES.VIEW} />,
render: (pageProps) => <Alert {...pageProps} mode={MODES.VIEW} />,
})
);
routes.register(
@@ -274,6 +294,6 @@ routes.register(
routeWithUserSession({
path: "/alerts/:alertId/edit",
title: "Alert",
render: pageProps => <Alert {...pageProps} mode={MODES.EDIT} />,
render: (pageProps) => <Alert {...pageProps} mode={MODES.EDIT} />,
})
);

View File

@@ -68,13 +68,23 @@ export default class AlertView extends React.Component {
<>
<Title name={name} alert={alert}>
<DynamicComponent name="AlertView.HeaderExtra" alert={alert} />
<Tooltip title={canEdit ? "" : "You do not have sufficient permissions to edit this alert"}>
<Button type="default" onClick={canEdit ? onEdit : null} className={cx({ disabled: !canEdit })}>
<i className="fa fa-edit m-r-5" aria-hidden="true" />
Edit
</Button>
{menuButton}
</Tooltip>
{canEdit ? (
<>
<Button type="default" onClick={canEdit ? onEdit : null} className={cx({ disabled: !canEdit })}>
<i className="fa fa-edit m-r-5" aria-hidden="true" />
Edit
</Button>
{menuButton}
</>
) : (
<Tooltip title="You do not have sufficient permissions to edit this alert">
<Button type="default" onClick={canEdit ? onEdit : null} className={cx({ disabled: !canEdit })}>
<i className="fa fa-edit m-r-5" aria-hidden="true" />
Edit
</Button>
{menuButton}
</Tooltip>
)}
</Title>
<div className="bg-white tiled p-20">
<Grid.Row type="flex" gutter={16}>

View File

@@ -54,23 +54,74 @@ export default function Criteria({ columnNames, resultValues, alertOptions, onCh
return null;
})();
const columnHint = (
<small className="alert-criteria-hint">
Top row value is <code className="p-0">{toString(columnValue) || "unknown"}</code>
</small>
);
let columnHint;
if (alertOptions.selector === "first") {
columnHint = (
<small className="alert-criteria-hint">
Top row value is <code className="p-0">{toString(columnValue) || "unknown"}</code>
</small>
);
} else if (alertOptions.selector === "max") {
columnHint = (
<small className="alert-criteria-hint">
Max column value is{" "}
<code className="p-0">
{toString(
Math.max(...resultValues.map((o) => Number(o[alertOptions.column])).filter((value) => !isNaN(value)))
) || "unknown"}
</code>
</small>
);
} else if (alertOptions.selector === "min") {
columnHint = (
<small className="alert-criteria-hint">
Min column value is{" "}
<code className="p-0">
{toString(
Math.min(...resultValues.map((o) => Number(o[alertOptions.column])).filter((value) => !isNaN(value)))
) || "unknown"}
</code>
</small>
);
}
return (
<div data-test="Criteria">
<div className="input-title">
<span className="input-label">Selector</span>
{editMode ? (
<Select
value={alertOptions.selector}
onChange={(selector) => onChange({ selector })}
optionLabelProp="label"
dropdownMatchSelectWidth={false}
style={{ width: 80 }}
>
<Select.Option value="first" label="first">
first
</Select.Option>
<Select.Option value="min" label="min">
min
</Select.Option>
<Select.Option value="max" label="max">
max
</Select.Option>
</Select>
) : (
<DisabledInput minWidth={60}>{alertOptions.selector}</DisabledInput>
)}
</div>
<div className="input-title">
<span className="input-label">Value column</span>
{editMode ? (
<Select
value={alertOptions.column}
onChange={column => onChange({ column })}
onChange={(column) => onChange({ column })}
dropdownMatchSelectWidth={false}
style={{ minWidth: 100 }}>
{columnNames.map(name => (
style={{ minWidth: 100 }}
>
{columnNames.map((name) => (
<Select.Option key={name}>{name}</Select.Option>
))}
</Select>
@@ -83,10 +134,11 @@ export default function Criteria({ columnNames, resultValues, alertOptions, onCh
{editMode ? (
<Select
value={alertOptions.op}
onChange={op => onChange({ op })}
onChange={(op) => onChange({ op })}
optionLabelProp="label"
dropdownMatchSelectWidth={false}
style={{ width: 55 }}>
style={{ width: 55 }}
>
<Select.Option value=">" label={CONDITIONS[">"]}>
{CONDITIONS[">"]} greater than
</Select.Option>
@@ -125,7 +177,7 @@ export default function Criteria({ columnNames, resultValues, alertOptions, onCh
id="threshold-criterion"
style={{ width: 90 }}
value={alertOptions.value}
onChange={e => onChange({ value: e.target.value })}
onChange={(e) => onChange({ value: e.target.value })}
/>
) : (
<DisabledInput minWidth={50}>{alertOptions.value}</DisabledInput>

View File

@@ -11,7 +11,7 @@ import LoadingOutlinedIcon from "@ant-design/icons/LoadingOutlined";
import EllipsisOutlinedIcon from "@ant-design/icons/EllipsisOutlined";
import PlainButton from "@/components/PlainButton";
export default function MenuButton({ doDelete, canEdit, mute, unmute, muted }) {
export default function MenuButton({ doDelete, canEdit, mute, unmute, evaluate, muted }) {
const [loading, setLoading] = useState(false);
const execute = useCallback(action => {
@@ -55,6 +55,9 @@ export default function MenuButton({ doDelete, canEdit, mute, unmute, muted }) {
<Menu.Item>
<PlainButton onClick={confirmDelete}>Delete</PlainButton>
</Menu.Item>
<Menu.Item>
<PlainButton onClick={() => execute(evaluate)}>Evaluate</PlainButton>
</Menu.Item>
</Menu>
}>
<Button aria-label="More actions">
@@ -69,6 +72,7 @@ MenuButton.propTypes = {
canEdit: PropTypes.bool.isRequired,
mute: PropTypes.func.isRequired,
unmute: PropTypes.func.isRequired,
evaluate: PropTypes.func.isRequired,
muted: PropTypes.bool,
};

View File

@@ -118,28 +118,9 @@ class ShareDashboardDialog extends React.Component {
/>
</Form.Item>
{dashboard.public_url && (
<>
<Form.Item>
<Alert
message={
<div>
Custom rule for hiding filter components when sharing links:
<br />
You can hide filter components by appending `&hide_filter={"{{"} component_name{"}}"}` to the
sharing URL.
<br />
Example: http://{"{{"}ip{"}}"}:{"{{"}port{"}}"}/public/dashboards/{"{{"}id{"}}"}
?p_country=ghana&p_site=10&hide_filter=country
</div>
}
type="warning"
/>
</Form.Item>
<Form.Item label="Secret address" {...this.formItemProps}>
<InputWithCopy value={dashboard.public_url} data-test="SecretAddress" />
</Form.Item>
</>
<Form.Item label="Secret address" {...this.formItemProps}>
<InputWithCopy value={dashboard.public_url} data-test="SecretAddress" />
</Form.Item>
)}
</Form>
</Modal>

View File

@@ -31,7 +31,8 @@ function DeprecatedEmbedFeatureAlert() {
<Link
href="https://discuss.redash.io/t/support-for-parameters-in-embedded-visualizations/3337"
target="_blank"
rel="noopener noreferrer">
rel="noopener noreferrer"
>
Read more
</Link>
.
@@ -43,7 +44,7 @@ function DeprecatedEmbedFeatureAlert() {
function EmailNotVerifiedAlert() {
const verifyEmail = () => {
axios.post("verification_email/").then(data => {
axios.post("verification_email/").then((data) => {
notification.success(data.message);
});
};
@@ -100,6 +101,6 @@ routes.register(
routeWithUserSession({
path: "/",
title: "Redash",
render: pageProps => <Home {...pageProps} />,
render: (pageProps) => <Home {...pageProps} />,
})
);

View File

@@ -9,6 +9,7 @@ import QueryControlDropdown from "@/components/EditVisualizationButton/QueryCont
import EditVisualizationButton from "@/components/EditVisualizationButton";
import useQueryResultData from "@/lib/useQueryResultData";
import { durationHumanize, pluralize, prettySize } from "@/lib/utils";
import { isUndefined } from "lodash";
import "./QueryExecutionMetadata.less";
@@ -51,7 +52,8 @@ export default function QueryExecutionMetadata({
"Result truncated to " +
queryResultData.rows.length +
" rows. Databricks may truncate query results that are unstably large."
}>
}
>
<WarningTwoTone twoToneColor="#FF9800" />
</Tooltip>
</span>
@@ -67,10 +69,9 @@ export default function QueryExecutionMetadata({
)}
{isQueryExecuting && <span>Running&hellip;</span>}
</span>
{queryResultData.metadata.data_scanned && (
{!isUndefined(queryResultData.metadata.data_scanned) && !isQueryExecuting && (
<span className="m-l-5">
Data Scanned
<strong>{prettySize(queryResultData.metadata.data_scanned)}</strong>
Data Scanned <strong>{prettySize(queryResultData.metadata.data_scanned)}</strong>
</span>
)}
</span>

View File

@@ -2,7 +2,7 @@ import PropTypes from "prop-types";
import React from "react";
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 = {

View File

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

View File

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

View File

@@ -17,14 +17,16 @@ export default function BeaconConsentSettings(props) {
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 })}>
onChange={(e) => onChange({ beacon_consent: e.target.checked })}
>
Help Redash improve by automatically sending anonymous usage data
</Checkbox>
)}

View File

@@ -36,6 +36,7 @@ const Alert = {
delete: data => axios.delete(`api/alerts/${data.id}`),
mute: data => axios.post(`api/alerts/${data.id}/mute`),
unmute: data => axios.delete(`api/alerts/${data.id}/mute`),
evaluate: data => axios.post(`api/alerts/${data.id}/eval`),
};
export default Alert;

View File

@@ -4,19 +4,19 @@ import { fetchDataFromJob } from "@/services/query-result";
export const SCHEMA_NOT_SUPPORTED = 1;
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) {
return map(columns, column => (isObject(column) ? column : { name: column }));
return map(columns, (column) => (isObject(column) ? column : { name: column }));
}
const DataSource = {
query: () => axios.get("api/data_sources"),
get: ({ id }) => axios.get(`api/data_sources/${id}`),
types: () => axios.get("api/data_sources/types"),
create: data => axios.post(`api/data_sources`, data),
save: data => axios.post(`api/data_sources/${data.id}`, data),
test: data => axios.post(`api/data_sources/${data.id}/test`),
create: (data) => axios.post(`api/data_sources`, data),
save: (data) => axios.post(`api/data_sources/${data.id}`, data),
test: (data) => axios.post(`api/data_sources/${data.id}/test`),
delete: ({ id }) => axios.delete(`api/data_sources/${id}`),
fetchSchema: (data, refresh = false) => {
const params = {};
@@ -27,15 +27,15 @@ const DataSource = {
return axios
.get(`api/data_sources/${data.id}/schema`, { params })
.then(data => {
.then((data) => {
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))
);
}
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

@@ -61,7 +61,7 @@ class DateParameter extends Parameter {
return value;
}
const normalizedValue = moment(value);
const normalizedValue = moment(value, moment.ISO_8601, true);
return normalizedValue.isValid() ? normalizedValue : null;
}

View File

@@ -0,0 +1,29 @@
import { toString, isNull } from "lodash";
import Parameter from "./Parameter";
class TextPatternParameter extends Parameter {
constructor(parameter, parentQueryId) {
super(parameter, parentQueryId);
this.regex = parameter.regex;
this.setValue(parameter.value);
}
// eslint-disable-next-line class-methods-use-this
normalizeValue(value) {
const normalizedValue = toString(value);
if (isNull(normalizedValue)) {
return null;
}
var re = new RegExp(this.regex);
if (re !== null) {
if (re.test(normalizedValue)) {
return normalizedValue;
}
}
return null;
}
}
export default TextPatternParameter;

View File

@@ -5,6 +5,7 @@ import EnumParameter from "./EnumParameter";
import QueryBasedDropdownParameter from "./QueryBasedDropdownParameter";
import DateParameter from "./DateParameter";
import DateRangeParameter from "./DateRangeParameter";
import TextPatternParameter from "./TextPatternParameter";
function createParameter(param, parentQueryId) {
switch (param.type) {
@@ -22,6 +23,8 @@ function createParameter(param, parentQueryId) {
case "datetime-range":
case "datetime-range-with-seconds":
return new DateRangeParameter(param, parentQueryId);
case "text-pattern":
return new TextPatternParameter({ ...param, type: "text-pattern" }, parentQueryId);
default:
return new TextParameter({ ...param, type: "text" }, parentQueryId);
}
@@ -34,6 +37,7 @@ function cloneParameter(param) {
export {
Parameter,
TextParameter,
TextPatternParameter,
NumberParameter,
EnumParameter,
QueryBasedDropdownParameter,

View File

@@ -1,6 +1,7 @@
import {
createParameter,
TextParameter,
TextPatternParameter,
NumberParameter,
EnumParameter,
QueryBasedDropdownParameter,
@@ -12,6 +13,7 @@ describe("Parameter", () => {
describe("create", () => {
const parameterTypes = [
["text", TextParameter],
["text-pattern", TextPatternParameter],
["number", NumberParameter],
["enum", EnumParameter],
["query", QueryBasedDropdownParameter],

View File

@@ -0,0 +1,21 @@
import { createParameter } from "..";
describe("TextPatternParameter", () => {
let param;
beforeEach(() => {
param = createParameter({ name: "param", title: "Param", type: "text-pattern", regex: "a+" });
});
describe("noramlizeValue", () => {
test("converts matching strings", () => {
const normalizedValue = param.normalizeValue("art");
expect(normalizedValue).toBe("art");
});
test("returns null when string does not match pattern", () => {
const normalizedValue = param.normalizeValue("brt");
expect(normalizedValue).toBeNull();
});
});
});

View File

@@ -114,7 +114,7 @@ export function fetchDataFromJob(jobId, interval = 1000) {
}
export function isDateTime(v) {
return isString(v) && moment(v).isValid() && /^\d{4}-\d{2}-\d{2}T/.test(v);
return isString(v) && moment(v, moment.ISO_8601, true).isValid() && /^\d{4}-\d{2}-\d{2}T/.test(v);
}
class QueryResult {

View File

@@ -1,6 +1,5 @@
/* eslint-disable import/no-extraneous-dependencies, no-console */
const { find } = require("lodash");
const atob = require("atob");
const { execSync } = require("child_process");
const { get, post } = require("request").defaults({ jar: true });
const { seedData } = require("./seed-data");
@@ -60,23 +59,11 @@ function stopServer() {
function runCypressCI() {
const {
PERCY_TOKEN_ENCODED,
CYPRESS_PROJECT_ID_ENCODED,
CYPRESS_RECORD_KEY_ENCODED,
GITHUB_REPOSITORY,
CYPRESS_OPTIONS, // eslint-disable-line no-unused-vars
} = process.env;
if (GITHUB_REPOSITORY === "getredash/redash") {
if (PERCY_TOKEN_ENCODED) {
process.env.PERCY_TOKEN = atob(`${PERCY_TOKEN_ENCODED}`);
}
if (CYPRESS_PROJECT_ID_ENCODED) {
process.env.CYPRESS_PROJECT_ID = atob(`${CYPRESS_PROJECT_ID_ENCODED}`);
}
if (CYPRESS_RECORD_KEY_ENCODED) {
process.env.CYPRESS_RECORD_KEY = atob(`${CYPRESS_RECORD_KEY_ENCODED}`);
}
if (GITHUB_REPOSITORY === "getredash/redash" && process.env.CYPRESS_RECORD_KEY) {
process.env.CYPRESS_OPTIONS = "--record";
}

View File

@@ -2,16 +2,14 @@ import { dragParam } from "../../support/parameters";
import dayjs from "dayjs";
function openAndSearchAntdDropdown(testId, paramOption) {
cy.getByTestId(testId)
.find(".ant-select-selection-search-input")
.type(paramOption, { force: true });
cy.getByTestId(testId).find(".ant-select-selection-search-input").type(paramOption, { force: true });
}
describe("Parameter", () => {
const expectDirtyStateChange = edit => {
const expectDirtyStateChange = (edit) => {
cy.getByTestId("ParameterName-test-parameter")
.find(".parameter-input")
.should($el => {
.should(($el) => {
assert.isUndefined($el.data("dirty"));
});
@@ -19,7 +17,7 @@ describe("Parameter", () => {
cy.getByTestId("ParameterName-test-parameter")
.find(".parameter-input")
.should($el => {
.should(($el) => {
assert.isTrue($el.data("dirty"));
});
};
@@ -42,9 +40,7 @@ describe("Parameter", () => {
});
it("updates the results after clicking Apply", () => {
cy.getByTestId("ParameterName-test-parameter")
.find("input")
.type("Redash");
cy.getByTestId("ParameterName-test-parameter").find("input").type("Redash");
cy.getByTestId("ParameterApplyButton").click();
@@ -53,13 +49,66 @@ describe("Parameter", () => {
it("sets dirty state when edited", () => {
expectDirtyStateChange(() => {
cy.getByTestId("ParameterName-test-parameter")
.find("input")
.type("Redash");
cy.getByTestId("ParameterName-test-parameter").find("input").type("Redash");
});
});
});
describe("Text Pattern Parameter", () => {
beforeEach(() => {
const queryData = {
name: "Text Pattern Parameter",
query: "SELECT '{{test-parameter}}' AS parameter",
options: {
parameters: [{ name: "test-parameter", title: "Test Parameter", type: "text-pattern", regex: "a.*a" }],
},
};
cy.createQuery(queryData, false).then(({ id }) => cy.visit(`/queries/${id}/source`));
});
it("updates the results after clicking Apply", () => {
cy.getByTestId("ParameterName-test-parameter").find("input").type("{selectall}arta");
cy.getByTestId("ParameterApplyButton").click();
cy.getByTestId("TableVisualization").should("contain", "arta");
cy.getByTestId("ParameterName-test-parameter").find("input").type("{selectall}arounda");
cy.getByTestId("ParameterApplyButton").click();
cy.getByTestId("TableVisualization").should("contain", "arounda");
});
it("throws error message with invalid query request", () => {
cy.getByTestId("ParameterName-test-parameter").find("input").type("{selectall}arta");
cy.getByTestId("ParameterApplyButton").click();
cy.getByTestId("ParameterName-test-parameter").find("input").type("{selectall}abcab");
cy.getByTestId("ParameterApplyButton").click();
cy.getByTestId("QueryExecutionStatus").should("exist");
});
it("sets dirty state when edited", () => {
expectDirtyStateChange(() => {
cy.getByTestId("ParameterName-test-parameter").find("input").type("{selectall}arta");
});
});
it("doesn't let user save invalid regex", () => {
cy.get(".fa-cog").click();
cy.getByTestId("RegexPatternInput").type("{selectall}[");
cy.contains("Invalid Regex Pattern").should("exist");
cy.getByTestId("SaveParameterSettings").click();
cy.get(".fa-cog").click();
cy.getByTestId("RegexPatternInput").should("not.equal", "[");
});
});
describe("Number Parameter", () => {
beforeEach(() => {
const queryData = {
@@ -74,17 +123,13 @@ describe("Parameter", () => {
});
it("updates the results after clicking Apply", () => {
cy.getByTestId("ParameterName-test-parameter")
.find("input")
.type("{selectall}42");
cy.getByTestId("ParameterName-test-parameter").find("input").type("{selectall}42");
cy.getByTestId("ParameterApplyButton").click();
cy.getByTestId("TableVisualization").should("contain", 42);
cy.getByTestId("ParameterName-test-parameter")
.find("input")
.type("{selectall}31415");
cy.getByTestId("ParameterName-test-parameter").find("input").type("{selectall}31415");
cy.getByTestId("ParameterApplyButton").click();
@@ -93,9 +138,7 @@ describe("Parameter", () => {
it("sets dirty state when edited", () => {
expectDirtyStateChange(() => {
cy.getByTestId("ParameterName-test-parameter")
.find("input")
.type("{selectall}42");
cy.getByTestId("ParameterName-test-parameter").find("input").type("{selectall}42");
});
});
});
@@ -119,10 +162,7 @@ describe("Parameter", () => {
openAndSearchAntdDropdown("ParameterName-test-parameter", "value2"); // asserts option filter prop
// only the filtered option should be on the DOM
cy.get(".ant-select-item-option")
.should("have.length", 1)
.and("contain", "value2")
.click();
cy.get(".ant-select-item-option").should("have.length", 1).and("contain", "value2").click();
cy.getByTestId("ParameterApplyButton").click();
// ensure that query is being executed
@@ -140,12 +180,10 @@ describe("Parameter", () => {
SaveParameterSettings
`);
cy.getByTestId("ParameterName-test-parameter")
.find(".ant-select-selection-search")
.click();
cy.getByTestId("ParameterName-test-parameter").find(".ant-select-selection-search").click();
// select all unselected options
cy.get(".ant-select-item-option").each($option => {
cy.get(".ant-select-item-option").each(($option) => {
if (!$option.hasClass("ant-select-item-option-selected")) {
cy.wrap($option).click();
}
@@ -160,9 +198,7 @@ describe("Parameter", () => {
it("sets dirty state when edited", () => {
expectDirtyStateChange(() => {
cy.getByTestId("ParameterName-test-parameter")
.find(".ant-select")
.click();
cy.getByTestId("ParameterName-test-parameter").find(".ant-select").click();
cy.contains(".ant-select-item-option", "value2").click();
});
@@ -176,7 +212,7 @@ describe("Parameter", () => {
name: "Dropdown Query",
query: "",
};
cy.createQuery(dropdownQueryData, true).then(dropdownQuery => {
cy.createQuery(dropdownQueryData, true).then((dropdownQuery) => {
const queryData = {
name: "Query Based Dropdown Parameter",
query: "SELECT '{{test-parameter}}' AS parameter",
@@ -208,7 +244,7 @@ describe("Parameter", () => {
SELECT 'value2' AS name, 2 AS value UNION ALL
SELECT 'value3' AS name, 3 AS value`,
};
cy.createQuery(dropdownQueryData, true).then(dropdownQuery => {
cy.createQuery(dropdownQueryData, true).then((dropdownQuery) => {
const queryData = {
name: "Query Based Dropdown Parameter",
query: "SELECT '{{test-parameter}}' AS parameter",
@@ -234,10 +270,7 @@ describe("Parameter", () => {
openAndSearchAntdDropdown("ParameterName-test-parameter", "value2"); // asserts option filter prop
// only the filtered option should be on the DOM
cy.get(".ant-select-item-option")
.should("have.length", 1)
.and("contain", "value2")
.click();
cy.get(".ant-select-item-option").should("have.length", 1).and("contain", "value2").click();
cy.getByTestId("ParameterApplyButton").click();
// ensure that query is being executed
@@ -255,12 +288,10 @@ describe("Parameter", () => {
SaveParameterSettings
`);
cy.getByTestId("ParameterName-test-parameter")
.find(".ant-select")
.click();
cy.getByTestId("ParameterName-test-parameter").find(".ant-select").click();
// make sure all options are unselected and select all
cy.get(".ant-select-item-option").each($option => {
cy.get(".ant-select-item-option").each(($option) => {
expect($option).not.to.have.class("ant-select-dropdown-menu-item-selected");
cy.wrap($option).click();
});
@@ -274,14 +305,10 @@ describe("Parameter", () => {
});
});
const selectCalendarDate = date => {
cy.getByTestId("ParameterName-test-parameter")
.find("input")
.click();
const selectCalendarDate = (date) => {
cy.getByTestId("ParameterName-test-parameter").find("input").click();
cy.get(".ant-picker-panel")
.contains(".ant-picker-cell-inner", date)
.click();
cy.get(".ant-picker-panel").contains(".ant-picker-cell-inner", date).click();
};
describe("Date Parameter", () => {
@@ -303,10 +330,10 @@ describe("Parameter", () => {
});
afterEach(() => {
cy.clock().then(clock => clock.restore());
cy.clock().then((clock) => clock.restore());
});
it("updates the results after selecting a date", function() {
it("updates the results after selecting a date", function () {
selectCalendarDate("15");
cy.getByTestId("ParameterApplyButton").click();
@@ -314,12 +341,10 @@ describe("Parameter", () => {
cy.getByTestId("TableVisualization").should("contain", dayjs(this.now).format("15/MM/YY"));
});
it("allows picking a dynamic date", function() {
it("allows picking a dynamic date", function () {
cy.getByTestId("DynamicButton").click();
cy.getByTestId("DynamicButtonMenu")
.contains("Today/Now")
.click();
cy.getByTestId("DynamicButtonMenu").contains("Today/Now").click();
cy.getByTestId("ParameterApplyButton").click();
@@ -350,14 +375,11 @@ describe("Parameter", () => {
});
afterEach(() => {
cy.clock().then(clock => clock.restore());
cy.clock().then((clock) => clock.restore());
});
it("updates the results after selecting a date and clicking in ok", function() {
cy.getByTestId("ParameterName-test-parameter")
.find("input")
.as("Input")
.click();
it("updates the results after selecting a date and clicking in ok", function () {
cy.getByTestId("ParameterName-test-parameter").find("input").as("Input").click();
selectCalendarDate("15");
@@ -368,27 +390,20 @@ describe("Parameter", () => {
cy.getByTestId("TableVisualization").should("contain", dayjs(this.now).format("YYYY-MM-15 HH:mm"));
});
it("shows the current datetime after clicking in Now", function() {
cy.getByTestId("ParameterName-test-parameter")
.find("input")
.as("Input")
.click();
it("shows the current datetime after clicking in Now", function () {
cy.getByTestId("ParameterName-test-parameter").find("input").as("Input").click();
cy.get(".ant-picker-panel")
.contains("Now")
.click();
cy.get(".ant-picker-panel").contains("Now").click();
cy.getByTestId("ParameterApplyButton").click();
cy.getByTestId("TableVisualization").should("contain", dayjs(this.now).format("YYYY-MM-DD HH:mm"));
});
it("allows picking a dynamic date", function() {
it("allows picking a dynamic date", function () {
cy.getByTestId("DynamicButton").click();
cy.getByTestId("DynamicButtonMenu")
.contains("Today/Now")
.click();
cy.getByTestId("DynamicButtonMenu").contains("Today/Now").click();
cy.getByTestId("ParameterApplyButton").click();
@@ -397,31 +412,20 @@ describe("Parameter", () => {
it("sets dirty state when edited", () => {
expectDirtyStateChange(() => {
cy.getByTestId("ParameterName-test-parameter")
.find("input")
.click();
cy.getByTestId("ParameterName-test-parameter").find("input").click();
cy.get(".ant-picker-panel")
.contains("Now")
.click();
cy.get(".ant-picker-panel").contains("Now").click();
});
});
});
describe("Date Range Parameter", () => {
const selectCalendarDateRange = (startDate, endDate) => {
cy.getByTestId("ParameterName-test-parameter")
.find("input")
.first()
.click();
cy.getByTestId("ParameterName-test-parameter").find("input").first().click();
cy.get(".ant-picker-panel")
.contains(".ant-picker-cell-inner", startDate)
.click();
cy.get(".ant-picker-panel").contains(".ant-picker-cell-inner", startDate).click();
cy.get(".ant-picker-panel")
.contains(".ant-picker-cell-inner", endDate)
.click();
cy.get(".ant-picker-panel").contains(".ant-picker-cell-inner", endDate).click();
};
beforeEach(() => {
@@ -442,10 +446,10 @@ describe("Parameter", () => {
});
afterEach(() => {
cy.clock().then(clock => clock.restore());
cy.clock().then((clock) => clock.restore());
});
it("updates the results after selecting a date range", function() {
it("updates the results after selecting a date range", function () {
selectCalendarDateRange("15", "20");
cy.getByTestId("ParameterApplyButton").click();
@@ -457,12 +461,10 @@ describe("Parameter", () => {
);
});
it("allows picking a dynamic date range", function() {
it("allows picking a dynamic date range", function () {
cy.getByTestId("DynamicButton").click();
cy.getByTestId("DynamicButtonMenu")
.contains("Last month")
.click();
cy.getByTestId("DynamicButtonMenu").contains("Last month").click();
cy.getByTestId("ParameterApplyButton").click();
@@ -479,15 +481,10 @@ describe("Parameter", () => {
});
describe("Apply Changes", () => {
const expectAppliedChanges = apply => {
cy.getByTestId("ParameterName-test-parameter-1")
.find("input")
.as("Input")
.type("Redash");
const expectAppliedChanges = (apply) => {
cy.getByTestId("ParameterName-test-parameter-1").find("input").as("Input").type("Redash");
cy.getByTestId("ParameterName-test-parameter-2")
.find("input")
.type("Redash");
cy.getByTestId("ParameterName-test-parameter-2").find("input").type("Redash");
cy.location("search").should("not.contain", "Redash");
@@ -523,10 +520,7 @@ describe("Parameter", () => {
it("shows and hides according to parameter dirty state", () => {
cy.getByTestId("ParameterApplyButton").should("not.be", "visible");
cy.getByTestId("ParameterName-test-parameter-1")
.find("input")
.as("Param")
.type("Redash");
cy.getByTestId("ParameterName-test-parameter-1").find("input").as("Param").type("Redash");
cy.getByTestId("ParameterApplyButton").should("be.visible");
@@ -536,21 +530,13 @@ describe("Parameter", () => {
});
it("updates dirty counter", () => {
cy.getByTestId("ParameterName-test-parameter-1")
.find("input")
.type("Redash");
cy.getByTestId("ParameterName-test-parameter-1").find("input").type("Redash");
cy.getByTestId("ParameterApplyButton")
.find(".ant-badge-count p.current")
.should("contain", "1");
cy.getByTestId("ParameterApplyButton").find(".ant-badge-count p.current").should("contain", "1");
cy.getByTestId("ParameterName-test-parameter-2")
.find("input")
.type("Redash");
cy.getByTestId("ParameterName-test-parameter-2").find("input").type("Redash");
cy.getByTestId("ParameterApplyButton")
.find(".ant-badge-count p.current")
.should("contain", "2");
cy.getByTestId("ParameterApplyButton").find(".ant-badge-count p.current").should("contain", "2");
});
it('applies changes from "Apply Changes" button', () => {
@@ -560,16 +546,13 @@ describe("Parameter", () => {
});
it('applies changes from "alt+enter" keyboard shortcut', () => {
expectAppliedChanges(input => {
expectAppliedChanges((input) => {
input.type("{alt}{enter}");
});
});
it('disables "Execute" button', () => {
cy.getByTestId("ParameterName-test-parameter-1")
.find("input")
.as("Input")
.type("Redash");
cy.getByTestId("ParameterName-test-parameter-1").find("input").as("Input").type("Redash");
cy.getByTestId("ExecuteButton").should("be.disabled");
cy.get("@Input").clear();
@@ -594,15 +577,12 @@ describe("Parameter", () => {
cy.createQuery(queryData, false).then(({ id }) => cy.visit(`/queries/${id}/source`));
cy.get(".parameter-block")
.first()
.invoke("width")
.as("paramWidth");
cy.get(".parameter-block").first().invoke("width").as("paramWidth");
cy.get("body").type("{alt}D"); // hide schema browser
});
it("is possible to rearrange parameters", function() {
it("is possible to rearrange parameters", function () {
cy.server();
cy.route("POST", "**/api/queries/*").as("QuerySave");

View File

@@ -26,33 +26,33 @@ const SQL = `
describe("Chart", () => {
beforeEach(() => {
cy.login();
cy.createQuery({ name: "Chart Visualization", query: SQL })
.its("id")
.as("queryId");
cy.createQuery({ name: "Chart Visualization", query: SQL }).its("id").as("queryId");
});
it("creates Bar charts", function() {
it("creates Bar charts", function () {
cy.visit(`queries/${this.queryId}/source`);
cy.getByTestId("ExecuteButton").click();
const getBarChartAssertionFunction = (specificBarChartAssertionFn = () => {}) => () => {
// checks for TabbedEditor standard tabs
assertTabbedEditor();
const getBarChartAssertionFunction =
(specificBarChartAssertionFn = () => {}) =>
() => {
// checks for TabbedEditor standard tabs
assertTabbedEditor();
// standard chart should be bar
cy.getByTestId("Chart.GlobalSeriesType").contains(".ant-select-selection-item", "Bar");
// standard chart should be bar
cy.getByTestId("Chart.GlobalSeriesType").contains(".ant-select-selection-item", "Bar");
// checks the plot canvas exists and is empty
assertPlotPreview("not.exist");
// checks the plot canvas exists and is empty
assertPlotPreview("not.exist");
// creates a chart and checks it is plotted
cy.getByTestId("Chart.ColumnMapping.x").selectAntdOption("Chart.ColumnMapping.x.stage");
cy.getByTestId("Chart.ColumnMapping.y").selectAntdOption("Chart.ColumnMapping.y.value1");
cy.getByTestId("Chart.ColumnMapping.y").selectAntdOption("Chart.ColumnMapping.y.value2");
assertPlotPreview("exist");
// creates a chart and checks it is plotted
cy.getByTestId("Chart.ColumnMapping.x").selectAntdOption("Chart.ColumnMapping.x.stage");
cy.getByTestId("Chart.ColumnMapping.y").selectAntdOption("Chart.ColumnMapping.y.value1");
cy.getByTestId("Chart.ColumnMapping.y").selectAntdOption("Chart.ColumnMapping.y.value2");
assertPlotPreview("exist");
specificBarChartAssertionFn();
};
specificBarChartAssertionFn();
};
const chartTests = [
{
@@ -95,8 +95,8 @@ describe("Chart", () => {
const withDashboardWidgetsAssertionFn = (widgetGetters, dashboardUrl) => {
cy.visit(dashboardUrl);
widgetGetters.forEach(widgetGetter => {
cy.get(`@${widgetGetter}`).then(widget => {
widgetGetters.forEach((widgetGetter) => {
cy.get(`@${widgetGetter}`).then((widget) => {
cy.getByTestId(getWidgetTestId(widget)).within(() => {
cy.get("g.points").should("exist");
});
@@ -107,4 +107,34 @@ describe("Chart", () => {
createDashboardWithCharts("Bar chart visualizations", chartGetters, withDashboardWidgetsAssertionFn);
cy.percySnapshot("Visualizations - Charts - Bar");
});
it("colors Bar charts", function () {
cy.visit(`queries/${this.queryId}/source`);
cy.getByTestId("ExecuteButton").click();
cy.getByTestId("NewVisualization").click();
cy.getByTestId("Chart.ColumnMapping.x").selectAntdOption("Chart.ColumnMapping.x.stage");
cy.getByTestId("Chart.ColumnMapping.y").selectAntdOption("Chart.ColumnMapping.y.value1");
cy.getByTestId("VisualizationEditor.Tabs.Colors").click();
cy.getByTestId("ColorScheme").click();
cy.getByTestId("ColorOptionViridis").click();
cy.getByTestId("ColorScheme").click();
cy.getByTestId("ColorOptionTableau 10").click();
cy.getByTestId("ColorScheme").click();
cy.getByTestId("ColorOptionD3 Category 10").click();
});
it("colors Pie charts", function () {
cy.visit(`queries/${this.queryId}/source`);
cy.getByTestId("ExecuteButton").click();
cy.getByTestId("NewVisualization").click();
cy.getByTestId("Chart.GlobalSeriesType").click();
cy.getByTestId("Chart.ChartType.pie").click();
cy.getByTestId("Chart.ColumnMapping.x").selectAntdOption("Chart.ColumnMapping.x.stage");
cy.getByTestId("Chart.ColumnMapping.y").selectAntdOption("Chart.ColumnMapping.y.value1");
cy.getByTestId("VisualizationEditor.Tabs.Colors").click();
cy.getByTestId("ColorScheme").click();
cy.getByTestId("ColorOptionViridis").click();
cy.getByTestId("ColorScheme").click();
cy.getByTestId("ColorOptionTableau 10").click();
cy.getByTestId("ColorScheme").click();
cy.getByTestId("ColorOptionD3 Category 10").click();
});
});

View File

@@ -22,10 +22,7 @@ function prepareVisualization(query, type, name, options) {
cy.get("body").type("{alt}D");
// do some pre-checks here to ensure that visualization was created and is visible
cy.getByTestId("TableVisualization")
.should("exist")
.find("table")
.should("exist");
cy.getByTestId("TableVisualization").should("exist").find("table").should("exist");
return cy.then(() => ({ queryId, visualizationId }));
});
@@ -53,7 +50,7 @@ describe("Table", () => {
});
describe("Sorting data", () => {
beforeEach(function() {
beforeEach(function () {
const { query, config } = MultiColumnSort;
prepareVisualization(query, "TABLE", "Sort data", config).then(({ queryId, visualizationId }) => {
this.queryId = queryId;
@@ -61,39 +58,22 @@ describe("Table", () => {
});
});
it("sorts data by a single column", function() {
cy.getByTestId("TableVisualization")
.find("table th")
.contains("c")
.should("exist")
.click();
it("sorts data by a single column", function () {
cy.getByTestId("TableVisualization").find("table th").contains("c").should("exist").click();
cy.percySnapshot("Visualizations - Table (Single-column sort)", { widths: [viewportWidth] });
});
it("sorts data by a multiple columns", function() {
cy.getByTestId("TableVisualization")
.find("table th")
.contains("a")
.should("exist")
.click();
it("sorts data by a multiple columns", function () {
cy.getByTestId("TableVisualization").find("table th").contains("a").should("exist").click();
cy.get("body").type("{shift}", { release: false });
cy.getByTestId("TableVisualization")
.find("table th")
.contains("b")
.should("exist")
.click();
cy.getByTestId("TableVisualization").find("table th").contains("b").should("exist").click();
cy.percySnapshot("Visualizations - Table (Multi-column sort)", { widths: [viewportWidth] });
});
it("sorts data in reverse order", function() {
cy.getByTestId("TableVisualization")
.find("table th")
.contains("c")
.should("exist")
.click()
.click();
it("sorts data in reverse order", function () {
cy.getByTestId("TableVisualization").find("table th").contains("c").should("exist").click().click();
cy.percySnapshot("Visualizations - Table (Single-column reverse sort)", { widths: [viewportWidth] });
});
});
@@ -101,10 +81,7 @@ describe("Table", () => {
it("searches in multiple columns", () => {
const { query, config } = SearchInData;
prepareVisualization(query, "TABLE", "Search", config).then(({ visualizationId }) => {
cy.getByTestId("TableVisualization")
.find("table input")
.should("exist")
.type("test");
cy.getByTestId("TableVisualization").find("table input").should("exist").type("test");
cy.percySnapshot("Visualizations - Table (Search in data)", { widths: [viewportWidth] });
});
});

View File

@@ -2,12 +2,12 @@
const { extend, get, merge, find } = Cypress._;
const post = options =>
const post = (options) =>
cy
.getCookie("csrf_token")
.then(csrf => cy.request({ ...options, method: "POST", headers: { "X-CSRF-TOKEN": csrf.value } }));
.then((csrf) => cy.request({ ...options, method: "POST", headers: { "X-CSRF-TOKEN": csrf.value } }));
Cypress.Commands.add("createDashboard", name => {
Cypress.Commands.add("createDashboard", (name) => {
return post({ url: "api/dashboards", body: { name } }).then(({ body }) => body);
});
@@ -28,7 +28,7 @@ Cypress.Commands.add("createQuery", (data, shouldPublish = true) => {
// eslint-disable-next-line cypress/no-assigning-return-values
let request = post({ url: "/api/queries", body: merged }).then(({ body }) => body);
if (shouldPublish) {
request = request.then(query =>
request = request.then((query) =>
post({ url: `/api/queries/${query.id}`, body: { is_draft: false } }).then(() => query)
);
}
@@ -86,6 +86,7 @@ Cypress.Commands.add("addWidget", (dashboardId, visualizationId, options = {}) =
Cypress.Commands.add("createAlert", (queryId, options = {}, name) => {
const defaultOptions = {
column: "?column?",
selector: "first",
op: "greater than",
rearm: 0,
value: 1,
@@ -109,7 +110,7 @@ Cypress.Commands.add("createUser", ({ name, email, password }) => {
url: "api/users?no_invite=yes",
body: { name, email },
failOnStatusCode: false,
}).then(xhr => {
}).then((xhr) => {
const { status, body } = xhr;
if (status < 200 || status > 400) {
throw new Error(xhr);
@@ -146,7 +147,7 @@ Cypress.Commands.add("getDestinations", () => {
Cypress.Commands.add("addDestinationSubscription", (alertId, destinationName) => {
return cy
.getDestinations()
.then(destinations => {
.then((destinations) => {
const destination = find(destinations, { name: destinationName });
if (!destination) {
throw new Error("Destination not found");
@@ -166,6 +167,6 @@ Cypress.Commands.add("addDestinationSubscription", (alertId, destinationName) =>
});
});
Cypress.Commands.add("updateOrgSettings", settings => {
Cypress.Commands.add("updateOrgSettings", (settings) => {
return post({ url: "api/settings/organization", body: settings }).then(({ body }) => body);
});

View File

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

View File

@@ -1,12 +1,10 @@
export function expectTableToHaveLength(length) {
cy.getByTestId("TableVisualization")
.find("tbody tr")
.should("have.length", length);
cy.getByTestId("TableVisualization").find("tbody tr").should("have.length", length);
}
export function expectFirstColumnToHaveMembers(values) {
cy.getByTestId("TableVisualization")
.find("tbody tr td:first-child")
.then($cell => Cypress.$.map($cell, item => Cypress.$(item).text()))
.then(firstColumnCells => expect(firstColumnCells).to.have.members(values));
.then(($cell) => Cypress.$.map($cell, (item) => Cypress.$(item).text()))
.then((firstColumnCells) => expect(firstColumnCells).to.have.members(values));
}

View File

@@ -1,6 +1,5 @@
# This configuration file is for the **development** setup.
# For a production example please refer to getredash/setup repository on GitHub.
version: "2.2"
x-redash-service: &redash-service
build:
context: .
@@ -11,6 +10,7 @@ x-redash-service: &redash-service
env_file:
- .env
x-redash-environment: &redash-environment
REDASH_HOST: http://localhost:5001
REDASH_LOG_LEVEL: "INFO"
REDASH_REDIS_URL: "redis://redis:6379/0"
REDASH_DATABASE_URL: "postgresql://postgres@postgres/postgres"
@@ -53,7 +53,7 @@ services:
image: redis:7-alpine
restart: unless-stopped
postgres:
image: pgautoupgrade/pgautoupgrade:15-alpine3.8
image: pgautoupgrade/pgautoupgrade:latest
ports:
- "15432:5432"
# The following turns the DB into less durable, but gains significant performance improvements for the tests run (x3

View File

@@ -7,7 +7,7 @@ Create Date: 2020-12-23 21:35:32.766354
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from sqlalchemy.dialects.postgresql import JSON
# revision identifiers, used by Alembic.
revision = '0ec979123ba4'
@@ -18,7 +18,7 @@ depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('dashboards', sa.Column('options', postgresql.JSON(astext_type=sa.Text()), server_default='{}', nullable=False))
op.add_column('dashboards', sa.Column('options', JSON(astext_type=sa.Text()), server_default='{}', nullable=False))
# ### end Alembic commands ###

View File

@@ -10,8 +10,7 @@ import json
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import table
from redash.models import MutableDict, PseudoJSON
from redash.models import MutableDict
# revision identifiers, used by Alembic.
@@ -41,7 +40,7 @@ def upgrade():
"queries",
sa.Column(
"schedule",
MutableDict.as_mutable(PseudoJSON),
sa.Text(),
nullable=False,
server_default=json.dumps({}),
),
@@ -51,7 +50,7 @@ def upgrade():
queries = table(
"queries",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("schedule", MutableDict.as_mutable(PseudoJSON)),
sa.Column("schedule", sa.Text()),
sa.Column("old_schedule", sa.String(length=10)),
)
@@ -85,7 +84,7 @@ def downgrade():
"queries",
sa.Column(
"old_schedule",
MutableDict.as_mutable(PseudoJSON),
sa.Text(),
nullable=False,
server_default=json.dumps({}),
),
@@ -93,8 +92,8 @@ def downgrade():
queries = table(
"queries",
sa.Column("schedule", MutableDict.as_mutable(PseudoJSON)),
sa.Column("old_schedule", MutableDict.as_mutable(PseudoJSON)),
sa.Column("schedule", sa.Text()),
sa.Column("old_schedule", sa.Text()),
)
op.execute(queries.update().values({"old_schedule": queries.c.schedule}))
@@ -106,7 +105,7 @@ def downgrade():
"queries",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("schedule", sa.String(length=10)),
sa.Column("old_schedule", MutableDict.as_mutable(PseudoJSON)),
sa.Column("old_schedule", sa.Text()),
)
conn = op.get_bind()

View File

@@ -0,0 +1,135 @@
"""change type of json fields from varchar to json
Revision ID: 7205816877ec
Revises: 7ce5925f832b
Create Date: 2024-01-03 13:55:18.885021
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB, JSON
# revision identifiers, used by Alembic.
revision = '7205816877ec'
down_revision = '7ce5925f832b'
branch_labels = None
depends_on = None
def upgrade():
connection = op.get_bind()
op.alter_column('queries', 'options',
existing_type=sa.Text(),
type_=JSONB(astext_type=sa.Text()),
nullable=True,
postgresql_using='options::jsonb',
)
op.alter_column('queries', 'schedule',
existing_type=sa.Text(),
type_=JSONB(astext_type=sa.Text()),
nullable=True,
postgresql_using='schedule::jsonb',
)
op.alter_column('events', 'additional_properties',
existing_type=sa.Text(),
type_=JSONB(astext_type=sa.Text()),
nullable=True,
postgresql_using='additional_properties::jsonb',
)
op.alter_column('organizations', 'settings',
existing_type=sa.Text(),
type_=JSONB(astext_type=sa.Text()),
nullable=True,
postgresql_using='settings::jsonb',
)
op.alter_column('alerts', 'options',
existing_type=JSON(astext_type=sa.Text()),
type_=JSONB(astext_type=sa.Text()),
nullable=True,
postgresql_using='options::jsonb',
)
op.alter_column('dashboards', 'options',
existing_type=JSON(astext_type=sa.Text()),
type_=JSONB(astext_type=sa.Text()),
postgresql_using='options::jsonb',
)
op.alter_column('dashboards', 'layout',
existing_type=sa.Text(),
type_=JSONB(astext_type=sa.Text()),
postgresql_using='layout::jsonb',
)
op.alter_column('changes', 'change',
existing_type=JSON(astext_type=sa.Text()),
type_=JSONB(astext_type=sa.Text()),
postgresql_using='change::jsonb',
)
op.alter_column('visualizations', 'options',
existing_type=sa.Text(),
type_=JSONB(astext_type=sa.Text()),
postgresql_using='options::jsonb',
)
op.alter_column('widgets', 'options',
existing_type=sa.Text(),
type_=JSONB(astext_type=sa.Text()),
postgresql_using='options::jsonb',
)
def downgrade():
connection = op.get_bind()
op.alter_column('queries', 'options',
existing_type=JSONB(astext_type=sa.Text()),
type_=sa.Text(),
postgresql_using='options::text',
existing_nullable=True,
)
op.alter_column('queries', 'schedule',
existing_type=JSONB(astext_type=sa.Text()),
type_=sa.Text(),
postgresql_using='schedule::text',
existing_nullable=True,
)
op.alter_column('events', 'additional_properties',
existing_type=JSONB(astext_type=sa.Text()),
type_=sa.Text(),
postgresql_using='additional_properties::text',
existing_nullable=True,
)
op.alter_column('organizations', 'settings',
existing_type=JSONB(astext_type=sa.Text()),
type_=sa.Text(),
postgresql_using='settings::text',
existing_nullable=True,
)
op.alter_column('alerts', 'options',
existing_type=JSONB(astext_type=sa.Text()),
type_=JSON(astext_type=sa.Text()),
postgresql_using='options::json',
existing_nullable=True,
)
op.alter_column('dashboards', 'options',
existing_type=JSONB(astext_type=sa.Text()),
type_=JSON(astext_type=sa.Text()),
postgresql_using='options::json',
)
op.alter_column('dashboards', 'layout',
existing_type=JSONB(astext_type=sa.Text()),
type_=sa.Text(),
postgresql_using='layout::text',
)
op.alter_column('changes', 'change',
existing_type=JSONB(astext_type=sa.Text()),
type_=JSON(astext_type=sa.Text()),
postgresql_using='change::json',
)
op.alter_column('visualizations', 'options',
type_=sa.Text(),
existing_type=JSONB(astext_type=sa.Text()),
postgresql_using='options::text',
)
op.alter_column('widgets', 'options',
type_=sa.Text(),
existing_type=JSONB(astext_type=sa.Text()),
postgresql_using='options::text',
)

View File

@@ -7,10 +7,9 @@ Create Date: 2019-01-17 13:22:21.729334
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from sqlalchemy.sql import table
from redash.models import MutableDict, PseudoJSON
from redash.models import MutableDict
# revision identifiers, used by Alembic.
revision = "73beceabb948"
@@ -43,7 +42,7 @@ def upgrade():
queries = table(
"queries",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("schedule", MutableDict.as_mutable(PseudoJSON)),
sa.Column("schedule", sa.Text()),
)
conn = op.get_bind()

View File

@@ -6,7 +6,7 @@ Create Date: 2018-01-31 15:20:30.396533
"""
import simplejson
import json
from alembic import op
import sqlalchemy as sa
@@ -27,7 +27,7 @@ def upgrade():
dashboard_result = db.session.execute("SELECT id, layout FROM dashboards")
for dashboard in dashboard_result:
print(" Updating dashboard: {}".format(dashboard["id"]))
layout = simplejson.loads(dashboard["layout"])
layout = json.loads(dashboard["layout"])
print(" Building widgets map:")
widgets = {}
@@ -53,7 +53,7 @@ def upgrade():
if widget is None:
continue
options = simplejson.loads(widget["options"]) or {}
options = json.loads(widget["options"]) or {}
options["position"] = {
"row": row_index,
"col": column_index * column_size,
@@ -62,7 +62,7 @@ def upgrade():
db.session.execute(
"UPDATE widgets SET options=:options WHERE id=:id",
{"options": simplejson.dumps(options), "id": widget_id},
{"options": json.dumps(options), "id": widget_id},
)
dashboard_result.close()

View File

@@ -7,7 +7,7 @@ Create Date: 2019-01-31 09:21:31.517265
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from sqlalchemy.dialects.postgresql import BYTEA
from sqlalchemy.sql import table
from sqlalchemy_utils.types.encrypted.encrypted_type import FernetEngine
@@ -18,7 +18,6 @@ from redash.models.types import (
Configuration,
MutableDict,
MutableList,
PseudoJSON,
)
# revision identifiers, used by Alembic.
@@ -31,7 +30,7 @@ depends_on = None
def upgrade():
op.add_column(
"data_sources",
sa.Column("encrypted_options", postgresql.BYTEA(), nullable=True),
sa.Column("encrypted_options", BYTEA(), nullable=True),
)
# copy values

View File

@@ -0,0 +1,64 @@
"""fix_hash
Revision ID: 9e8c841d1a30
Revises: 7205816877ec
Create Date: 2024-10-05 18:55:35.730573
"""
import logging
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import table
from sqlalchemy import select
from redash.query_runner import BaseQueryRunner, get_query_runner
# revision identifiers, used by Alembic.
revision = '9e8c841d1a30'
down_revision = '7205816877ec'
branch_labels = None
depends_on = None
def update_query_hash(record):
should_apply_auto_limit = record['options'].get("apply_auto_limit", False) if record['options'] else False
query_runner = get_query_runner(record['type'], {}) if record['type'] else BaseQueryRunner({})
query_text = record['query']
parameters_dict = {p["name"]: p.get("value") for p in record['options'].get('parameters', [])} if record.options else {}
if any(parameters_dict):
print(f"Query {record['query_id']} has parameters. Hash might be incorrect.")
return query_runner.gen_query_hash(query_text, should_apply_auto_limit)
def upgrade():
conn = op.get_bind()
metadata = sa.MetaData(bind=conn)
queries = sa.Table("queries", metadata, autoload=True)
data_sources = sa.Table("data_sources", metadata, autoload=True)
joined_table = queries.outerjoin(data_sources, queries.c.data_source_id == data_sources.c.id)
query = select([
queries.c.id.label("query_id"),
queries.c.query,
queries.c.query_hash,
queries.c.options,
data_sources.c.id.label("data_source_id"),
data_sources.c.type
]).select_from(joined_table)
for record in conn.execute(query):
new_hash = update_query_hash(record)
print(f"Updating hash for query {record['query_id']} from {record['query_hash']} to {new_hash}")
conn.execute(
queries.update()
.where(queries.c.id == record['query_id'])
.values(query_hash=new_hash))
def downgrade():
pass

View File

@@ -9,7 +9,7 @@ import re
from funcy import flatten, compact
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from sqlalchemy.dialects.postgresql import ARRAY
from redash import models
# revision identifiers, used by Alembic.
@@ -21,10 +21,10 @@ depends_on = None
def upgrade():
op.add_column(
"dashboards", sa.Column("tags", postgresql.ARRAY(sa.Unicode()), nullable=True)
"dashboards", sa.Column("tags", ARRAY(sa.Unicode()), nullable=True)
)
op.add_column(
"queries", sa.Column("tags", postgresql.ARRAY(sa.Unicode()), nullable=True)
"queries", sa.Column("tags", ARRAY(sa.Unicode()), nullable=True)
)

View File

@@ -7,7 +7,7 @@ Create Date: 2020-12-14 21:42:48.661684
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from sqlalchemy.dialects.postgresql import BYTEA
from sqlalchemy.sql import table
from sqlalchemy_utils.types.encrypted.encrypted_type import FernetEngine
@@ -30,7 +30,7 @@ depends_on = None
def upgrade():
op.add_column(
"notification_destinations",
sa.Column("encrypted_options", postgresql.BYTEA(), nullable=True)
sa.Column("encrypted_options", BYTEA(), nullable=True)
)
# copy values

View File

@@ -7,7 +7,7 @@ Create Date: 2018-11-08 16:12:17.023569
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from sqlalchemy.dialects.postgresql import JSON
# revision identifiers, used by Alembic.
revision = "e7f8a917aa8e"
@@ -21,7 +21,7 @@ def upgrade():
"users",
sa.Column(
"details",
postgresql.JSON(astext_type=sa.Text()),
JSON(astext_type=sa.Text()),
server_default="{}",
nullable=True,
),

View File

@@ -7,7 +7,7 @@ Create Date: 2022-01-31 15:24:16.507888
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from sqlalchemy.dialects.postgresql import JSON, JSONB
from redash.models import db
@@ -23,12 +23,12 @@ def upgrade():
### commands auto generated by Alembic - please adjust! ###
op.alter_column('users', 'details',
existing_type=postgresql.JSON(astext_type=sa.Text()),
type_=postgresql.JSONB(astext_type=sa.Text()),
existing_type=JSON(astext_type=sa.Text()),
type_=JSONB(astext_type=sa.Text()),
existing_nullable=True,
existing_server_default=sa.text("'{}'::jsonb"))
### end Alembic commands ###
update_query = """
update users
set details = details::jsonb || ('{"profile_image_url": "' || profile_image_url || '"}')::jsonb
@@ -52,8 +52,8 @@ def downgrade():
connection.execute(update_query)
db.session.commit()
op.alter_column('users', 'details',
existing_type=postgresql.JSONB(astext_type=sa.Text()),
type_=postgresql.JSON(astext_type=sa.Text()),
existing_type=JSONB(astext_type=sa.Text()),
type_=JSON(astext_type=sa.Text()),
existing_nullable=True,
existing_server_default=sa.text("'{}'::json"))

View File

@@ -6,7 +6,7 @@
command = "cd ../ && yarn cache clean && yarn --frozen-lockfile --network-concurrency 1 && yarn build && cd ./client"
[build.environment]
NODE_VERSION = "16.20.1"
NODE_VERSION = "18"
NETLIFY_USE_YARN = "true"
YARN_VERSION = "1.22.19"
CYPRESS_INSTALL_BINARY = "0"

View File

@@ -1,20 +1,19 @@
{
"name": "redash-client",
"version": "23.12.0-dev",
"version": "25.03.0-dev",
"description": "The frontend part of Redash.",
"main": "index.js",
"scripts": {
"start": "npm-run-all --parallel watch:viz webpack-dev-server",
"clean": "rm -rf ./client/dist/",
"build:viz": "(cd viz-lib && yarn build:babel)",
"build": "yarn clean && yarn build:viz && NODE_ENV=production webpack",
"build:old-node-version": "yarn clean && NODE_ENV=production node --max-old-space-size=4096 node_modules/.bin/webpack",
"watch:app": "webpack watch --progress",
"build": "yarn clean && yarn build:viz && NODE_OPTIONS=--openssl-legacy-provider NODE_ENV=production webpack",
"watch:app": "NODE_OPTIONS=--openssl-legacy-provider webpack watch --progress",
"watch:viz": "(cd viz-lib && yarn watch:babel)",
"watch": "npm-run-all --parallel watch:*",
"webpack-dev-server": "webpack-dev-server",
"analyze": "yarn clean && BUNDLE_ANALYZER=on webpack",
"analyze:build": "yarn clean && NODE_ENV=production BUNDLE_ANALYZER=on webpack",
"analyze": "yarn clean && BUNDLE_ANALYZER=on NODE_OPTIONS=--openssl-legacy-provider webpack",
"analyze:build": "yarn clean && NODE_ENV=production BUNDLE_ANALYZER=on NODE_OPTIONS=--openssl-legacy-provider webpack",
"lint": "yarn lint:base --ext .js --ext .jsx --ext .ts --ext .tsx ./client",
"lint:fix": "yarn lint:base --fix --ext .js --ext .jsx --ext .ts --ext .tsx ./client",
"lint:base": "eslint --config ./client/.eslintrc.js --ignore-path ./client/.eslintignore",
@@ -34,7 +33,8 @@
"url": "git+https://github.com/getredash/redash.git"
},
"engines": {
"node": ">14.16.0 <17.0.0",
"node": ">16.0 <21.0",
"npm": "please-use-yarn",
"yarn": "^1.22.10"
},
"author": "Redash Contributors",
@@ -50,11 +50,12 @@
"antd": "^4.4.3",
"axios": "0.27.2",
"axios-auth-refresh": "3.3.6",
"bootstrap": "^3.3.7",
"bootstrap": "^3.4.1",
"classnames": "^2.2.6",
"d3": "^3.5.17",
"debug": "^3.2.7",
"dompurify": "^2.0.17",
"elliptic": "^6.6.0",
"font-awesome": "^4.7.0",
"history": "^4.10.1",
"hoist-non-react-statics": "^3.3.0",
@@ -63,7 +64,7 @@
"mousetrap": "^1.6.1",
"mustache": "^2.3.0",
"numeral": "^2.0.6",
"path-to-regexp": "^3.1.0",
"path-to-regexp": "^3.3.0",
"prop-types": "^15.6.1",
"query-string": "^6.9.0",
"react": "16.14.0",
@@ -178,6 +179,10 @@
"viz-lib/**"
]
},
"browser": {
"fs": false,
"path": false
},
"//": "browserslist set to 'Async functions' compatibility",
"browserslist": [
"Edge >= 15",

2101
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@ force-exclude = '''
[tool.poetry]
name = "redash"
version = "23.12.0-dev"
version = "25.03.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
@@ -29,7 +29,7 @@ authlib = "0.15.5"
backoff = "2.2.1"
blinker = "1.6.2"
click = "8.1.3"
cryptography = "41.0.6"
cryptography = "43.0.1"
disposable-email-domains = ">=0.0.52"
flask = "2.3.2"
flask-limiter = "3.3.1"
@@ -43,10 +43,10 @@ flask-wtf = "1.1.1"
funcy = "1.13"
gevent = "23.9.1"
greenlet = "2.0.2"
gunicorn = "20.0.4"
gunicorn = "22.0.0"
httplib2 = "0.19.0"
itsdangerous = "2.1.2"
jinja2 = "3.1.2"
jinja2 = "3.1.5"
jsonschema = "3.1.1"
markupsafe = "2.1.1"
maxminddb-geolite2 = "2018.703"
@@ -54,7 +54,7 @@ parsedatetime = "2.4"
passlib = "1.7.3"
psycopg2-binary = "2.9.6"
pyjwt = "2.4.0"
pyopenssl = "23.2.0"
pyopenssl = "24.2.1"
pypd = "1.1.0"
pysaml2 = "7.3.1"
pystache = "0.6.0"
@@ -64,28 +64,31 @@ pytz = ">=2019.3"
pyyaml = "6.0.1"
redis = "4.6.0"
regex = "2023.8.8"
requests = "2.31.0"
restrictedpython = "6.2"
rq = "1.9.0"
rq-scheduler = "0.11.0"
requests = "2.32.3"
restrictedpython = "7.3"
rq = "1.16.1"
rq-scheduler = "0.13.1"
semver = "2.8.1"
sentry-sdk = "1.28.1"
simplejson = "3.16.0"
sentry-sdk = "1.45.1"
sqlalchemy = "1.3.24"
sqlalchemy-searchable = "1.2.0"
sqlalchemy-utils = "0.34.2"
sqlparse = "0.4.4"
sqlalchemy-utils = "0.38.3"
sqlparse = "0.5.0"
sshtunnel = "0.1.5"
statsd = "3.3.0"
supervisor = "4.1.0"
supervisor-checks = "0.8.1"
ua-parser = "0.18.0"
urllib3 = "1.26.18"
urllib3 = "1.26.19"
user-agents = "2.0"
werkzeug = "2.3.8"
wtforms = "2.2.1"
xlsxwriter = "1.2.2"
tzlocal = "4.3.1"
pyodbc = "5.1.0"
debugpy = "^1.8.9"
paramiko = "3.4.1"
oracledb = "2.5.1"
[tool.poetry.group.all_ds]
optional = true
@@ -104,33 +107,32 @@ google-api-python-client = "1.7.11"
gspread = "5.11.2"
impyla = "0.16.0"
influxdb = "5.2.3"
influxdb-client = "1.38.0"
memsql = "3.2.0"
mysqlclient = "2.1.1"
nzalchemy = "^11.0.2"
nzpy = ">=1.15"
oauth2client = "4.1.3"
openpyxl = "3.0.7"
oracledb = "1.4.0"
pandas = "1.3.4"
phoenixdb = "0.7"
pinotdb = ">=0.4.5"
protobuf = "3.20.2"
pyathena = ">=1.5.0,<=1.11.5"
pyathena = "2.25.2"
pydgraph = "2.0.2"
pydruid = "0.5.7"
pyexasol = "0.12.0"
pyhive = "0.6.1"
pyignite = "0.6.1"
pymongo = { version = "4.3.3", extras = ["srv", "tls"] }
pymssql = "2.2.8"
pyodbc = "4.0.28"
pymongo = { version = "4.6.3", extras = ["srv", "tls"] }
pymssql = "^2.3.1"
pyodbc = "5.1.0"
python-arango = "6.1.0"
python-rapidjson = "1.1.0"
qds-sdk = ">=1.9.6"
python-rapidjson = "1.20"
requests-aws-sign = "0.1.5"
sasl = ">=0.1.3"
simple-salesforce = "0.74.3"
snowflake-connector-python = "3.4.0"
snowflake-connector-python = "3.12.3"
td-client = "1.0.0"
thrift = ">=0.8.0"
thrift-sasl = ">=0.1.0"
@@ -152,11 +154,10 @@ optional = true
pytest = "7.4.0"
coverage = "7.2.7"
freezegun = "1.2.1"
jwcrypto = "1.5.0"
jwcrypto = "1.5.6"
mock = "5.0.2"
pre-commit = "3.3.3"
ptpython = "3.0.23"
ptvsd = "4.3.2"
pytest-cov = "4.1.0"
watchdog = "3.0.0"
ruff = "0.0.289"
@@ -168,7 +169,7 @@ build-backend = "poetry.core.masonry.api"
[tool.ruff]
exclude = [".git", "viz-lib", "node_modules", "migrations"]
ignore = ["E501"]
select = ["C9", "E", "F", "W", "I001"]
select = ["C9", "E", "F", "W", "I001", "UP004"]
[tool.ruff.mccabe]
max-complexity = 15

View File

@@ -14,13 +14,14 @@ from redash.app import create_app # noqa
from redash.destinations import import_destinations
from redash.query_runner import import_query_runners
__version__ = "23.12.0-dev"
__version__ = "25.03.0-dev"
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():

View File

@@ -1,8 +1,8 @@
import json
import logging
import jwt
import requests
import simplejson
logger = logging.getLogger("jwt_auth")
@@ -25,7 +25,7 @@ def get_public_key_from_net(url):
if "keys" in data:
public_keys = []
for key_dict in data["keys"]:
public_key = jwt.algorithms.RSAAlgorithm.from_jwk(simplejson.dumps(key_dict))
public_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(key_dict))
public_keys.append(public_key)
get_public_keys.key_cache[url] = public_keys

View File

@@ -8,6 +8,7 @@ from redash import settings
try:
from ldap3 import Connection, Server
from ldap3.utils.conv import escape_filter_chars
except ImportError:
if settings.LDAP_LOGIN_ENABLED:
sys.exit(
@@ -69,6 +70,7 @@ def login(org_slug=None):
def auth_ldap_user(username, password):
clean_username = escape_filter_chars(username)
server = Server(settings.LDAP_HOST_URL, use_ssl=settings.LDAP_SSL)
if settings.LDAP_BIND_DN is not None:
conn = Connection(
@@ -83,7 +85,7 @@ def auth_ldap_user(username, password):
conn.search(
settings.LDAP_SEARCH_DN,
settings.LDAP_SEARCH_TEMPLATE % {"username": username},
settings.LDAP_SEARCH_TEMPLATE % {"username": clean_username},
attributes=[settings.LDAP_DISPLAY_NAME_KEY, settings.LDAP_EMAIL_KEY],
)

View File

@@ -1,5 +1,6 @@
import json
import click
import simplejson
from flask import current_app
from flask.cli import FlaskGroup, run_command, with_appcontext
from rq import Connection
@@ -53,7 +54,7 @@ def version():
@manager.command()
def status():
with Connection(rq_redis_connection):
print(simplejson.dumps(get_status(), indent=2))
print(json.dumps(get_status(), indent=2))
@manager.command()

View File

@@ -5,6 +5,22 @@ from sqlalchemy.orm.exc import NoResultFound
manager = AppGroup(help="Queries management commands.")
@manager.command(name="rehash")
def rehash():
from redash import models
for q in models.Query.query.all():
old_hash = q.query_hash
q.update_query_hash()
new_hash = q.query_hash
if old_hash != new_hash:
print(f"Query {q.id} has changed hash from {old_hash} to {new_hash}")
models.db.session.add(q)
models.db.session.commit()
@manager.command(name="add_tag")
@argument("query_id")
@argument("tag")

View File

@@ -5,7 +5,7 @@ logger = logging.getLogger(__name__)
__all__ = ["BaseDestination", "register", "get_destination", "import_destinations"]
class BaseDestination(object):
class BaseDestination:
deprecated = False
def __init__(self, configuration):

View File

@@ -42,8 +42,8 @@ class Discord(BaseDestination):
"inline": True,
},
]
if alert.options.get("custom_body"):
fields.append({"name": "Description", "value": alert.options["custom_body"]})
if alert.custom_body:
fields.append({"name": "Description", "value": alert.custom_body})
if new_state == Alert.TRIGGERED_STATE:
if alert.options.get("custom_subject"):
text = alert.options["custom_subject"]

View File

@@ -26,13 +26,13 @@ class Slack(BaseDestination):
fields = [
{
"title": "Query",
"type": "mrkdwn",
"value": "{host}/queries/{query_id}".format(host=host, query_id=query.id),
"short": True,
},
{
"title": "Alert",
"type": "mrkdwn",
"value": "{host}/alerts/{alert_id}".format(host=host, alert_id=alert.id),
"short": True,
},
]
if alert.custom_body:
@@ -50,7 +50,7 @@ class Slack(BaseDestination):
payload = {"attachments": [{"text": text, "color": color, "fields": fields}]}
try:
resp = requests.post(options.get("url"), data=json_dumps(payload), timeout=5.0)
resp = requests.post(options.get("url"), data=json_dumps(payload).encode("utf-8"), timeout=5.0)
logging.warning(resp.text)
if resp.status_code != 200:
logging.error("Slack send ERROR. status_code => {status}".format(status=resp.status_code))

View File

@@ -1,3 +1,5 @@
import html
import json
import logging
from copy import deepcopy
@@ -37,6 +39,129 @@ class Webex(BaseDestination):
@staticmethod
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 [
{
"contentType": "application/vnd.microsoft.card.adaptive",
@@ -44,44 +169,7 @@ class Webex(BaseDestination):
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.0",
"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,
},
],
},
],
}
],
"body": body,
},
}
]
@@ -116,6 +204,10 @@ class Webex(BaseDestination):
# destinations is guaranteed to be a comma-separated string
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[payload_tag] = destination_id
self.post_message(payload, headers)

View File

@@ -1,7 +1,7 @@
from flask import request
from funcy import project
from redash import models
from redash import models, utils
from redash.handlers.base import (
BaseResource,
get_object_or_404,
@@ -14,6 +14,10 @@ from redash.permissions import (
view_only,
)
from redash.serializers import serialize_alert
from redash.tasks.alerts import (
notify_subscriptions,
should_notify,
)
class AlertResource(BaseResource):
@@ -43,6 +47,21 @@ class AlertResource(BaseResource):
models.db.session.commit()
class AlertEvaluateResource(BaseResource):
def post(self, alert_id):
alert = get_object_or_404(models.Alert.get_by_id_and_org, alert_id, self.current_org)
require_admin_or_owner(alert.user.id)
new_state = alert.evaluate()
if should_notify(alert, new_state):
alert.state = new_state
alert.last_triggered_at = utils.utcnow()
models.db.session.commit()
notify_subscriptions(alert, new_state, {})
self.record_event({"action": "evaluate", "object_id": alert.id, "object_type": "alert"})
class AlertMuteResource(BaseResource):
def post(self, alert_id):
alert = get_object_or_404(models.Alert.get_by_id_and_org, alert_id, self.current_org)

View File

@@ -3,6 +3,7 @@ from flask_restful import Api
from werkzeug.wrappers import Response
from redash.handlers.alerts import (
AlertEvaluateResource,
AlertListResource,
AlertMuteResource,
AlertResource,
@@ -117,6 +118,7 @@ def json_representation(data, code, headers=None):
api.add_org_resource(AlertResource, "/api/alerts/<alert_id>", endpoint="alert")
api.add_org_resource(AlertMuteResource, "/api/alerts/<alert_id>/mute", endpoint="alert_mute")
api.add_org_resource(AlertEvaluateResource, "/api/alerts/<alert_id>/eval", endpoint="alert_eval")
api.add_org_resource(
AlertSubscriptionListResource,
"/api/alerts/<alert_id>/subscriptions",

View File

@@ -29,6 +29,7 @@ def get_google_auth_url(next_path):
def render_token_login_page(template, org_slug, token, invite):
error_message = None
try:
user_id = validate_token(token)
org = current_org._get_current_object()
@@ -40,19 +41,19 @@ def render_token_login_page(template, org_slug, token, invite):
user_id,
org_slug,
)
error_message = "Your invite link is invalid. Bad user id in token. Please ask for a new one."
except SignatureExpired:
logger.exception("Token signature has expired. Token: %s, org=%s", token, org_slug)
error_message = "Your invite link has expired. Please ask for a new one."
except BadSignature:
logger.exception("Bad signature for the token: %s, org=%s", token, org_slug)
error_message = "Your invite link is invalid. Bad signature. Please double-check the token."
if error_message:
return (
render_template(
"error.html",
error_message="Invalid invite link. Please ask for a new one.",
),
400,
)
except (SignatureExpired, BadSignature):
logger.exception("Failed to verify invite token: %s, org=%s", token, org_slug)
return (
render_template(
"error.html",
error_message="Your invite link has expired. Please ask for a new one.",
error_message=error_message,
),
400,
)

View File

@@ -5,15 +5,15 @@ from flask import Blueprint, current_app, request
from flask_login import current_user, login_required
from flask_restful import Resource, abort
from sqlalchemy import cast
from sqlalchemy.dialects import postgresql
from sqlalchemy.dialects.postgresql import ARRAY
from sqlalchemy.orm.exc import NoResultFound
from sqlalchemy_utils.functions import sort_query
from redash import settings
from redash.authentication import current_org
from redash.models import db
from redash.tasks import record_event as record_event_task
from redash.utils import json_dumps
from redash.utils.query_order import sort_query
routes = Blueprint("redash", __name__, template_folder=settings.fix_assets_path("templates"))
@@ -114,7 +114,7 @@ def json_response(response):
def filter_by_tags(result_set, column):
if request.args.getlist("tags"):
tags = request.args.getlist("tags")
result_set = result_set.filter(cast(column, postgresql.ARRAY(db.Text)).contains(tags))
result_set = result_set.filter(cast(column, ARRAY(db.Text)).contains(tags))
return result_set

View File

@@ -96,7 +96,7 @@ class DashboardListResource(BaseResource):
org=self.current_org,
user=self.current_user,
is_draft=True,
layout="[]",
layout=[],
)
models.db.session.add(dashboard)
models.db.session.commit()

View File

@@ -7,7 +7,6 @@ from redash.permissions import (
require_permission,
)
from redash.serializers import serialize_visualization
from redash.utils import json_dumps
class VisualizationListResource(BaseResource):
@@ -18,7 +17,6 @@ class VisualizationListResource(BaseResource):
query = get_object_or_404(models.Query.get_by_id_and_org, kwargs.pop("query_id"), self.current_org)
require_object_modify_permission(query, self.current_user)
kwargs["options"] = json_dumps(kwargs["options"])
kwargs["query_rel"] = query
vis = models.Visualization(**kwargs)
@@ -34,8 +32,6 @@ class VisualizationResource(BaseResource):
require_object_modify_permission(vis.query_rel, self.current_user)
kwargs = request.get_json(force=True)
if "options" in kwargs:
kwargs["options"] = json_dumps(kwargs["options"])
kwargs.pop("id", None)
kwargs.pop("query_id", None)

View File

@@ -1,6 +1,6 @@
import json
import os
import simplejson
from flask import url_for
WEBPACK_MANIFEST_PATH = os.path.join(os.path.dirname(__file__), "../../client/dist/", "asset-manifest.json")
@@ -15,7 +15,7 @@ def configure_webpack(app):
if assets is None or app.debug:
try:
with open(WEBPACK_MANIFEST_PATH) as fp:
assets = simplejson.load(fp)
assets = json.load(fp)
except IOError:
app.logger.exception("Unable to load webpack manifest")
assets = {}

View File

@@ -9,7 +9,6 @@ from redash.permissions import (
view_only,
)
from redash.serializers import serialize_widget
from redash.utils import json_dumps
class WidgetListResource(BaseResource):
@@ -30,7 +29,6 @@ class WidgetListResource(BaseResource):
dashboard = models.Dashboard.get_by_id_and_org(widget_properties.get("dashboard_id"), self.current_org)
require_object_modify_permission(dashboard, self.current_user)
widget_properties["options"] = json_dumps(widget_properties["options"])
widget_properties.pop("id", None)
visualization_id = widget_properties.pop("visualization_id")
@@ -44,7 +42,6 @@ class WidgetListResource(BaseResource):
widget = models.Widget(**widget_properties)
models.db.session.add(widget)
models.db.session.commit()
models.db.session.commit()
return serialize_widget(widget)
@@ -65,7 +62,7 @@ class WidgetResource(BaseResource):
require_object_modify_permission(widget.dashboard, self.current_user)
widget_properties = request.get_json(force=True)
widget.text = widget_properties["text"]
widget.options = json_dumps(widget_properties["options"])
widget.options = widget_properties["options"]
models.db.session.commit()
return serialize_widget(widget)

View File

@@ -5,7 +5,7 @@ from flask import g, has_request_context
from sqlalchemy.engine import Engine
from sqlalchemy.event import listens_for
from sqlalchemy.orm.util import _ORMJoin
from sqlalchemy.sql.selectable import Alias
from sqlalchemy.sql.selectable import Alias, Join
from redash import statsd_client
@@ -18,7 +18,7 @@ def _table_name_from_select_element(elt):
if isinstance(t, Alias):
t = t.original.froms[0]
while isinstance(t, _ORMJoin):
while isinstance(t, _ORMJoin) or isinstance(t, Join):
t = t.left
return t.name

View File

@@ -6,7 +6,7 @@ import time
import pytz
from sqlalchemy import UniqueConstraint, and_, cast, distinct, func, or_
from sqlalchemy.dialects import postgresql
from sqlalchemy.dialects.postgresql import ARRAY, DOUBLE_PRECISION, JSONB
from sqlalchemy.event import listens_for
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import (
@@ -40,14 +40,18 @@ from redash.models.base import (
from redash.models.changes import Change, ChangeTrackingMixin # noqa
from redash.models.mixins import BelongsToOrgMixin, TimestampMixin
from redash.models.organizations import Organization
from redash.models.parameterized_query import ParameterizedQuery
from redash.models.parameterized_query import (
InvalidParameterError,
ParameterizedQuery,
QueryDetachedFromDataSourceError,
)
from redash.models.types import (
Configuration,
EncryptedConfiguration,
JSONText,
MutableDict,
MutableList,
PseudoJSON,
pseudo_json_cast_property,
json_cast_property,
)
from redash.models.users import ( # noqa
AccessPermission,
@@ -80,7 +84,7 @@ from redash.utils.configuration import ConfigurationContainer
logger = logging.getLogger(__name__)
class ScheduledQueriesExecutions(object):
class ScheduledQueriesExecutions:
KEY_NAME = "sq:executed_at"
def __init__(self):
@@ -123,7 +127,10 @@ class DataSource(BelongsToOrgMixin, db.Model):
data_source_groups = db.relationship("DataSourceGroup", back_populates="data_source", cascade="all")
__tablename__ = "data_sources"
__table_args__ = (db.Index("data_sources_org_id_name", "org_id", "name"),)
__table_args__ = (
db.Index("data_sources_org_id_name", "org_id", "name"),
{"extend_existing": True},
)
def __eq__(self, other):
return self.id == other.id
@@ -297,34 +304,11 @@ class DataSourceGroup(db.Model):
view_only = Column(db.Boolean, default=False)
__tablename__ = "data_source_groups"
DESERIALIZED_DATA_ATTR = "_deserialized_data"
class DBPersistence(object):
@property
def data(self):
if self._data is None:
return None
if not hasattr(self, DESERIALIZED_DATA_ATTR):
setattr(self, DESERIALIZED_DATA_ATTR, json_loads(self._data))
return self._deserialized_data
@data.setter
def data(self, data):
if hasattr(self, DESERIALIZED_DATA_ATTR):
delattr(self, DESERIALIZED_DATA_ATTR)
self._data = data
QueryResultPersistence = settings.dynamic_settings.QueryResultPersistence or DBPersistence
__table_args__ = ({"extend_existing": True},)
@generic_repr("id", "org_id", "data_source_id", "query_hash", "runtime", "retrieved_at")
class QueryResult(db.Model, QueryResultPersistence, BelongsToOrgMixin):
class QueryResult(db.Model, BelongsToOrgMixin):
id = primary_key("QueryResult")
org_id = Column(key_type("Organization"), db.ForeignKey("organizations.id"))
org = db.relationship(Organization)
@@ -332,8 +316,8 @@ class QueryResult(db.Model, QueryResultPersistence, BelongsToOrgMixin):
data_source = db.relationship(DataSource, backref=backref("query_results"))
query_hash = Column(db.String(32), index=True)
query_text = Column("query", db.Text)
_data = Column("data", db.Text)
runtime = Column(postgresql.DOUBLE_PRECISION)
data = Column(JSONText, nullable=True)
runtime = Column(DOUBLE_PRECISION)
retrieved_at = Column(db.DateTime(True))
__tablename__ = "query_results"
@@ -403,6 +387,10 @@ class QueryResult(db.Model, QueryResultPersistence, BelongsToOrgMixin):
def should_schedule_next(previous_iteration, now, interval, time=None, day_of_week=None, failures=0):
# if previous_iteration is None, it means the query has never been run before
# so we should schedule it immediately
if previous_iteration is None:
return True
# if time exists then interval > 23 hours (82800s)
# if day_of_week exists then interval > 6 days (518400s)
if time is None:
@@ -474,11 +462,11 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
last_modified_by = db.relationship(User, backref="modified_queries", foreign_keys=[last_modified_by_id])
is_archived = Column(db.Boolean, default=False, index=True)
is_draft = Column(db.Boolean, default=True, index=True)
schedule = Column(MutableDict.as_mutable(PseudoJSON), nullable=True)
interval = pseudo_json_cast_property(db.Integer, "schedule", "interval", default=0)
schedule = Column(MutableDict.as_mutable(JSONB), nullable=True)
interval = json_cast_property(db.Integer, "schedule", "interval", default=0)
schedule_failures = Column(db.Integer, default=0)
visualizations = db.relationship("Visualization", cascade="all, delete-orphan")
options = Column(MutableDict.as_mutable(PseudoJSON), default={})
options = Column(MutableDict.as_mutable(JSONB), default={})
search_vector = Column(
TSVectorType(
"id",
@@ -489,7 +477,7 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
),
nullable=True,
)
tags = Column("tags", MutableList.as_mutable(postgresql.ARRAY(db.Unicode)), nullable=True)
tags = Column("tags", MutableList.as_mutable(ARRAY(db.Unicode)), nullable=True)
query_class = SearchBaseQuery
__tablename__ = "queries"
@@ -525,7 +513,7 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
name="Table",
description="",
type="TABLE",
options="{}",
options={},
)
)
return query
@@ -591,11 +579,12 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
@classmethod
def past_scheduled_queries(cls):
now = utils.utcnow()
queries = Query.query.filter(Query.schedule.isnot(None)).order_by(Query.id)
queries = Query.query.filter(func.jsonb_typeof(Query.schedule) != "null").order_by(Query.id)
return [
query
for query in queries
if query.schedule["until"] is not None
if "until" in query.schedule
and query.schedule["until"] is not None
and pytz.utc.localize(datetime.datetime.strptime(query.schedule["until"], "%Y-%m-%d")) <= now
]
@@ -603,7 +592,7 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
def outdated_queries(cls):
queries = (
Query.query.options(joinedload(Query.latest_query_data).load_only("retrieved_at"))
.filter(Query.schedule.isnot(None))
.filter(func.jsonb_typeof(Query.schedule) != "null")
.order_by(Query.id)
.all()
)
@@ -617,6 +606,11 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
if query.schedule.get("disabled"):
continue
# Skip queries that have None for all schedule values. It's unclear whether this
# something that can happen in practice, but we have a test case for it.
if all(value is None for value in query.schedule.values()):
continue
if query.schedule["until"]:
schedule_until = pytz.utc.localize(datetime.datetime.strptime(query.schedule["until"], "%Y-%m-%d"))
@@ -628,7 +622,7 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
)
if should_schedule_next(
retrieved_at or now,
retrieved_at,
now,
query.schedule["interval"],
query.schedule["time"],
@@ -831,7 +825,20 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
def update_query_hash(self):
should_apply_auto_limit = self.options.get("apply_auto_limit", False) if self.options else False
query_runner = self.data_source.query_runner if self.data_source else BaseQueryRunner({})
self.query_hash = query_runner.gen_query_hash(self.query_text, should_apply_auto_limit)
query_text = self.query_text
parameters_dict = {p["name"]: p.get("value") for p in self.parameters} if self.options else {}
if any(parameters_dict):
try:
query_text = self.parameterized.apply(parameters_dict).query
except InvalidParameterError as e:
logging.info(f"Unable to update hash for query {self.id} because of invalid parameters: {str(e)}")
except QueryDetachedFromDataSourceError as e:
logging.info(
f"Unable to update hash for query {self.id} because of dropdown query {e.query_id} is unattached from datasource"
)
self.query_hash = query_runner.gen_query_hash(query_text, should_apply_auto_limit)
@listens_for(Query, "before_insert")
@@ -901,6 +908,7 @@ def next_state(op, value, threshold):
# boolean value is Python specific and most likely will be confusing to
# users.
value = str(value).lower()
value_is_number = False
else:
try:
value = float(value)
@@ -918,6 +926,8 @@ def next_state(op, value, threshold):
if op(value, threshold):
new_state = Alert.TRIGGERED_STATE
elif not value_is_number and op not in [OPERATORS.get("!="), OPERATORS.get("=="), OPERATORS.get("equals")]:
new_state = Alert.UNKNOWN_STATE
else:
new_state = Alert.OK_STATE
@@ -929,6 +939,7 @@ class Alert(TimestampMixin, BelongsToOrgMixin, db.Model):
UNKNOWN_STATE = "unknown"
OK_STATE = "ok"
TRIGGERED_STATE = "triggered"
TEST_STATE = "test"
id = primary_key("Alert")
name = Column(db.String(255))
@@ -936,7 +947,7 @@ class Alert(TimestampMixin, BelongsToOrgMixin, db.Model):
query_rel = db.relationship(Query, backref=backref("alerts", cascade="all"))
user_id = Column(key_type("User"), db.ForeignKey("users.id"))
user = db.relationship(User, backref="alerts")
options = Column(MutableDict.as_mutable(PseudoJSON))
options = Column(MutableDict.as_mutable(JSONB), nullable=True)
state = Column(db.String(255), default=UNKNOWN_STATE)
subscriptions = db.relationship("AlertSubscription", cascade="all, delete-orphan")
last_triggered_at = Column(db.DateTime(True), nullable=True)
@@ -958,17 +969,38 @@ class Alert(TimestampMixin, BelongsToOrgMixin, db.Model):
return super(Alert, cls).get_by_id_and_org(object_id, org, Query)
def evaluate(self):
data = self.query_rel.latest_query_data.data
data = self.query_rel.latest_query_data.data if self.query_rel.latest_query_data else None
new_state = self.UNKNOWN_STATE
if 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)
value = data["rows"][0][self.options["column"]]
if "selector" not in self.options:
selector = "first"
else:
selector = self.options["selector"]
try:
if selector == "max":
max_val = float("-inf")
for i in range(len(data["rows"])):
max_val = max(max_val, float(data["rows"][i][self.options["column"]]))
value = max_val
elif selector == "min":
min_val = float("inf")
for i in range(len(data["rows"])):
min_val = min(min_val, float(data["rows"][i][self.options["column"]]))
value = min_val
else:
value = data["rows"][0][self.options["column"]]
except ValueError:
return self.UNKNOWN_STATE
threshold = self.options["value"]
new_state = next_state(op, value, threshold)
else:
new_state = self.UNKNOWN_STATE
if value is not None:
new_state = next_state(op, value, threshold)
return new_state
@@ -991,11 +1023,11 @@ class Alert(TimestampMixin, BelongsToOrgMixin, db.Model):
result_table = [] # A two-dimensional array which can rendered as a table in Mustache
for row in data["rows"]:
result_table.append([row[col["name"]] for col in data["columns"]])
context = {
"ALERT_NAME": self.name,
"ALERT_URL": "{host}/alerts/{alert_id}".format(host=host, alert_id=self.id),
"ALERT_STATUS": self.state.upper(),
"ALERT_SELECTOR": self.options["selector"],
"ALERT_CONDITION": self.options["op"],
"ALERT_THRESHOLD": self.options["value"],
"QUERY_NAME": self.query_rel.name,
@@ -1047,13 +1079,13 @@ class Dashboard(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model
user_id = Column(key_type("User"), db.ForeignKey("users.id"))
user = db.relationship(User)
# layout is no longer used, but kept so we know how to render old dashboards.
layout = Column(db.Text)
layout = Column(MutableList.as_mutable(JSONB), default=[])
dashboard_filters_enabled = Column(db.Boolean, default=False)
is_archived = Column(db.Boolean, default=False, index=True)
is_draft = Column(db.Boolean, default=True, index=True)
widgets = db.relationship("Widget", backref="dashboard", lazy="dynamic")
tags = Column("tags", MutableList.as_mutable(postgresql.ARRAY(db.Unicode)), nullable=True)
options = Column(MutableDict.as_mutable(postgresql.JSON), server_default="{}", default={})
tags = Column("tags", MutableList.as_mutable(ARRAY(db.Unicode)), nullable=True)
options = Column(MutableDict.as_mutable(JSONB), default={})
__tablename__ = "dashboards"
__mapper_args__ = {"version_id_col": version}
@@ -1166,7 +1198,7 @@ class Visualization(TimestampMixin, BelongsToOrgMixin, db.Model):
query_rel = db.relationship(Query, back_populates="visualizations")
name = Column(db.String(255))
description = Column(db.String(4096), nullable=True)
options = Column(db.Text)
options = Column(MutableDict.as_mutable(JSONB), nullable=True)
__tablename__ = "visualizations"
@@ -1193,7 +1225,7 @@ class Widget(TimestampMixin, BelongsToOrgMixin, db.Model):
visualization = db.relationship(Visualization, backref=backref("widgets", cascade="delete"))
text = Column(db.Text, nullable=True)
width = Column(db.Integer)
options = Column(db.Text)
options = Column(MutableDict.as_mutable(JSONB), default={})
dashboard_id = Column(key_type("Dashboard"), db.ForeignKey("dashboards.id"), index=True)
__tablename__ = "widgets"
@@ -1225,7 +1257,7 @@ class Event(db.Model):
action = Column(db.String(255))
object_type = Column(db.String(255))
object_id = Column(db.String(255), nullable=True)
additional_properties = Column(MutableDict.as_mutable(PseudoJSON), nullable=True, default={})
additional_properties = Column(MutableDict.as_mutable(JSONB), nullable=True, default={})
created_at = Column(db.DateTime(True), default=db.func.now())
__tablename__ = "events"

View File

@@ -1,13 +1,13 @@
import functools
from flask_sqlalchemy import BaseQuery, SQLAlchemy
from sqlalchemy.dialects import postgresql
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import object_session
from sqlalchemy.pool import NullPool
from sqlalchemy_searchable import SearchQueryMixin, make_searchable, vectorizer
from redash import settings
from redash.utils import json_dumps
from redash.utils import json_dumps, json_loads
class RedashSQLAlchemy(SQLAlchemy):
@@ -28,7 +28,10 @@ class RedashSQLAlchemy(SQLAlchemy):
return options
db = RedashSQLAlchemy(session_options={"expire_on_commit": False})
db = RedashSQLAlchemy(
session_options={"expire_on_commit": False},
engine_options={"json_serializer": json_dumps, "json_deserializer": json_loads},
)
# Make sure the SQLAlchemy mappers are all properly configured first.
# This is required by SQLAlchemy-Searchable as it adds DDL listeners
# on the configuration phase of models.
@@ -50,7 +53,7 @@ def integer_vectorizer(column):
return db.func.cast(column, db.Text)
@vectorizer(postgresql.UUID)
@vectorizer(UUID)
def uuid_vectorizer(column):
return db.func.cast(column, db.Text)
@@ -68,7 +71,7 @@ def gfk_type(cls):
return cls
class GFKBase(object):
class GFKBase:
"""
Compatibility with 'generic foreign key' approach Peewee used.
"""

View File

@@ -1,8 +1,8 @@
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.inspection import inspect
from sqlalchemy_utils.models import generic_repr
from .base import Column, GFKBase, db, key_type, primary_key
from .types import PseudoJSON
@generic_repr("id", "object_type", "object_id", "created_at")
@@ -13,7 +13,7 @@ class Change(GFKBase, db.Model):
object_version = Column(db.Integer, default=0)
user_id = Column(key_type("User"), db.ForeignKey("users.id"))
user = db.relationship("User", backref="changes")
change = Column(PseudoJSON)
change = Column(JSONB)
created_at = Column(db.DateTime(True), default=db.func.now())
__tablename__ = "changes"
@@ -45,7 +45,7 @@ class Change(GFKBase, db.Model):
)
class ChangeTrackingMixin(object):
class ChangeTrackingMixin:
skipped_fields = ("id", "created_at", "updated_at", "version")
_clean_values = None

View File

@@ -3,7 +3,7 @@ from sqlalchemy.event import listens_for
from .base import Column, db
class TimestampMixin(object):
class TimestampMixin:
updated_at = Column(db.DateTime(True), default=db.func.now(), nullable=False)
created_at = Column(db.DateTime(True), default=db.func.now(), nullable=False)
@@ -17,7 +17,7 @@ def timestamp_before_update(mapper, connection, target):
target.updated_at = db.func.now()
class BelongsToOrgMixin(object):
class BelongsToOrgMixin:
@classmethod
def get_by_id_and_org(cls, object_id, org, org_cls=None):
query = cls.query.filter(cls.id == object_id)

View File

@@ -1,3 +1,4 @@
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm.attributes import flag_modified
from sqlalchemy_utils.models import generic_repr
@@ -5,7 +6,7 @@ from redash.settings.organization import settings as org_settings
from .base import Column, db, primary_key
from .mixins import TimestampMixin
from .types import MutableDict, PseudoJSON
from .types import MutableDict
from .users import Group, User
@@ -17,7 +18,7 @@ class Organization(TimestampMixin, db.Model):
id = primary_key("Organization")
name = Column(db.String(255))
slug = Column(db.String(255), unique=True)
settings = Column(MutableDict.as_mutable(PseudoJSON))
settings = Column(MutableDict.as_mutable(JSONB), default={})
groups = db.relationship("Group", lazy="dynamic")
events = db.relationship("Event", lazy="dynamic", order_by="desc(Event.created_at)")

View File

@@ -1,3 +1,4 @@
import re
from functools import partial
from numbers import Number
@@ -88,6 +89,16 @@ def _is_number(string):
return True
def _is_regex_pattern(value, regex):
try:
if re.compile(regex).fullmatch(value):
return True
else:
return False
except re.error:
return False
def _is_date(string):
parse(string)
return True
@@ -103,7 +114,7 @@ def _is_value_within_options(value, dropdown_options, allow_list=False):
return str(value) in dropdown_options
class ParameterizedQuery(object):
class ParameterizedQuery:
def __init__(self, template, schema=None, org=None):
self.schema = schema or []
self.org = org
@@ -135,6 +146,7 @@ class ParameterizedQuery(object):
enum_options = definition.get("enumOptions")
query_id = definition.get("queryId")
regex = definition.get("regex")
allow_multiple_values = isinstance(definition.get("multiValuesOptions"), dict)
if isinstance(enum_options, str):
@@ -142,6 +154,7 @@ class ParameterizedQuery(object):
validators = {
"text": lambda value: isinstance(value, str),
"text-pattern": lambda value: _is_regex_pattern(value, regex),
"number": _is_number,
"enum": lambda value: _is_value_within_options(value, enum_options, allow_multiple_values),
"query": lambda value: _is_value_within_options(

View File

@@ -1,5 +1,3 @@
from sqlalchemy import cast
from sqlalchemy.dialects.postgresql import JSON
from sqlalchemy.ext.indexable import index_property
from sqlalchemy.ext.mutable import Mutable
from sqlalchemy.types import TypeDecorator
@@ -31,8 +29,8 @@ class EncryptedConfiguration(EncryptedType):
)
# XXX replace PseudoJSON and MutableDict with real JSON field
class PseudoJSON(TypeDecorator):
# Utilized for cases when JSON size is bigger than JSONB (255MB) or JSON (10MB) limit
class JSONText(TypeDecorator):
impl = db.Text
def process_bind_param(self, value, dialect):
@@ -107,19 +105,3 @@ class json_cast_property(index_property):
def expr(self, model):
expr = super(json_cast_property, self).expr(model)
return expr.astext.cast(self.cast_type)
class pseudo_json_cast_property(index_property):
"""
A SQLAlchemy index property that is able to cast the
entity attribute as the specified cast type. Useful
for PseudoJSON colums for easier querying/filtering.
"""
def __init__(self, cast_type, *args, **kwargs):
super().__init__(*args, **kwargs)
self.cast_type = cast_type
def expr(self, model):
expr = cast(getattr(model, self.attr_name), JSON)[self.index]
return expr.astext.cast(self.cast_type)

View File

@@ -8,7 +8,7 @@ from operator import or_
from flask import current_app, request_started, url_for
from flask_login import AnonymousUserMixin, UserMixin, current_user
from passlib.apps import custom_app_context as pwd_context
from sqlalchemy.dialects import postgresql
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
from sqlalchemy_utils import EmailType
from sqlalchemy_utils.models import generic_repr
@@ -60,7 +60,7 @@ def init_app(app):
request_started.connect(update_user_active_at, app)
class PermissionsCheckMixin(object):
class PermissionsCheckMixin:
def has_permission(self, permission):
return self.has_permissions((permission,))
@@ -84,14 +84,14 @@ class User(TimestampMixin, db.Model, BelongsToOrgMixin, UserMixin, PermissionsCh
password_hash = Column(db.String(128), nullable=True)
group_ids = Column(
"groups",
MutableList.as_mutable(postgresql.ARRAY(key_type("Group"))),
MutableList.as_mutable(ARRAY(key_type("Group"))),
nullable=True,
)
api_key = Column(db.String(40), default=lambda: generate_token(40), unique=True)
disabled_at = Column(db.DateTime(True), default=None, nullable=True)
details = Column(
MutableDict.as_mutable(postgresql.JSONB),
MutableDict.as_mutable(JSONB),
nullable=True,
server_default="{}",
default={},
@@ -166,7 +166,7 @@ class User(TimestampMixin, db.Model, BelongsToOrgMixin, UserMixin, PermissionsCh
if self._profile_image_url:
return self._profile_image_url
email_md5 = hashlib.md5(self.email.lower().encode()).hexdigest()
email_md5 = hashlib.md5(self.email.lower().encode(), usedforsecurity=False).hexdigest()
return "https://www.gravatar.com/avatar/{}?s=40&d=identicon".format(email_md5)
@property
@@ -233,7 +233,9 @@ class User(TimestampMixin, db.Model, BelongsToOrgMixin, UserMixin, PermissionsCh
return AccessPermission.exists(obj, access_type, grantee=self)
def get_id(self):
identity = hashlib.md5("{},{}".format(self.email, self.password_hash).encode()).hexdigest()
identity = hashlib.md5(
"{},{}".format(self.email, self.password_hash).encode(), usedforsecurity=False
).hexdigest()
return "{0}-{1}".format(self.id, identity)
def get_actual_user(self):
@@ -267,7 +269,7 @@ class Group(db.Model, BelongsToOrgMixin):
org = db.relationship("Organization", back_populates="groups")
type = Column(db.String(255), default=REGULAR_GROUP)
name = Column(db.String(100))
permissions = Column(postgresql.ARRAY(db.String(255)), default=DEFAULT_PERMISSIONS)
permissions = Column(ARRAY(db.String(255)), default=DEFAULT_PERMISSIONS)
created_at = Column(db.DateTime(True), default=db.func.now())
__tablename__ = "groups"

View File

@@ -59,7 +59,7 @@ def get_status():
def rq_job_ids():
queues = Queue.all(connection=redis_connection)
queues = Queue.all(connection=rq_redis_connection)
started_jobs = [StartedJobRegistry(queue=q).get_job_ids() for q in queues]
queued_jobs = [q.job_ids for q in queues]

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