mirror of
https://github.com/getredash/redash.git
synced 2025-12-25 01:03:20 -05:00
Compare commits
5 Commits
v3.0.0
...
v0.11.1.b2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14ea79b1e9 | ||
|
|
432fe9b8a3 | ||
|
|
33909a1b32 | ||
|
|
9bca3933e7 | ||
|
|
e1dd1b3f71 |
@@ -1,22 +0,0 @@
|
||||
engines:
|
||||
pep8:
|
||||
enabled: true
|
||||
eslint:
|
||||
enabled: true
|
||||
channel: "eslint-3"
|
||||
config:
|
||||
config: client/.eslintrc.js
|
||||
checks:
|
||||
import/no-unresolved:
|
||||
enabled: false
|
||||
ratings:
|
||||
paths:
|
||||
- "redash/**/*.py"
|
||||
- "client/**/*.js"
|
||||
exclude_paths:
|
||||
- tests/**/*.py
|
||||
- migrations/**/*.py
|
||||
- old_migrations/**/*.py
|
||||
- setup/**/*
|
||||
- bin/**/*
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
client/.tmp/
|
||||
node_modules/
|
||||
.tmp/
|
||||
rd_ui/.tmp/
|
||||
rd_ui/node_modules/
|
||||
.git/
|
||||
.vagrant/
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.py]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[*.{js,css,html}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
5
.env.example
Normal file
5
.env.example
Normal file
@@ -0,0 +1,5 @@
|
||||
REDASH_STATIC_ASSETS_PATH="../rd_ui/app/"
|
||||
REDASH_LOG_LEVEL="INFO"
|
||||
REDASH_REDIS_URL=redis://localhost:6379/1
|
||||
REDASH_DATABASE_URL="postgresql://redash"
|
||||
REDASH_COOKIE_SECRET=veryverysecret
|
||||
15
.gitignore
vendored
15
.gitignore
vendored
@@ -2,19 +2,28 @@
|
||||
.idea
|
||||
*.pyc
|
||||
.coverage
|
||||
client/dist
|
||||
rd_ui/dist
|
||||
.DS_Store
|
||||
celerybeat-schedule*
|
||||
.#*
|
||||
\#*#
|
||||
*~
|
||||
_build
|
||||
.vscode
|
||||
|
||||
# Vagrant related
|
||||
.vagrant
|
||||
Berksfile.lock
|
||||
redash/dump.rdb
|
||||
.env
|
||||
.ruby-version
|
||||
venv
|
||||
|
||||
dump.rdb
|
||||
|
||||
# Docker related
|
||||
docker-compose.yml
|
||||
|
||||
node_modules
|
||||
.tmp
|
||||
.sass-cache
|
||||
npm-debug.log
|
||||
rd_ui/app/bower_components
|
||||
|
||||
2
.landscape.yaml
Normal file
2
.landscape.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
ignore-paths:
|
||||
- migrations
|
||||
569
CHANGELOG.md
569
CHANGELOG.md
@@ -1,569 +0,0 @@
|
||||
# Change Log
|
||||
|
||||
## v3.0.0 - UNRELEASED
|
||||
|
||||
### Added
|
||||
|
||||
- Query Result data source (run queries on query results).
|
||||
- Athena: option to load schema from Glue catalog. @myouju
|
||||
- Allow running any command inside the container via the Docker entrypoint script. @jezdez
|
||||
- Make invitation token max age configurable. @hhamalai
|
||||
- Redshift: add support for the new ACM root CA.
|
||||
- Redshift: support for Spectrum (external) tables. @atharvai
|
||||
- MongoDB: option to set allowDiskUse in queries.
|
||||
- Option to disable SQLAlchemy connection pool.
|
||||
- Option to set a time limit on adhoc queries.
|
||||
- Option to disable sending an invite to a new user.
|
||||
- Azure SQL Data Warehouse query runner. @kitsuyui
|
||||
- Prometheus query runner. @yershalom
|
||||
- Option to set the Flask-Limiter storage engine.
|
||||
- Option to set UnicodeWriter's error handling method. @fan-t-endo
|
||||
- PostgreSQL: SSL configuration option. @TylerBrock
|
||||
- Counter visualization: additional formatting options. @deecay
|
||||
- Query based drop down parameter. @rohithmenon
|
||||
- MySQL: multiple queries support & connection timeout.
|
||||
- Ability to select all in multi-filter. @Posnet
|
||||
- LDAP (Active Directory) support. @amarjayr
|
||||
|
||||
### Changed
|
||||
|
||||
- Copy parameters when forking a query. @kyoshidajp
|
||||
- Prevent using Query API Key with refresh API (previously it was just failing).
|
||||
- Reduce boilerplate in frontend code.
|
||||
- Set auto focus in first input items. @kyoshidajp
|
||||
- Update gunicorn to latest version.
|
||||
- Make log format configurable.
|
||||
- Sort series by name.
|
||||
- Allow setting test file with Docker test run. @meinac
|
||||
- Use outdated queries count stored already in Redis.
|
||||
- Show links based on permissions the user have.
|
||||
- Cassandra: update driver version. @yershalom
|
||||
- Docker-Compose: update configuration to always restart services. @muddydixon
|
||||
- Modernize Python 2 code to get ready for Python 3. @cclauss
|
||||
- Cohort visualization: make it friendlier to use by better handle gaps in data, so it's easier to generate the data needed.
|
||||
- Use a different markdown library. @alexmuller
|
||||
- Salesforce: improve error messages we receive from the API. @akiray03
|
||||
- Custom JS code visualization improvements. @deecay
|
||||
- DQL: Update version to 0.5.24. @aterreno
|
||||
- Cassandra: get_schema support for both C* 2.x and 3.x, support for SortedSet type serialization. (@mfouilleul)
|
||||
- Replace deprecated ng-annotate with babel plugin. @44px
|
||||
- Update Python dependencies to recent versions. @alison985
|
||||
- Bootstrap script: create /opt/redash directory only if it doesn't exist. @isomura
|
||||
- Bootstrap script: make use of REDASH_BASE_PATH variable in setup script. @sylvain
|
||||
|
||||
### Fixed
|
||||
|
||||
- Require full data source access to fork a query.
|
||||
- API key of one query could be used to get results of another one.
|
||||
- Delete group id from user object when deleting the group. @kyoshidajp
|
||||
- Sorting of X axis wasn't working for Box plot type visualizations. @deecay
|
||||
- Exporting query results as excel was failing when one of the columns had array data. @kyoshidajp
|
||||
- Show query editor's Archive/Publish Query drop-down only on saved queries. @cyriac
|
||||
- Move misplaced configuration in docker-compose.production.yml. @yutannihilation
|
||||
- MySQL: support UTF8 schema.
|
||||
- TreasureData queries were failing when returning 0 rows.
|
||||
- Use series color for Boxplot. @deecay
|
||||
- Revoke permission should respect to given grantee and access type. @meinac
|
||||
- Fixed eslint "Cannot read property 'length' of undefined" error. @kravets-levko
|
||||
- Don't crash query editor when there are unclosed curly brackets.
|
||||
- Error value in charts wasn't displayed if it was 0.
|
||||
- Prevent line breaks in EditInPlace description when using Firefox. @alexmuller
|
||||
- Queries#all_queries was sometimes returning wrong number of queries.
|
||||
- record_event fails for API events.
|
||||
- Cancel button on tasks admin page was broken.
|
||||
- Remove deprecated cx_Oracle types. @queeno
|
||||
- Textbox widgets were updating their value even when editor was cancelled. @alison985
|
||||
- Collaborators couldn't edit visualizations or schedule.
|
||||
- Use series color for error bar. @deecay
|
||||
- Upgrade script was using the wrong restart command on new AMIs.
|
||||
|
||||
## v2.0.1 - 2017-10-22
|
||||
|
||||
This is a patch release, that adds support for Redshift ACM certificates (see #2044 for details).
|
||||
|
||||
|
||||
## v2.0.0 - 2017-08-08
|
||||
|
||||
### Added
|
||||
|
||||
- [Cassandra] Support for UUID serializing and setting protocol version. @mfouilleul
|
||||
- [BigQuery] Add maximumBillingTier to BigQuery configuration. @dotneet
|
||||
- Add the propertyOrder field to specify order of data source settings. @rmakulov
|
||||
- Add Plotly based Boxplot visualization. @deecay
|
||||
- [Presto] Add: query cancellation support. @fbertsch
|
||||
- [MongoDB] add $oids JSON extension.
|
||||
- [PostgreSQL] support for loading materialized views in schema.
|
||||
- [MySQL] Add option to hide SSL settings.
|
||||
- [MySQL] support for RDS MySQL and SSL.
|
||||
- [Google Analytics] support for mcf queries & better errors.
|
||||
- Add: static enum parameter type. @rockwotj
|
||||
- Add: option to hide pivot table controls. @deecay
|
||||
- Retry reload of query results if it had an error.
|
||||
- [Data Sources] Add: MemSQL query runner. @alexanderlz
|
||||
- "Dumb" recents option (see #1779 for details)
|
||||
- Athena: direct query runner using the instead of JDBC proxy. @laughingman7743
|
||||
- Optionally support parameters in embeds. @ziahamza
|
||||
- Sorting ability in alerts view.
|
||||
- Option to change default encoding of CSV writer. @yamamanx
|
||||
- Ability to set dashboard level filters from UI.
|
||||
- CLI command to open IPython shell.
|
||||
- Add link to query page from admin view. @miketheman
|
||||
- Add the option to write logs to STDOUT instead of STDERR. @eyalzek
|
||||
- Add limit parameter to tasks API. @alexpekurovsky
|
||||
- Add SQLAlchemy pool settings.
|
||||
- Support for category type y axis.
|
||||
- Add 12 & 24 hours refresh rate option to dashboards.
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgrade Google API client library for all Google data sources. @ahamino
|
||||
- [JIRA JQL] change default max results limit from 50 to 1000. @jvanegmond
|
||||
- Upgrade to newer Plotly version. @deecay
|
||||
- [Athena] Configuration flag to disable query annotations for Athena. @suemoc
|
||||
- Ignore extra columns in CSV output. @alexanderlz
|
||||
- [TreasureData] improve error handling and upgrade client.
|
||||
- [InfluxDB] simpler test connection query (show databases requires admin).
|
||||
- [MSSQL] Mark integers as decimals as well, as sometimes decimal columns being returned
|
||||
with integer column type.
|
||||
- [Google Spreadsheets] add timeout to requests.
|
||||
- Sort dashboards list by name. @deecay
|
||||
- Include Celery task name in statsd metrics.
|
||||
- Don't include paused datasource's queries in outdated queries count.
|
||||
- Cohort: handle the case where the value/total might be strings.
|
||||
- Query results: better type guessing on the client side.
|
||||
- Counter: support negative indexes to iterate from the end of the results.
|
||||
- Data sources and destinations configuration: change order of name and type (type first now).
|
||||
- Show API Key in a modal dialog instead of alert.
|
||||
- Sentry: upgrade client version.
|
||||
- Sentry: don't install logging hook.
|
||||
- Split refresh schemas into separate tasks and add a timeout.
|
||||
- Execute scheduled queries with parameters using their default value.
|
||||
- Keep track of last query execution (including failed ones) for scheduling purposes.
|
||||
- Same view for input on search result page as in header. @44px
|
||||
- Metrics: report endpoints without dots for metrics.
|
||||
- Redirect to / when org not found.
|
||||
- Improve parameters label placement. @44px
|
||||
- Auto-publish queries when they are named (with option to disable; #1830).
|
||||
- Show friendly error message in case of duplicate data source name.
|
||||
- Don't allow saving dashboard with empty name.
|
||||
- Enable strict checking for Angular DI.
|
||||
- Disable Angular debug info (should improve performance).
|
||||
- Update to Webpack 2. @44px
|
||||
- Remove /forgot endpoint if REDASH_PASSWORD_LOGIN_ENABLED is false. @amarjayr
|
||||
- Docker: make Gunicorn worker count configurable. @unixwitch
|
||||
- Snowflake support is no longer enabled by default.
|
||||
- Enable memory optimization for Excel exporter.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix: set default values in options to enable 'default: True' for checkbox. @rmakulov
|
||||
- Support MULTI_ORG again.
|
||||
- [Google Spreadsheets] handle distant future dates.
|
||||
- [SQLite] better handle utf-8 error messages.
|
||||
- Fix: don't remove locks for queries with task status of PENDING.
|
||||
- Only split columns with __/:: that end with filter/MultiFilter.
|
||||
- Alert notifications fail (sometime) with a SQLAlchemy error.
|
||||
- Safeguard against empty query results when checking alert status. @danielerapati
|
||||
- Delete data source doesn't work when query results referenced by queries.
|
||||
- Fix redirect to /setup on the last setup step. @44px
|
||||
- Cassandra: use port setting in connection options. @yershalom
|
||||
- Metrics: table name wasn't found for count queries.
|
||||
- BigQuery wasn't loading due to bad import.
|
||||
- DynamicForm component was inserting empty values.
|
||||
- Clear null values from data source options dictionary.
|
||||
- /api/session API call wasn't working when multi tenancy enabled
|
||||
- If column had no type it would use previous column's type.
|
||||
- Alert destination details were not updating.
|
||||
- When setting rearm on a new alert, it wasn't persisted.
|
||||
- Salesforce: sandbox parameter should be optional. @msnider
|
||||
- Alert page wasn't properly linked from alerts list. @alison985
|
||||
- PostgreSQL passwords with spaces were not supported. (#1056)
|
||||
- PivotTable wasn't updating after first save.
|
||||
|
||||
|
||||
## v1.0.3 - 2017-04-18
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix: sort by column no longer working.
|
||||
|
||||
## v1.0.2 - 2017-04-18
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix: favicon wasn't showing up.
|
||||
- Fix: support for unicode in dashboard tags. @deecay
|
||||
- Fix: page freezes when rendering large result set.
|
||||
- Fix: chart embeds were not rendering in PhantomJS.
|
||||
|
||||
## v1.0.1 - 2017-04-02
|
||||
|
||||
### Added
|
||||
|
||||
- Add: bubble charts support.
|
||||
- Add "Refresh Schema" button to the datasource @44px
|
||||
- [Data Sources] Add: ATSD query runner @rmakulov
|
||||
- [Data Sources] Add: SalesForce query runner @msnider
|
||||
- Add: scheduled query backoff in case of errors @washort
|
||||
- Add: use results row count as the value for the counter visualization. @deecay
|
||||
|
||||
### Changed
|
||||
|
||||
- Moved CSV/Excel query results generation code to models. @akiray03
|
||||
- Add support for filtered data in Pivot table visualization @deecay
|
||||
- Friendlier labels for archived state of dashboard/query
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix: optimize queries to avoid N+1 queries.
|
||||
- Fix: percent stacking math was wrong. @spasovski
|
||||
- Fix: set query filter to match value from URL query string. @benmargo
|
||||
- [Clickhouse] Fix: detection of various data types. @denisov-vlad
|
||||
- Fix: user can't edit their own alert.
|
||||
- Fix: angular minification issue in textbox editor and schema browser.
|
||||
- Fixes to better support IE11 (add polyfill for Object.assign and show vertical scrollbar). @deecay
|
||||
- Fix: datetime parameters were not using a date picker.
|
||||
- Fix: Impala schema wasn't loading.
|
||||
- Fix: query embed dialog close button wasn't working @r0fls
|
||||
- Fix: make errors from Presto runner JSON-serializable @washort
|
||||
- Fix: race condition in query task status reporting @washort
|
||||
- Fix: remove $$hashKey from Pivot table
|
||||
- Fix: map visualization had severe performance issue.
|
||||
- Fix: pemrission dialog wasn't rendering.
|
||||
- Fix: word cloud visualization didn't show column names.
|
||||
- Fix: wrong timestamps in admin tasks page.
|
||||
- Fix: page header wasn't updating on dashboards page @MichaelJAndy
|
||||
- Fix: keyboard shortcuts didn't work in parameter inputs
|
||||
|
||||
### Other
|
||||
|
||||
- Change default job expiry times to: job lock expire after 12 hours (previously: 6 hours) and Celery task result object expire after 4 hours (previously: 1 hour). @shimpeko
|
||||
|
||||
## v1.0.0-rc.2 - 2017-02-22
|
||||
|
||||
### Changed
|
||||
|
||||
- [#1563](https://github.com/getredash/redash/pull/1563) Send events to webhook as JSON with a schema.
|
||||
- [#1601] [Presto] friendlier error messages. (@aslotnick)
|
||||
- Move the query runner unavailable log message to be DEBUG level instead of WARNING, as it was mainly confusing people.
|
||||
- Remove "Send to Cloud" button from Plotly based visualizations.
|
||||
- Change Plotly's default hover mode to "Compare".
|
||||
- [#1612] Change: Improvements to the dashboards list page.
|
||||
|
||||
### Fixed
|
||||
|
||||
- [#1564] Fix: map visualization column picker wasn't populated. (@janusd)
|
||||
- [#1597] [SQL Server] Fix: schema wasn't loading on case sensitive servers. (@deecay)
|
||||
- Fix: dashbonard owner couldn't edit his dashboard.
|
||||
- Fix: toggle_publish event wasn't logged properly.
|
||||
- Fix: events with API keys were not logged.
|
||||
- Fix: share dashboard dialog was broken after code minification.
|
||||
- Fix: public dashboard endpoint was broken.
|
||||
- Fix: public dashboard page was broken after code minification.
|
||||
- Fix: visualization embed page was broken after code minification.
|
||||
- Fix: schema browser has dark background.
|
||||
- Fix: Google button missing on invite page.
|
||||
- Fix: global parameters don't render on dashboards with text boxes.
|
||||
- Fix: sunburst / Sankey visualizations have bad data.
|
||||
- Fix: extra whitespace created by the filters component.
|
||||
- Fix: query results cleanup task was trying to delete query objects.
|
||||
- Fix: alert subscriptions were not triggered.
|
||||
- [DynamoDB] Fix: count(*) queries were broken. (@kopanitsa)
|
||||
- Fix: Redash is using too many database connections.
|
||||
- Fix: download links were not working in dashboards.
|
||||
- Fix: the first selection in multi filters was broken in dashboards.
|
||||
|
||||
### Other
|
||||
|
||||
- [#1555] Change sourcemaps to generate a sourcemap per module. (@44px)
|
||||
- [#1570] Fix Docker Compose configuration for nginx. (@btmc)
|
||||
- [#1582] Update Dockerfile to build frontend assets and update the folder ownership.
|
||||
- Dockerfile: change the uid of the redash user to match host user uid.
|
||||
- Update npm-shrinkwrap.json file to use http proctocol instead of git. (@deecay)
|
||||
|
||||
## v1.0.0-rc.1 - 2017-01-31
|
||||
|
||||
This version has two big changes behind the scenes:
|
||||
|
||||
* Refactor the frontend to use latest (at the time) Angular version (1.5) along with better frontend pipeline based on
|
||||
WebPack.
|
||||
* Refactor the backend code to use SQLAlchemy and Alembic, for easier migrations/upgrades.
|
||||
|
||||
Along with that we have many fixes, additions, new data sources (Google Analytics, ClickHouse, Amazon Athena, Snowflake)
|
||||
and fixes to the existing ones (mainly ElasticSearch and Cassandra).
|
||||
|
||||
When upgrading make sure to upgrade from version 0.12.0 and update your .env file:
|
||||
|
||||
1. If you have local PostreSQL database, you will need to update the URL from `postgresql://redash` to `postgresql:///redash`.
|
||||
2. Remove the `REDASH_STATIC_ASSETS_PATH` definition.
|
||||
|
||||
Make sure to make these changes before running upgrade as otherwise it will fail.
|
||||
|
||||
We're releasing a new upgrade script -- see [here](https://redash.io/help-onpremise/maintenance/how-to-upgrade-redash.html) for details.
|
||||
|
||||
### Added
|
||||
|
||||
- [#1546](https://github.com/getredash/redash/pull/1546) Add: API docstrings (@washort)
|
||||
- [#1504](https://github.com/getredash/redash/pull/1504) Add: global parameters for dashboards (Tyler Rockwood)
|
||||
- [#1508](https://github.com/getredash/redash/pull/1508) [Jira JQL] Add: support custom JIRA fields and enhance value mapping (@sseifert)
|
||||
- [#1530](https://github.com/getredash/redash/pull/1530) Add: Docker based developer workflow (Arik Fraimovich)
|
||||
- [#1515](https://github.com/getredash/redash/pull/1515) [Python] Add: get_source_schema method (Vladislav Denisov)
|
||||
- [#1512](https://github.com/getredash/redash/pull/1512) [Python] Add: define more safe_builtins (Vladislav Denisov)
|
||||
- [#1513](https://github.com/getredash/redash/pull/1513) Add: get_by_id & get_by_name methods for Query and DataSource classes (Vladislav Denisov)
|
||||
- [#1482](https://github.com/getredash/redash/pull/1482) [Cassandra] Add: schema browser support & explicit protocol version (@yershalom)
|
||||
- [#1488](https://github.com/getredash/redash/pull/1488) [Data Sources] Add: Snowflake query runner (@arikfr)
|
||||
- [#1479](https://github.com/getredash/redash/pull/1479) [ElasticSearch] Add: enable schema browser (@adamlwgriffiths)
|
||||
- [#1475](https://github.com/getredash/redash/pull/1475) [Cassnadra] Added set_keyspace for easier query cassandra (@yershalom)
|
||||
- [#1468](https://github.com/getredash/redash/pull/1468) [Datasources] Add: Amazon Athena query runner (@arikfr)
|
||||
- [#1433](https://github.com/getredash/redash/pull/1433) [Charts] Add: errors bands in graphs (@luke14free)
|
||||
- [#1405](https://github.com/getredash/redash/pull/1405) [Datasources] Add: simple Google Analytics query runner (@denisov-vlad)
|
||||
- [#1409](https://github.com/getredash/redash/pull/1409) [Datasources] Add: Add query runner for Yandex ClickHouse (@denisov-vlad)
|
||||
- [#1373](https://github.com/getredash/redash/pull/1373) Add: rate limit the login page (@AntoineAugusti)
|
||||
|
||||
### Changed
|
||||
|
||||
- [#1549](https://github.com/getredash/redash/pull/1549) Change: disable version counter for queries: (Arik Fraimovich)
|
||||
- [#1548](https://github.com/getredash/redash/pull/1548) Change: improve UI in small resolution: (Arik Fraimovich)
|
||||
- [#1547](https://github.com/getredash/redash/pull/1547) Change: Improve drafts UX (Arik Fraimovich)
|
||||
- [#1540](https://github.com/getredash/redash/pull/1540) [MySQL] Change: faster retrieval of schema (Yaning Zhu)
|
||||
- [#1517](https://github.com/getredash/redash/pull/1517) [ClickHouse] Change: convert UInt64 columns to integer type (Vladislav Denisov)
|
||||
- [#1528](https://github.com/getredash/redash/pull/1528) [Vertica] Change: set longer read_timeout (lab79)
|
||||
- [#1522](https://github.com/getredash/redash/pull/1522) Change: move package.json/webpack.config to root directory (Arik Fraimovich)
|
||||
- [#1514](https://github.com/getredash/redash/pull/1514) [Athena] Change: enable query annotations (Gaurav Awadhwal)
|
||||
- [#1525](https://github.com/getredash/redash/pull/1525) Change: update amazon linux bootstrap.sh (Karri Niemelä)
|
||||
- [#1509](https://github.com/getredash/redash/pull/1509) [Presto/Athena] Change: remove special rule around public schema (@GAwadhwalAtlassian)
|
||||
- [#1485](https://github.com/getredash/redash/pull/1485) Close #1453: more minimal notification of draft status for query/dashboard (@arikfr)
|
||||
- [#1474](https://github.com/getredash/redash/pull/1474) [Cassandra] Change: test connection query (@yershalom)
|
||||
- [#1464](https://github.com/getredash/redash/pull/1464) [Clickhouse] Change: use UTF-8 encoding for POST data (@jaykelin)
|
||||
- [#1417](https://github.com/getredash/redash/pull/1417) Change: Replace Peewee with SQLAlchemy/Alembic (@arikfr, @washort)
|
||||
- [#1458](https://github.com/getredash/redash/pull/1458) Change: switch from flask_script to click, add CLI unit tests and upgrade Flask version (@washort)
|
||||
- [#1438](https://github.com/getredash/redash/pull/1438) [ElasticSearch] Change: use simplejson for better error descriptions (@adamlwgriffiths)
|
||||
- [#1435](https://github.com/getredash/redash/pull/1435) Whitelisting more builtin primitives (@mattrobenolt)
|
||||
- [#1376](https://github.com/getredash/redash/pull/1376) Change: upgrade the frontend stack (@arikfr, @luke14free)
|
||||
- [#1429](https://github.com/getredash/redash/pull/1429) Add missing error check from #1402 (@adamlwgriffiths)
|
||||
- [#1256](https://github.com/getredash/redash/pull/1256) Change: when forking a query, copy all visualizations (@ninneko)
|
||||
- [#1421](https://github.com/getredash/redash/pull/1421) Change: [BigQuery] only specify useLegacySQL is it's True (@arikfr)
|
||||
- [#1353](https://github.com/getredash/redash/pull/1353) Change: make draft status for queries and dashboards toggleable (@washort)
|
||||
- [#1419](https://github.com/getredash/redash/pull/1419) Change: use redash.utils.json_dumps instead of json.dumps in Python query runner (@ehfeng)
|
||||
- [#1402](https://github.com/getredash/redash/pull/1402) Change: correctly propagate ElasticSearch errors to the UI (@adamlwgriffiths)
|
||||
- [#1371](https://github.com/getredash/redash/pull/1371) Change: display user's password reset link to the admin when mail server disabled (@vitorbaptista)
|
||||
|
||||
### Fixed
|
||||
|
||||
- [#1551](https://github.com/getredash/redash/pull/1551) Fix: flask-admin - exclude created_at/updated_at so models can be saved (Arik Fraimovich)
|
||||
- [#1545](https://github.com/getredash/redash/pull/1545) [ElasticSearch] Fix: query fails when properties key is missing (hgs847825)
|
||||
- [#1526](https://github.com/getredash/redash/pull/1526) [ElasticSearch] Fix for #1521 (Adam Griffiths)
|
||||
- [#1521](https://github.com/getredash/redash/pull/1521) [ElasticSearch] Fix: wrong variable name. (Arik Fraimovich)
|
||||
- [#1497](https://github.com/getredash/redash/pull/1497) Fix #16: when updating dashboard name refresh dashboards dropdown (@arikfr)
|
||||
- [#1491](https://github.com/getredash/redash/pull/1491) Fix: DynamoDB test connection was broken (@arikfr)
|
||||
- [#1487](https://github.com/getredash/redash/pull/1487) Fix #1432: delete visualization sends full visualization body instead… (@arikfr)
|
||||
- [#1484](https://github.com/getredash/redash/pull/1484) Fix #1457: sort was using the string value (@arikfr)
|
||||
- [#1478](https://github.com/getredash/redash/pull/1478) [ElasticSearch] Fix: connection test was always succesfful (@adamlwgriffiths)
|
||||
- [#1440](https://github.com/getredash/redash/pull/1440) Fix: API errors for dashboards with invalid layout data (@whummer)
|
||||
- [#1427](https://github.com/getredash/redash/pull/1427) [Cassandra] Fix: remove reference to non existing Error class (@arikfr)
|
||||
- [#1423](https://github.com/getredash/redash/pull/1423) [Cassandra] Fix: cassandra.cluster.Error wasn't imported (@arikfr)
|
||||
- Fix #1001: queries with a column named "length" were not rendered.
|
||||
- Fix #578: dashboard list not scrollable.
|
||||
- Fix #137: add direction indicators when sorting query results.
|
||||
|
||||
## v0.12.0 - 2016-11-20
|
||||
|
||||
### Added
|
||||
|
||||
- 61fe16e #1374: Add: allow '*' in REDASH_CORS_ACCESS_CONTROL_ALLOW_ORIGIN (Allen Short)
|
||||
- 2f09043 #1113: Add: share modify/access permissions for queries and dashboard (whummer)
|
||||
- 3db0eea #1341: Add: support for specifying SAML nameid-format (zoetrope)
|
||||
- b0ecd0e #1343: Add: support for local SAML metadata file (zoetrope)
|
||||
- 0235d37 #1335: Add: allow changing alert email subject. (Arik Fraimovich)
|
||||
- 2135dfd #1333: Add: control over y axis min/max values (Arik Fraimovich)
|
||||
- 49e788a #1328: Add: support for snapshot generation service (Arik Fraimovich)
|
||||
- 229ca6c #1323: Add: collect runtime metrics for Celery tasks (Arik Fraimovich)
|
||||
- 931a1f3 #1315: Add: support for loading BigQuery schema (Arik Fraimovich)
|
||||
- 39b4f9a #1314: Add: support MongoDB SSL connections (Arik Fraimovich)
|
||||
- ca1ca9b #1312: Add: additional configuration for Celery jobs (Arik Fraimovich)
|
||||
- fc00e61 #1310: Add: support for date/time with seconds parameters (Arik Fraimovich)
|
||||
- d72a198 #1307: Add: API to force refresh data source schema (Arik Fraimovich)
|
||||
- beb89ec #1305: Add: UI to edit dashboard text box widget (Kazuhito Hokamura)
|
||||
- 808fdd4 #1298: Add: JIRA (JQL) query runner (Arik Fraimovich)
|
||||
- ff9e844 #1280: Add: configuration flag to disable scheduled queries (Hirotaka Suzuki)
|
||||
- ef4699a #1269: Add: Google Drive federated tables support in BigQuery query runner (Kurt Gooden)
|
||||
- 2eeb947 #1236: Add: query runner for Cassandra and ScyllaDB (syerushalmy)
|
||||
- 10b398e #1249: Add: override slack webhook parameters (mystelynx)
|
||||
- 2b5e340 #1252: Add: Schema loading support for Presto query runner (using information_schema) (Rohan Dhupelia)
|
||||
- 2aaf5dd #1250: Add: query snippets feature (Arik Fraimovich)
|
||||
- 8d8af73 #1226: Add: Sankey visualization (Arik Fraimovich)
|
||||
- a02edda #1222: Add: additional results format for sunburst visualization (Arik Fraimovich)
|
||||
- 0e70188 #1213: Add: new sunburst sequence visualization (Arik Fraimovich)
|
||||
- 9a6d2d7 #1204: Add: show views in schema browser for Vertica data sources (Matthew Carter)
|
||||
- 600afa5 #1138: Add: ability to register user defined function (UDF) resources for BigQuery DataSource/Query (fabito)
|
||||
- b410410 #1166: Add: "every 14 days" refresh option (Arik Fraimovich)
|
||||
- 906365f #967: Add: extend ElasticSearch query_runner to support aggregations (lloydw)
|
||||
|
||||
### Changed
|
||||
|
||||
- 2de4aa2 #1395: Change: switch to requests in URL query runner (Arik Fraimovich)
|
||||
- db1a941 #1392: Change: Update documentation links to point at the new location. (Arik Fraimovich)
|
||||
- 002f794 #1368: Change: added ability to disable auto update in admin views (Arik Fraimovich)
|
||||
- aa5d14e #1366: Change: improve error message for exception in the Python query runner (deecay)
|
||||
- 880627c #1355: Change: pass the user object to the run_query method (Arik Fraimovich)
|
||||
- 23c605b #1342: SAML: specify entity id (zoetrope)
|
||||
- 015b1dc #1334: Change: allow specifying recipient address when sending email test message (Arik Fraimovich)
|
||||
- 39aaa2f #1292: Change: improvements to map visualization (Arik Fraimovich)
|
||||
- b22191b #1332: Change: upgrade Python packages (Arik Fraimovich)
|
||||
- 23ba98b #1331: Celery: Upgrade Celery to more recent version. (Arik Fraimovich)
|
||||
- 3283116 #1330: Change: upgrade Requests to latest version. (Arik Fraimovich)
|
||||
- 39091e0 #1324: Change: add more logging and information for refresh schemas task (Arik Fraimovich)
|
||||
- 462faea #1316: Change: remove deprecated settings (Arik Fraimovich)
|
||||
- 73e1837 #1313: Change: more flexible column width calculation (Arik Fraimovich)
|
||||
- e8eb840 #1279: Change: update bootstrap.sh to support Ubuntu 16.04 (IllusiveMilkman)
|
||||
- 8cf0252 #1262: Change: upgrade Plot.ly version and switch to smaller build (Arik Fraimovich)
|
||||
- 0b79fb8 #1306: Change: paginate queries page & add explicit urls. (Arik Fraimovich)
|
||||
- 41f99f5 #1299: Change: send Content-Type header (application/json) in query results responses (Tsuyoshi Tatsukawa)
|
||||
- dfb1a20 #1297: Change: update Slack configuration titles. (Arik Fraimovich)
|
||||
- 8c1056c #1294: Change: don't annotate BigQuery queries (Arik Fraimovich)
|
||||
- a3cf92e #1289: Change: use key_as_string when available (ElasticSearch query runner) (Arik Fraimovich)
|
||||
- e155191 #1285: Change: do not display Oracle tablespace name in schema browser (Matthew Carter)
|
||||
- 6cbc39c #1282: Change: deduplicate Google Spreadsheet columns (Arik Fraimovich)
|
||||
- 4caf2e3 #1277: Set specific version of cryptography lib (Arik Fraimovich)
|
||||
- d22f0d4 #1216: Change: bootstrap.sh - use non interactive dist-upgrade (Atsushi Sasaki)
|
||||
- 19530f4 #1245: Change: switch from CodeMirror to Ace editor (Arik Fraimovich)
|
||||
- dfb92db #1234: Change: MongoDB query runner set DB name as mandatory (Arik Fraimovich)
|
||||
- b750843 #1230: Change: annotate Presto queries with metadata (Noriaki Katayama)
|
||||
- 5b20fe2 #1217: Change: install libffi-dev for Cryptography (Ubuntu setup script) (Atsushi Sasaki)
|
||||
- a9fac34 #1206: Change: update pymssql version to 2.1.3 (kitsuyui)
|
||||
- 5d43cbe #1198: Change: add support for Standard SQL in BigQuery query runner (mystelynx)
|
||||
- 84d0c22 #1193: Change: modify the argument order of moment.add function call (Kenya Yamaguchi)
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
- d6febb0 #1375: Fix: Download Dataset does not work when not logged in (Joshua Dechant)
|
||||
- 96553ad #1369: Fix: missing format call in Elasticsearch test method (Adam Griffiths)
|
||||
- c57c765 #1365: Fix: compare retrieval times in UTC timezone (Allen Short)
|
||||
- 37dff5f #1360: Fix: connection test was broken for MySQL (ichihara)
|
||||
- 360028c #1359: Fix: schema loading query for Hive was wrong for non default schema (laughingman7743)
|
||||
- 7ee41d4 #1358: Fix: make sure all calls to run_query updated with new parameter (Arik Fraimovich)
|
||||
- 0d94479 #1329: Fix: Redis memory leak. (Arik Fraimovich)
|
||||
- 7145aa2 #1325: Fix: queries API was doing N+1 queries in most cases (Arik Fraimovich)
|
||||
- cd2e927 #1311: Fix: BoxPlot visualization wasn't rendering on a dashboard (Arik Fraimovich)
|
||||
- a562ce7 #1309: Fix: properly render checkboxes in dynamic forms (Arik Fraimovich)
|
||||
- d48192c #1308: Fix: support for Unicode columns name in Google Spreadsheets (Arik Fraimovich)
|
||||
- e42f93f #1283: Fix: schema browser was unstable after opening a table (Arik Fraimovich)
|
||||
- 170bd65 #1272: Fix: TreasureData get_schema method was returning array instead of string as column name (ariarijp)
|
||||
- 4710c41 #1265: Fix: refresh modal not working for unsaved query (Arik Fraimovich)
|
||||
- bc3a5ab #1264: Fix: dashboard refresh not working (Arik Fraimovich)
|
||||
- 6202d09 #1240: Fix: when shared dashboard token not found, return 404 (Wesley Batista)
|
||||
- 93aac14 #1251: Fix: autocomplete went crazy when database has no autocomplete. (Arik Fraimovich)
|
||||
- b8eca28 #1246: Fix: support large schemas in schema browser (Arik Fraimovich)
|
||||
- b781003 #1223: Fix: Alert: when hipchat Alert.name is multibyte character, occur error. (toyama0919)
|
||||
- 0b928e6 #1227: Fix: Bower install fails in vagrant (Kazuhito Hokamura)
|
||||
- a411af2 #1232: Fix: don't show warning when query string (parameters value) changes (Kazuhito Hokamura)
|
||||
- 3dbb5a6 #1221: Fix: sunburst didn't handle all cases of path lengths (Arik Fraimovich)
|
||||
- a7cc1ee #1218: Fix: updated result not being saved when changing query text. (Arik Fraimovich)
|
||||
- 0617833 #1215: Fix: email alerts not working (Arik Fraimovich)
|
||||
- 78f65b1 #1187: Fix: read only users receive the permission error modal in query view (Arik Fraimovich)
|
||||
- bba801f #1167: Fix the version of setuptools on bootstrap script for Ubuntu (Takuya Arita)
|
||||
- ce81d69 #1160: Fix indentation in docker-compose-example.yml (Hirofumi Wakasugi)
|
||||
- dd759fe #1155: Fix: make all configuration values of Oracle required (Arik Fraimovich)
|
||||
|
||||
### Docs
|
||||
|
||||
- a69ee0c #1225: Fix: RST formatting of the Vagrant documentation (Kazuhito Hokamura)
|
||||
- 03837c0 #1242: Docs: add warning re. quotes on column names and BigQuery (Ereli)
|
||||
- 9a98075 #1255: Docs: add documentation for InfluxDB (vishesh92)
|
||||
- e0485de #1195: Docs: fix typo in maintenance page title (Antoine Augusti)
|
||||
- 7681d3e #1164: Docs: update permission documentation (Daniel Darabos)
|
||||
- bcd3670 #1156: Docs: add SSL parameters to nginx configuration (Josh Cox)
|
||||
|
||||
## v0.11.1.b2095 - 2016-08-02
|
||||
|
||||
This is a hotfix release, which fixes an issue with email alerts in v0.11.0.
|
||||
|
||||
## v0.11.0.b2016 - 2016-07-03
|
||||
|
||||
The main features of this release are:
|
||||
|
||||
- Alert Destinations: ability to define multiple destinations for alert notifications (currently implemented: HipChat, Slack, Webhook and email).
|
||||
- The long-awaited UI for query parameters (see example in #1069).
|
||||
|
||||
Also, this release includes numerous smaller features, improvements, and bug fixes.
|
||||
|
||||
A big thank you goes to all who contributed code and documentation in this release: @AntoineAugusti, @James226, @adamlwgriffiths, @alexdebrie, @anthony-coble, @ariarijp, @dheerajrav, @edwardsharp, @machira, @nabilblk, @ninneko, @ordd, @tomerben, @toru-takahashi, @vishesh92, @vorakumar and @whummer.
|
||||
|
||||
### Added
|
||||
|
||||
- d5e5b24 #1136: Feature: add --org option to all relevant CLI commands. (@adamlwgriffiths)
|
||||
- 87e25f2 #1129: Feature: support for JSON query formatting (Mongo, ElasticSearch) (@arikfr)
|
||||
- 6bb2716 #1121: Show error when failing to communicate with server (@arikfr)
|
||||
- f21276e #1119: Feature: add UI to delete alerts (@arikfr)
|
||||
- 8656540 #1069: Feature: UI for query parameters (@arikfr)
|
||||
- 790128c #1067: Feature: word cloud visualization (@anthony-coble)
|
||||
- 8b73a2b #1098: Feature: UI for alert destinations & new destination types (@alexdebrie)
|
||||
- 1fbeb5d #1092: Add Heroku support (@adamlwgriffiths)
|
||||
- f64622d #1089: Add support for serialising UUID type within MSSQL #961 (@James226)
|
||||
- 857caab #1085: Feature: API to pause a data source (@arikfr)
|
||||
- 214aa3b #1060: Feature: support configuring user's groups with SAML (@vorakumar)
|
||||
- e20a005 #1007: Issue#1006: Make bottom margin editable for Chart visualization (@vorakumar)
|
||||
- 6e0dd2b #1063: Add support for date/time Y axis (@tomerben)
|
||||
- b5a4a6b #979: Feature: Add CLI to edit group permissions (@ninneko)
|
||||
- 6d495d2 #1014: Add server-side parameter handling for embeds (@whummer)
|
||||
- 5255804 #1091: Add caching for queries used in embeds (@whummer)
|
||||
|
||||
### Changed
|
||||
|
||||
- 0314313 #1149: Presto QueryRunner supports tinyint and smallint (@toru-takahashi)
|
||||
- 8fa6fdb #1030: Make sure data sources list ordered by id (@arikfr)
|
||||
- 8df822e #1141: Make create data source button more prominent (@arikfr)
|
||||
- 96dd811 #1127: Mark basic_auth_password as secret (@adamlwgriffiths)
|
||||
- ad65391 #1130: Improve Slack notification style (@AntoineAugusti)
|
||||
- df637e3 #1116: Return meaningful error when there is no cached result. (@arikfr)
|
||||
- 65635ec #1102: Switch to HipChat V2 API (@arikfr)
|
||||
- 14fcf01 #1072: Remove counter from the tasks Done tab (as it always shows 50). #1047 (@arikfr)
|
||||
- 1a1160e #1062: DynamoDB: Better exception handling (@arikfr)
|
||||
- ed45dcb #1044: Improve vagrant flow (@staritza)
|
||||
- 8b5dc8e #1036: Add optional block for more scripts in template (@arikfr)
|
||||
|
||||
### Fixed
|
||||
|
||||
- dbd48e1 #1143: Fix: use the email input type where needed (@ariarijp)
|
||||
- 7445972 #1142: Fix: dates in filters might be duplicated (@arikfr)
|
||||
- 5d0ed02 #1140: Fix: Hive should use the enabled variable (@arikfr)
|
||||
- 392627d #1139: Fix: Impala data source referencing wrong variable (@arikfr)
|
||||
- c5bfbba #1133: Fix: query scrolling issues (@vishesh92)
|
||||
- c01d266 #1128: Fix: visualization options not updating after changing type (@arikfr)
|
||||
- 6bc0e7a #1126: Fix #669: save fails when doing partial save of new query (@arikfr)
|
||||
- 3ce27b9 #1118: Fix: remove alerts for archived queries (@arikfr)
|
||||
- 4fabaae #1117: Fix #1052: filter not working for date/time values (@arikfr)
|
||||
- c107c94 #1077: Fix: install needed dependencies to use Hive in Docker image (@nabilblk)
|
||||
- abc790c #1115: Fix: allow non integers in alert reference value (@arikfr)
|
||||
- 4ec473c #1110: Fix #1109: mixed group permissions resulting in wrong permission (@arikfr)
|
||||
- 1ca5262 #1099: Fix RST syntax for links (@adamlwgriffiths)
|
||||
- daa6c1c #1096: Fix typo in env variable VERSION_CHECK (@AntoineAugusti)
|
||||
- cd06d27 #1095: Fix: use create_query permission for new query button. (@ordd)
|
||||
- 2bc0b27 #1061: Fix: area chart stacking doesn't work (@machira)
|
||||
- 8c21e91 #1108: Remove potnetially concurrency not safe code form enqueue_query (@arikfr)
|
||||
- e831218 #1084: Fix #1049: duplicate alerts when data source belongs to multiple groups (@arikfr)
|
||||
- 6edb0ca #1080: Fix typo (@jeffwidman)
|
||||
- 64d7538 #1074: Fix: ElasticSearch wasn't using correct type names (@toyama0919)
|
||||
- 3f90dd9 #1064: Fix: old task trackers were not really removed (@arikfr)
|
||||
- e10ecd2 #1058: Bring back filters if dashboard filters are enabled (@AntoineAugusti)
|
||||
- 701035f #1059: Fix: DynamoDB having issues when setting host (@arikfr)
|
||||
- 2924d4f #1040: Small fixes to visualizations view (@arikfr)
|
||||
- fec0d5f #1037: Fix: multi filter wasn't working with __ syntax (@dheerajrav)
|
||||
- b066ce4 #1033: Fix: only ask for notification permissions if wasn't denied (@arikfr)
|
||||
- 960c416 #1032: Fix: make sure we return dashboards only for current org only (@arikfr)
|
||||
- b3844d3 #1029: Hive: close connection only if it exists (@arikfr)
|
||||
|
||||
### Docs
|
||||
|
||||
- 6bb09d8 #1146: Docs: add a link to settings documentation. (@adamlwgriffiths)
|
||||
- 095e759 #1103: Docs: add section about monitoring (@AntoineAugusti)
|
||||
- e942486 #1090: Contributing Guide (@arikfr)
|
||||
- 3037c4f #1066: Docs: command type-o fix. (@edwardsharp)
|
||||
- 2ee0065 #1038: Add an ISSUE_TEMPLATE.md to direct people at the forum (@arikfr)
|
||||
- f7322a4 #1021: Vagrant docs: add purging the cache step (@ariarijp)
|
||||
|
||||
---
|
||||
|
||||
For older releases check the GitHub releases page:
|
||||
https://github.com/getredash/redash/releases
|
||||
@@ -6,10 +6,10 @@ The following is a set of guidelines for contributing to Redash. These are guide
|
||||
|
||||
## Quick Links:
|
||||
|
||||
- [Feature Roadmap](https://trello.com/b/b2LUHU7A/redash-roadmap)
|
||||
- [Feature Roadmap](https://trello.com/b/b2LUHU7A/re-dash-roadmap)
|
||||
- [Feature Requests](https://discuss.redash.io/c/feature-requests)
|
||||
- [Gitter Chat](https://gitter.im/getredash/redash) or [Slack](https://slack.redash.io)
|
||||
- [Documentation](https://redash.io/help/)
|
||||
- [Documentation](http://docs.redash.io)
|
||||
- [Blog](http://blog.redash.io/)
|
||||
- [Twitter](https://twitter.com/getredash)
|
||||
|
||||
@@ -18,7 +18,7 @@ The following is a set of guidelines for contributing to Redash. These are guide
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## Table of Contents
|
||||
|
||||
[How can I contribute?](#how-can-i-contribute)
|
||||
@@ -28,12 +28,12 @@ The following is a set of guidelines for contributing to Redash. These are guide
|
||||
- [Pull Requests](#pull-requests)
|
||||
- [Documentation](#documentation)
|
||||
- Design?
|
||||
|
||||
[Additional Notes](#additional-notes)
|
||||
|
||||
[Addtional Notes](#additional-notes)
|
||||
|
||||
- [Release Method](#release-method)
|
||||
- [Code of Conduct](#code-of-conduct)
|
||||
|
||||
|
||||
## How can I contribute?
|
||||
|
||||
### Reporting Bugs
|
||||
@@ -43,24 +43,26 @@ When creating a new bug report, please make sure to:
|
||||
- Search for existing issues first. If you find a previous report of your issue, please update the existing issue with additional information instead of creating a new one.
|
||||
- If you are not sure if your issue is really a bug or just some configuration/setup problem, please start a discussion in [the support forum](https://discuss.redash.io/c/support) first. Unless you can provide clear steps to reproduce, it's probably better to start with a thread in the forum and later to open an issue.
|
||||
- If you still decide to open an issue, please review the template and guidelines and include as much details as possible.
|
||||
|
||||
|
||||
### Suggesting Enhancements / Feature Requests
|
||||
|
||||
If you would like to suggest an enhancement or ask for a new feature:
|
||||
If you would like to suggest an enchancement or ask for a new feature:
|
||||
|
||||
- Please check [the roadmap](https://trello.com/b/b2LUHU7A/redash-roadmap) for existing Trello card for what you want to suggest/ask. If there is, feel free to upvote it to signal interest or add your comments.
|
||||
- Please check [the roadmap](https://trello.com/b/b2LUHU7A/re-dash-roadmap) for existing Trello card for what you want to suggest/ask. If there is, feel free to upvote it to signal interest or add your comments.
|
||||
- If there is no existing card, open a thread in [the forum](https://discuss.redash.io/c/feature-requests) to start a discussion about what you want to suggest. Try to provide as much details and context as possible and include information about *the problem you want to solve* rather only *your proposed solution*.
|
||||
|
||||
|
||||
### Pull Requests
|
||||
|
||||
- **Code contributions are welcomed**. For big changes or significant features, it's usually better to reach out first and discuss what you want to implement and how (we recommend reading: [Pull Request First](https://medium.com/practical-blend/pull-request-first-f6bb667a9b6#.ozlqxvj36)). This to make sure that what you want to implement is aligned with our goals for the project and that no one else is already working on it.
|
||||
- Include screenshots and animated GIFs in your pull request whenever possible.
|
||||
- Please add [documentation](#documentation) for new features or changes in functionality along with the code.
|
||||
- Please follow existing code style. We use PEP8 for Python and sensible style for JavaScript.
|
||||
|
||||
- Please follow existing code style. We use PEP8 for Python and sensible style for Javascript.
|
||||
|
||||
### Documentation
|
||||
|
||||
The project's documentation can be found at [https://redash.io/help/](https://redash.io/help/). The [documentation sources](https://github.com/getredash/website/tree/master/user-guide) are hosted on GitHub. To contribute edits / new pages, you can use GitHub's interface. Click the "Edit on GitHub" link on the documentation page to quickly open the edit interface.
|
||||
The project's documentation can be found at [docs.redash.io](http://docs.redash.io/). The [documentation sources](https://github.com/getredash/redash/tree/master/docs) are managed along with the code and to contribute edits / new pages, you can use GitHub's interface. Click the "Edit on GitHub" link on the documentation page to quickly open the edit interface.
|
||||
|
||||
The pages are written in *reStructuredText* format, which is very similar to Markdown.
|
||||
|
||||
## Additional Notes
|
||||
|
||||
|
||||
60
Dockerfile
60
Dockerfile
@@ -1,13 +1,53 @@
|
||||
FROM redash/base:latest
|
||||
FROM ubuntu:trusty
|
||||
|
||||
# We first copy only the requirements file, to avoid rebuilding on every file
|
||||
# change.
|
||||
COPY requirements.txt requirements_dev.txt requirements_all_ds.txt ./
|
||||
RUN pip install -r requirements.txt -r requirements_dev.txt -r requirements_all_ds.txt
|
||||
# Ubuntu packages
|
||||
RUN apt-get update && \
|
||||
apt-get install -y python-pip python-dev curl build-essential pwgen libffi-dev sudo git-core wget \
|
||||
# Postgres client
|
||||
libpq-dev \
|
||||
# Additional packages required for data sources:
|
||||
libssl-dev libmysqlclient-dev freetds-dev libsasl2-dev && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY . ./
|
||||
RUN npm install && npm run build && rm -rf node_modules
|
||||
RUN chown -R redash /app
|
||||
USER redash
|
||||
# Users creation
|
||||
RUN useradd --system --comment " " --create-home redash
|
||||
|
||||
ENTRYPOINT ["/app/bin/docker-entrypoint"]
|
||||
# Pip requirements for all data source types
|
||||
RUN pip install -U setuptools==23.1.0 && \
|
||||
pip install supervisor==3.1.2
|
||||
|
||||
COPY . /opt/redash/current
|
||||
RUN chown -R redash /opt/redash/current
|
||||
|
||||
# Setting working directory
|
||||
WORKDIR /opt/redash/current
|
||||
|
||||
ENV REDASH_STATIC_ASSETS_PATH="../rd_ui/dist/"
|
||||
|
||||
# Install project specific dependencies
|
||||
RUN pip install -r requirements_all_ds.txt && \
|
||||
pip install -r requirements.txt
|
||||
|
||||
RUN curl https://deb.nodesource.com/setup_4.x | bash - && \
|
||||
apt-get install -y nodejs && \
|
||||
sudo -u redash -H make deps && \
|
||||
rm -rf node_modules rd_ui/node_modules /home/redash/.npm /home/redash/.cache && \
|
||||
apt-get purge -y nodejs && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Setup supervisord
|
||||
RUN mkdir -p /opt/redash/supervisord && \
|
||||
mkdir -p /opt/redash/logs && \
|
||||
cp /opt/redash/current/setup/docker/supervisord/supervisord.conf /opt/redash/supervisord/supervisord.conf
|
||||
|
||||
# Fix permissions
|
||||
RUN chown -R redash /opt/redash
|
||||
|
||||
# Expose ports
|
||||
EXPOSE 5000
|
||||
EXPOSE 9001
|
||||
|
||||
# Startup script
|
||||
CMD ["supervisord", "-c", "/opt/redash/supervisord/supervisord.conf"]
|
||||
|
||||
2
LICENSE
2
LICENSE
@@ -1,4 +1,4 @@
|
||||
Copyright (c) 2013-2017, Arik Fraimovich.
|
||||
Copyright (c) 2013-2016, Arik Fraimovich.
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification,
|
||||
|
||||
11
Makefile
11
Makefile
@@ -4,18 +4,19 @@ FULL_VERSION=$(VERSION)+b$(CIRCLE_BUILD_NUM)
|
||||
BASE_VERSION=$(shell python ./manage.py version | cut -d + -f 1)
|
||||
# VERSION gets evaluated every time it's referenced, therefore we need to use VERSION here instead of FULL_VERSION.
|
||||
FILENAME=$(CIRCLE_ARTIFACTS)/$(NAME).$(VERSION).tar.gz
|
||||
TEST_ARGS?=--with-coverage --cover-package=redash tests/
|
||||
|
||||
deps:
|
||||
if [ -d "./client/app" ]; then npm install; fi
|
||||
if [ -d "./client/app" ]; then npm run build; fi
|
||||
if [ -d "./rd_ui/app" ]; then npm install; fi
|
||||
if [ -d "./rd_ui/app" ]; then npm run bower install; fi
|
||||
if [ -d "./rd_ui/app" ]; then npm run build; fi
|
||||
|
||||
pack:
|
||||
sed -ri "s/^__version__ = '([0-9.]*)'/__version__ = '$(FULL_VERSION)'/" redash/__init__.py
|
||||
tar -zcv -f $(FILENAME) --exclude="optipng*" --exclude=".git*" --exclude="*.pyc" --exclude="*.pyo" --exclude="venv" --exclude="node_modules" *
|
||||
tar -zcv -f $(FILENAME) --exclude="optipng*" --exclude=".git*" --exclude="*.pyc" --exclude="*.pyo" --exclude="venv" --exclude="node_modules" --exclude="rd_ui/dist/bower_components" --exclude="rd_ui/app" *
|
||||
|
||||
upload:
|
||||
python bin/release_manager.py $(CIRCLE_SHA1) $(BASE_VERSION) $(FILENAME)
|
||||
|
||||
test:
|
||||
nosetests $(TEST_ARGS)
|
||||
nosetests --with-coverage --cover-package=redash tests/
|
||||
#grunt test
|
||||
|
||||
2
Procfile.dev
Normal file
2
Procfile.dev
Normal file
@@ -0,0 +1,2 @@
|
||||
web: ./manage.py runserver -p $PORT --host 0.0.0.0
|
||||
worker: ./bin/run celery worker --app=redash.worker --beat -Qqueries,celery,scheduled_queries
|
||||
2
Procfile.heroku
Normal file
2
Procfile.heroku
Normal file
@@ -0,0 +1,2 @@
|
||||
web: ./manage.py runserver -d -r -p $PORT --host 0.0.0.0
|
||||
worker: celery worker --app=redash.worker -c2 --beat -Q queries,celery,scheduled_queries
|
||||
30
README.md
30
README.md
@@ -1,36 +1,42 @@
|
||||
More details about the future of re:dash : http://bit.ly/journey-first-step
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<img title="Redash" src='https://redash.io/assets/images/logo.png' width="200px"/>
|
||||
<img title="re:dash" src='http://redash.io/static/old_img/redash_logo.png' width="200px"/>
|
||||
</p>
|
||||
<p align="center">
|
||||
<img title="Build Status" src='https://circleci.com/gh/getredash/redash.png?circle-token=8a695aa5ec2cbfa89b48c275aea298318016f040'/>
|
||||
</p>
|
||||
|
||||
[](https://gitter.im/getredash/redash?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
|
||||
[](https://redash.io/help/)
|
||||
[](http://docs.redash.io)
|
||||
|
||||
**_Redash_** is our take on freeing the data within our company in a way that will better fit our culture and usage patterns.
|
||||
**_re:dash_** is our take on freeing the data within our company in a way that will better fit our culture and usage patterns.
|
||||
|
||||
Prior to **_Redash_**, we tried to use traditional BI suites and discovered a set of bloated, technically challenged and slow tools/flows. What we were looking for was a more hacker'ish way to look at data, so we built one.
|
||||
Prior to **_re:dash_**, we tried to use traditional BI suites and discovered a set of bloated, technically challenged and slow tools/flows. What we were looking for was a more hacker'ish way to look at data, so we built one.
|
||||
|
||||
**_Redash_** was built to allow fast and easy access to billions of records, that we process and collect using Amazon Redshift ("petabyte scale data warehouse" that "speaks" PostgreSQL).
|
||||
Today **_Redash_** has support for querying multiple databases, including: Redshift, Google BigQuery, PostgreSQL, MySQL, Graphite,
|
||||
**_re:dash_** was built to allow fast and easy access to billions of records, that we process and collect using Amazon Redshift ("petabyte scale data warehouse" that "speaks" PostgreSQL).
|
||||
Today **_re:dash_** has support for querying multiple databases, including: Redshift, Google BigQuery, PostgreSQL, MySQL, Graphite,
|
||||
Presto, Google Spreadsheets, Cloudera Impala, Hive and custom scripts.
|
||||
|
||||
**_Redash_** consists of two parts:
|
||||
**_re:dash_** consists of two parts:
|
||||
|
||||
1. **Query Editor**: think of [JS Fiddle](http://jsfiddle.net) for SQL queries. It's your way to share data in the organization in an open way, by sharing both the dataset and the query that generated it. This way everyone can peer review not only the resulting dataset but also the process that generated it. Also it's possible to fork it and generate new datasets and reach new insights.
|
||||
2. **Dashboards/Visualizations**: once you have a dataset, you can create different visualizations out of it, and then combine several visualizations into a single dashboard. Currently it supports charts, pivot table and cohorts.
|
||||
|
||||
**_re:dash_** is a work in progress and has its rough edges and way to go to fulfill its full potential. The Query Editor part is quite solid, but the visualizations need more work to enrich them and to make them more user friendly.
|
||||
|
||||
## Demo
|
||||
|
||||
<img src="https://cloud.githubusercontent.com/assets/71468/17391289/8e83878e-5a1d-11e6-8938-af9054a33b19.gif" width="60%"/>
|
||||
<img src="https://cloud.githubusercontent.com/assets/71468/12611424/1faf4d6a-c4f5-11e5-89b5-31efc1155d2c.gif" width="60%"/>
|
||||
|
||||
You can try out the demo instance: http://demo.redash.io/ (login with any Google account).
|
||||
|
||||
## Getting Started
|
||||
|
||||
* [Setting up Redash instance](https://redash.io/help-onpremise/setup/setting-up-redash-instance.html) (includes links to ready made AWS/GCE images).
|
||||
* [Documentation](https://redash.io/help/).
|
||||
* [Setting up re:dash instance](http://redash.io/deployment/setup.html) (includes links to ready made AWS/GCE images).
|
||||
* [Documentation](http://docs.redash.io).
|
||||
|
||||
|
||||
## Getting Help
|
||||
@@ -43,8 +49,8 @@ You can try out the demo instance: http://demo.redash.io/ (login with any Google
|
||||
## Reporting Bugs and Contributing Code
|
||||
|
||||
* Want to report a bug or request a feature? Please open [an issue](https://github.com/getredash/redash/issues/new).
|
||||
* Want to help us build **_Redash_**? Fork the project, edit in a [dev environment](https://redash.io/help-onpremise/dev/guide.html), and make a pull request. We need all the help we can get!
|
||||
* Want to help us build **_re:dash_**? Fork the project, edit in a [dev environment](http://docs.redash.io/en/latest/dev/vagrant.html), and make a pull request. We need all the help we can get!
|
||||
|
||||
## License
|
||||
|
||||
BSD-2-Clause.
|
||||
See [LICENSE](https://github.com/getredash/redash/blob/master/LICENSE) file.
|
||||
|
||||
15
Vagrantfile
vendored
Normal file
15
Vagrantfile
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
# -*- mode: ruby -*-
|
||||
# vi: set ft=ruby :
|
||||
|
||||
# Vagrantfile API/syntax version. Don't touch unless you know what you're doing!
|
||||
VAGRANTFILE_API_VERSION = "2"
|
||||
|
||||
Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
|
||||
config.vm.box = "redash/dev"
|
||||
config.vm.synced_folder "./", "/opt/redash/current"
|
||||
config.vm.network "forwarded_port", guest: 5000, host: 9001
|
||||
config.vm.provision "shell" do |s|
|
||||
s.inline = "/opt/redash/current/setup/vagrant/provision.sh"
|
||||
s.privileged = false
|
||||
end
|
||||
end
|
||||
@@ -1,81 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
worker() {
|
||||
WORKERS_COUNT=${WORKERS_COUNT:-2}
|
||||
QUEUES=${QUEUES:-queries,scheduled_queries,celery}
|
||||
|
||||
echo "Starting $WORKERS_COUNT workers for queues: $QUEUES..."
|
||||
exec /usr/local/bin/celery worker --app=redash.worker -c$WORKERS_COUNT -Q$QUEUES -linfo --maxtasksperchild=10 -Ofair
|
||||
}
|
||||
|
||||
scheduler() {
|
||||
WORKERS_COUNT=${WORKERS_COUNT:-1}
|
||||
QUEUES=${QUEUES:-celery}
|
||||
|
||||
echo "Starting scheduler and $WORKERS_COUNT workers for queues: $QUEUES..."
|
||||
|
||||
exec /usr/local/bin/celery worker --app=redash.worker --beat -c$WORKERS_COUNT -Q$QUEUES -linfo --maxtasksperchild=10 -Ofair
|
||||
}
|
||||
|
||||
server() {
|
||||
exec /usr/local/bin/gunicorn -b 0.0.0.0:5000 --name redash -w${REDASH_WEB_WORKERS:-4} redash.wsgi:app
|
||||
}
|
||||
|
||||
help() {
|
||||
echo "Redash Docker."
|
||||
echo ""
|
||||
echo "Usage:"
|
||||
echo ""
|
||||
|
||||
echo "server -- start Redash server (with gunicorn)"
|
||||
echo "worker -- start Celery worker"
|
||||
echo "scheduler -- start Celery worker with a beat (scheduler) process"
|
||||
echo ""
|
||||
echo "shell -- open shell"
|
||||
echo "dev_server -- start Flask development server with debugger and auto reload"
|
||||
echo "create_db -- create database tables"
|
||||
echo "manage -- CLI to manage redash"
|
||||
}
|
||||
|
||||
tests() {
|
||||
export REDASH_DATABASE_URL="postgresql://postgres@postgres/tests"
|
||||
exec make test
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
worker)
|
||||
shift
|
||||
worker
|
||||
;;
|
||||
server)
|
||||
shift
|
||||
server
|
||||
;;
|
||||
scheduler)
|
||||
shift
|
||||
scheduler
|
||||
;;
|
||||
dev_server)
|
||||
exec /app/manage.py runserver --debugger --reload -h 0.0.0.0
|
||||
;;
|
||||
shell)
|
||||
exec /app/manage.py shell
|
||||
;;
|
||||
create_db)
|
||||
exec /app/manage.py database create_tables
|
||||
;;
|
||||
manage)
|
||||
shift
|
||||
exec /app/manage.py $*
|
||||
;;
|
||||
tests)
|
||||
tests
|
||||
;;
|
||||
help)
|
||||
help
|
||||
;;
|
||||
*)
|
||||
exec "$@"
|
||||
;;
|
||||
esac
|
||||
@@ -1,4 +1,3 @@
|
||||
from __future__ import print_function
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
@@ -96,7 +95,7 @@ def get_changelog(commit_sha):
|
||||
try:
|
||||
pull_request = re.match("Merge pull request #(\d+)", subject).groups()[0]
|
||||
pull_request = " #{}".format(pull_request)
|
||||
except Exception as ex:
|
||||
except Exception, ex:
|
||||
pull_request = ""
|
||||
|
||||
author = subprocess.check_output(['git', 'log', '-1', '--pretty=format:"%an"', parents.split(' ')[-1]])[1:-1]
|
||||
@@ -125,7 +124,7 @@ def update_release(version, build_filepath, commit_sha):
|
||||
else:
|
||||
release = create_release(version, commit_sha)
|
||||
|
||||
print("Using release id: {}".format(release['id']))
|
||||
print "Using release id: {}".format(release['id'])
|
||||
|
||||
remove_previous_builds(release)
|
||||
response = upload_asset(release, build_filepath)
|
||||
@@ -136,8 +135,8 @@ def update_release(version, build_filepath, commit_sha):
|
||||
if response.status_code != 200:
|
||||
raise exception_from_error("Failed updating release description", response)
|
||||
|
||||
except Exception as ex:
|
||||
print(ex)
|
||||
except Exception, ex:
|
||||
print ex
|
||||
|
||||
if __name__ == '__main__':
|
||||
commit_sha = sys.argv[1]
|
||||
|
||||
239
bin/upgrade
239
bin/upgrade
@@ -1,239 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import namedtuple
|
||||
from fnmatch import fnmatch
|
||||
|
||||
import requests
|
||||
|
||||
try:
|
||||
import semver
|
||||
except ImportError:
|
||||
print("Missing required library: semver.")
|
||||
exit(1)
|
||||
|
||||
REDASH_HOME = os.environ.get('REDASH_HOME', '/opt/redash')
|
||||
CURRENT_VERSION_PATH = '{}/current'.format(REDASH_HOME)
|
||||
|
||||
|
||||
def run(cmd, cwd=None):
|
||||
if not cwd:
|
||||
cwd = REDASH_HOME
|
||||
|
||||
return subprocess.check_output(cmd, cwd=cwd, shell=True, stderr=subprocess.STDOUT)
|
||||
|
||||
|
||||
def confirm(question):
|
||||
reply = str(raw_input(question + ' (y/n): ')).lower().strip()
|
||||
|
||||
if reply[0] == 'y':
|
||||
return True
|
||||
if reply[0] == 'n':
|
||||
return False
|
||||
else:
|
||||
return confirm("Please use 'y' or 'n'")
|
||||
|
||||
|
||||
def version_path(version_name):
|
||||
return "{}/{}".format(REDASH_HOME, version_name)
|
||||
|
||||
END_CODE = '\033[0m'
|
||||
|
||||
|
||||
def colored_string(text, color):
|
||||
if sys.stdout.isatty():
|
||||
return "{}{}{}".format(color, text, END_CODE)
|
||||
else:
|
||||
return text
|
||||
|
||||
|
||||
def h1(text):
|
||||
print(colored_string(text, '\033[4m\033[1m'))
|
||||
|
||||
|
||||
def green(text):
|
||||
print(colored_string(text, '\033[92m'))
|
||||
|
||||
|
||||
def red(text):
|
||||
print(colored_string(text, '\033[91m'))
|
||||
|
||||
|
||||
class Release(namedtuple('Release', ('version', 'download_url', 'filename', 'description'))):
|
||||
def v1_or_newer(self):
|
||||
return semver.compare(self.version, '1.0.0-alpha') >= 0
|
||||
|
||||
def is_newer(self, version):
|
||||
return semver.compare(self.version, version) > 0
|
||||
|
||||
@property
|
||||
def version_name(self):
|
||||
return self.filename.replace('.tar.gz', '')
|
||||
|
||||
|
||||
def get_latest_release_from_ci():
|
||||
response = requests.get('https://circleci.com/api/v1.1/project/github/getredash/redash/latest/artifacts')
|
||||
|
||||
if response.status_code != 200:
|
||||
exit("Failed getting releases (status code: %s)." % response.status_code)
|
||||
|
||||
tarball_asset = filter(lambda asset: asset['url'].endswith('.tar.gz'), response.json())[0]
|
||||
filename = tarball_asset['pretty_path'].replace('$CIRCLE_ARTIFACTS/', '')
|
||||
version = filename.replace('redash.', '').replace('.tar.gz', '')
|
||||
|
||||
release = Release(version, tarball_asset['url'], filename, '')
|
||||
|
||||
return release
|
||||
|
||||
|
||||
def get_release(channel):
|
||||
if channel == 'ci':
|
||||
return get_latest_release_from_ci()
|
||||
|
||||
response = requests.get('https://version.redash.io/api/releases?channel={}'.format(channel))
|
||||
release = response.json()[0]
|
||||
|
||||
filename = release['download_url'].split('/')[-1]
|
||||
release = Release(release['version'], release['download_url'], filename, release['description'])
|
||||
|
||||
return release
|
||||
|
||||
|
||||
def link_to_current(version_name):
|
||||
green("Linking to current version...")
|
||||
run('ln -nfs {} {}'.format(version_path(version_name), CURRENT_VERSION_PATH))
|
||||
|
||||
|
||||
def restart_services():
|
||||
# We're doing this instead of simple 'supervisorctl restart all' because
|
||||
# otherwise it won't notice that /opt/redash/current pointing at a different
|
||||
# directory.
|
||||
green("Restarting...")
|
||||
try:
|
||||
run('sudo /etc/init.d/redash_supervisord restart')
|
||||
except subprocess.CalledProcessError as e:
|
||||
run('sudo service supervisor restart')
|
||||
|
||||
|
||||
def update_requirements(version_name):
|
||||
green("Installing new Python packages (if needed)...")
|
||||
new_requirements_file = '{}/requirements.txt'.format(version_path(version_name))
|
||||
|
||||
install_requirements = False
|
||||
|
||||
try:
|
||||
run('diff {}/requirements.txt {}'.format(CURRENT_VERSION_PATH, new_requirements_file)) != 0
|
||||
except subprocess.CalledProcessError as e:
|
||||
if e.returncode != 0:
|
||||
install_requirements = True
|
||||
|
||||
if install_requirements:
|
||||
run('sudo pip install -r {}'.format(new_requirements_file))
|
||||
|
||||
|
||||
def apply_migrations(release):
|
||||
green("Running migrations (if needed)...")
|
||||
if not release.v1_or_newer():
|
||||
return apply_migrations_pre_v1(release.version_name)
|
||||
|
||||
run("sudo -u redash bin/run ./manage.py db upgrade", cwd=version_path(release.version_name))
|
||||
|
||||
|
||||
def find_migrations(version_name):
|
||||
current_migrations = set([f for f in os.listdir("{}/migrations".format(CURRENT_VERSION_PATH)) if fnmatch(f, '*_*.py')])
|
||||
new_migrations = sorted([f for f in os.listdir("{}/migrations".format(version_path(version_name))) if fnmatch(f, '*_*.py')])
|
||||
|
||||
return [m for m in new_migrations if m not in current_migrations]
|
||||
|
||||
|
||||
def apply_migrations_pre_v1(version_name):
|
||||
new_migrations = find_migrations(version_name)
|
||||
|
||||
if new_migrations:
|
||||
green("New migrations to run: ")
|
||||
print(', '.join(new_migrations))
|
||||
else:
|
||||
print("No new migrations in this version.")
|
||||
|
||||
if new_migrations and confirm("Apply new migrations? (make sure you have backup)"):
|
||||
for migration in new_migrations:
|
||||
print("Applying {}...".format(migration))
|
||||
run("sudo sudo -u redash PYTHONPATH=. bin/run python migrations/{}".format(migration), cwd=version_path(version_name))
|
||||
|
||||
|
||||
def download_and_unpack(release):
|
||||
directory_name = release.version_name
|
||||
|
||||
green("Downloading release tarball...")
|
||||
run('sudo wget --header="Accept: application/octet-stream" -O {} {}'.format(release.filename, release.download_url))
|
||||
green("Unpacking to: {}...".format(directory_name))
|
||||
run('sudo mkdir -p {}'.format(directory_name))
|
||||
run('sudo tar -C {} -xvf {}'.format(directory_name, release.filename))
|
||||
|
||||
green("Changing ownership to redash...")
|
||||
run('sudo chown redash {}'.format(directory_name))
|
||||
|
||||
green("Linking .env file...")
|
||||
run('sudo ln -nfs {}/.env {}/.env'.format(REDASH_HOME, version_path(directory_name)))
|
||||
|
||||
|
||||
def current_version():
|
||||
real_current_path = os.path.realpath(CURRENT_VERSION_PATH).replace('.b', '+b')
|
||||
return real_current_path.replace(REDASH_HOME + '/', '').replace('redash.', '')
|
||||
|
||||
|
||||
def verify_minimum_version():
|
||||
green("Current version: " + current_version())
|
||||
if semver.compare(current_version(), '0.12.0') < 0:
|
||||
red("You need to have Redash v0.12.0 or newer to upgrade to post v1.0.0 releases.")
|
||||
green("To upgrade to v0.12.0, run the upgrade script set to the legacy channel (--channel legacy).")
|
||||
exit(1)
|
||||
|
||||
|
||||
def show_description_and_confirm(description):
|
||||
if description:
|
||||
print(description)
|
||||
|
||||
if not confirm("Continue with upgrade?"):
|
||||
red("Cancelling upgrade.")
|
||||
exit(1)
|
||||
|
||||
|
||||
def verify_newer_version(release):
|
||||
if not release.is_newer(current_version()):
|
||||
red("The found release is not newer than your current deployed release ({}). Aborting upgrade.".format(current_version()))
|
||||
exit(1)
|
||||
|
||||
|
||||
def deploy_release(channel):
|
||||
h1("Starting Redash upgrade:")
|
||||
|
||||
release = get_release(channel)
|
||||
green("Found version: {}".format(release.version))
|
||||
|
||||
if release.v1_or_newer():
|
||||
verify_minimum_version()
|
||||
|
||||
verify_newer_version(release)
|
||||
show_description_and_confirm(release.description)
|
||||
|
||||
try:
|
||||
download_and_unpack(release)
|
||||
update_requirements(release.version_name)
|
||||
apply_migrations(release)
|
||||
link_to_current(release.version_name)
|
||||
restart_services()
|
||||
green("Done! Enjoy.")
|
||||
except subprocess.CalledProcessError as e:
|
||||
red("Failed running: {}".format(e.cmd))
|
||||
red("Exit status: {}\nOutput:\n{}".format(e.returncode, e.output))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--channel", help="The channel to get release from (default: stable).", default='stable')
|
||||
args = parser.parse_args()
|
||||
|
||||
deploy_release(args.channel)
|
||||
21
bin/vagrant_ctl.sh
Executable file
21
bin/vagrant_ctl.sh
Executable file
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
help() {
|
||||
echo "Usage: "
|
||||
echo "`basename "$0"` {start, test}"
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
start)
|
||||
vagrant up
|
||||
vagrant ssh -c "cd /opt/redash/current; bin/run honcho start -f Procfile.dev;"
|
||||
;;
|
||||
test)
|
||||
vagrant up
|
||||
vagrant ssh -c "cd /opt/redash/current; make test"
|
||||
;;
|
||||
*)
|
||||
help
|
||||
;;
|
||||
esac
|
||||
47
bower.json
Normal file
47
bower.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "redash",
|
||||
"version": "0.11.1",
|
||||
"dependencies": {
|
||||
"angular": "1.2.18",
|
||||
"angular-resource": "1.2.18",
|
||||
"angular-route": "1.2.18",
|
||||
"angular-growl": "0.4.0",
|
||||
"json3": "3.2.4",
|
||||
"jquery": "1.9.1",
|
||||
"bootstrap": "3.3.6",
|
||||
"es5-shim": "2.0.8",
|
||||
"angular-moment": "0.10.3",
|
||||
"moment": "~2.8.0",
|
||||
"codemirror": "4.8.0",
|
||||
"underscore": "1.5.1",
|
||||
"pivottable": "2.0.2",
|
||||
"cornelius": "https://github.com/restorando/cornelius.git",
|
||||
"gridster": "0.2.0",
|
||||
"mousetrap": "~1.4.6",
|
||||
"jquery-ui": "~1.10.4",
|
||||
"underscore.string": "~2.3.3",
|
||||
"marked": "~0.3.2",
|
||||
"pace": "~0.5.1",
|
||||
"font-awesome": "~4.2.0",
|
||||
"mustache": "~1.0.0",
|
||||
"canvg": "gabelerner/canvg",
|
||||
"angular-ui-bootstrap-bower": "~0.12.1",
|
||||
"leaflet": "~0.7.3",
|
||||
"angular-base64-upload": "~0.1.11",
|
||||
"angular-ui-select": "~0.13.2",
|
||||
"angular-bootstrap-show-errors": "~2.3.0",
|
||||
"angular-sanitize": "1.2.18",
|
||||
"d3": "3.5.6",
|
||||
"angular-ui-sortable": "~0.13.4",
|
||||
"angular-resizable": "^1.2.0",
|
||||
"material-design-iconic-font": "^2.2.0",
|
||||
"plotly.js": "^1.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"angular-mocks": "1.2.18",
|
||||
"angular-scenario": "1.2.18"
|
||||
},
|
||||
"resolutions": {
|
||||
"angular": "1.2.18"
|
||||
}
|
||||
}
|
||||
22
circle.yml
22
circle.yml
@@ -1,32 +1,34 @@
|
||||
machine:
|
||||
services:
|
||||
- docker
|
||||
- redis
|
||||
node:
|
||||
version:
|
||||
6.9.1
|
||||
0.12.4
|
||||
python:
|
||||
version:
|
||||
2.7.3
|
||||
dependencies:
|
||||
override:
|
||||
- pip install --upgrade setuptools
|
||||
pre:
|
||||
- pip install -r requirements_dev.txt
|
||||
- pip install -r requirements.txt
|
||||
- pip install pymongo==3.2.1
|
||||
- make deps
|
||||
cache_directories:
|
||||
- node_modules/
|
||||
- rd_ui/app/bower_components/
|
||||
test:
|
||||
override:
|
||||
- nosetests --with-xunit --xunit-file=$CIRCLE_TEST_REPORTS/junit.xml --with-coverage --cover-package=redash tests/
|
||||
deployment:
|
||||
github_and_docker:
|
||||
branch: [master, /release.*/]
|
||||
branch: [master, /release_.*/]
|
||||
commands:
|
||||
- make pack
|
||||
# Skipping uploads for now, until master is stable.
|
||||
# - make upload
|
||||
#- echo "client/app" >> .dockerignore
|
||||
#- docker pull redash/redash:latest
|
||||
- docker login -e $DOCKER_EMAIL -u $DOCKER_USER -p $DOCKER_PASS
|
||||
- make upload
|
||||
- echo "rd_ui/app" >> .dockerignore
|
||||
- docker pull redash/redash:latest
|
||||
- docker build -t redash/redash:$(./manage.py version | sed -e "s/\+/./") .
|
||||
- docker login -e $DOCKER_EMAIL -u $DOCKER_USER -p $DOCKER_PASS
|
||||
- docker push redash/redash:$(./manage.py version | sed -e "s/\+/./")
|
||||
notify:
|
||||
webhooks:
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"presets": ["es2015", "stage-2"],
|
||||
"plugins": ["angularjs-annotate", "transform-object-assign"]
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
build/*.js
|
||||
config/*.js
|
||||
node_modules
|
||||
@@ -1,30 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: "airbnb-base",
|
||||
settings: {
|
||||
"import/resolver": "webpack"
|
||||
},
|
||||
env: {
|
||||
"browser": true,
|
||||
"node": true
|
||||
},
|
||||
rules: {
|
||||
// allow debugger during development
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0,
|
||||
'no-param-reassign': 0,
|
||||
'no-mixed-operators': 0,
|
||||
'no-underscore-dangle': 0,
|
||||
"prefer-destructuring": "off",
|
||||
"prefer-template": "off",
|
||||
"no-restricted-properties": "off",
|
||||
"no-restricted-globals": "off",
|
||||
"no-multi-assign": "off",
|
||||
"max-len": ['error', 120, 2, {
|
||||
ignoreUrls: true,
|
||||
ignoreComments: false,
|
||||
ignoreRegExpLiterals: true,
|
||||
ignoreStrings: true,
|
||||
ignoreTemplateLiterals: true,
|
||||
}]
|
||||
}
|
||||
};
|
||||
1
client/.gitignore
vendored
1
client/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
dist
|
||||
@@ -1,113 +0,0 @@
|
||||
import { contains, without, compact } from 'underscore';
|
||||
import template from './alert-subscriptions.html';
|
||||
|
||||
function controller($scope, $q, $sce, currentUser, AlertSubscription, Destination, toastr) {
|
||||
'ngInject';
|
||||
|
||||
$scope.newSubscription = {};
|
||||
$scope.subscribers = [];
|
||||
$scope.destinations = [];
|
||||
$scope.currentUser = currentUser;
|
||||
|
||||
$q
|
||||
.all([
|
||||
Destination.query().$promise,
|
||||
AlertSubscription.query({ alertId: $scope.alertId }).$promise,
|
||||
])
|
||||
.then((responses) => {
|
||||
const destinations = responses[0];
|
||||
const subscribers = responses[1];
|
||||
|
||||
const mapF = s => s.destination && s.destination.id;
|
||||
const subscribedDestinations = compact(subscribers.map(mapF));
|
||||
|
||||
const subscribedUsers = compact(subscribers.map(s => !s.destination && s.user.id));
|
||||
|
||||
$scope.destinations = destinations.filter(d => !contains(subscribedDestinations, d.id));
|
||||
|
||||
if (!contains(subscribedUsers, currentUser.id)) {
|
||||
$scope.destinations.unshift({ user: { name: currentUser.name } });
|
||||
}
|
||||
|
||||
$scope.newSubscription.destination = $scope.destinations[0];
|
||||
$scope.subscribers = subscribers;
|
||||
});
|
||||
|
||||
$scope.destinationsDisplay = (d) => {
|
||||
if (!d) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let destination = d;
|
||||
if (d.destination) {
|
||||
destination = destination.destination;
|
||||
} else if (destination.user) {
|
||||
destination = {
|
||||
name: `${d.user.name} (Email)`,
|
||||
icon: 'fa-envelope',
|
||||
type: 'user',
|
||||
};
|
||||
}
|
||||
|
||||
return $sce.trustAsHtml(`<i class="fa ${destination.icon}"></i> ${destination.name}`);
|
||||
};
|
||||
|
||||
$scope.saveSubscriber = () => {
|
||||
const sub = new AlertSubscription({ alert_id: $scope.alertId });
|
||||
if ($scope.newSubscription.destination.id) {
|
||||
sub.destination_id = $scope.newSubscription.destination.id;
|
||||
}
|
||||
|
||||
sub.$save(
|
||||
() => {
|
||||
toastr.success('Subscribed.');
|
||||
$scope.subscribers.push(sub);
|
||||
$scope.destinations = without($scope.destinations, $scope.newSubscription.destination);
|
||||
if ($scope.destinations.length > 0) {
|
||||
$scope.newSubscription.destination = $scope.destinations[0];
|
||||
} else {
|
||||
$scope.newSubscription.destination = undefined;
|
||||
}
|
||||
},
|
||||
() => {
|
||||
toastr.error('Failed saving subscription.');
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
$scope.unsubscribe = (subscriber) => {
|
||||
const destination = subscriber.destination;
|
||||
const user = subscriber.user;
|
||||
|
||||
subscriber.$delete(
|
||||
() => {
|
||||
toastr.success('Unsubscribed');
|
||||
$scope.subscribers = without($scope.subscribers, subscriber);
|
||||
if (destination) {
|
||||
$scope.destinations.push(destination);
|
||||
} else if (user.id === currentUser.id) {
|
||||
$scope.destinations.push({ user: { name: currentUser.name } });
|
||||
}
|
||||
|
||||
if ($scope.destinations.length === 1) {
|
||||
$scope.newSubscription.destination = $scope.destinations[0];
|
||||
}
|
||||
},
|
||||
() => {
|
||||
toastr.error('Failed unsubscribing.');
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.directive('alertSubscriptions', () => ({
|
||||
restrict: 'E',
|
||||
replace: true,
|
||||
scope: {
|
||||
alertId: '=',
|
||||
},
|
||||
template,
|
||||
controller,
|
||||
}));
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
.menu-search {
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.menu-search input[type="text"] {
|
||||
height: 30px;
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
<nav class="navbar navbar-inverse navbar-fixed-top app-header" role="navigation">
|
||||
<div class="container">
|
||||
<div class="navbar-header">
|
||||
<button type="button" class="navbar-toggle" ng-click="isNavOpen = !isNavOpen">
|
||||
<span class="sr-only">Toggle navigation</span>
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></span>
|
||||
</button>
|
||||
<a class="navbar-brand" ng-href="{{$ctrl.basePath}}"><img ng-src="{{$ctrl.logoUrl}}"/></a>
|
||||
</div>
|
||||
<div class="collapse navbar-collapse" uib-collapse="!isNavOpen">
|
||||
<ul class="nav navbar-nav">
|
||||
<li class="dropdown" ng-show="$ctrl.showDashboardsMenu" uib-dropdown>
|
||||
<a href="#" class="dropdown-toggle" uib-dropdown-toggle title="Dashboards">
|
||||
<span class="visible-xs visible-md visible-lg">Dashboards <b class="caret"></b></span>
|
||||
<span class="visible-sm"><i class="zmdi zmdi-view-dashboard"></i> <b class="caret"></b></span>
|
||||
</a>
|
||||
<ul class="dropdown-menu" uib-dropdown-menu>
|
||||
<li><a ng-show="$ctrl.currentUser.hasPermission('create_dashboard')" ng-click="$ctrl.newDashboard()">New Dashboard</a></li>
|
||||
<li><a href="dashboards">Dashboards</a></li>
|
||||
<li class="divider" ng-if="$ctrl.dashboards | notEmpty"></li>
|
||||
<li ng-repeat="dashboard in $ctrl.dashboards">
|
||||
<a href="dashboard/{{dashboard.slug}}" ng-bind="dashboard.name"></a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="dropdown" ng-show="$ctrl.showQueriesMenu" uib-dropdown>
|
||||
<a href="#" class="dropdown-toggle" uib-dropdown-toggle>Queries <b class="caret"></b></a>
|
||||
<ul class="dropdown-menu" uib-dropdown-menu>
|
||||
<li ng-show="$ctrl.showNewQueryMenu"><a href="queries/new">New Query</a></li>
|
||||
<li><a href="queries">Queries</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li ng-if="$ctrl.showAlertsLink">
|
||||
<a href="alerts">Alerts</a>
|
||||
</li>
|
||||
</ul>
|
||||
<form class="navbar-form navbar-left" role="search" ng-submit="$ctrl.searchQueries()">
|
||||
<div class="input-group menu-search">
|
||||
<input type="text" ng-model="$ctrl.term" class="form-control" placeholder="Search queries...">
|
||||
<span class="input-group-btn">
|
||||
<button type="submit" class="btn btn-default"><span class="zmdi zmdi-search"></span></button>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
<ul class="nav navbar-nav navbar-right">
|
||||
<li ng-show="$ctrl.currentUser.isAdmin">
|
||||
<a href="data_sources" title="Data Sources"><i class="fa fa-database"></i></a>
|
||||
</li>
|
||||
<li ng-show="$ctrl.showSettingsMenu">
|
||||
<a href="users" title="Settings"><i class="fa fa-cog"></i></a>
|
||||
</li>
|
||||
<li class="dropdown" uib-dropdown>
|
||||
<a href="#" class="dropdown-toggle" uib-dropdown-toggle><span ng-bind="$ctrl.currentUser.name"></span> <span
|
||||
class="caret"></span></a>
|
||||
<ul class="dropdown-menu" dropdown-menu>
|
||||
<li style="width:300px">
|
||||
<a ng-href="users/{{$ctrl.currentUser.id}}">
|
||||
<div class="row">
|
||||
<div class="col-sm-2">
|
||||
<img ng-src="{{$ctrl.currentUser.gravatar_url}}" size="40px" class="img-circle" />
|
||||
</div>
|
||||
<div class="col-sm-10">
|
||||
<p><strong>{{$ctrl.currentUser.name}}</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
<li class="divider" ng-if="$ctrl.currentUser.hasPermission('super_admin')">
|
||||
<li ng-if="$ctrl.currentUser.hasPermission('super_admin')"><a href="admin/status">System Status</a></li>
|
||||
<li class="divider">
|
||||
</li>
|
||||
<li>
|
||||
<a ng-click="$ctrl.logout()">Log out</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -1,51 +0,0 @@
|
||||
import debug from 'debug';
|
||||
|
||||
import logoUrl from '@/assets/images/redash_icon_small.png';
|
||||
import template from './app-header.html';
|
||||
import './app-header.css';
|
||||
|
||||
const logger = debug('redash:appHeader');
|
||||
|
||||
function controller($rootScope, $location, $uibModal, Auth, currentUser, clientConfig, Dashboard) {
|
||||
this.logoUrl = logoUrl;
|
||||
this.basePath = clientConfig.basePath;
|
||||
this.currentUser = currentUser;
|
||||
this.showQueriesMenu = currentUser.hasPermission('view_query');
|
||||
this.showAlertsLink = currentUser.hasPermission('list_alerts');
|
||||
this.showNewQueryMenu = currentUser.hasPermission('create_query');
|
||||
this.showSettingsMenu = currentUser.hasPermission('list_users');
|
||||
this.showDashboardsMenu = currentUser.hasPermission('list_dashboards');
|
||||
|
||||
this.reloadDashboards = () => {
|
||||
logger('Reloading dashboards.');
|
||||
this.dashboards = Dashboard.recent();
|
||||
};
|
||||
|
||||
this.reloadDashboards();
|
||||
|
||||
$rootScope.$on('reloadDashboards', this.reloadDashboards);
|
||||
|
||||
this.newDashboard = () => {
|
||||
$uibModal.open({
|
||||
component: 'editDashboardDialog',
|
||||
resolve: {
|
||||
dashboard: () => ({ name: null, layout: null }),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
this.searchQueries = () => {
|
||||
$location.path('/queries/search').search({ q: this.term });
|
||||
};
|
||||
|
||||
this.logout = () => {
|
||||
Auth.logout();
|
||||
};
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('appHeader', {
|
||||
template,
|
||||
controller,
|
||||
});
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
function cancelQueryButton() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
queryId: '=',
|
||||
taskId: '=',
|
||||
},
|
||||
transclude: true,
|
||||
template: '<button class="btn btn-default" ng-disabled="inProgress" ng-click="cancelExecution()"><i class="zmdi zmdi-spinner zmdi-hc-spin" ng-if="inProgress"></i> Cancel</button>',
|
||||
replace: true,
|
||||
controller($scope, $http, currentUser, Events) {
|
||||
$scope.inProgress = false;
|
||||
|
||||
$scope.cancelExecution = () => {
|
||||
$http.delete(`api/jobs/${$scope.taskId}`).success(() => {
|
||||
});
|
||||
|
||||
let queryId = $scope.queryId;
|
||||
if ($scope.queryId === 'adhoc') {
|
||||
queryId = null;
|
||||
}
|
||||
|
||||
Events.record('cancel_execute', 'query', queryId, { admin: true });
|
||||
$scope.inProgress = true;
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.directive('cancelQueryButton', cancelQueryButton);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" ng-disabled="$ctrl.saveInProgress" aria-hidden="true" ng-click="$ctrl.dismiss()">×</button>
|
||||
<h4 class="modal-title">Add Widget</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="btn-group">
|
||||
<button type="button" class="btn btn-default" ng-class="{active: $ctrl.isVisualization()}" ng-click="$ctrl.setType('visualization')">Visualization</button>
|
||||
<button type="button" class="btn btn-default" ng-class="{active: $ctrl.isTextBox()}" ng-click="$ctrl.setType('textbox')">Text Box</button>
|
||||
</p>
|
||||
|
||||
<div ng-show="$ctrl.isTextBox()">
|
||||
<div class="form-group">
|
||||
<textarea class="form-control" ng-model="$ctrl.text" rows="3"></textarea>
|
||||
</div>
|
||||
<div ng-show="$ctrl.text">
|
||||
<strong>Preview:</strong>
|
||||
<p ng-bind-html="$ctrl.text | markdown"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-show="$ctrl.isVisualization()">
|
||||
<div class="form-group">
|
||||
<ui-select ng-model="$ctrl.query.selected" theme="bootstrap" reset-search-input="false" on-select="$ctrl.onQuerySelect($item, $model)">
|
||||
<ui-select-match placeholder="Search a query by name">{{$select.selected.name}}</ui-select-match>
|
||||
<ui-select-choices repeat="q in $ctrl.queries"
|
||||
refresh="$ctrl.searchQueries($select.search)"
|
||||
refresh-delay="0">
|
||||
<div ng-bind-html="$ctrl.trustAsHtml(q.name | highlight: $select.search)"></div>
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
</div>
|
||||
|
||||
<div ng-show="$ctrl.selected_query">
|
||||
<div class="form-group">
|
||||
<label for="">Choose Visualization</label>
|
||||
<select ng-model="$ctrl.selectedVis" ng-options="vis as vis.name group by vis.type for vis in $ctrl.selected_query.visualizations" class="form-control"></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="">Widget Size</label>
|
||||
<select class="form-control" ng-model="$ctrl.widgetSize"
|
||||
ng-options="c.value as c.name for c in $ctrl.widgetSizes"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" ng-disabled="$ctrl.saveInProgress" ng-click="$ctrl.dismiss()">Close</button>
|
||||
<button type="button" class="btn btn-primary" ng-disabled="$ctrl.saveInProgress || !($ctrl.selectedVis || $ctrl.isTextBox())" ng-click="$ctrl.saveWidget()">Add to Dashboard</button>
|
||||
</div>
|
||||
@@ -1,102 +0,0 @@
|
||||
import template from './add-widget-dialog.html';
|
||||
|
||||
const AddWidgetDialog = {
|
||||
template,
|
||||
bindings: {
|
||||
resolve: '<',
|
||||
close: '&',
|
||||
dismiss: '&',
|
||||
},
|
||||
controller($sce, toastr, Query, Widget) {
|
||||
'ngInject';
|
||||
|
||||
this.dashboard = this.resolve.dashboard;
|
||||
this.saveInProgress = false;
|
||||
this.widgetSize = 1;
|
||||
this.selectedVis = null;
|
||||
this.query = {};
|
||||
this.selected_query = undefined;
|
||||
this.text = '';
|
||||
this.existing_text = '';
|
||||
this.new_text = '';
|
||||
this.widgetSizes = [{
|
||||
name: 'Regular',
|
||||
value: 1,
|
||||
}, {
|
||||
name: 'Double',
|
||||
value: 2,
|
||||
}];
|
||||
|
||||
this.type = 'visualization';
|
||||
|
||||
this.trustAsHtml = html => $sce.trustAsHtml(html);
|
||||
this.isVisualization = () => this.type === 'visualization';
|
||||
this.isTextBox = () => this.type === 'textbox';
|
||||
|
||||
this.setType = (type) => {
|
||||
this.type = type;
|
||||
if (type === 'textbox') {
|
||||
this.widgetSizes.push({ name: 'Hidden', value: 0 });
|
||||
} else if (this.widgetSizes.length > 2) {
|
||||
this.widgetSizes.pop();
|
||||
}
|
||||
};
|
||||
|
||||
this.onQuerySelect = () => {
|
||||
if (!this.query.selected) {
|
||||
return;
|
||||
}
|
||||
|
||||
Query.get({ id: this.query.selected.id }, (query) => {
|
||||
if (query) {
|
||||
this.selected_query = query;
|
||||
if (query.visualizations.length) {
|
||||
this.selectedVis = query.visualizations[0];
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
this.searchQueries = (term) => {
|
||||
if (!term || term.length < 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
Query.search({ q: term }, (results) => {
|
||||
this.queries = results;
|
||||
});
|
||||
};
|
||||
|
||||
this.saveWidget = () => {
|
||||
this.saveInProgress = true;
|
||||
const widget = new Widget({
|
||||
visualization_id: this.selectedVis && this.selectedVis.id,
|
||||
dashboard_id: this.dashboard.id,
|
||||
options: {},
|
||||
width: this.widgetSize,
|
||||
text: this.text,
|
||||
});
|
||||
|
||||
widget.$save().then((response) => {
|
||||
// update dashboard layout
|
||||
this.dashboard.layout = response.layout;
|
||||
this.dashboard.version = response.version;
|
||||
const newWidget = new Widget(response.widget);
|
||||
if (response.new_row) {
|
||||
this.dashboard.widgets.push([newWidget]);
|
||||
} else {
|
||||
this.dashboard.widgets[this.dashboard.widgets.length - 1].push(newWidget);
|
||||
}
|
||||
this.close();
|
||||
}).catch(() => {
|
||||
toastr.error('Widget can not be added');
|
||||
}).finally(() => {
|
||||
this.saveInProgress = false;
|
||||
});
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('addWidgetDialog', AddWidgetDialog);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" ng-click="$ctrl.dismiss()" ng-disabled="$ctrl.saveInProgress" aria-hidden="true">×</button>
|
||||
<h4 class="modal-title">Edit: {{$ctrl.dashboard.name}}</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>
|
||||
<input type="text" class="form-control" placeholder="Dashboard Name" ng-model="$ctrl.dashboard.name" autofocus>
|
||||
</p>
|
||||
|
||||
<p ng-if="$ctrl.dashboard.id">
|
||||
<label>
|
||||
<input name="input" type="checkbox" ng-model="$ctrl.dashboard.dashboard_filters_enabled">
|
||||
Use Dashboard Level Filters
|
||||
</label>
|
||||
</p>
|
||||
|
||||
<div gridster="$ctrl.gridsterOptions" ng-if="$ctrl.items | notEmpty">
|
||||
<ul>
|
||||
<li gridster-item="item" ng-repeat="item in $ctrl.items" class="widget panel panel-default gs-w">
|
||||
<div class="heading">{{item.name}}</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" ng-disabled="$ctrl.saveInProgress" ng-click="$ctrl.dismiss()">Close</button>
|
||||
<button type="button" class="btn btn-primary" ng-disabled="$ctrl.saveInProgress || !$ctrl.isFormValid()" ng-click="$ctrl.saveDashboard()">Save</button>
|
||||
</div>
|
||||
@@ -1,104 +0,0 @@
|
||||
import { isEmpty, sortBy } from 'underscore';
|
||||
import template from './edit-dashboard-dialog.html';
|
||||
|
||||
const EditDashboardDialog = {
|
||||
bindings: {
|
||||
resolve: '<',
|
||||
close: '&',
|
||||
dismiss: '&',
|
||||
},
|
||||
template,
|
||||
controller($rootScope, $location, $http, toastr, Events, Dashboard) {
|
||||
'ngInject';
|
||||
|
||||
this.dashboard = this.resolve.dashboard;
|
||||
this.gridsterOptions = {
|
||||
margins: [5, 5],
|
||||
rowHeight: 100,
|
||||
colWidth: 260,
|
||||
columns: 2,
|
||||
mobileModeEnabled: false,
|
||||
swapping: true,
|
||||
minRows: 1,
|
||||
draggable: {
|
||||
enabled: true,
|
||||
},
|
||||
resizable: {
|
||||
enabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
this.items = [];
|
||||
|
||||
if (this.dashboard.widgets) {
|
||||
this.dashboard.widgets.forEach((row, rowIndex) => {
|
||||
row.forEach((widget, colIndex) => {
|
||||
this.items.push({
|
||||
id: widget.id,
|
||||
col: colIndex,
|
||||
row: rowIndex,
|
||||
sizeY: 1,
|
||||
sizeX: widget.width,
|
||||
name: widget.getName(), // visualization.query.name
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
this.isFormValid = () => !isEmpty(this.dashboard.name);
|
||||
|
||||
this.saveDashboard = () => {
|
||||
this.saveInProgress = true;
|
||||
|
||||
if (this.dashboard.id) {
|
||||
const layout = [];
|
||||
const sortedItems = sortBy(this.items, item => item.row * 10 + item.col);
|
||||
|
||||
sortedItems.forEach((item) => {
|
||||
layout[item.row] = layout[item.row] || [];
|
||||
if (item.col > 0 && layout[item.row][item.col - 1] === undefined) {
|
||||
layout[item.row][item.col - 1] = item.id;
|
||||
} else {
|
||||
layout[item.row][item.col] = item.id;
|
||||
}
|
||||
});
|
||||
|
||||
const request = {
|
||||
slug: this.dashboard.id,
|
||||
name: this.dashboard.name,
|
||||
version: this.dashboard.version,
|
||||
dashboard_filters_enabled: this.dashboard.dashboard_filters_enabled,
|
||||
layout: JSON.stringify(layout),
|
||||
};
|
||||
|
||||
Dashboard.save(request, (dashboard) => {
|
||||
this.dashboard = dashboard;
|
||||
this.saveInProgress = false;
|
||||
this.close({ $value: this.dashboard });
|
||||
$rootScope.$broadcast('reloadDashboards');
|
||||
}, (error) => {
|
||||
this.saveInProgress = false;
|
||||
if (error.status === 403) {
|
||||
toastr.error('Unable to save dashboard: Permission denied.');
|
||||
} else if (error.status === 409) {
|
||||
toastr.error('It seems like the dashboard has been modified by another user. ' +
|
||||
'Please copy/backup your changes and reload this page.', { autoDismiss: false });
|
||||
}
|
||||
});
|
||||
Events.record('edit', 'dashboard', this.dashboard.id);
|
||||
} else {
|
||||
$http.post('api/dashboards', {
|
||||
name: this.dashboard.name,
|
||||
}).success((response) => {
|
||||
this.close();
|
||||
$location.path(`/dashboard/${response.slug}`).replace();
|
||||
});
|
||||
Events.record('create', 'dashboard');
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('editDashboardDialog', EditDashboardDialog);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" aria-label="Close" ng-click="$ctrl.close()"><span aria-hidden="true">×</span></button>
|
||||
<h4 class="modal-title">Edit TextBox</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<textarea class="form-control" ng-model="$ctrl.widget.new_text" rows="3"></textarea>
|
||||
</div>
|
||||
<div ng-show="$ctrl.widget.new_text">
|
||||
<strong>Preview:</strong>
|
||||
<p ng-bind-html="$ctrl.widget.new_text | markdown"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" ng-disabled="$ctrl.saveInProgress" ng-click="$ctrl.close()">Close</button>
|
||||
<button type="button" class="btn btn-primary" ng-disabled="$ctrl.saveInProgress" ng-click="$ctrl.saveWidget()">Save</button>
|
||||
</div>
|
||||
@@ -1,77 +0,0 @@
|
||||
<div class="col-lg-{{$ctrl.widget.width | colWidth}}">
|
||||
<div class="tile" ng-if="$ctrl.type=='visualization'">
|
||||
<div class="t-header widget">
|
||||
<div class="th-title">
|
||||
<p class="hidden-print">
|
||||
<span ng-hide="$ctrl.canViewQuery">{{$ctrl.query.name}}</span>
|
||||
<query-link query="$ctrl.query" visualization="$ctrl.widget.visualization" ng-show="$ctrl.canViewQuery"></query-link>
|
||||
<small><visualization-name visualization="$ctrl.widget.visualization"/></small>
|
||||
</p>
|
||||
<p class="visible-print">
|
||||
{{$ctrl.query.name}}
|
||||
<visualization-name visualization="$ctrl.widget.visualization"/>
|
||||
</p>
|
||||
<div class="text-muted" ng-bind-html="$ctrl.query.description | markdown"></div>
|
||||
</div>
|
||||
<div class="actions dropdown" uib-dropdown ng-if="!$ctrl.public">
|
||||
<a data-toggle="dropdown" uib-dropdown-toggle><i class="zmdi zmdi-more"></i></a>
|
||||
|
||||
<ul class="dropdown-menu pull-right" uib-dropdown-menu style="z-index:1000000">
|
||||
<li ng-class="{'disabled': $ctrl.queryResult.isEmpty()}"><a ng-href="{{$ctrl.queryResult.getLink($ctrl.query.id, 'csv')}}" download="{{$ctrl.queryResult.getName($ctrl.query.name, 'csv')}}" target="_self">Download as CSV File</a></li>
|
||||
<li ng-class="{'disabled': $ctrl.queryResult.isEmpty()}"><a ng-href="{{$ctrl.queryResult.getLink($ctrl.query.id, 'xlsx')}}" download="{{$ctrl.queryResult.getName($ctrl.query.name, 'xlsx')}}" target="_self">Download as Excel File</a></li>
|
||||
<li><a ng-href="queries/{{$ctrl.query.id}}#{{$ctrl.widget.visualization.id}}" ng-show="$ctrl.canViewQuery">View Query</a></li>
|
||||
<li><a ng-show="$ctrl.dashboard.canEdit()" ng-click="$ctrl.deleteWidget()">Remove From Dashboard</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<parameters parameters="$ctrl.localParametersDefs()"></parameters>
|
||||
|
||||
<div ng-switch="$ctrl.queryResult.getStatus()">
|
||||
<div ng-switch-when="failed">
|
||||
<div class="alert alert-danger m-5" ng-show="$ctrl.queryResult.getError()">Error running query: <strong>{{$ctrl.queryResult.getError()}}</strong></div>
|
||||
</div>
|
||||
<div ng-switch-when="done">
|
||||
<visualization-renderer visualization="$ctrl.widget.visualization" query-result="$ctrl.queryResult" class="t-body"></visualization-renderer>
|
||||
</div>
|
||||
<div ng-switch-default class="text-center">
|
||||
<i class="zmdi zmdi-refresh zmdi-hc-spin zmdi-hc-5x"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-5 clearfix" style="line-height:28px;">
|
||||
<span class="small hidden-print">Updated: <span am-time-ago="$ctrl.queryResult.getUpdatedAt()"></span></span>
|
||||
<span class="visible-print">
|
||||
Updated: {{$ctrl.queryResult.getUpdatedAt() | dateTime}}
|
||||
</span>
|
||||
<button class="btn btn-sm btn-default pull-right hidden-print" ng-click="$ctrl.reload(true)" ng-if="!$ctrl.public"><i class="zmdi zmdi-refresh"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tile" ng-if="$ctrl.type=='restricted'">
|
||||
<div class="t-body">
|
||||
<div class="text-center">
|
||||
<h1><span class="zmdi zmdi-lock"></span></h1>
|
||||
<p class="text-muted">
|
||||
This widget requires access to a data source you don't have access to.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tile" ng-hide="$ctrl.widget.width === 0" ng-if="$ctrl.type=='textbox'">
|
||||
<div class="t-body">
|
||||
<div class="dropdown" uib-dropdown ng-if="!$ctrl.public && $ctrl.dashboard.canEdit()">
|
||||
<div class="dropdown-header">
|
||||
<a data-toggle="dropdown" uib-dropdown-toggle class="actions"><i class="zmdi zmdi-more"></i></a>
|
||||
</div>
|
||||
|
||||
<ul class="dropdown-menu pull-right" uib-dropdown-menu style="z-index:1000000">
|
||||
<li><a ng-show="$ctrl.dashboard.canEdit()" ng-click="$ctrl.editTextBox()">Edit</a></li>
|
||||
<li><a ng-show="$ctrl.dashboard.canEdit()" ng-click="$ctrl.deleteWidget()">Remove From Dashboard</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<p ng-bind-html="$ctrl.widget.text | markdown" class="p-5"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,114 +0,0 @@
|
||||
import template from './widget.html';
|
||||
import editTextBoxTemplate from './edit-text-box.html';
|
||||
|
||||
const EditTextBoxComponent = {
|
||||
template: editTextBoxTemplate,
|
||||
bindings: {
|
||||
resolve: '<',
|
||||
close: '&',
|
||||
dismiss: '&',
|
||||
},
|
||||
controller(toastr) {
|
||||
'ngInject';
|
||||
|
||||
this.saveInProgress = false;
|
||||
this.widget = this.resolve.widget;
|
||||
this.saveWidget = () => {
|
||||
this.saveInProgress = true;
|
||||
if (this.widget.new_text !== this.widget.existing_text) {
|
||||
this.widget.text = this.widget.new_text;
|
||||
this.widget.$save().then(() => {
|
||||
this.close();
|
||||
}).catch(() => {
|
||||
toastr.error('Widget can not be updated');
|
||||
}).finally(() => {
|
||||
this.saveInProgress = false;
|
||||
});
|
||||
} else {
|
||||
this.close();
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
function DashboardWidgetCtrl($location, $uibModal, $window, Events, currentUser) {
|
||||
this.canViewQuery = currentUser.hasPermission('view_query');
|
||||
|
||||
this.editTextBox = () => {
|
||||
this.widget.existing_text = this.widget.text;
|
||||
this.widget.new_text = this.widget.text;
|
||||
$uibModal.open({
|
||||
component: 'editTextBox',
|
||||
resolve: {
|
||||
widget: this.widget,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
this.localParametersDefs = () => {
|
||||
if (!this.localParameters) {
|
||||
this.localParameters = this.widget.query.getParametersDefs().filter(p => !p.global);
|
||||
}
|
||||
return this.localParameters;
|
||||
};
|
||||
|
||||
this.deleteWidget = () => {
|
||||
if (!$window.confirm(`Are you sure you want to remove "${this.widget.getName()}" from the dashboard?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Events.record('delete', 'widget', this.widget.id);
|
||||
|
||||
this.widget.$delete((response) => {
|
||||
this.dashboard.widgets =
|
||||
this.dashboard.widgets.map(row => row.filter(widget => widget.id !== undefined));
|
||||
|
||||
this.dashboard.widgets = this.dashboard.widgets.filter(row => row.length > 0);
|
||||
|
||||
this.dashboard.layout = response.layout;
|
||||
this.dashboard.version = response.version;
|
||||
|
||||
if (this.deleted) {
|
||||
this.deleted({});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
Events.record('view', 'widget', this.widget.id);
|
||||
|
||||
this.reload = (force) => {
|
||||
let maxAge = $location.search().maxAge;
|
||||
if (force) {
|
||||
maxAge = 0;
|
||||
}
|
||||
this.queryResult = this.query.getQueryResult(maxAge);
|
||||
};
|
||||
|
||||
if (this.widget.visualization) {
|
||||
Events.record('view', 'query', this.widget.visualization.query.id, { dashboard: true });
|
||||
Events.record('view', 'visualization', this.widget.visualization.id, { dashboard: true });
|
||||
|
||||
this.query = this.widget.getQuery();
|
||||
this.reload(false);
|
||||
|
||||
this.type = 'visualization';
|
||||
} else if (this.widget.restricted) {
|
||||
this.type = 'restricted';
|
||||
} else {
|
||||
this.type = 'textbox';
|
||||
}
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('editTextBox', EditTextBoxComponent);
|
||||
ngModule.component('dashboardWidget', {
|
||||
template,
|
||||
controller: DashboardWidgetCtrl,
|
||||
bindings: {
|
||||
widget: '<',
|
||||
public: '<',
|
||||
dashboard: '<',
|
||||
deleted: '&onDelete',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
<form name="dataSourceForm">
|
||||
<div class="form-group">
|
||||
<label for="type">Type</label>
|
||||
<select name="type" class="form-control" ng-options="type.type as type.name for type in types" ng-model="target.type" autofocus></select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="dataSourceName">Name</label>
|
||||
<input type="string" class="form-control" name="dataSourceName" ng-model="target.name" required>
|
||||
</div>
|
||||
<div class="form-group" ng-class='{"has-error": !inner.input.$valid}' ng-form="inner" ng-repeat="field in fields">
|
||||
<label ng-if="field.property.type !== 'checkbox'">{{field.property.title || field.name | capitalize}}</label>
|
||||
<input name="input" type="{{field.property.type}}" class="form-control" ng-model="target.options[field.name]" ng-required="field.property.required"
|
||||
ng-if="field.property.type !== 'file' && field.property.type !== 'checkbox'" accesskey="tab" placeholder="{{field.property.default}}">
|
||||
|
||||
<label ng-if="field.property.type=='checkbox'">
|
||||
<input name="input" type="{{field.property.type}}" ng-model="target.options[field.name]" ng-required="field.property.required"
|
||||
ng-if="field.property.type !== 'file'" accesskey="tab" placeholder="{{field.property.default}}">
|
||||
{{field.property.title || field.name | capitalize}}
|
||||
</label>
|
||||
|
||||
<input name="input" type="file" class="form-control" ng-model="files[field.name]" ng-required="field.property.required && !target.options[field.name]"
|
||||
base-sixty-four-input
|
||||
ng-if="field.property.type === 'file'">
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" ng-disabled="!dataSourceForm.$valid" ng-click="saveChanges()">Save</button>
|
||||
<span ng-repeat="action in actions">
|
||||
<button class="btn"
|
||||
ng-class="action.class"
|
||||
ng-if="target.id"
|
||||
ng-disabled="(action.disableWhenDirty && dataSourceForm.$dirty) || inProgressActions[action.name]"
|
||||
ng-click="action.callback()" ng-bind-html="action.name"></button>
|
||||
</span>
|
||||
|
||||
<span ng-transclude>
|
||||
|
||||
</span>
|
||||
</form>
|
||||
@@ -1,149 +0,0 @@
|
||||
import { isUndefined, each, contains, find } from 'underscore';
|
||||
import endsWith from 'underscore.string/endsWith';
|
||||
import template from './dynamic-form.html';
|
||||
|
||||
function DynamicForm($http, toastr, $q) {
|
||||
function orderedInputs(properties, order) {
|
||||
const inputs = new Array(order.length);
|
||||
Object.keys(properties).forEach((key) => {
|
||||
const position = order.indexOf(key);
|
||||
const input = { name: key, property: properties[key] };
|
||||
if (position > -1) {
|
||||
inputs[position] = input;
|
||||
} else {
|
||||
inputs.push(input);
|
||||
}
|
||||
});
|
||||
return inputs;
|
||||
}
|
||||
|
||||
return {
|
||||
restrict: 'E',
|
||||
replace: 'true',
|
||||
transclude: true,
|
||||
template,
|
||||
scope: {
|
||||
target: '=',
|
||||
type: '@type',
|
||||
actions: '=',
|
||||
},
|
||||
link($scope) {
|
||||
function setType(types) {
|
||||
if ($scope.target.type === undefined) {
|
||||
$scope.target.type = types[0].type;
|
||||
}
|
||||
|
||||
const type = find(types, t => t.type === $scope.target.type);
|
||||
const configurationSchema = type.configuration_schema;
|
||||
|
||||
$scope.fields = orderedInputs(
|
||||
configurationSchema.properties,
|
||||
configurationSchema.order || [],
|
||||
);
|
||||
|
||||
return type;
|
||||
}
|
||||
|
||||
$scope.inProgressActions = {};
|
||||
if ($scope.actions) {
|
||||
$scope.actions.forEach((action) => {
|
||||
const originalCallback = action.callback;
|
||||
const name = action.name;
|
||||
action.callback = () => {
|
||||
action.name = `<i class="zmdi zmdi-spinner zmdi-hc-spin"></i> ${name}`;
|
||||
|
||||
$scope.inProgressActions[action.name] = true;
|
||||
function release() {
|
||||
$scope.inProgressActions[action.name] = false;
|
||||
action.name = name;
|
||||
}
|
||||
|
||||
originalCallback(release);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
$scope.files = {};
|
||||
|
||||
$scope.$watchCollection('files', () => {
|
||||
each($scope.files, (v, k) => {
|
||||
// THis is needed because angular-base64-upload sets the value to null at initialization,
|
||||
// causing the field to be marked as dirty even if it wasn't changed.
|
||||
if (!v && $scope.target.options[k]) {
|
||||
$scope.dataSourceForm.$setPristine();
|
||||
}
|
||||
if (v) {
|
||||
$scope.target.options[k] = v.base64;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const typesPromise = $http.get(`api/${$scope.type}/types`);
|
||||
|
||||
$q.all([typesPromise, $scope.target.$promise]).then((responses) => {
|
||||
const types = responses[0].data;
|
||||
setType(types);
|
||||
|
||||
$scope.types = types;
|
||||
|
||||
types.forEach((type) => {
|
||||
each(type.configuration_schema.properties, (prop, name) => {
|
||||
if (name === 'password' || name === 'passwd') {
|
||||
prop.type = 'password';
|
||||
}
|
||||
|
||||
if (endsWith(name, 'File')) {
|
||||
prop.type = 'file';
|
||||
}
|
||||
|
||||
if (prop.type === 'boolean') {
|
||||
prop.type = 'checkbox';
|
||||
}
|
||||
|
||||
prop.required = contains(type.configuration_schema.required, name);
|
||||
});
|
||||
});
|
||||
|
||||
$scope.$watch('target.type', (current, prev) => {
|
||||
if (prev !== current) {
|
||||
if (prev !== undefined) {
|
||||
$scope.target.options = {};
|
||||
}
|
||||
|
||||
const type = setType($scope.types);
|
||||
|
||||
if (Object.keys($scope.target.options).length === 0) {
|
||||
const properties = type.configuration_schema.properties;
|
||||
Object.keys(properties).forEach((property) => {
|
||||
if (!isUndefined(properties[property].default)) {
|
||||
$scope.target.options[property] = properties[property].default;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
$scope.saveChanges = () => {
|
||||
$scope.target.$save(
|
||||
() => {
|
||||
toastr.success('Saved.');
|
||||
$scope.dataSourceForm.$setPristine();
|
||||
},
|
||||
(error) => {
|
||||
if (error.status === 400 && 'message' in error.data) {
|
||||
toastr.error(error.data.message);
|
||||
} else {
|
||||
toastr.error('Failed saving.');
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.directive('dynamicForm', DynamicForm);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
dynamic-table > div {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
th.sortable-column {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<div>
|
||||
<table class="table table-condensed table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th ng-repeat="column in $ctrl.columns" ng-click="$ctrl.orderBy(column)" class="sortable-column">
|
||||
{{column.title}} <span ng-if="$ctrl.sortIcon(column)"><i class="fa fa-sort-{{$ctrl.sortIcon(column)}}"></i></span>
|
||||
</th>
|
||||
</tr>
|
||||
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr ng-repeat="row in $ctrl.rowsToDisplay">
|
||||
<td ng-repeat="column in $ctrl.columns" ng-bind-html="$ctrl.sanitize(column.formatFunction(row[column.name]))">
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<ul uib-pagination total-items="$ctrl.rowsCount"
|
||||
items-per-page="$ctrl.itemsPerPage"
|
||||
ng-model="$ctrl.page"
|
||||
max-size="6"
|
||||
class="pagination"
|
||||
boundary-link-numbers="true"
|
||||
rotate="false"
|
||||
next-text='>'
|
||||
previous-text='<'
|
||||
ng-change="$ctrl.pageChanged()"></ul>
|
||||
</div>
|
||||
@@ -1,77 +0,0 @@
|
||||
import { sortBy } from 'underscore';
|
||||
import template from './dynamic-table.html';
|
||||
import './dynamic-table.css';
|
||||
|
||||
function DynamicTable($sanitize) {
|
||||
'ngInject';
|
||||
|
||||
this.itemsPerPage = this.count = 15;
|
||||
this.page = 1;
|
||||
this.rowsCount = 0;
|
||||
this.orderByField = undefined;
|
||||
this.orderByReverse = false;
|
||||
|
||||
this.pageChanged = () => {
|
||||
const first = this.count * (this.page - 1);
|
||||
const last = this.count * (this.page);
|
||||
|
||||
this.rowsToDisplay = this.rows.slice(first, last);
|
||||
};
|
||||
|
||||
this.$onChanges = (changes) => {
|
||||
if (changes.columns) {
|
||||
this.columns = changes.columns.currentValue;
|
||||
}
|
||||
|
||||
if (changes.rows) {
|
||||
this.rows = changes.rows.currentValue;
|
||||
}
|
||||
|
||||
this.rowsCount = this.rows.length;
|
||||
|
||||
this.pageChanged();
|
||||
};
|
||||
|
||||
this.orderBy = (column) => {
|
||||
if (column === this.orderByField) {
|
||||
this.orderByReverse = !this.orderByReverse;
|
||||
} else {
|
||||
this.orderByField = column;
|
||||
this.orderByReverse = false;
|
||||
}
|
||||
|
||||
if (this.orderByField) {
|
||||
this.rows = sortBy(this.rows, this.orderByField.name);
|
||||
if (this.orderByReverse) {
|
||||
this.rows = this.rows.reverse();
|
||||
}
|
||||
this.pageChanged();
|
||||
}
|
||||
};
|
||||
|
||||
this.sanitize = value => $sanitize(value);
|
||||
|
||||
this.sortIcon = (column) => {
|
||||
if (column !== this.orderByField) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.orderByReverse) {
|
||||
return 'desc';
|
||||
}
|
||||
|
||||
return 'asc';
|
||||
};
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('dynamicTable', {
|
||||
template,
|
||||
controller: DynamicTable,
|
||||
bindings: {
|
||||
rows: '<',
|
||||
columns: '<',
|
||||
count: '<',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import $ from 'jquery';
|
||||
import { isEmpty } from 'underscore';
|
||||
|
||||
// From: http://jsfiddle.net/joshdmiller/NDFHg/
|
||||
function EditInPlace() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
value: '=',
|
||||
ignoreBlanks: '=',
|
||||
editable: '=',
|
||||
done: '=',
|
||||
},
|
||||
template(tElement, tAttrs) {
|
||||
const elType = tAttrs.editor || 'input';
|
||||
const placeholder = tAttrs.placeholder || 'Click to edit';
|
||||
|
||||
let viewMode = '';
|
||||
|
||||
if (tAttrs.markdown === 'true') {
|
||||
viewMode = '<span ng-click="editable && edit()" ng-bind-html="value|markdown" ng-class="{editable: editable}"></span>';
|
||||
} else {
|
||||
viewMode = '<span ng-click="editable && edit()" ng-bind="value" ng-class="{editable: editable}"></span>';
|
||||
}
|
||||
|
||||
const placeholderSpan = `<span ng-click="editable && edit()"
|
||||
ng-show="editable && !value"
|
||||
ng-class="{editable: editable}">${placeholder}</span>`;
|
||||
const editor = '<{elType} ng-model="value" class="rd-form-control"></{elType}>'.replace('{elType}', elType);
|
||||
|
||||
return viewMode + placeholderSpan + editor;
|
||||
},
|
||||
link($scope, element) {
|
||||
// Let's get a reference to the input element, as we'll want to reference it.
|
||||
const inputElement = $(element.children()[2]);
|
||||
const keycodeEnter = 13;
|
||||
const keycodeEscape = 27;
|
||||
|
||||
// This directive should have a set class so we can style it.
|
||||
element.addClass('edit-in-place');
|
||||
|
||||
// Initially, we're not editing.
|
||||
$scope.editing = false;
|
||||
|
||||
// ng-click handler to activate edit-in-place
|
||||
$scope.edit = () => {
|
||||
$scope.oldValue = $scope.value;
|
||||
|
||||
$scope.editing = true;
|
||||
|
||||
// We control display through a class on the directive itself. See the CSS.
|
||||
element.addClass('active');
|
||||
|
||||
// And we must focus the element.
|
||||
// `angular.element()` provides a chainable array, like jQuery so to access
|
||||
// a native DOM function, we have to reference the first element in the array.
|
||||
inputElement[0].focus();
|
||||
};
|
||||
|
||||
function save() {
|
||||
if ($scope.editing) {
|
||||
if ($scope.ignoreBlanks && isEmpty($scope.value)) {
|
||||
$scope.value = $scope.oldValue;
|
||||
}
|
||||
$scope.editing = false;
|
||||
element.removeClass('active');
|
||||
|
||||
if ($scope.value !== $scope.oldValue) {
|
||||
if ($scope.done) {
|
||||
$scope.done();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$(inputElement).keydown((e) => {
|
||||
// 'return' or 'enter' key pressed
|
||||
// allow 'shift' to break lines
|
||||
if (e.which === keycodeEnter && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
save();
|
||||
} else if (e.which === keycodeEscape) {
|
||||
$scope.value = $scope.oldValue;
|
||||
$scope.$apply(() => {
|
||||
$(inputElement[0]).blur();
|
||||
});
|
||||
}
|
||||
}).blur(() => {
|
||||
save();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.directive('editInPlace', EditInPlace);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
function controller(clientConfig, currentUser) {
|
||||
this.showMailWarning = clientConfig.mailSettingsMissing && currentUser.isAdmin;
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('emailSettingsWarning', {
|
||||
bindings: {
|
||||
function: '<',
|
||||
},
|
||||
template: '<p class="alert alert-danger" ng-if="$ctrl.showMailWarning">It looks like your mail server isn\'t configured. Make sure to configure it for the {{$ctrl.function}} to work.</p>',
|
||||
controller,
|
||||
});
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
const ErrorMessagesComponent = {
|
||||
template: `
|
||||
<div class="help-block" ng-messages="$ctrl.input.$error" ng-show="$ctrl.input.$touched || $ctrl.form.$submitted">
|
||||
<span class="error" ng-message="required">This field is required.</span>
|
||||
<span class="error" ng-message="minlength">This field is too short.</span>
|
||||
<span class="error" ng-message="email">This needs to be a valid email.</span>
|
||||
</div>
|
||||
`,
|
||||
replace: true,
|
||||
bindings: {
|
||||
input: '<',
|
||||
form: '<',
|
||||
},
|
||||
controller() {
|
||||
},
|
||||
};
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('errorMessages', ErrorMessagesComponent);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
<div class="container bg-white p-5" ng-show="$ctrl.filters | notEmpty">
|
||||
<div class="row">
|
||||
<div class="col-sm-6 m-t-5" ng-repeat="filter in $ctrl.filters">
|
||||
<label>{{filter.friendlyName}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm-6 m-t-5" ng-repeat="filter in $ctrl.filters">
|
||||
<ui-select ng-model="filter.current" ng-if="!filter.multiple" on-select="$ctrl.filterChangeListener(filter, $model)" on-remove="$ctrl.filterChangeListener(filter, $model)"
|
||||
remove-selected="false">
|
||||
<ui-select-match placeholder="Select value for {{filter.friendlyName}}...">{{$select.selected | filterValue:filter}}</ui-select-match>
|
||||
<ui-select-choices repeat="value in filter.values | filter: $select.search">
|
||||
{{value | filterValue:filter }}
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
|
||||
<ui-select ng-model="filter.current" multiple ng-if="filter.multiple" on-select="$ctrl.filterChangeListener(filter, $model)"
|
||||
on-remove="$ctrl.filterChangeListener(filter, $model)" remove-selected="false">
|
||||
<ui-select-match placeholder="Select value for {{filter.friendlyName}}...">{{$item | filterValue:filter}}</ui-select-match>
|
||||
<ui-select-choices repeat="value in filter.values | filter: $select.search" group-by="$ctrl.itemGroup">
|
||||
<span ng-if="value == '*'">
|
||||
Select All
|
||||
</span>
|
||||
<span ng-if="value == '-'">
|
||||
Clear
|
||||
</span>
|
||||
<span ng-if="value != '*' && value != '-'">
|
||||
{{value | filterValue:filter }}
|
||||
</span>
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,29 +0,0 @@
|
||||
import template from './filters.html';
|
||||
|
||||
const FiltersComponent = {
|
||||
template,
|
||||
bindings: {
|
||||
onChange: '&',
|
||||
filters: '<',
|
||||
},
|
||||
controller() {
|
||||
'ngInject';
|
||||
|
||||
this.filterChangeListener = (filter, modal) => {
|
||||
this.onChange({ filter, $modal: modal });
|
||||
};
|
||||
|
||||
this.itemGroup = (item) => {
|
||||
if (item === '*' || item === '-') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return 'Values';
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('filters', FiltersComponent);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
<div id="footer">
|
||||
<a href="http://redash.io">Redash</a> <span ng-bind="$ctrl.version"></span> <small ng-if="$ctrl.newVersionAvailable" ng-cloak class="ng-cloak"><a href="https://version.redash.io/">(New Redash version available)</a></small>
|
||||
|
||||
<ul class="f-menu">
|
||||
<li><a href="https://redash.io/help/">Documentation</a></li>
|
||||
<li><a href="http://github.com/getredash/redash">Contribute</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -1,13 +0,0 @@
|
||||
import template from './footer.html';
|
||||
|
||||
function controller(clientConfig, currentUser) {
|
||||
this.version = clientConfig.version;
|
||||
this.newVersionAvailable = clientConfig.newVersionAvailable && currentUser.isAdmin;
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('footer', {
|
||||
template,
|
||||
controller,
|
||||
});
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">{{$ctrl.title}}</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form class="form">
|
||||
<input type="text" ng-model="$ctrl.group.name" placeholder="Group Name" class="form-control" autofocus/>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-default" ng-click="$ctrl.close()">Cancel</button>
|
||||
<button class="btn btn-primary" ng-click="$ctrl.ok()">{{$ctrl.saveButtonText}}</button>
|
||||
</div>
|
||||
@@ -1,39 +0,0 @@
|
||||
import template from './edit-group-dialog.html';
|
||||
|
||||
const EditGroupDialogComponent = {
|
||||
template,
|
||||
bindings: {
|
||||
resolve: '<',
|
||||
close: '&',
|
||||
dismiss: '&',
|
||||
},
|
||||
controller($location) {
|
||||
'ngInject';
|
||||
|
||||
this.group = this.resolve.group;
|
||||
const newGroup = this.group.id === undefined;
|
||||
|
||||
if (newGroup) {
|
||||
this.saveButtonText = 'Create';
|
||||
this.title = 'Create a New Group';
|
||||
} else {
|
||||
this.saveButtonText = 'Save';
|
||||
this.title = 'Edit Group';
|
||||
}
|
||||
|
||||
this.ok = () => {
|
||||
this.group.$save((group) => {
|
||||
if (newGroup) {
|
||||
$location.path(`/groups/${group.id}`).replace();
|
||||
this.close();
|
||||
} else {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('editGroupDialog', EditGroupDialogComponent);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
function controller($window, $location, toastr, currentUser) {
|
||||
this.canEdit = () => currentUser.isAdmin && this.group.type !== 'builtin';
|
||||
|
||||
this.saveName = () => {
|
||||
this.group.$save();
|
||||
};
|
||||
|
||||
this.deleteGroup = () => {
|
||||
if ($window.confirm('Are you sure you want to delete this group?')) {
|
||||
this.group.$delete(() => {
|
||||
$location.path('/groups').replace();
|
||||
toastr.success('Group deleted successfully.');
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('groupName', {
|
||||
bindings: {
|
||||
group: '<',
|
||||
},
|
||||
transclude: true,
|
||||
template: `
|
||||
<h2 class="p-l-5">
|
||||
<edit-in-place editable="$ctrl.canEdit()" done="$ctrl.saveName" ignore-blanks='true' value="$ctrl.group.name"></edit-in-place>
|
||||
<button class="btn btn-xs btn-danger" ng-if="$ctrl.canEdit()" ng-click="$ctrl.deleteGroup()">Delete this group</button>
|
||||
</h2>
|
||||
`,
|
||||
replace: true,
|
||||
controller,
|
||||
});
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
const Overlay = {
|
||||
template: `
|
||||
<div>
|
||||
<div class="overlay"></div>
|
||||
<div style="width: 100%; position:absolute; top:50px; z-index:2000">
|
||||
<div class="well well-lg" style="width: 70%; margin: auto;" ng-transclude>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
transclude: true,
|
||||
};
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('overlay', Overlay);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import template from './page-header.html';
|
||||
|
||||
function controller() {
|
||||
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('pageHeader', {
|
||||
template,
|
||||
controller,
|
||||
transclude: true,
|
||||
bindings: {
|
||||
title: '@',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
class PaginatorCtrl {
|
||||
constructor() {
|
||||
this.page = this.paginator.page;
|
||||
}
|
||||
pageChanged() {
|
||||
this.paginator.setPage(this.page);
|
||||
}
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('paginator', {
|
||||
template: `
|
||||
<div class="text-center">
|
||||
<ul uib-pagination total-items="$ctrl.paginator.totalCount"
|
||||
items-per-page="$ctrl.paginator.itemsPerPage"
|
||||
ng-model="$ctrl.page"
|
||||
max-size="6"
|
||||
class="pagination"
|
||||
boundary-link-numbers="true"
|
||||
rotate="false"
|
||||
next-text='>'
|
||||
previous-text='<'
|
||||
ng-change="$ctrl.pageChanged()"></ul>
|
||||
</div>
|
||||
`,
|
||||
bindings: {
|
||||
paginator: '<',
|
||||
},
|
||||
controller: PaginatorCtrl,
|
||||
});
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" aria-label="Close" ng-click="$ctrl.close()"><span aria-hidden="true">×</span></button>
|
||||
<h4 class="modal-title">{{$ctrl.parameter.name}}</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form">
|
||||
<div class="form-group">
|
||||
<label>Title</label>
|
||||
<input type="text" class="form-control" ng-model="$ctrl.parameter.title">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Type</label>
|
||||
<select ng-model="$ctrl.parameter.type" class="form-control">
|
||||
<option value="text">Text</option>
|
||||
<option value="number">Number</option>
|
||||
<option value="enum">Dropdown List</option>
|
||||
<option value="query">Query Based Dropdown List</option>
|
||||
<option value="date">Date</option>
|
||||
<option value="datetime-local">Date and Time</option>
|
||||
<option value="datetime-with-seconds">Date and Time (with seconds)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" class="form-inline" ng-model="$ctrl.parameter.global">
|
||||
Global
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group" ng-if="$ctrl.parameter.type === 'enum'">
|
||||
<label>Dropdown List Values (newline delimited)</label>
|
||||
<textarea class="form-control" rows="3" ng-model="$ctrl.parameter.enumOptions"></textarea>
|
||||
</div>
|
||||
<div class="form-group" ng-if="$ctrl.parameter.type === 'query'">
|
||||
<label>Query to load dropdown values from:</label>
|
||||
<ui-select ng-model="$ctrl.parameter.queryId" reset-search-input="false">
|
||||
<ui-select-match placeholder="Search a query by name">{{$select.selected.name}}</ui-select-match>
|
||||
<ui-select-choices repeat="q.id as q in $ctrl.queries"
|
||||
refresh="$ctrl.searchQueries($select.search)"
|
||||
refresh-delay="0">
|
||||
<div class="form-group" ng-bind-html="$ctrl.trustAsHtml(q.name | highlight: $select.search)"></div>
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,28 +0,0 @@
|
||||
<div class="form-inline bg-white p-5"
|
||||
ng-if="parameters | notEmpty"
|
||||
ui-sortable="{ 'ui-floating': true, 'disabled': !editable }"
|
||||
ng-model="parameters">
|
||||
<div class="form-group m-l-10 m-r-10"
|
||||
ng-repeat="param in parameters">
|
||||
<label class="parameter-label">{{param.title}}</label>
|
||||
<button class="btn btn-default btn-xs"
|
||||
ng-click="showParameterSettings(param)"
|
||||
ng-if="editable">
|
||||
<i class="zmdi zmdi-settings"></i>
|
||||
</button>
|
||||
<span ng-switch="param.type">
|
||||
<input ng-switch-when="datetime-with-seconds" type="datetime-local" step="1" class="form-control" ng-model="param.ngModel">
|
||||
<input ng-switch-when="datetime-local" type="datetime-local" class="form-control" ng-model="param.ngModel">
|
||||
<input ng-switch-when="date" type="date" class="form-control" ng-model="param.ngModel">
|
||||
<span ng-switch-when="enum">
|
||||
<select ng-model="param.value" class="form-control">
|
||||
<option ng-repeat="option in extractEnumOptions(param.enumOptions)" value="{{option}}">{{option}}</option>
|
||||
</select>
|
||||
</span>
|
||||
<span ng-switch-when="query">
|
||||
<query-based-parameter param="param" query-id="param.queryId"></query-based-parameter>
|
||||
</span>
|
||||
<input ng-switch-default type="{{param.type}}" class="form-control" ng-model="param.ngModel">
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,156 +0,0 @@
|
||||
import { find } from 'underscore';
|
||||
import template from './parameters.html';
|
||||
import queryBasedParameterTemplate from './query-based-parameter.html';
|
||||
import parameterSettingsTemplate from './parameter-settings.html';
|
||||
|
||||
const ParameterSettingsComponent = {
|
||||
template: parameterSettingsTemplate,
|
||||
bindings: {
|
||||
resolve: '<',
|
||||
close: '&',
|
||||
dismiss: '&',
|
||||
},
|
||||
controller($sce, Query) {
|
||||
'ngInject';
|
||||
|
||||
this.trustAsHtml = html => $sce.trustAsHtml(html);
|
||||
this.parameter = this.resolve.parameter;
|
||||
|
||||
if (this.parameter.queryId) {
|
||||
Query.get({ id: this.parameter.queryId }, (query) => {
|
||||
this.queries = [query];
|
||||
});
|
||||
}
|
||||
|
||||
this.searchQueries = (term) => {
|
||||
if (!term || term.length < 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
Query.search({ q: term }, (results) => {
|
||||
this.queries = results;
|
||||
});
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
function optionsFromQueryResult(queryResult) {
|
||||
const columns = queryResult.data.columns;
|
||||
const numColumns = columns.length;
|
||||
let options = [];
|
||||
// If there are multiple columns, check if there is a column
|
||||
// named 'name' and column named 'value'. If name column is present
|
||||
// in results, use name from name column. Similar for value column.
|
||||
// Default: Use first string column for name and value.
|
||||
if (numColumns > 0) {
|
||||
let nameColumn = null;
|
||||
let valueColumn = null;
|
||||
columns.forEach((column) => {
|
||||
const columnName = column.name.toLowerCase();
|
||||
if (columnName === 'name') {
|
||||
nameColumn = column.name;
|
||||
}
|
||||
if (columnName === 'value') {
|
||||
valueColumn = column.name;
|
||||
}
|
||||
// Assign first string column as name and value column.
|
||||
if (nameColumn === null) {
|
||||
nameColumn = column.name;
|
||||
}
|
||||
if (valueColumn === null) {
|
||||
valueColumn = column.name;
|
||||
}
|
||||
});
|
||||
if (nameColumn !== null && valueColumn !== null) {
|
||||
options = queryResult.data.rows.map((row) => {
|
||||
const queryResultOption = {
|
||||
name: row[nameColumn],
|
||||
value: row[valueColumn],
|
||||
};
|
||||
return queryResultOption;
|
||||
});
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function updateCurrentValue(param, options) {
|
||||
const found = find(options, option => option.value === param.value) !== undefined;
|
||||
|
||||
if (!found) {
|
||||
param.value = options[0].value;
|
||||
}
|
||||
}
|
||||
|
||||
const QueryBasedParameterComponent = {
|
||||
template: queryBasedParameterTemplate,
|
||||
bindings: {
|
||||
param: '<',
|
||||
queryId: '<',
|
||||
},
|
||||
controller(Query) {
|
||||
'ngInject';
|
||||
|
||||
this.$onChanges = (changes) => {
|
||||
if (changes.queryId) {
|
||||
Query.resultById({ id: this.queryId }, (result) => {
|
||||
const queryResult = result.query_result;
|
||||
this.queryResultOptions = optionsFromQueryResult(queryResult);
|
||||
updateCurrentValue(this.param, this.queryResultOptions);
|
||||
});
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
function ParametersDirective($location, $uibModal) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
transclude: true,
|
||||
scope: {
|
||||
parameters: '=',
|
||||
syncValues: '=?',
|
||||
editable: '=?',
|
||||
changed: '&onChange',
|
||||
},
|
||||
template,
|
||||
link(scope) {
|
||||
// is this the correct location for this logic?
|
||||
if (scope.syncValues !== false) {
|
||||
scope.$watch('parameters', () => {
|
||||
if (scope.changed) {
|
||||
scope.changed({});
|
||||
}
|
||||
scope.parameters.forEach((param) => {
|
||||
if (param.value !== null || param.value !== '') {
|
||||
$location.search(`p_${param.name}`, param.value);
|
||||
}
|
||||
});
|
||||
}, true);
|
||||
}
|
||||
|
||||
// These are input as newline delimited values,
|
||||
// so we split them here.
|
||||
scope.extractEnumOptions = (enumOptions) => {
|
||||
if (enumOptions) {
|
||||
return enumOptions.split('\n');
|
||||
}
|
||||
return [];
|
||||
};
|
||||
scope.showParameterSettings = (param) => {
|
||||
$uibModal.open({
|
||||
component: 'parameterSettings',
|
||||
resolve: {
|
||||
parameter: param,
|
||||
},
|
||||
});
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.directive('parameters', ParametersDirective);
|
||||
ngModule.component('queryBasedParameter', QueryBasedParameterComponent);
|
||||
ngModule.component('parameterSettings', ParameterSettingsComponent);
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { contains, each } from 'underscore';
|
||||
import template from './permissions-editor.html';
|
||||
|
||||
const PermissionsEditorComponent = {
|
||||
template,
|
||||
bindings: {
|
||||
resolve: '<',
|
||||
close: '&',
|
||||
dismiss: '&',
|
||||
},
|
||||
controller($http, User) {
|
||||
'ngInject';
|
||||
|
||||
this.grantees = [];
|
||||
this.newGrantees = {};
|
||||
this.aclUrl = this.resolve.aclUrl.url;
|
||||
|
||||
// List users that are granted permissions
|
||||
const loadGrantees = () => {
|
||||
$http.get(this.aclUrl).success((result) => {
|
||||
this.grantees = [];
|
||||
|
||||
each(result, (grantees, accessType) => {
|
||||
grantees.forEach((grantee) => {
|
||||
grantee.access_type = accessType;
|
||||
this.grantees.push(grantee);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
loadGrantees();
|
||||
|
||||
// Search for user
|
||||
this.findUser = (search) => {
|
||||
if (search === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.foundUsers === undefined) {
|
||||
User.query((users) => {
|
||||
const existingIds = this.grantees.map(m => m.id);
|
||||
users.forEach((user) => { user.alreadyGrantee = contains(existingIds, user.id); });
|
||||
this.foundUsers = users;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Add new user to grantees list
|
||||
this.addGrantee = (user) => {
|
||||
this.newGrantees.selected = undefined;
|
||||
const body = { access_type: 'modify', user_id: user.id };
|
||||
$http.post(this.aclUrl, body).success(() => {
|
||||
user.alreadyGrantee = true;
|
||||
loadGrantees();
|
||||
});
|
||||
};
|
||||
|
||||
// Remove user from grantees list
|
||||
this.removeGrantee = (user) => {
|
||||
const body = { access_type: 'modify', user_id: user.id };
|
||||
$http({
|
||||
url: this.aclUrl,
|
||||
method: 'DELETE',
|
||||
data: body,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}).success(() => {
|
||||
this.grantees = this.grantees.filter(m => m !== user);
|
||||
|
||||
if (this.foundUsers) {
|
||||
this.foundUsers.forEach((u) => { if (u.id === user.id) { u.alreadyGrantee = false; } });
|
||||
}
|
||||
});
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('permissionsEditor', PermissionsEditorComponent);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" aria-label="Close" ng-click="$ctrl.close()"><span aria-hidden="true">×</span></button>
|
||||
<h4 class="modal-title">Manage Permissions</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div style="overflow: auto; height: 300px">
|
||||
<ui-select ng-model="$ctrl.newGrantee.selected" on-select="$ctrl.addGrantee($item)">
|
||||
<ui-select-match placeholder="Add New User"></ui-select-match>
|
||||
<ui-select-choices repeat="user in $ctrl.foundUsers | filter:$select.search"
|
||||
refresh="$ctrl.findUser($select.search)"
|
||||
refresh-delay="0"
|
||||
ui-disable-choice="user.alreadyGrantee">
|
||||
<div>
|
||||
<img ng-src="{{user.gravatar_url}}" height="24px"> {{user.name}}
|
||||
<small ng-if="user.alreadyGrantee">(already has permission)</small>
|
||||
</div>
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
<br/>
|
||||
<table class="table table-condensed table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>User</th>
|
||||
<th>Permission</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr ng-repeat="grantee in $ctrl.grantees">
|
||||
<td width="50px"><img ng-src="{{grantee.gravatar_url}}" height="40px"/></td>
|
||||
<td>{{grantee.name}} </td>
|
||||
<td>{{grantee.access_type}}</td>
|
||||
<td><button class="pull-right btn btn-sm btn-danger" ng-click="$ctrl.removeGrantee(grantee)">Remove</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,37 +0,0 @@
|
||||
function alertUnsavedChanges($window) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
replace: true,
|
||||
scope: {
|
||||
isDirty: '=',
|
||||
},
|
||||
link($scope) {
|
||||
const unloadMessage = 'You will lose your changes if you leave';
|
||||
const confirmMessage = `${unloadMessage}\n\nAre you sure you want to leave this page?`;
|
||||
// store original handler (if any)
|
||||
const _onbeforeunload = $window.onbeforeunload;
|
||||
|
||||
$window.onbeforeunload = function onbeforeunload() {
|
||||
return $scope.isDirty ? unloadMessage : null;
|
||||
};
|
||||
|
||||
$scope.$on('$locationChangeStart', (event, next, current) => {
|
||||
if (next.split('?')[0] === current.split('?')[0] || next.split('#')[0] === current.split('#')[0]) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($scope.isDirty && !$window.confirm(confirmMessage)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
$scope.$on('$destroy', () => {
|
||||
$window.onbeforeunload = _onbeforeunload;
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.directive('alertUnsavedChanges', alertUnsavedChanges);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
const ApiKeyDialog = {
|
||||
template: `<div class="modal-header">
|
||||
<button type="button" class="close" aria-label="Close" ng-click="$ctrl.close()"><span aria-hidden="true">×</span></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<h5>API Key</h5>
|
||||
<pre>{{$ctrl.apiKey}}</pre>
|
||||
|
||||
<h5>Example API Calls:</h5>
|
||||
|
||||
<div>
|
||||
Results in CSV format:
|
||||
|
||||
<pre>{{$ctrl.csvUrl}}</pre>
|
||||
|
||||
Results in JSON format:
|
||||
|
||||
<pre>{{$ctrl.jsonUrl}}</pre>
|
||||
</div>
|
||||
</div>`,
|
||||
controller(clientConfig) {
|
||||
'ngInject';
|
||||
|
||||
this.apiKey = this.resolve.query.api_key;
|
||||
this.csvUrl = `${clientConfig.basePath}api/queries/${this.resolve.query.id}/results.csv?api_key=${this.apiKey}`;
|
||||
this.jsonUrl = `${clientConfig.basePath}api/queries/${this.resolve.query.id}/results.json?api_key=${this.apiKey}`;
|
||||
},
|
||||
bindings: {
|
||||
resolve: '<',
|
||||
close: '&',
|
||||
dismiss: '&',
|
||||
},
|
||||
};
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('apiKeyDialog', ApiKeyDialog);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" aria-label="Close" ng-click="$ctrl.close()"><span aria-hidden="true">×</span></button>
|
||||
<h4 class="modal-title">Embed Code</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<h5>IFrame Embed</h5>
|
||||
<div>
|
||||
<code><iframe src="{{ $ctrl.embedUrl }}" width="720" height="391"></iframe></code>
|
||||
</div>
|
||||
<span class="text-muted">(height should be adjusted)</span>
|
||||
<div ng-if="$ctrl.snapshotUrl">
|
||||
<h5>Image Embed</h5>
|
||||
<div>
|
||||
<code style="overflow-wrap:break-word;">{{$ctrl.snapshotUrl}}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,25 +0,0 @@
|
||||
import template from './embed-code-dialog.html';
|
||||
|
||||
const EmbedCodeDialog = {
|
||||
controller(clientConfig) {
|
||||
'ngInject';
|
||||
|
||||
this.query = this.resolve.query;
|
||||
this.visualization = this.resolve.visualization;
|
||||
|
||||
this.embedUrl = `${clientConfig.basePath}embed/query/${this.query.id}/visualization/${this.visualization.id}?api_key=${this.query.api_key}`;
|
||||
if (window.snapshotUrlBuilder) {
|
||||
this.snapshotUrl = window.snapshotUrlBuilder(this.query, this.visualization);
|
||||
}
|
||||
},
|
||||
bindings: {
|
||||
resolve: '<',
|
||||
close: '&',
|
||||
dismiss: '&',
|
||||
},
|
||||
template,
|
||||
};
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('embedCodeDialog', EmbedCodeDialog);
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import 'brace';
|
||||
import 'brace/mode/python';
|
||||
import 'brace/mode/sql';
|
||||
import 'brace/mode/json';
|
||||
import 'brace/ext/language_tools';
|
||||
import { map } from 'underscore';
|
||||
|
||||
// By default Ace will try to load snippet files for the different modes and fail.
|
||||
// We don't need them, so we use these placeholders until we define our own.
|
||||
function defineDummySnippets(mode) {
|
||||
window.ace.define(`ace/snippets/${mode}`, ['require', 'exports', 'module'], (require, exports) => {
|
||||
exports.snippetText = '';
|
||||
exports.scope = mode;
|
||||
});
|
||||
}
|
||||
|
||||
defineDummySnippets('python');
|
||||
defineDummySnippets('sql');
|
||||
defineDummySnippets('json');
|
||||
|
||||
function queryEditor(QuerySnippet) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
query: '=',
|
||||
schema: '=',
|
||||
syntax: '=',
|
||||
},
|
||||
template: '<div ui-ace="editorOptions" ng-model="query.query"></div>',
|
||||
link: {
|
||||
pre($scope) {
|
||||
$scope.syntax = $scope.syntax || 'sql';
|
||||
|
||||
$scope.editorOptions = {
|
||||
mode: 'json',
|
||||
// require: ['ace/ext/language_tools'],
|
||||
advanced: {
|
||||
behavioursEnabled: true,
|
||||
enableSnippets: true,
|
||||
enableBasicAutocompletion: true,
|
||||
enableLiveAutocompletion: true,
|
||||
autoScrollEditorIntoView: true,
|
||||
},
|
||||
onLoad(editor) {
|
||||
// Release Cmd/Ctrl+L to the browser
|
||||
editor.commands.bindKey('Cmd+L', null);
|
||||
editor.commands.bindKey('Ctrl+L', null);
|
||||
|
||||
QuerySnippet.query((snippets) => {
|
||||
window.ace.acequire(['ace/snippets'], (snippetsModule) => {
|
||||
const snippetManager = snippetsModule.snippetManager;
|
||||
const m = {
|
||||
snippetText: '',
|
||||
};
|
||||
m.snippets = snippetManager.parseSnippetFile(m.snippetText);
|
||||
snippets.forEach((snippet) => {
|
||||
m.snippets.push(snippet.getSnippet());
|
||||
});
|
||||
|
||||
snippetManager.register(m.snippets || [], m.scope);
|
||||
});
|
||||
});
|
||||
|
||||
editor.$blockScrolling = Infinity;
|
||||
editor.getSession().setUseWrapMode(true);
|
||||
editor.setShowPrintMargin(false);
|
||||
|
||||
$scope.$watch('syntax', (syntax) => {
|
||||
const newMode = `ace/mode/${syntax}`;
|
||||
editor.getSession().setMode(newMode);
|
||||
});
|
||||
|
||||
$scope.$watch('schema', (newSchema, oldSchema) => {
|
||||
if (newSchema !== oldSchema) {
|
||||
const tokensCount =
|
||||
newSchema.reduce((totalLength, table) => totalLength + table.columns.length, 0);
|
||||
// If there are too many tokens we disable live autocomplete,
|
||||
// as it makes typing slower.
|
||||
if (tokensCount > 5000) {
|
||||
editor.setOption('enableLiveAutocompletion', false);
|
||||
} else {
|
||||
editor.setOption('enableLiveAutocompletion', true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$scope.$parent.$on('angular-resizable.resizing', () => {
|
||||
editor.resize();
|
||||
});
|
||||
|
||||
editor.focus();
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
const schemaCompleter = {
|
||||
getCompletions(state, session, pos, prefix, callback) {
|
||||
if (prefix.length === 0 || !$scope.schema) {
|
||||
callback(null, []);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$scope.schema.keywords) {
|
||||
const keywords = {};
|
||||
|
||||
$scope.schema.forEach((table) => {
|
||||
keywords[table.name] = 'Table';
|
||||
|
||||
table.columns.forEach((c) => {
|
||||
keywords[c] = 'Column';
|
||||
keywords[`${table.name}.${c}`] = 'Column';
|
||||
});
|
||||
});
|
||||
|
||||
$scope.schema.keywords = map(keywords, (v, k) =>
|
||||
({
|
||||
name: k,
|
||||
value: k,
|
||||
score: 0,
|
||||
meta: v,
|
||||
}));
|
||||
}
|
||||
callback(null, $scope.schema.keywords);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
window.ace.acequire(['ace/ext/language_tools'], (langTools) => {
|
||||
langTools.addCompleter(schemaCompleter);
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.directive('queryEditor', queryEditor);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import moment from 'moment';
|
||||
|
||||
function queryResultLink() {
|
||||
return {
|
||||
restrict: 'A',
|
||||
link(scope, element, attrs) {
|
||||
const fileType = attrs.fileType ? attrs.fileType : 'csv';
|
||||
scope.$watch('queryResult && queryResult.getData()', (data) => {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (scope.queryResult.getId() == null) {
|
||||
element.attr('href', '');
|
||||
} else {
|
||||
element.attr('href', `api/queries/${scope.query.id}/results/${scope.queryResult.getId()}.${fileType}${scope.embed ? `?api_key=${scope.apiKey}` : ''}`);
|
||||
element.attr('download', `${scope.query.name.replace(' ', '_') + moment(scope.queryResult.getUpdatedAt()).format('_YYYY_MM_DD')}.${fileType}`);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.directive('queryResultLink', queryResultLink);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" aria-label="Close" ng-click="$ctrl.close()"><span aria-hidden="true">×</span></button>
|
||||
<h4 class="modal-title">Refresh Schedule</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input type="radio" value="periodic" ng-model="$ctrl.refreshType">
|
||||
<query-refresh-select refresh-type="$ctrl.refreshType" query="$ctrl.query" save-query="$ctrl.saveQuery"></query-refresh-select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input type="radio" value="daily" ng-model="$ctrl.refreshType">
|
||||
<query-time-picker refresh-type="$ctrl.refreshType" query="$ctrl.query" save-query="$ctrl.saveQuery"></query-time-picker>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,150 +0,0 @@
|
||||
import moment from 'moment';
|
||||
import { map, range, partial } from 'underscore';
|
||||
|
||||
import template from './schedule-dialog.html';
|
||||
|
||||
function padWithZeros(size, v) {
|
||||
let str = String(v);
|
||||
if (str.length < size) {
|
||||
str = `0${str}`;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
function queryTimePicker() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
refreshType: '=',
|
||||
query: '=',
|
||||
saveQuery: '=',
|
||||
},
|
||||
template: `
|
||||
<select ng-disabled="refreshType != 'daily'" ng-model="hour" ng-change="updateSchedule()" ng-options="c as c for c in hourOptions"></select> :
|
||||
<select ng-disabled="refreshType != 'daily'" ng-model="minute" ng-change="updateSchedule()" ng-options="c as c for c in minuteOptions"></select>
|
||||
`,
|
||||
link($scope) {
|
||||
$scope.hourOptions = map(range(0, 24), partial(padWithZeros, 2));
|
||||
$scope.minuteOptions = map(range(0, 60, 5), partial(padWithZeros, 2));
|
||||
|
||||
if ($scope.query.hasDailySchedule()) {
|
||||
const parts = $scope.query.scheduleInLocalTime().split(':');
|
||||
$scope.minute = parts[1];
|
||||
$scope.hour = parts[0];
|
||||
} else {
|
||||
$scope.minute = '15';
|
||||
$scope.hour = '00';
|
||||
}
|
||||
|
||||
$scope.updateSchedule = () => {
|
||||
const newSchedule = moment().hour($scope.hour)
|
||||
.minute($scope.minute)
|
||||
.utc()
|
||||
.format('HH:mm');
|
||||
|
||||
if (newSchedule !== $scope.query.schedule) {
|
||||
$scope.query.schedule = newSchedule;
|
||||
$scope.saveQuery();
|
||||
}
|
||||
};
|
||||
|
||||
$scope.$watch('refreshType', () => {
|
||||
if ($scope.refreshType === 'daily') {
|
||||
$scope.updateSchedule();
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function queryRefreshSelect() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
refreshType: '=',
|
||||
query: '=',
|
||||
saveQuery: '=',
|
||||
},
|
||||
template: `<select
|
||||
ng-disabled="refreshType != 'periodic'"
|
||||
ng-model="query.schedule"
|
||||
ng-change="saveQuery()"
|
||||
ng-options="c.value as c.name for c in refreshOptions">
|
||||
<option value="">No Refresh</option>
|
||||
</select>`,
|
||||
link($scope) {
|
||||
$scope.refreshOptions = [
|
||||
{
|
||||
value: '60',
|
||||
name: 'Every minute',
|
||||
},
|
||||
];
|
||||
|
||||
[5, 10, 15, 30].forEach((i) => {
|
||||
$scope.refreshOptions.push({
|
||||
value: String(i * 60),
|
||||
name: `Every ${i} minutes`,
|
||||
});
|
||||
});
|
||||
|
||||
range(1, 13).forEach((i) => {
|
||||
$scope.refreshOptions.push({
|
||||
value: String(i * 3600),
|
||||
name: `Every ${i}h`,
|
||||
});
|
||||
});
|
||||
|
||||
$scope.refreshOptions.push({
|
||||
value: String(24 * 3600),
|
||||
name: 'Every 24h',
|
||||
});
|
||||
$scope.refreshOptions.push({
|
||||
value: String(7 * 24 * 3600),
|
||||
name: 'Every 7 days',
|
||||
});
|
||||
$scope.refreshOptions.push({
|
||||
value: String(14 * 24 * 3600),
|
||||
name: 'Every 14 days',
|
||||
});
|
||||
$scope.refreshOptions.push({
|
||||
value: String(30 * 24 * 3600),
|
||||
name: 'Every 30 days',
|
||||
});
|
||||
|
||||
$scope.$watch('refreshType', () => {
|
||||
if ($scope.refreshType === 'periodic') {
|
||||
if ($scope.query.hasDailySchedule()) {
|
||||
$scope.query.schedule = null;
|
||||
$scope.saveQuery();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
const ScheduleForm = {
|
||||
controller() {
|
||||
this.query = this.resolve.query;
|
||||
this.saveQuery = this.resolve.saveQuery;
|
||||
|
||||
if (this.query.hasDailySchedule()) {
|
||||
this.refreshType = 'daily';
|
||||
} else {
|
||||
this.refreshType = 'periodic';
|
||||
}
|
||||
},
|
||||
bindings: {
|
||||
resolve: '<',
|
||||
close: '&',
|
||||
dismiss: '&',
|
||||
},
|
||||
template,
|
||||
};
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.directive('queryTimePicker', queryTimePicker);
|
||||
ngModule.directive('queryRefreshSelect', queryRefreshSelect);
|
||||
ngModule.component('scheduleDialog', ScheduleForm);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<div class="schema-container">
|
||||
<div class="schema-control">
|
||||
<input type="text" placeholder="Search schema..." class="form-control" ng-model="$ctrl.schemaFilter">
|
||||
<button class="btn btn-default"
|
||||
title="Refresh Schema"
|
||||
ng-click="$ctrl.onRefresh()">
|
||||
<span class="zmdi zmdi-refresh"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="schema-browser" vs-repeat vs-size="$ctrl.getSize(table)">
|
||||
<div ng-repeat="table in $ctrl.schema | filter:$ctrl.schemaFilter track by table.name">
|
||||
<div class="table-name" ng-click="$ctrl.showTable(table)">
|
||||
<i class="fa fa-table"></i>
|
||||
<strong>
|
||||
<span title="{{table.name}}">{{table.name}}</span>
|
||||
<span ng-if="table.size !== undefined"> ({{table.size}})</span>
|
||||
</strong>
|
||||
</div>
|
||||
<div uib-collapse="table.collapsed">
|
||||
<div ng-repeat="column in table.columns track by column" style="padding-left:16px;">{{column}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,33 +0,0 @@
|
||||
import template from './schema-browser.html';
|
||||
|
||||
function SchemaBrowserCtrl($scope) {
|
||||
'ngInject';
|
||||
|
||||
this.showTable = (table) => {
|
||||
table.collapsed = !table.collapsed;
|
||||
$scope.$broadcast('vsRepeatTrigger');
|
||||
};
|
||||
|
||||
this.getSize = (table) => {
|
||||
let size = 18;
|
||||
|
||||
if (!table.collapsed) {
|
||||
size += 18 * table.columns.length;
|
||||
}
|
||||
|
||||
return size;
|
||||
};
|
||||
}
|
||||
|
||||
const SchemaBrowser = {
|
||||
bindings: {
|
||||
schema: '<',
|
||||
onRefresh: '&',
|
||||
},
|
||||
controller: SchemaBrowserCtrl,
|
||||
template,
|
||||
};
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('schemaBrowser', SchemaBrowser);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
<div class="tile m-10">
|
||||
<div class="t-heading p-10">
|
||||
<h3 class="th-title">
|
||||
<p>
|
||||
<img ng-src="{{$ctrl.logoUrl}}" style="height: 24px;"/>
|
||||
{{$ctrl.query.name}}
|
||||
<small><visualization-name visualization="$ctrl.visualization"/></small>
|
||||
</p>
|
||||
<small ng-if="$ctrl.showQueryDescription">
|
||||
<div class="text-muted" ng-bind-html="$ctrl.query.description | markdown"></div>
|
||||
</small>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<visualization-renderer visualization="$ctrl.visualization" query-result="$ctrl.queryResult" class="t-body">
|
||||
</visualization-renderer>
|
||||
|
||||
<div class="bg-ace clearfix p-10">
|
||||
<div class="col-sm-6">
|
||||
<span class="label label-default hidden-print">Updated: <span am-time-ago="$ctrl.queryResult.getUpdatedAt()"></span></span>
|
||||
<span class="label label-default visible-print">Updated: {{$ctrl.queryResult.getUpdatedAt() | dateTime}}</span>
|
||||
</div>
|
||||
<div class="col-sm-6 text-right hidden-print">
|
||||
<a class="btn btn-default btn-xs" ng-href="queries/{{$ctrl.query.id}}#{{$ctrl.visualization.id}}" target="_blank" tooltip="Open in Redash">
|
||||
<span class="zmdi zmdi-link"></span>
|
||||
</a>
|
||||
|
||||
<div class="btn-group" uib-dropdown>
|
||||
<button type="button" class="btn btn-default btn-xs dropdown-toggle" aria-haspopup="true" uib-dropdown-toggle
|
||||
aria-expanded="false">
|
||||
Download Dataset <span class="caret"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu pull-right" uib-dropdown-menu>
|
||||
<li>
|
||||
<a ng-href="{{$ctrl.queryResult.getLink($ctrl.query.id, 'csv', $ctrl.apiKey)}}" download="{{$ctrl.queryResult.getName($ctrl.query.name, 'csv')}}" target="_self">
|
||||
<span class="fa fa-file-o"></span> Download as CSV File
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a ng-href="{{$ctrl.queryResult.getLink($ctrl.query.id, 'xlsx', $ctrl.apiKey)}}" download="{{$ctrl.queryResult.getName($ctrl.query.name, 'xlsx')}}" target="_self">
|
||||
<span class="fa fa-file-excel-o"></span> Download as Excel File
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,53 +0,0 @@
|
||||
import { find } from 'underscore';
|
||||
import logoUrl from '@/assets/images/redash_icon_small.png';
|
||||
import template from './visualization-embed.html';
|
||||
|
||||
const VisualizationEmbed = {
|
||||
template,
|
||||
bindings: {
|
||||
data: '<',
|
||||
},
|
||||
controller($routeParams, Query, QueryResult) {
|
||||
'ngInject';
|
||||
|
||||
document.querySelector('body').classList.add('headless');
|
||||
const visualizationId = parseInt($routeParams.visualizationId, 10);
|
||||
this.showQueryDescription = $routeParams.showDescription;
|
||||
this.apiKey = $routeParams.api_key;
|
||||
this.logoUrl = logoUrl;
|
||||
this.query = new Query(this.data[0]);
|
||||
this.queryResult = new QueryResult(this.data[1]);
|
||||
this.visualization =
|
||||
find(this.query.visualizations, visualization => visualization.id === visualizationId);
|
||||
},
|
||||
};
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('visualizationEmbed', VisualizationEmbed);
|
||||
|
||||
function session($http, $route, Auth) {
|
||||
'ngInject';
|
||||
|
||||
const apiKey = $route.current.params.api_key;
|
||||
Auth.setApiKey(apiKey);
|
||||
return Auth.loadConfig();
|
||||
}
|
||||
|
||||
function loadData($http, $route, $q, Auth) {
|
||||
return session($http, $route, Auth).then(() => {
|
||||
const queryId = $route.current.params.queryId;
|
||||
const query = $http.get(`api/queries/${queryId}`).then(response => response.data);
|
||||
const queryResult = $http.get(`api/queries/${queryId}/results.json${location.search}`).then(response => response.data);
|
||||
return $q.all([query, queryResult]);
|
||||
});
|
||||
}
|
||||
|
||||
ngModule.config(($routeProvider) => {
|
||||
$routeProvider.when('/embed/query/:queryId/visualization/:visualizationId', {
|
||||
template: '<visualization-embed data="$resolve.data"></visualization-embed>',
|
||||
resolve: {
|
||||
data: loadData,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
<select ng-model="$ctrl.param.value" class="form-control" ng-options="option.value as option.name for option in $ctrl.queryResultOptions">
|
||||
</select>
|
||||
@@ -1,25 +0,0 @@
|
||||
|
||||
function QueryLinkController() {
|
||||
let hash = null;
|
||||
if (this.visualization) {
|
||||
if (this.visualization.type === 'TABLE') {
|
||||
// link to hard-coded table tab instead of the (hidden) visualization tab
|
||||
hash = 'table';
|
||||
} else {
|
||||
hash = this.visualization.id;
|
||||
}
|
||||
}
|
||||
|
||||
this.link = this.query.getUrl(false, hash);
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('queryLink', {
|
||||
bindings: {
|
||||
query: '<',
|
||||
visualization: '<',
|
||||
},
|
||||
template: '<a ng-href="{{$ctrl.link}}" class="query-link">{{$ctrl.query.name}}</a>',
|
||||
controller: QueryLinkController,
|
||||
});
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
function rdTab($location) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
tabId: '@',
|
||||
name: '@',
|
||||
basePath: '=?',
|
||||
},
|
||||
transclude: true,
|
||||
template: '<li class="rd-tab" ng-class="{active: tabId==selectedTab}"><a href="{{basePath}}#{{tabId}}">{{name}}<span ng-transclude></span></a></li>',
|
||||
replace: true,
|
||||
link(scope) {
|
||||
scope.basePath = scope.basePath || $location.path().substring(1);
|
||||
scope.$watch(
|
||||
() => scope.$parent.selectedTab,
|
||||
(tab) => { scope.selectedTab = tab; },
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.directive('rdTab', rdTab);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
const RdTimeAgo = {
|
||||
bindings: {
|
||||
value: '=',
|
||||
},
|
||||
controller() {
|
||||
},
|
||||
template: '<span>' +
|
||||
'<span ng-show="$ctrl.value" am-time-ago="$ctrl.value"></span>' +
|
||||
'<span ng-hide="$ctrl.value">-</span>' +
|
||||
'</span>',
|
||||
};
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('rdTimeAgo', RdTimeAgo);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import moment from 'moment';
|
||||
|
||||
function rdTimer() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: { timestamp: '=' },
|
||||
template: '{{currentTime}}',
|
||||
controller($scope) {
|
||||
$scope.currentTime = '00:00:00';
|
||||
|
||||
// We're using setInterval directly instead of $timeout, to avoid using $apply, to
|
||||
// prevent the digest loop being run every second.
|
||||
let currentTimer = setInterval(() => {
|
||||
$scope.currentTime = moment(moment() - moment($scope.timestamp)).utc().format('HH:mm:ss');
|
||||
$scope.$digest();
|
||||
}, 1000);
|
||||
|
||||
$scope.$on('$destroy', () => {
|
||||
if (currentTimer) {
|
||||
clearInterval(currentTimer);
|
||||
currentTimer = null;
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.directive('rdTimer', rdTimer);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('routeStatus', {
|
||||
template: '<overlay ng-if="$ctrl.permissionDenied">You do not have permission to load this page.',
|
||||
|
||||
controller($rootScope) {
|
||||
this.permissionDenied = false;
|
||||
|
||||
$rootScope.$on('$routeChangeSuccess', () => {
|
||||
this.permissionDenied = false;
|
||||
});
|
||||
|
||||
$rootScope.$on('$routeChangeError', (event, current, previous, rejection) => {
|
||||
if (rejection.status === 403) {
|
||||
this.permissionDenied = true;
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import startsWith from 'underscore.string/startsWith';
|
||||
import template from './settings-screen.html';
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.directive('settingsScreen', $location => ({
|
||||
restrict: 'E',
|
||||
transclude: true,
|
||||
template,
|
||||
controller($scope, currentUser) {
|
||||
$scope.usersPage = startsWith($location.path(), '/users');
|
||||
$scope.groupsPage = startsWith($location.path(), '/groups');
|
||||
$scope.dsPage = startsWith($location.path(), '/data_sources');
|
||||
$scope.destinationsPage = startsWith($location.path(), '/destinations');
|
||||
$scope.snippetsPage = startsWith($location.path(), '/query_snippets');
|
||||
|
||||
$scope.showGroupsLink = currentUser.hasPermission('list_users');
|
||||
$scope.showUsersLink = currentUser.hasPermission('list_users');
|
||||
$scope.showDsLink = currentUser.hasPermission('admin');
|
||||
$scope.showDestinationsLink = currentUser.hasPermission('admin');
|
||||
$scope.showQuerySnippetsLink = currentUser.hasPermission('create_query');
|
||||
},
|
||||
}));
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('sortIcon', {
|
||||
template: '<span ng-if="$ctrl.showIcon"><i class="fa fa-sort-{{$ctrl.icon}}"></i></span>',
|
||||
bindings: {
|
||||
column: '<',
|
||||
sortColumn: '<',
|
||||
reverse: '<',
|
||||
},
|
||||
controller() {
|
||||
this.$onChanges = (changes) => {
|
||||
['column', 'sortColumn', 'reverse'].forEach((v) => {
|
||||
if (v in changes) {
|
||||
this[v] = changes[v].currentValue;
|
||||
}
|
||||
});
|
||||
|
||||
this.showIcon = false;
|
||||
|
||||
if (this.column === this.sortColumn) {
|
||||
this.showIcon = true;
|
||||
this.icon = this.reverse ? 'desc' : 'asc';
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import startsWith from 'underscore.string/startsWith';
|
||||
|
||||
function controller($location) {
|
||||
this.tabs.forEach((tab) => {
|
||||
if (tab.isActive) {
|
||||
tab.active = tab.isActive($location.path());
|
||||
} else {
|
||||
tab.active = startsWith($location.path(), `/${tab.path}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('tabNav', {
|
||||
template: '<ul class="tab-nav bg-white">' +
|
||||
'<li ng-repeat="tab in $ctrl.tabs" ng-class="{\'active\': tab.active }"><a ng-href="{{tab.path}}">{{tab.name}}</a></li>' +
|
||||
'</ul>',
|
||||
controller,
|
||||
bindings: {
|
||||
tabs: '<',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
export default function VisualizationName(Visualization) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
visualization: '=',
|
||||
},
|
||||
template: '{{name}}',
|
||||
replace: false,
|
||||
link(scope) {
|
||||
const currentType = scope.visualization.type;
|
||||
const nameByType = Visualization.visualizations[currentType].name;
|
||||
const currentName = scope.visualization.name;
|
||||
if (nameByType !== currentName) {
|
||||
scope.name = scope.visualization.name;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
// This polyfill is needed to support PhantomJS which we use to generate PNGs from embeds.
|
||||
import 'core-js/fn/typed/array-buffer';
|
||||
|
||||
import 'pace-progress';
|
||||
import debug from 'debug';
|
||||
import angular from 'angular';
|
||||
import ngSanitize from 'angular-sanitize';
|
||||
import ngRoute from 'angular-route';
|
||||
import ngResource from 'angular-resource';
|
||||
import uiBootstrap from 'angular-ui-bootstrap';
|
||||
import uiSelect from 'ui-select';
|
||||
import ngMessages from 'angular-messages';
|
||||
import toastr from 'angular-toastr';
|
||||
import ngUpload from 'angular-base64-upload';
|
||||
import vsRepeat from 'angular-vs-repeat';
|
||||
import 'angular-moment';
|
||||
import 'brace';
|
||||
import 'angular-ui-ace';
|
||||
import 'angular-resizable';
|
||||
import ngGridster from 'angular-gridster';
|
||||
import { each } from 'underscore';
|
||||
|
||||
import '@/lib/sortable';
|
||||
|
||||
import * as filters from '@/filters';
|
||||
import registerDirectives from '@/directives';
|
||||
import markdownFilter from '@/filters/markdown';
|
||||
import dateTimeFilter from '@/filters/datetime';
|
||||
|
||||
const logger = debug('redash:config');
|
||||
|
||||
const requirements = [
|
||||
ngRoute,
|
||||
ngResource,
|
||||
ngSanitize,
|
||||
uiBootstrap,
|
||||
ngMessages,
|
||||
uiSelect,
|
||||
'angularMoment',
|
||||
toastr,
|
||||
'ui.ace',
|
||||
ngUpload,
|
||||
'angularResizable',
|
||||
vsRepeat,
|
||||
'ui.sortable',
|
||||
ngGridster.name,
|
||||
];
|
||||
|
||||
const ngModule = angular.module('app', requirements);
|
||||
|
||||
function registerAll(context) {
|
||||
const modules = context
|
||||
.keys()
|
||||
.map(context)
|
||||
.map(module => module.default);
|
||||
|
||||
return modules.map(f => f(ngModule));
|
||||
}
|
||||
|
||||
function registerComponents() {
|
||||
// We repeat this code in other register functions, because if we don't use a literal for the path
|
||||
// Webpack won't be able to statcily analyze our imports.
|
||||
const context = require.context('@/components', true, /^((?![\\/]test[\\/]).)*\.js$/);
|
||||
registerAll(context);
|
||||
}
|
||||
|
||||
function registerServices() {
|
||||
const context = require.context('@/services', true, /^((?![\\/]test[\\/]).)*\.js$/);
|
||||
registerAll(context);
|
||||
}
|
||||
|
||||
function registerVisualizations() {
|
||||
const context = require.context('@/visualizations', true, /^((?![\\/]test[\\/]).)*\.js$/);
|
||||
registerAll(context);
|
||||
}
|
||||
|
||||
function registerPages() {
|
||||
const context = require.context('@/pages', true, /^((?![\\/]test[\\/]).)*\.js$/);
|
||||
const routesCollection = registerAll(context);
|
||||
routesCollection.forEach((routes) => {
|
||||
ngModule.config(($routeProvider) => {
|
||||
each(routes, (route, path) => {
|
||||
logger('Registering route: %s', path);
|
||||
// This is a workaround, to make sure app-header and footer are loaded only
|
||||
// for the authenticated routes.
|
||||
// We should look into switching to ui-router, that has built in support for
|
||||
// such things.
|
||||
route.template = `<app-header></app-header><route-status></route-status>${route.template}<footer></footer>`;
|
||||
route.authenticated = true;
|
||||
$routeProvider.when(path, route);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function registerFilters() {
|
||||
each(filters, (filter, name) => {
|
||||
ngModule.filter(name, () => filter);
|
||||
});
|
||||
}
|
||||
|
||||
registerDirectives(ngModule);
|
||||
registerServices();
|
||||
registerFilters();
|
||||
markdownFilter(ngModule);
|
||||
dateTimeFilter(ngModule);
|
||||
registerComponents();
|
||||
registerPages();
|
||||
registerVisualizations(ngModule);
|
||||
|
||||
export default ngModule;
|
||||
@@ -1,11 +0,0 @@
|
||||
import 'material-design-iconic-font/dist/css/material-design-iconic-font.css';
|
||||
import 'font-awesome/css/font-awesome.css';
|
||||
import 'ui-select/dist/select.css';
|
||||
import 'angular-toastr/dist/angular-toastr.css';
|
||||
import 'angular-resizable/src/angular-resizable.css';
|
||||
import 'angular-gridster/dist/angular-gridster.css';
|
||||
import 'pace-progress/themes/blue/pace-theme-minimal.css';
|
||||
|
||||
import '@/assets/css/superflat_redash.css';
|
||||
import '@/assets/css/redash.css';
|
||||
import '@/assets/css/main.scss';
|
||||
@@ -1,78 +0,0 @@
|
||||
import debug from 'debug';
|
||||
|
||||
const logger = debug('redash:directives');
|
||||
|
||||
function compareTo() {
|
||||
return {
|
||||
require: 'ngModel',
|
||||
scope: {
|
||||
otherModelValue: '=compareTo',
|
||||
},
|
||||
link(scope, element, attributes, ngModel) {
|
||||
const validate = (value) => {
|
||||
ngModel.$setValidity('compareTo', value === scope.otherModelValue);
|
||||
};
|
||||
|
||||
scope.$watch('otherModelValue', () => {
|
||||
validate(ngModel.$modelValue);
|
||||
});
|
||||
|
||||
ngModel.$parsers.push((value) => {
|
||||
validate(value);
|
||||
return value;
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function autofocus($timeout) {
|
||||
return {
|
||||
link(scope, element) {
|
||||
$timeout(() => {
|
||||
element[0].focus();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function TitleService($rootScope) {
|
||||
const Title = {
|
||||
title: 'Redash',
|
||||
set(newTitle) {
|
||||
this.title = newTitle;
|
||||
$rootScope.$broadcast('$titleChange');
|
||||
},
|
||||
get() {
|
||||
return this.title;
|
||||
},
|
||||
};
|
||||
|
||||
return Title;
|
||||
}
|
||||
|
||||
function title($rootScope, Title) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
link(scope, element) {
|
||||
function updateTitle() {
|
||||
const newTitle = Title.get();
|
||||
logger('Updating title to: %s', newTitle);
|
||||
element.text(newTitle);
|
||||
}
|
||||
|
||||
$rootScope.$on('$routeChangeSuccess', (event, to) => {
|
||||
if (to.title) {
|
||||
Title.set(to.title);
|
||||
}
|
||||
});
|
||||
$rootScope.$on('$titleChange', updateTitle);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.factory('Title', TitleService);
|
||||
ngModule.directive('title', title);
|
||||
ngModule.directive('compareTo', compareTo);
|
||||
ngModule.directive('autofocus', autofocus);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import moment from 'moment';
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.filter('toMilliseconds', () => value => value * 1000.0);
|
||||
|
||||
ngModule.filter('dateTime', clientConfig =>
|
||||
function dateTime(value) {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return moment(value).format(clientConfig.dateTimeFormat);
|
||||
});
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import moment from 'moment';
|
||||
import _capitalize from 'underscore.string/capitalize';
|
||||
import { isEmpty } from 'underscore';
|
||||
|
||||
// eslint-disable-next-line
|
||||
const urlPattern = /(^|[\s\n]|<br\/?>)((?:https?|ftp):\/\/[\-A-Z0-9+\u0026\u2019@#\/%?=()~_|!:,.;]*[\-A-Z0-9+\u0026@#\/%=~()_|])/gi;
|
||||
|
||||
export function durationHumanize(duration) {
|
||||
let humanized = '';
|
||||
|
||||
if (duration === undefined) {
|
||||
humanized = '-';
|
||||
} else if (duration < 60) {
|
||||
const seconds = Math.round(duration);
|
||||
humanized = `${seconds}s`;
|
||||
} else if (duration > 3600 * 24) {
|
||||
const days = Math.round(parseFloat(duration) / 60.0 / 60.0 / 24.0);
|
||||
humanized = `${days}days`;
|
||||
} else if (duration >= 3600) {
|
||||
const hours = Math.round(parseFloat(duration) / 60.0 / 60.0);
|
||||
humanized = `${hours}h`;
|
||||
} else {
|
||||
const minutes = Math.round(parseFloat(duration) / 60.0);
|
||||
humanized = `${minutes}m`;
|
||||
}
|
||||
return humanized;
|
||||
}
|
||||
|
||||
export function scheduleHumanize(schedule) {
|
||||
if (schedule === null) {
|
||||
return 'Never';
|
||||
} else if (schedule.match(/\d\d:\d\d/) !== null) {
|
||||
const parts = schedule.split(':');
|
||||
const localTime = moment.utc()
|
||||
.hour(parts[0])
|
||||
.minute(parts[1])
|
||||
.local()
|
||||
.format('HH:mm');
|
||||
|
||||
return `Every day at ${localTime}`;
|
||||
}
|
||||
|
||||
return `Every ${durationHumanize(parseInt(schedule, 10))}`;
|
||||
}
|
||||
|
||||
export function toHuman(text) {
|
||||
return text.replace(/_/g, ' ').replace(/(?:^|\s)\S/g, a =>
|
||||
a.toUpperCase());
|
||||
}
|
||||
|
||||
export function colWidth(widgetWidth) {
|
||||
if (widgetWidth === 0) {
|
||||
return 0;
|
||||
} else if (widgetWidth === 1) {
|
||||
return 6;
|
||||
} else if (widgetWidth === 2) {
|
||||
return 12;
|
||||
}
|
||||
return widgetWidth;
|
||||
}
|
||||
|
||||
export function capitalize(text) {
|
||||
if (text) {
|
||||
return _capitalize(text);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function linkify(text) {
|
||||
return text.replace(urlPattern, "$1<a href='$2' target='_blank'>$2</a>");
|
||||
}
|
||||
|
||||
export function remove(items, item) {
|
||||
if (items === undefined) {
|
||||
return items;
|
||||
}
|
||||
|
||||
let notEquals;
|
||||
|
||||
if (item instanceof Array) {
|
||||
notEquals = other => item.indexOf(other) === -1;
|
||||
} else {
|
||||
notEquals = other => item !== other;
|
||||
}
|
||||
|
||||
const filtered = [];
|
||||
|
||||
for (let i = 0; i < items.length; i += 1) {
|
||||
if (notEquals(items[i])) {
|
||||
filtered.push(items[i]);
|
||||
}
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
export function notEmpty(collection) {
|
||||
return !isEmpty(collection);
|
||||
}
|
||||
|
||||
export function showError(field, form) {
|
||||
return (field.$touched && field.$invalid) || form.$submitted;
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { markdown } from 'markdown';
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.filter('markdown', ($sce, clientConfig) =>
|
||||
function parseMarkdown(text) {
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let html = markdown.toHTML(String(text));
|
||||
if (clientConfig.allowScriptsInUserInput) {
|
||||
html = $sce.trustAsHtml(html);
|
||||
}
|
||||
|
||||
return html;
|
||||
});
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html ng-app="app" ng-strict-di>
|
||||
<head lang="en">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta charset="UTF-8">
|
||||
<base href="/">
|
||||
<title>Redash</title>
|
||||
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/images/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/images/favicon-96x96.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/images/favicon-16x16.png">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<section>
|
||||
<div ng-view></div>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,25 +0,0 @@
|
||||
import '@/config/styles';
|
||||
import ngModule from '@/config';
|
||||
|
||||
ngModule.config(($locationProvider, $compileProvider, uiSelectConfig, toastrConfig) => {
|
||||
$compileProvider.debugInfoEnabled(false);
|
||||
$compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|http|data):/);
|
||||
$locationProvider.html5Mode(true);
|
||||
uiSelectConfig.theme = 'bootstrap';
|
||||
|
||||
Object.assign(toastrConfig, {
|
||||
positionClass: 'toast-bottom-right',
|
||||
timeOut: 2000,
|
||||
});
|
||||
});
|
||||
|
||||
// Update ui-select's template to use Font-Awesome instead of glyphicon.
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
ngModule.run(($templateCache, OfflineListener) => {
|
||||
const templateName = 'bootstrap/match.tpl.html';
|
||||
let template = $templateCache.get(templateName);
|
||||
template = template.replace('glyphicon glyphicon-remove', 'fa fa-remove');
|
||||
$templateCache.put(templateName, template);
|
||||
});
|
||||
|
||||
export default ngModule;
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default as Paginator } from './paginator';
|
||||
export { default as LivePaginator } from './live-paginator';
|
||||
@@ -1,27 +0,0 @@
|
||||
export default class LivePaginator {
|
||||
constructor(rowsFetcher, { page = 1, itemsPerPage = 20 } = {}) {
|
||||
this.page = page;
|
||||
this.itemsPerPage = itemsPerPage;
|
||||
this.rowsFetcher = rowsFetcher;
|
||||
this.rowsFetcher(this.page, this.itemsPerPage, this);
|
||||
}
|
||||
|
||||
setPage(page) {
|
||||
this.page = page;
|
||||
this.rowsFetcher(page, this.itemsPerPage, this);
|
||||
}
|
||||
|
||||
getPageRows() {
|
||||
return this.rows;
|
||||
}
|
||||
|
||||
updateRows(rows, totalCount = undefined) {
|
||||
this.rows = rows;
|
||||
if (this.rows) {
|
||||
this.totalCount = totalCount || rows.length;
|
||||
} else {
|
||||
this.totalCount = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { sortBy } from 'underscore';
|
||||
|
||||
export default class Paginator {
|
||||
constructor(rows, { page = 1, itemsPerPage = 20, totalCount = undefined } = {}) {
|
||||
this.page = page;
|
||||
this.itemsPerPage = itemsPerPage;
|
||||
this.updateRows(rows, totalCount);
|
||||
this.orderByField = undefined;
|
||||
this.orderByReverse = false;
|
||||
}
|
||||
|
||||
setPage(page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
getPageRows() {
|
||||
const first = this.itemsPerPage * (this.page - 1);
|
||||
const last = this.itemsPerPage * (this.page);
|
||||
|
||||
return this.rows.slice(first, last);
|
||||
}
|
||||
|
||||
updateRows(rows, totalCount = undefined) {
|
||||
this.rows = rows;
|
||||
if (this.rows) {
|
||||
this.totalCount = totalCount || rows.length;
|
||||
} else {
|
||||
this.totalCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
orderBy(column) {
|
||||
if (column === this.orderByField) {
|
||||
this.orderByReverse = !this.orderByReverse;
|
||||
} else {
|
||||
this.orderByField = column;
|
||||
this.orderByReverse = false;
|
||||
}
|
||||
|
||||
if (this.orderByField) {
|
||||
this.rows = sortBy(this.rows, this.orderByField);
|
||||
if (this.orderByReverse) {
|
||||
this.rows = this.rows.reverse();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,509 +0,0 @@
|
||||
/* eslint-disable */
|
||||
/*
|
||||
TODO:
|
||||
1. export module name.
|
||||
2. publish into its own repo?
|
||||
3. fix lint errors.
|
||||
*/
|
||||
import angular from 'angular';
|
||||
import jQuery from 'jquery';
|
||||
import sortable from 'jquery-ui/ui/widgets/sortable';
|
||||
|
||||
/*
|
||||
jQuery UI Sortable plugin wrapper
|
||||
|
||||
@param [ui-sortable] {object} Options to pass to $.fn.sortable() merged onto ui.config
|
||||
*/
|
||||
angular.module('ui.sortable', [])
|
||||
.value('uiSortableConfig', {
|
||||
// the default for jquery-ui sortable is "> *", we need to restrict this to
|
||||
// ng-repeat items
|
||||
// if the user uses
|
||||
items: '> [ng-repeat],> [data-ng-repeat],> [x-ng-repeat]',
|
||||
})
|
||||
.directive('uiSortable', [
|
||||
'uiSortableConfig', '$timeout', '$log',
|
||||
function (uiSortableConfig, $timeout, $log) {
|
||||
return {
|
||||
require: '?ngModel',
|
||||
scope: {
|
||||
ngModel: '=',
|
||||
uiSortable: '=',
|
||||
},
|
||||
link(scope, element, attrs, ngModel) {
|
||||
let savedNodes;
|
||||
element = jQuery(element);
|
||||
|
||||
function combineCallbacks(first, second) {
|
||||
const firstIsFunc = typeof first === 'function';
|
||||
const secondIsFunc = typeof second === 'function';
|
||||
if (firstIsFunc && secondIsFunc) {
|
||||
return function () {
|
||||
first.apply(this, arguments);
|
||||
second.apply(this, arguments);
|
||||
};
|
||||
} else if (secondIsFunc) {
|
||||
return second;
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
function getSortableWidgetInstance(element) {
|
||||
// this is a fix to support jquery-ui prior to v1.11.x
|
||||
// otherwise we should be using `element.sortable('instance')`
|
||||
const data = element.data('ui-sortable');
|
||||
if (data && typeof data === 'object' && data.widgetFullName === 'ui-sortable') {
|
||||
return data;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function patchSortableOption(key, value) {
|
||||
if (callbacks[key]) {
|
||||
if (key === 'stop') {
|
||||
// call apply after stop
|
||||
value = combineCallbacks(
|
||||
value, () => { scope.$apply(); });
|
||||
|
||||
value = combineCallbacks(value, afterStop);
|
||||
}
|
||||
// wrap the callback
|
||||
value = combineCallbacks(callbacks[key], value);
|
||||
} else if (wrappers[key]) {
|
||||
value = wrappers[key](value);
|
||||
}
|
||||
|
||||
// patch the options that need to have values set
|
||||
if (!value && (key === 'items' || key === 'ui-model-items')) {
|
||||
value = uiSortableConfig.items;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function patchUISortableOptions(newVal, oldVal, sortableWidgetInstance) {
|
||||
function addDummyOptionKey(value, key) {
|
||||
if (!(key in opts)) {
|
||||
// add the key in the opts object so that
|
||||
// the patch function detects and handles it
|
||||
opts[key] = null;
|
||||
}
|
||||
}
|
||||
// for this directive to work we have to attach some callbacks
|
||||
angular.forEach(callbacks, addDummyOptionKey);
|
||||
|
||||
// only initialize it in case we have to
|
||||
// update some options of the sortable
|
||||
let optsDiff = null;
|
||||
|
||||
if (oldVal) {
|
||||
// reset deleted options to default
|
||||
let defaultOptions;
|
||||
angular.forEach(oldVal, (oldValue, key) => {
|
||||
if (!newVal || !(key in newVal)) {
|
||||
if (key in directiveOpts) {
|
||||
if (key === 'ui-floating') {
|
||||
opts[key] = 'auto';
|
||||
} else {
|
||||
opts[key] = patchSortableOption(key, undefined);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!defaultOptions) {
|
||||
defaultOptions = sortable().options;
|
||||
}
|
||||
let defaultValue = defaultOptions[key];
|
||||
defaultValue = patchSortableOption(key, defaultValue);
|
||||
|
||||
if (!optsDiff) {
|
||||
optsDiff = {};
|
||||
}
|
||||
optsDiff[key] = defaultValue;
|
||||
opts[key] = defaultValue;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// update changed options
|
||||
angular.forEach(newVal, (value, key) => {
|
||||
// if it's a custom option of the directive,
|
||||
// handle it approprietly
|
||||
if (key in directiveOpts) {
|
||||
if (key === 'ui-floating' && (value === false || value === true) && sortableWidgetInstance) {
|
||||
sortableWidgetInstance.floating = value;
|
||||
}
|
||||
|
||||
opts[key] = patchSortableOption(key, value);
|
||||
return;
|
||||
}
|
||||
|
||||
value = patchSortableOption(key, value);
|
||||
|
||||
if (!optsDiff) {
|
||||
optsDiff = {};
|
||||
}
|
||||
optsDiff[key] = value;
|
||||
opts[key] = value;
|
||||
});
|
||||
|
||||
return optsDiff;
|
||||
}
|
||||
|
||||
function getPlaceholderElement(element) {
|
||||
const placeholder = element.sortable('option', 'placeholder');
|
||||
|
||||
// placeholder.element will be a function if the placeholder, has
|
||||
// been created (placeholder will be an object). If it hasn't
|
||||
// been created, either placeholder will be false if no
|
||||
// placeholder class was given or placeholder.element will be
|
||||
// undefined if a class was given (placeholder will be a string)
|
||||
if (placeholder && placeholder.element && typeof placeholder.element === 'function') {
|
||||
let result = placeholder.element();
|
||||
// workaround for jquery ui 1.9.x,
|
||||
// not returning jquery collection
|
||||
result = jQuery(result);
|
||||
return result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getPlaceholderExcludesludes(element, placeholder) {
|
||||
// exact match with the placeholder's class attribute to handle
|
||||
// the case that multiple connected sortables exist and
|
||||
// the placeholder option equals the class of sortable items
|
||||
const notCssSelector = opts['ui-model-items'].replace(/[^,]*>/g, '');
|
||||
const excludes = element.find(`[class="${placeholder.attr('class')}"]:not(${notCssSelector})`);
|
||||
return excludes;
|
||||
}
|
||||
|
||||
function hasSortingHelper(element, ui) {
|
||||
const helperOption = element.sortable('option', 'helper');
|
||||
return helperOption === 'clone' || (typeof helperOption === 'function' && ui.item.sortable.isCustomHelperUsed());
|
||||
}
|
||||
|
||||
function getSortingHelper(element, ui, savedNodes) {
|
||||
let result = null;
|
||||
if (hasSortingHelper(element, ui) &&
|
||||
element.sortable('option', 'appendTo') === 'parent') {
|
||||
// The .ui-sortable-helper element (that's the default class name)
|
||||
// is placed last.
|
||||
result = savedNodes.last();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// thanks jquery-ui
|
||||
function isFloating(item) {
|
||||
return (/left|right/).test(item.css('float')) || (/inline|table-cell/).test(item.css('display'));
|
||||
}
|
||||
|
||||
function getElementScope(elementScopes, element) {
|
||||
let result = null;
|
||||
for (let i = 0; i < elementScopes.length; i++) {
|
||||
const x = elementScopes[i];
|
||||
if (x.element[0] === element[0]) {
|
||||
result = x.scope;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function afterStop(e, ui) {
|
||||
ui.item.sortable._destroy();
|
||||
}
|
||||
|
||||
// return the index of ui.item among the items
|
||||
// we can't just do ui.item.index() because there it might have siblings
|
||||
// which are not items
|
||||
function getItemIndex(item) {
|
||||
return item.parent()
|
||||
.find(opts['ui-model-items'])
|
||||
.index(item);
|
||||
}
|
||||
|
||||
let opts = {};
|
||||
|
||||
// directive specific options
|
||||
let directiveOpts = {
|
||||
'ui-floating': undefined,
|
||||
'ui-model-items': uiSortableConfig.items,
|
||||
};
|
||||
|
||||
let callbacks = {
|
||||
receive: null,
|
||||
remove: null,
|
||||
start: null,
|
||||
stop: null,
|
||||
update: null,
|
||||
};
|
||||
|
||||
let wrappers = {
|
||||
helper: null,
|
||||
};
|
||||
|
||||
angular.extend(opts, directiveOpts, uiSortableConfig, scope.uiSortable);
|
||||
|
||||
function wireUp() {
|
||||
// When we add or remove elements, we need the sortable to 'refresh'
|
||||
// so it can find the new/removed elements.
|
||||
scope.$watchCollection('ngModel', () => {
|
||||
// Timeout to let ng-repeat modify the DOM
|
||||
$timeout(() => {
|
||||
// ensure that the jquery-ui-sortable widget instance
|
||||
// is still bound to the directive's element
|
||||
if (!!getSortableWidgetInstance(element)) {
|
||||
element.sortable('refresh');
|
||||
}
|
||||
}, 0, false);
|
||||
});
|
||||
|
||||
callbacks.start = function (e, ui) {
|
||||
if (opts['ui-floating'] === 'auto') {
|
||||
// since the drag has started, the element will be
|
||||
// absolutely positioned, so we check its siblings
|
||||
const siblings = ui.item.siblings();
|
||||
const sortableWidgetInstance = getSortableWidgetInstance(jQuery(e.target));
|
||||
sortableWidgetInstance.floating = isFloating(siblings);
|
||||
}
|
||||
|
||||
// Save the starting position of dragged item
|
||||
const index = getItemIndex(ui.item);
|
||||
ui.item.sortable = {
|
||||
model: ngModel.$modelValue[index],
|
||||
index,
|
||||
source: ui.item.parent(),
|
||||
sourceModel: ngModel.$modelValue,
|
||||
cancel() {
|
||||
ui.item.sortable._isCanceled = true;
|
||||
},
|
||||
isCanceled() {
|
||||
return ui.item.sortable._isCanceled;
|
||||
},
|
||||
isCustomHelperUsed() {
|
||||
return !!ui.item.sortable._isCustomHelperUsed;
|
||||
},
|
||||
_isCanceled: false,
|
||||
_isCustomHelperUsed: ui.item.sortable._isCustomHelperUsed,
|
||||
_destroy() {
|
||||
angular.forEach(ui.item.sortable, (value, key) => {
|
||||
ui.item.sortable[key] = undefined;
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
callbacks.activate = function (e, ui) {
|
||||
// We need to make a copy of the current element's contents so
|
||||
// we can restore it after sortable has messed it up.
|
||||
// This is inside activate (instead of start) in order to save
|
||||
// both lists when dragging between connected lists.
|
||||
savedNodes = element.contents();
|
||||
|
||||
// If this list has a placeholder (the connected lists won't),
|
||||
// don't inlcude it in saved nodes.
|
||||
const placeholder = getPlaceholderElement(element);
|
||||
if (placeholder && placeholder.length) {
|
||||
const excludes = getPlaceholderExcludesludes(element, placeholder);
|
||||
savedNodes = savedNodes.not(excludes);
|
||||
}
|
||||
|
||||
// save the directive's scope so that it is accessible from ui.item.sortable
|
||||
const connectedSortables = ui.item.sortable._connectedSortables || [];
|
||||
|
||||
connectedSortables.push({
|
||||
element,
|
||||
scope,
|
||||
});
|
||||
|
||||
ui.item.sortable._connectedSortables = connectedSortables;
|
||||
};
|
||||
|
||||
callbacks.update = function (e, ui) {
|
||||
// Save current drop position but only if this is not a second
|
||||
// update that happens when moving between lists because then
|
||||
// the value will be overwritten with the old value
|
||||
if (!ui.item.sortable.received) {
|
||||
ui.item.sortable.dropindex = getItemIndex(ui.item);
|
||||
const droptarget = ui.item.parent();
|
||||
ui.item.sortable.droptarget = droptarget;
|
||||
|
||||
const droptargetScope = getElementScope(ui.item.sortable._connectedSortables, droptarget);
|
||||
ui.item.sortable.droptargetModel = droptargetScope.ngModel;
|
||||
|
||||
// Cancel the sort (let ng-repeat do the sort for us)
|
||||
// Don't cancel if this is the received list because it has
|
||||
// already been canceled in the other list, and trying to cancel
|
||||
// here will mess up the DOM.
|
||||
element.sortable('cancel');
|
||||
}
|
||||
|
||||
// Put the nodes back exactly the way they started (this is very
|
||||
// important because ng-repeat uses comment elements to delineate
|
||||
// the start and stop of repeat sections and sortable doesn't
|
||||
// respect their order (even if we cancel, the order of the
|
||||
// comments are still messed up).
|
||||
const sortingHelper = !ui.item.sortable.received && getSortingHelper(element, ui, savedNodes);
|
||||
if (sortingHelper && sortingHelper.length) {
|
||||
// Restore all the savedNodes except from the sorting helper element.
|
||||
// That way it will be garbage collected.
|
||||
savedNodes = savedNodes.not(sortingHelper);
|
||||
}
|
||||
savedNodes.appendTo(element);
|
||||
|
||||
// If this is the target connected list then
|
||||
// it's safe to clear the restored nodes since:
|
||||
// update is currently running and
|
||||
// stop is not called for the target list.
|
||||
if (ui.item.sortable.received) {
|
||||
savedNodes = null;
|
||||
}
|
||||
|
||||
// If received is true (an item was dropped in from another list)
|
||||
// then we add the new item to this list otherwise wait until the
|
||||
// stop event where we will know if it was a sort or item was
|
||||
// moved here from another list
|
||||
if (ui.item.sortable.received && !ui.item.sortable.isCanceled()) {
|
||||
scope.$apply(() => {
|
||||
ngModel.$modelValue.splice(ui.item.sortable.dropindex, 0,
|
||||
ui.item.sortable.moved);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
callbacks.stop = function (e, ui) {
|
||||
// If the received flag hasn't be set on the item, this is a
|
||||
// normal sort, if dropindex is set, the item was moved, so move
|
||||
// the items in the list.
|
||||
if (!ui.item.sortable.received &&
|
||||
('dropindex' in ui.item.sortable) &&
|
||||
!ui.item.sortable.isCanceled()) {
|
||||
scope.$apply(() => {
|
||||
ngModel.$modelValue.splice(
|
||||
ui.item.sortable.dropindex, 0,
|
||||
ngModel.$modelValue.splice(ui.item.sortable.index, 1)[0]);
|
||||
});
|
||||
} else {
|
||||
// if the item was not moved, then restore the elements
|
||||
// so that the ngRepeat's comment are correct.
|
||||
if ((!('dropindex' in ui.item.sortable) || ui.item.sortable.isCanceled()) &&
|
||||
!angular.equals(element.contents(), savedNodes)) {
|
||||
const sortingHelper = getSortingHelper(element, ui, savedNodes);
|
||||
if (sortingHelper && sortingHelper.length) {
|
||||
// Restore all the savedNodes except from the sorting helper element.
|
||||
// That way it will be garbage collected.
|
||||
savedNodes = savedNodes.not(sortingHelper);
|
||||
}
|
||||
savedNodes.appendTo(element);
|
||||
}
|
||||
}
|
||||
|
||||
// It's now safe to clear the savedNodes
|
||||
// since stop is the last callback.
|
||||
savedNodes = null;
|
||||
};
|
||||
|
||||
callbacks.receive = function (e, ui) {
|
||||
// An item was dropped here from another list, set a flag on the
|
||||
// item.
|
||||
ui.item.sortable.received = true;
|
||||
};
|
||||
|
||||
callbacks.remove = function (e, ui) {
|
||||
// Workaround for a problem observed in nested connected lists.
|
||||
// There should be an 'update' event before 'remove' when moving
|
||||
// elements. If the event did not fire, cancel sorting.
|
||||
if (!('dropindex' in ui.item.sortable)) {
|
||||
element.sortable('cancel');
|
||||
ui.item.sortable.cancel();
|
||||
}
|
||||
|
||||
// Remove the item from this list's model and copy data into item,
|
||||
// so the next list can retrive it
|
||||
if (!ui.item.sortable.isCanceled()) {
|
||||
scope.$apply(() => {
|
||||
ui.item.sortable.moved = ngModel.$modelValue.splice(
|
||||
ui.item.sortable.index, 1)[0];
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
wrappers.helper = function (inner) {
|
||||
if (inner && typeof inner === 'function') {
|
||||
return function (e, item) {
|
||||
const oldItemSortable = item.sortable;
|
||||
const index = getItemIndex(item);
|
||||
item.sortable = {
|
||||
model: ngModel.$modelValue[index],
|
||||
index,
|
||||
source: item.parent(),
|
||||
sourceModel: ngModel.$modelValue,
|
||||
_restore() {
|
||||
angular.forEach(item.sortable, (value, key) => {
|
||||
item.sortable[key] = undefined;
|
||||
});
|
||||
|
||||
item.sortable = oldItemSortable;
|
||||
},
|
||||
};
|
||||
|
||||
const innerResult = inner.apply(this, arguments);
|
||||
item.sortable._restore();
|
||||
item.sortable._isCustomHelperUsed = item !== innerResult;
|
||||
return innerResult;
|
||||
};
|
||||
}
|
||||
return inner;
|
||||
};
|
||||
|
||||
scope.$watchCollection('uiSortable', (newVal, oldVal) => {
|
||||
// ensure that the jquery-ui-sortable widget instance
|
||||
// is still bound to the directive's element
|
||||
const sortableWidgetInstance = getSortableWidgetInstance(element);
|
||||
if (!!sortableWidgetInstance) {
|
||||
const optsDiff = patchUISortableOptions(newVal, oldVal, sortableWidgetInstance);
|
||||
|
||||
if (optsDiff) {
|
||||
element.sortable('option', optsDiff);
|
||||
}
|
||||
}
|
||||
}, true);
|
||||
|
||||
patchUISortableOptions(opts);
|
||||
}
|
||||
|
||||
function init() {
|
||||
if (ngModel) {
|
||||
wireUp();
|
||||
} else {
|
||||
$log.info('ui.sortable: ngModel not provided!', element);
|
||||
}
|
||||
|
||||
// Create sortable
|
||||
element.sortable(opts);
|
||||
}
|
||||
|
||||
function initIfEnabled() {
|
||||
if (scope.uiSortable && scope.uiSortable.disabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
// Stop Watcher
|
||||
initIfEnabled.cancelWatcher();
|
||||
initIfEnabled.cancelWatcher = angular.noop;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
initIfEnabled.cancelWatcher = angular.noop;
|
||||
|
||||
if (!initIfEnabled()) {
|
||||
initIfEnabled.cancelWatcher = scope.$watch('uiSortable.disabled', initIfEnabled);
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
]);
|
||||
328
client/app/lib/visualizations/d3box.js
vendored
328
client/app/lib/visualizations/d3box.js
vendored
@@ -1,328 +0,0 @@
|
||||
/* eslint-disable */
|
||||
// Inspired by http://informationandvisualization.de/blog/box-plot
|
||||
function box() {
|
||||
let width = 1,
|
||||
height = 1,
|
||||
duration = 0,
|
||||
domain = null,
|
||||
value = Number,
|
||||
whiskers = boxWhiskers,
|
||||
quartiles = boxQuartiles,
|
||||
tickFormat = null;
|
||||
|
||||
// For each small multiple…
|
||||
function box(g) {
|
||||
g.each(function(d, i) {
|
||||
d = d.map(value).sort(d3.ascending);
|
||||
let g = d3.select(this),
|
||||
n = d.length,
|
||||
min = d[0],
|
||||
max = d[n - 1];
|
||||
|
||||
// Compute quartiles. Must return exactly 3 elements.
|
||||
const quartileData = (d.quartiles = quartiles(d));
|
||||
|
||||
// Compute whiskers. Must return exactly 2 elements, or null.
|
||||
let whiskerIndices = whiskers && whiskers.call(this, d, i),
|
||||
whiskerData = whiskerIndices && whiskerIndices.map(i => d[i]);
|
||||
|
||||
// Compute outliers. If no whiskers are specified, all data are "outliers".
|
||||
// We compute the outliers as indices, so that we can join across transitions!
|
||||
const outlierIndices = whiskerIndices
|
||||
? d3.range(0, whiskerIndices[0]).concat(d3.range(whiskerIndices[1] + 1, n))
|
||||
: d3.range(n);
|
||||
|
||||
// Compute the new x-scale.
|
||||
const x1 = d3.scale
|
||||
.linear()
|
||||
.domain((domain && domain.call(this, d, i)) || [min, max])
|
||||
.range([height, 0]);
|
||||
|
||||
// Retrieve the old x-scale, if this is an update.
|
||||
const x0 =
|
||||
this.__chart__ ||
|
||||
d3.scale
|
||||
.linear()
|
||||
.domain([0, Infinity])
|
||||
.range(x1.range());
|
||||
|
||||
// Stash the new scale.
|
||||
this.__chart__ = x1;
|
||||
|
||||
// Note: the box, median, and box tick elements are fixed in number,
|
||||
// so we only have to handle enter and update. In contrast, the outliers
|
||||
// and other elements are variable, so we need to exit them! Variable
|
||||
// elements also fade in and out.
|
||||
|
||||
// Update center line: the vertical line spanning the whiskers.
|
||||
const center = g.selectAll('line.center').data(whiskerData ? [whiskerData] : []);
|
||||
|
||||
center
|
||||
.enter()
|
||||
.insert('line', 'rect')
|
||||
.attr('class', 'center')
|
||||
.attr('x1', width / 2)
|
||||
.attr('y1', d => x0(d[0]))
|
||||
.attr('x2', width / 2)
|
||||
.attr('y2', d => x0(d[1]))
|
||||
.style('opacity', 1e-6)
|
||||
.transition()
|
||||
.duration(duration)
|
||||
.style('opacity', 1)
|
||||
.attr('y1', d => x1(d[0]))
|
||||
.attr('y2', d => x1(d[1]));
|
||||
|
||||
center
|
||||
.transition()
|
||||
.duration(duration)
|
||||
.style('opacity', 1)
|
||||
.attr('y1', d => x1(d[0]))
|
||||
.attr('y2', d => x1(d[1]));
|
||||
|
||||
center
|
||||
.exit()
|
||||
.transition()
|
||||
.duration(duration)
|
||||
.style('opacity', 1e-6)
|
||||
.attr('y1', d => x1(d[0]))
|
||||
.attr('y2', d => x1(d[1]))
|
||||
.remove();
|
||||
|
||||
// Update innerquartile box.
|
||||
const box = g.selectAll('rect.box').data([quartileData]);
|
||||
|
||||
box
|
||||
.enter()
|
||||
.append('rect')
|
||||
.attr('class', 'box')
|
||||
.attr('x', 0)
|
||||
.attr('y', d => x0(d[2]))
|
||||
.attr('width', width)
|
||||
.attr('height', d => x0(d[0]) - x0(d[2]))
|
||||
.transition()
|
||||
.duration(duration)
|
||||
.attr('y', d => x1(d[2]))
|
||||
.attr('height', d => x1(d[0]) - x1(d[2]));
|
||||
|
||||
box
|
||||
.transition()
|
||||
.duration(duration)
|
||||
.attr('y', d => x1(d[2]))
|
||||
.attr('height', d => x1(d[0]) - x1(d[2]));
|
||||
|
||||
box.exit().remove();
|
||||
|
||||
// Update median line.
|
||||
const medianLine = g.selectAll('line.median').data([quartileData[1]]);
|
||||
|
||||
medianLine
|
||||
.enter()
|
||||
.append('line')
|
||||
.attr('class', 'median')
|
||||
.attr('x1', 0)
|
||||
.attr('y1', x0)
|
||||
.attr('x2', width)
|
||||
.attr('y2', x0)
|
||||
.transition()
|
||||
.duration(duration)
|
||||
.attr('y1', x1)
|
||||
.attr('y2', x1);
|
||||
|
||||
medianLine
|
||||
.transition()
|
||||
.duration(duration)
|
||||
.attr('y1', x1)
|
||||
.attr('y2', x1);
|
||||
|
||||
medianLine.exit().remove();
|
||||
|
||||
// Update whiskers.
|
||||
const whisker = g.selectAll('line.whisker').data(whiskerData || []);
|
||||
|
||||
whisker
|
||||
.enter()
|
||||
.insert('line', 'circle, text')
|
||||
.attr('class', 'whisker')
|
||||
.attr('x1', 0)
|
||||
.attr('y1', x0)
|
||||
.attr('x2', width)
|
||||
.attr('y2', x0)
|
||||
.style('opacity', 1e-6)
|
||||
.transition()
|
||||
.duration(duration)
|
||||
.attr('y1', x1)
|
||||
.attr('y2', x1)
|
||||
.style('opacity', 1);
|
||||
|
||||
whisker
|
||||
.transition()
|
||||
.duration(duration)
|
||||
.attr('y1', x1)
|
||||
.attr('y2', x1)
|
||||
.style('opacity', 1);
|
||||
|
||||
whisker
|
||||
.exit()
|
||||
.transition()
|
||||
.duration(duration)
|
||||
.attr('y1', x1)
|
||||
.attr('y2', x1)
|
||||
.style('opacity', 1e-6)
|
||||
.remove();
|
||||
|
||||
// Update outliers.
|
||||
const outlier = g.selectAll('circle.outlier').data(outlierIndices, Number);
|
||||
|
||||
outlier
|
||||
.enter()
|
||||
.insert('circle', 'text')
|
||||
.attr('class', 'outlier')
|
||||
.attr('r', 5)
|
||||
.attr('cx', width / 2)
|
||||
.attr('cy', i => x0(d[i]))
|
||||
.style('opacity', 1e-6)
|
||||
.transition()
|
||||
.duration(duration)
|
||||
.attr('cy', i => x1(d[i]))
|
||||
.style('opacity', 1);
|
||||
|
||||
outlier
|
||||
.transition()
|
||||
.duration(duration)
|
||||
.attr('cy', i => x1(d[i]))
|
||||
.style('opacity', 1);
|
||||
|
||||
outlier
|
||||
.exit()
|
||||
.transition()
|
||||
.duration(duration)
|
||||
.attr('cy', i => x1(d[i]))
|
||||
.style('opacity', 1e-6)
|
||||
.remove();
|
||||
|
||||
// Compute the tick format.
|
||||
const format = tickFormat || x1.tickFormat(8);
|
||||
|
||||
// Update box ticks.
|
||||
const boxTick = g.selectAll('text.box').data(quartileData);
|
||||
|
||||
boxTick
|
||||
.enter()
|
||||
.append('text')
|
||||
.attr('class', 'box')
|
||||
.attr('dy', '.3em')
|
||||
.attr('dx', (d, i) => (i & 1 ? 6 : -6))
|
||||
.attr('x', (d, i) => (i & 1 ? width : 0))
|
||||
.attr('y', x0)
|
||||
.attr('text-anchor', (d, i) => (i & 1 ? 'start' : 'end'))
|
||||
.text(format)
|
||||
.transition()
|
||||
.duration(duration)
|
||||
.attr('y', x1);
|
||||
|
||||
boxTick
|
||||
.transition()
|
||||
.duration(duration)
|
||||
.text(format)
|
||||
.attr('y', x1);
|
||||
|
||||
boxTick.exit().remove();
|
||||
|
||||
// Update whisker ticks. These are handled separately from the box
|
||||
// ticks because they may or may not exist, and we want don't want
|
||||
// to join box ticks pre-transition with whisker ticks post-.
|
||||
const whiskerTick = g.selectAll('text.whisker').data(whiskerData || []);
|
||||
|
||||
whiskerTick
|
||||
.enter()
|
||||
.append('text')
|
||||
.attr('class', 'whisker')
|
||||
.attr('dy', '.3em')
|
||||
.attr('dx', 6)
|
||||
.attr('x', width)
|
||||
.attr('y', x0)
|
||||
.text(format)
|
||||
.style('opacity', 1e-6)
|
||||
.transition()
|
||||
.duration(duration)
|
||||
.attr('y', x1)
|
||||
.style('opacity', 1);
|
||||
|
||||
whiskerTick
|
||||
.transition()
|
||||
.duration(duration)
|
||||
.text(format)
|
||||
.attr('y', x1)
|
||||
.style('opacity', 1);
|
||||
|
||||
whiskerTick
|
||||
.exit()
|
||||
.transition()
|
||||
.duration(duration)
|
||||
.attr('y', x1)
|
||||
.style('opacity', 1e-6)
|
||||
.remove();
|
||||
});
|
||||
d3.timer.flush();
|
||||
}
|
||||
|
||||
box.width = function(x) {
|
||||
if (!arguments.length) return width;
|
||||
width = x;
|
||||
return box;
|
||||
};
|
||||
|
||||
box.height = function(x) {
|
||||
if (!arguments.length) return height;
|
||||
height = x;
|
||||
return box;
|
||||
};
|
||||
|
||||
box.tickFormat = function(x) {
|
||||
if (!arguments.length) return tickFormat;
|
||||
tickFormat = x;
|
||||
return box;
|
||||
};
|
||||
|
||||
box.duration = function(x) {
|
||||
if (!arguments.length) return duration;
|
||||
duration = x;
|
||||
return box;
|
||||
};
|
||||
|
||||
box.domain = function(x) {
|
||||
if (!arguments.length) return domain;
|
||||
domain = x == null ? x : d3.functor(x);
|
||||
return box;
|
||||
};
|
||||
|
||||
box.value = function(x) {
|
||||
if (!arguments.length) return value;
|
||||
value = x;
|
||||
return box;
|
||||
};
|
||||
|
||||
box.whiskers = function(x) {
|
||||
if (!arguments.length) return whiskers;
|
||||
whiskers = x;
|
||||
return box;
|
||||
};
|
||||
|
||||
box.quartiles = function(x) {
|
||||
if (!arguments.length) return quartiles;
|
||||
quartiles = x;
|
||||
return box;
|
||||
};
|
||||
|
||||
return box;
|
||||
}
|
||||
|
||||
function boxWhiskers(d) {
|
||||
return [0, d.length - 1];
|
||||
}
|
||||
|
||||
function boxQuartiles(d) {
|
||||
return [d3.quantile(d, 0.25), d3.quantile(d, 0.5), d3.quantile(d, 0.75)];
|
||||
}
|
||||
|
||||
export default box;
|
||||
336
client/app/lib/visualizations/d3sankey.js
vendored
336
client/app/lib/visualizations/d3sankey.js
vendored
@@ -1,336 +0,0 @@
|
||||
/* eslint-disable */
|
||||
|
||||
import d3 from 'd3';
|
||||
|
||||
function center(node) {
|
||||
return node.y + node.dy / 2;
|
||||
}
|
||||
|
||||
function value(link) {
|
||||
return link.value;
|
||||
}
|
||||
|
||||
function Sankey() {
|
||||
const sankey = {};
|
||||
let nodeWidth = 24;
|
||||
let nodePadding = 8;
|
||||
let size = [1, 1];
|
||||
let nodes = [];
|
||||
let links = [];
|
||||
|
||||
// Populate the sourceLinks and targetLinks for each node.
|
||||
// Also, if the source and target are not objects, assume they are indices.
|
||||
function computeNodeLinks() {
|
||||
nodes.forEach(node => {
|
||||
node.sourceLinks = [];
|
||||
node.targetLinks = [];
|
||||
});
|
||||
links.forEach(link => {
|
||||
let source = link.source;
|
||||
let target = link.target;
|
||||
if (typeof source === 'number') source = link.source = nodes[link.source];
|
||||
if (typeof target === 'number') target = link.target = nodes[link.target];
|
||||
source.sourceLinks.push(link);
|
||||
target.targetLinks.push(link);
|
||||
});
|
||||
}
|
||||
|
||||
// Compute the value (size) of each node by summing the associated links.
|
||||
function computeNodeValues() {
|
||||
nodes.forEach(node => {
|
||||
node.value = Math.max(d3.sum(node.sourceLinks, value), d3.sum(node.targetLinks, value));
|
||||
});
|
||||
}
|
||||
|
||||
function moveSinksRight(x) {
|
||||
nodes.forEach(node => {
|
||||
if (!node.sourceLinks.length) {
|
||||
node.x = x - 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function scaleNodeBreadths(kx) {
|
||||
nodes.forEach(node => {
|
||||
node.x *= kx;
|
||||
});
|
||||
}
|
||||
|
||||
// Iteratively assign the breadth (x-position) for each node.
|
||||
// Nodes are assigned the maximum breadth of incoming neighbors plus one;
|
||||
// nodes with no incoming links are assigned breadth zero, while
|
||||
// nodes with no outgoing links are assigned the maximum breadth.
|
||||
function computeNodeBreadths() {
|
||||
let remainingNodes = nodes;
|
||||
let nextNodes;
|
||||
let x = 0;
|
||||
|
||||
function assignBreadth(node) {
|
||||
node.x = x;
|
||||
node.dx = nodeWidth;
|
||||
node.sourceLinks.forEach(link => {
|
||||
if (nextNodes.indexOf(link.target) < 0) {
|
||||
nextNodes.push(link.target);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
while (remainingNodes.length) {
|
||||
nextNodes = [];
|
||||
remainingNodes.forEach(assignBreadth);
|
||||
remainingNodes = nextNodes;
|
||||
x += 1;
|
||||
}
|
||||
|
||||
//
|
||||
moveSinksRight(x);
|
||||
scaleNodeBreadths((size[0] - nodeWidth) / (x - 1));
|
||||
}
|
||||
|
||||
function moveSourcesRight() {
|
||||
nodes.forEach(node => {
|
||||
if (!node.targetLinks.length) {
|
||||
node.x = d3.min(node.sourceLinks, d => d.target.x) - 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function computeNodeDepths(iterations) {
|
||||
const nodesByBreadth = d3
|
||||
.nest()
|
||||
.key(d => d.x)
|
||||
.sortKeys(d3.ascending)
|
||||
.entries(nodes)
|
||||
.map(d => d.values);
|
||||
|
||||
function initializeNodeDepth() {
|
||||
const ky = d3.min(
|
||||
nodesByBreadth,
|
||||
n => (size[1] - (n.length - 1) * nodePadding) / d3.sum(n, value),
|
||||
);
|
||||
|
||||
nodesByBreadth.forEach(n => {
|
||||
n.forEach((node, i) => {
|
||||
node.y = i;
|
||||
node.dy = node.value * ky;
|
||||
});
|
||||
});
|
||||
|
||||
links.forEach(link => {
|
||||
link.dy = link.value * ky;
|
||||
});
|
||||
}
|
||||
|
||||
function relaxLeftToRight(alpha) {
|
||||
function weightedSource(link) {
|
||||
return center(link.source) * link.value;
|
||||
}
|
||||
|
||||
nodesByBreadth.forEach(n => {
|
||||
n.forEach(node => {
|
||||
if (node.targetLinks.length) {
|
||||
const y = d3.sum(node.targetLinks, weightedSource) / d3.sum(node.targetLinks, value);
|
||||
node.y += (y - center(node)) * alpha;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCollisions() {
|
||||
nodesByBreadth.forEach(nodes => {
|
||||
const n = nodes.length;
|
||||
let node;
|
||||
let dy;
|
||||
let y0 = 0;
|
||||
let i;
|
||||
|
||||
// Push any overlapping nodes down.
|
||||
nodes.sort(ascendingDepth);
|
||||
for (i = 0; i < n; ++i) {
|
||||
node = nodes[i];
|
||||
dy = y0 - node.y;
|
||||
if (dy > 0) node.y += dy;
|
||||
y0 = node.y + node.dy + nodePadding;
|
||||
}
|
||||
|
||||
// If the bottommost node goes outside the bounds, push it back up.
|
||||
dy = y0 - nodePadding - size[1];
|
||||
if (dy > 0) {
|
||||
y0 = node.y -= dy;
|
||||
|
||||
// Push any overlapping nodes back up.
|
||||
for (i = n - 2; i >= 0; --i) {
|
||||
node = nodes[i];
|
||||
dy = node.y + node.dy + nodePadding - y0;
|
||||
if (dy > 0) node.y -= dy;
|
||||
y0 = node.y;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCollisions() {
|
||||
nodesByBreadth.forEach(nodes => {
|
||||
let node,
|
||||
dy,
|
||||
y0 = 0,
|
||||
n = nodes.length,
|
||||
i;
|
||||
|
||||
// Push any overlapping nodes down.
|
||||
nodes.sort(ascendingDepth);
|
||||
for (i = 0; i < n; ++i) {
|
||||
node = nodes[i];
|
||||
dy = y0 - node.y;
|
||||
if (dy > 0) node.y += dy;
|
||||
y0 = node.y + node.dy + nodePadding;
|
||||
}
|
||||
|
||||
// If the bottommost node goes outside the bounds, push it back up.
|
||||
dy = y0 - nodePadding - size[1];
|
||||
if (dy > 0) {
|
||||
y0 = node.y -= dy;
|
||||
|
||||
// Push any overlapping nodes back up.
|
||||
for (i = n - 2; i >= 0; --i) {
|
||||
node = nodes[i];
|
||||
dy = node.y + node.dy + nodePadding - y0;
|
||||
if (dy > 0) node.y -= dy;
|
||||
y0 = node.y;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
initializeNodeDepth();
|
||||
resolveCollisions();
|
||||
|
||||
for (let alpha = 1; iterations > 0; iterations -= 1) {
|
||||
relaxRightToLeft((alpha *= 0.99));
|
||||
resolveCollisions();
|
||||
relaxLeftToRight(alpha);
|
||||
resolveCollisions();
|
||||
}
|
||||
|
||||
function relaxRightToLeft(alpha) {
|
||||
nodesByBreadth
|
||||
.slice()
|
||||
.reverse()
|
||||
.forEach(nodes => {
|
||||
nodes.forEach(node => {
|
||||
if (node.sourceLinks.length) {
|
||||
const y = d3.sum(node.sourceLinks, weightedTarget) / d3.sum(node.sourceLinks, value);
|
||||
node.y += (y - center(node)) * alpha;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function weightedTarget(link) {
|
||||
return center(link.target) * link.value;
|
||||
}
|
||||
}
|
||||
|
||||
function ascendingDepth(a, b) {
|
||||
return a.y - b.y;
|
||||
}
|
||||
}
|
||||
|
||||
function computeLinkDepths() {
|
||||
nodes.forEach(node => {
|
||||
node.sourceLinks.sort(ascendingTargetDepth);
|
||||
node.targetLinks.sort(ascendingSourceDepth);
|
||||
});
|
||||
nodes.forEach(node => {
|
||||
let sy = 0,
|
||||
ty = 0;
|
||||
node.sourceLinks.forEach(link => {
|
||||
link.sy = sy;
|
||||
sy += link.dy;
|
||||
});
|
||||
node.targetLinks.forEach(link => {
|
||||
link.ty = ty;
|
||||
ty += link.dy;
|
||||
});
|
||||
});
|
||||
|
||||
function ascendingSourceDepth(a, b) {
|
||||
return a.source.y - b.source.y;
|
||||
}
|
||||
|
||||
function ascendingTargetDepth(a, b) {
|
||||
return a.target.y - b.target.y;
|
||||
}
|
||||
}
|
||||
|
||||
sankey.nodeWidth = function(_) {
|
||||
if (!arguments.length) return nodeWidth;
|
||||
nodeWidth = +_;
|
||||
return sankey;
|
||||
};
|
||||
|
||||
sankey.nodePadding = function(_) {
|
||||
if (!arguments.length) return nodePadding;
|
||||
nodePadding = +_;
|
||||
return sankey;
|
||||
};
|
||||
|
||||
sankey.nodes = function(_) {
|
||||
if (!arguments.length) return nodes;
|
||||
nodes = _;
|
||||
return sankey;
|
||||
};
|
||||
|
||||
sankey.links = function(_) {
|
||||
if (!arguments.length) return links;
|
||||
links = _;
|
||||
return sankey;
|
||||
};
|
||||
|
||||
sankey.size = function(_) {
|
||||
if (!arguments.length) return size;
|
||||
size = _;
|
||||
return sankey;
|
||||
};
|
||||
|
||||
sankey.layout = function(iterations) {
|
||||
computeNodeLinks();
|
||||
computeNodeValues();
|
||||
computeNodeBreadths();
|
||||
computeNodeDepths(iterations);
|
||||
computeLinkDepths();
|
||||
return sankey;
|
||||
};
|
||||
|
||||
sankey.relayout = function() {
|
||||
computeLinkDepths();
|
||||
return sankey;
|
||||
};
|
||||
|
||||
sankey.link = function() {
|
||||
let curvature = 0.5;
|
||||
|
||||
function link(d) {
|
||||
const x0 = d.source.x + d.source.dx;
|
||||
const x1 = d.target.x;
|
||||
const xi = d3.interpolateNumber(x0, x1);
|
||||
const x2 = xi(curvature);
|
||||
const x3 = xi(1 - curvature);
|
||||
const y0 = d.source.y + d.sy + d.dy / 2;
|
||||
const y1 = d.target.y + d.ty + d.dy / 2;
|
||||
|
||||
return `M${x0},${y0}C${x2},${y0} ${x3},${y1} ${x1},${y1}`;
|
||||
}
|
||||
|
||||
link.curvature = _ => {
|
||||
if (!arguments.length) return curvature;
|
||||
curvature = +_;
|
||||
return link;
|
||||
};
|
||||
|
||||
return link;
|
||||
};
|
||||
|
||||
return sankey;
|
||||
}
|
||||
|
||||
export default Sankey;
|
||||
@@ -1,400 +0,0 @@
|
||||
import * as d3 from 'd3';
|
||||
import _ from 'underscore';
|
||||
import angular from 'angular';
|
||||
|
||||
const exitNode = '<<<Exit>>>';
|
||||
const colors = d3.scale.category10();
|
||||
|
||||
// helper function colorMap - color gray if "end" is detected
|
||||
function colorMap(d) {
|
||||
return colors(d.name);
|
||||
}
|
||||
|
||||
// Return array of ancestors of nodes, highest first, but excluding the root.
|
||||
function getAncestors(node) {
|
||||
const path = [];
|
||||
let current = node;
|
||||
|
||||
while (current.parent) {
|
||||
path.unshift(current);
|
||||
current = current.parent;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
// The following is based on @chrisrzhou's example from: http://bl.ocks.org/chrisrzhou/d5bdd8546f64ca0e4366.
|
||||
function Sunburst(scope, element) {
|
||||
this.element = element;
|
||||
this.watches = [];
|
||||
|
||||
// svg dimensions
|
||||
const width = element[0].parentElement.clientWidth;
|
||||
const height = scope.visualization.options.height;
|
||||
const radius = Math.min(width, height) / 2;
|
||||
|
||||
// Breadcrumb dimensions: width, height, spacing, width of tip/tail.
|
||||
const b = {
|
||||
w: width / 6,
|
||||
h: 30,
|
||||
s: 3,
|
||||
t: 10,
|
||||
};
|
||||
|
||||
// margins
|
||||
const margin = {
|
||||
top: radius,
|
||||
bottom: 50,
|
||||
left: radius,
|
||||
right: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Drawing variables:
|
||||
*
|
||||
* e.g. colors, totalSize, partitions, arcs
|
||||
*/
|
||||
// Mapping of nodes to colorscale.
|
||||
|
||||
// Total size of all nodes, to be used later when data is loaded
|
||||
let totalSize = 0;
|
||||
|
||||
// create d3.layout.partition
|
||||
const partition = d3.layout
|
||||
.partition()
|
||||
.size([2 * Math.PI, radius * radius])
|
||||
.value(d => d.size);
|
||||
|
||||
// create arcs for drawing D3 paths
|
||||
const arc = d3.svg
|
||||
.arc()
|
||||
.startAngle(d => d.x)
|
||||
.endAngle(d => d.x + d.dx)
|
||||
.innerRadius(d => Math.sqrt(d.y))
|
||||
.outerRadius(d => Math.sqrt(d.y + d.dy));
|
||||
|
||||
/**
|
||||
* Define and initialize D3 select references and div-containers
|
||||
*
|
||||
* e.g. vis, breadcrumbs, lastCrumb, summary, sunburst, legend
|
||||
*/
|
||||
// create main vis selection
|
||||
const vis = d3
|
||||
.select(element[0])
|
||||
.append('div')
|
||||
.classed('vis-container', true)
|
||||
.style('position', 'relative')
|
||||
.style('margin-top', '5px')
|
||||
.style('height', `${height + 2 * b.h}px`);
|
||||
|
||||
// create and position breadcrumbs container and svg
|
||||
const breadcrumbs = vis
|
||||
.append('div')
|
||||
.classed('breadcrumbs-container', true)
|
||||
.append('svg')
|
||||
.attr('width', width)
|
||||
.attr('height', b.h)
|
||||
.attr('fill', 'white')
|
||||
.attr('font-weight', 600);
|
||||
|
||||
const marginLeft = (width - radius * 2) / 2;
|
||||
|
||||
// create and position SVG
|
||||
const sunburst = vis
|
||||
.append('div')
|
||||
.classed('sunburst-container', true)
|
||||
.style('z-index', '2')
|
||||
// .style("margin-left", marginLeft + "px")
|
||||
.style('left', `${marginLeft}px`)
|
||||
.style('position', 'absolute')
|
||||
.append('svg')
|
||||
.attr('width', width)
|
||||
.attr('height', height)
|
||||
.append('g')
|
||||
.attr('transform', `translate(${margin.left},${margin.top})`);
|
||||
|
||||
// create last breadcrumb element
|
||||
const lastCrumb = breadcrumbs.append('text').classed('lastCrumb', true);
|
||||
|
||||
// create and position summary container
|
||||
const summary = vis
|
||||
.append('div')
|
||||
.classed('summary-container', true)
|
||||
.style('position', 'absolute')
|
||||
.style('top', `${b.h + radius * 0.8}px`)
|
||||
.style('left', `${marginLeft + radius / 2}px`)
|
||||
.style('width', `${radius}px`)
|
||||
.style('height', `${radius}px`)
|
||||
.style('text-align', 'center')
|
||||
.style('font-size', '11px')
|
||||
.style('color', '#666')
|
||||
.style('z-index', '1');
|
||||
|
||||
// Generate a string representation for drawing a breadcrumb polygon.
|
||||
function breadcrumbPoints(d, i) {
|
||||
const points = [];
|
||||
points.push('0,0');
|
||||
points.push(`${b.w},0`);
|
||||
points.push(`${b.w + b.t},${b.h / 2}`);
|
||||
points.push(`${b.w},${b.h}`);
|
||||
points.push(`0,${b.h}`);
|
||||
|
||||
if (i > 0) {
|
||||
// Leftmost breadcrumb; don't include 6th vertex.
|
||||
points.push(`${b.t},${b.h / 2}`);
|
||||
}
|
||||
return points.join(' ');
|
||||
}
|
||||
|
||||
// Update the breadcrumb breadcrumbs to show the current sequence and percentage.
|
||||
function updateBreadcrumbs(ancestors, percentageString) {
|
||||
// Data join, where primary key = name + depth.
|
||||
const g = breadcrumbs.selectAll('g').data(ancestors, d => d.name + d.depth);
|
||||
|
||||
// Add breadcrumb and label for entering nodes.
|
||||
const breadcrumb = g.enter().append('g');
|
||||
|
||||
breadcrumb
|
||||
.append('polygon')
|
||||
.classed('breadcrumbs-shape', true)
|
||||
.attr('points', breadcrumbPoints)
|
||||
.attr('fill', colorMap);
|
||||
|
||||
breadcrumb
|
||||
.append('text')
|
||||
.classed('breadcrumbs-text', true)
|
||||
.attr('x', (b.w + b.t) / 2)
|
||||
.attr('y', b.h / 2)
|
||||
.attr('dy', '0.35em')
|
||||
.attr('font-size', '10px')
|
||||
.attr('text-anchor', 'middle')
|
||||
.text(d => d.name);
|
||||
|
||||
// Set position for entering and updating nodes.
|
||||
g.attr('transform', (d, i) => `translate(${i * (b.w + b.s)}, 0)`);
|
||||
|
||||
// Remove exiting nodes.
|
||||
g.exit().remove();
|
||||
|
||||
// Update percentage at the lastCrumb.
|
||||
lastCrumb
|
||||
.attr('x', (ancestors.length + 0.5) * (b.w + b.s))
|
||||
.attr('y', b.h / 2)
|
||||
.attr('dy', '0.35em')
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('fill', 'black')
|
||||
.attr('font-weight', 600)
|
||||
.text(percentageString);
|
||||
}
|
||||
|
||||
// helper function mouseover to handle mouseover events/animations and calculation
|
||||
// of ancestor nodes etc
|
||||
function mouseover(d) {
|
||||
// build percentage string
|
||||
const percentage = (100 * d.value / totalSize).toPrecision(3);
|
||||
let percentageString = `${percentage}%`;
|
||||
if (percentage < 1) {
|
||||
percentageString = '< 1.0%';
|
||||
}
|
||||
|
||||
// update breadcrumbs (get all ancestors)
|
||||
const ancestors = getAncestors(d);
|
||||
updateBreadcrumbs(ancestors, percentageString);
|
||||
|
||||
// update sunburst (Fade all the segments and highlight only ancestors of current segment)
|
||||
sunburst.selectAll('path').attr('opacity', 0.3);
|
||||
sunburst
|
||||
.selectAll('path')
|
||||
.filter(node => ancestors.indexOf(node) >= 0)
|
||||
.attr('opacity', 1);
|
||||
|
||||
// update summary
|
||||
summary.html(`Stage: ${d.depth}<br />` +
|
||||
`<span class='percentage' style='font-size: 2em;'>${percentageString}</span><br />${d.value} of ${totalSize}<br />`);
|
||||
|
||||
// display summary and breadcrumbs if hidden
|
||||
summary.style('visibility', '');
|
||||
breadcrumbs.style('visibility', '');
|
||||
}
|
||||
|
||||
// helper function click to handle mouseleave events/animations
|
||||
function click() {
|
||||
// Deactivate all segments then retransition each segment to full opacity.
|
||||
sunburst.selectAll('path').on('mouseover', null);
|
||||
sunburst
|
||||
.selectAll('path')
|
||||
.transition()
|
||||
.duration(1000)
|
||||
.attr('opacity', 1)
|
||||
.each('end', function endClick() {
|
||||
d3.select(this).on('mouseover', mouseover);
|
||||
});
|
||||
|
||||
// hide summary and breadcrumbs if visible
|
||||
breadcrumbs.style('visibility', 'hidden');
|
||||
summary.style('visibility', 'hidden');
|
||||
}
|
||||
|
||||
// helper function to draw the sunburst and breadcrumbs
|
||||
function drawSunburst(json) {
|
||||
// Build only nodes of a threshold "visible" sizes to improve efficiency
|
||||
// 0.005 radians = 0.29 degrees
|
||||
const nodes = partition.nodes(json).filter(d => d.dx > 0.005 && d.name !== exitNode);
|
||||
|
||||
// this section is required to update the colors.domain() every time the data updates
|
||||
const uniqueNames = (function uniqueNames(a) {
|
||||
const output = [];
|
||||
a.forEach((d) => {
|
||||
if (output.indexOf(d.name) === -1) output.push(d.name);
|
||||
});
|
||||
return output;
|
||||
}(nodes));
|
||||
colors.domain(uniqueNames); // update domain colors
|
||||
|
||||
// create path based on nodes
|
||||
const path = sunburst
|
||||
.data([json])
|
||||
.selectAll('path')
|
||||
.data(nodes)
|
||||
.enter()
|
||||
.append('path')
|
||||
.classed('nodePath', true)
|
||||
.attr('display', d => (d.depth ? null : 'none'))
|
||||
.attr('d', arc)
|
||||
.attr('fill', colorMap)
|
||||
.attr('opacity', 1)
|
||||
.attr('stroke', 'white')
|
||||
.on('mouseover', mouseover);
|
||||
|
||||
// // trigger mouse click over sunburst to reset visualization summary
|
||||
vis.on('click', click);
|
||||
|
||||
// Update totalSize of the tree = value of root node from partition.
|
||||
totalSize = path.node().__data__.value;
|
||||
}
|
||||
|
||||
// visualize json tree structure
|
||||
function createVisualization(json) {
|
||||
drawSunburst(json); // draw sunburst
|
||||
}
|
||||
|
||||
function removeVisualization() {
|
||||
sunburst.selectAll('.nodePath').remove();
|
||||
// legend.selectAll("g").remove();
|
||||
}
|
||||
|
||||
function buildNodes(raw) {
|
||||
let values;
|
||||
|
||||
if (
|
||||
_.has(raw[0], 'sequence') &&
|
||||
_.has(raw[0], 'stage') &&
|
||||
_.has(raw[0], 'node') &&
|
||||
_.has(raw[0], 'value')
|
||||
) {
|
||||
const grouped = _.groupBy(raw, 'sequence');
|
||||
|
||||
values = _.map(grouped, (value) => {
|
||||
const sorted = _.sortBy(value, 'stage');
|
||||
return {
|
||||
size: value[0].value,
|
||||
sequence: value[0].sequence,
|
||||
nodes: _.pluck(sorted, 'node'),
|
||||
};
|
||||
});
|
||||
} else {
|
||||
const validKey = key => key !== 'value' && key.indexOf('$$') !== 0;
|
||||
const keys = _.sortBy(_.filter(_.keys(raw[0]), validKey), _.identity);
|
||||
|
||||
values = _.map(raw, (row, sequence) => ({
|
||||
size: row.value,
|
||||
sequence,
|
||||
nodes: _.compact(_.map(keys, key => row[key])),
|
||||
}));
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
function buildHierarchy(csv) {
|
||||
const data = buildNodes(csv);
|
||||
|
||||
// build tree
|
||||
const root = {
|
||||
name: 'root',
|
||||
children: [],
|
||||
};
|
||||
|
||||
data.forEach((d) => {
|
||||
const nodes = d.nodes;
|
||||
const size = parseInt(d.size, 10);
|
||||
|
||||
// build graph, nodes, and child nodes
|
||||
let currentNode = root;
|
||||
for (let j = 0; j < nodes.length; j += 1) {
|
||||
let children = currentNode.children;
|
||||
const nodeName = nodes[j];
|
||||
const isLeaf = j + 1 === nodes.length;
|
||||
|
||||
if (!children) {
|
||||
currentNode.children = children = [];
|
||||
children.push({
|
||||
name: exitNode,
|
||||
size: currentNode.size,
|
||||
});
|
||||
}
|
||||
|
||||
let childNode = _.find(children, child => child.name === nodeName);
|
||||
|
||||
if (isLeaf && childNode) {
|
||||
childNode.children.push({
|
||||
name: exitNode,
|
||||
size,
|
||||
});
|
||||
} else if (isLeaf) {
|
||||
children.push({
|
||||
name: nodeName,
|
||||
size,
|
||||
});
|
||||
} else {
|
||||
if (!childNode) {
|
||||
childNode = {
|
||||
name: nodeName,
|
||||
children: [],
|
||||
};
|
||||
children.push(childNode);
|
||||
}
|
||||
|
||||
currentNode = childNode;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
const json = buildHierarchy(data); // build json tree
|
||||
removeVisualization(); // remove existing visualization if any
|
||||
createVisualization(json); // visualize json tree
|
||||
}
|
||||
|
||||
function refreshData() {
|
||||
const queryData = scope.queryResult.getData();
|
||||
if (queryData) {
|
||||
render(queryData);
|
||||
}
|
||||
}
|
||||
|
||||
refreshData();
|
||||
this.watches.push(scope.$watch('visualization.options', refreshData, true));
|
||||
this.watches.push(scope.$watch('queryResult && queryResult.getData()', refreshData));
|
||||
}
|
||||
|
||||
Sunburst.prototype.remove = function remove() {
|
||||
this.watches.forEach((unregister) => {
|
||||
unregister();
|
||||
});
|
||||
angular.element(this.element[0]).empty('.vis-container');
|
||||
};
|
||||
|
||||
export default Sunburst;
|
||||
@@ -1,19 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html ng-app="app" ng-strict-di>
|
||||
<head lang="en">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta charset="UTF-8">
|
||||
<base href="{{base_href}}">
|
||||
<title>Redash</title>
|
||||
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/images/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/images/favicon-96x96.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/images/favicon-16x16.png">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<section>
|
||||
<div ng-view></div>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user