Repository: getredash/redash
Branch: master
Commit: fc1b23fda7a5
Files: 1155
Total size: 5.7 MB
Directory structure:
gitextract_b07d1bqv/
├── .ci/
│ ├── Dockerfile.cypress
│ ├── compose.ci.yaml
│ ├── compose.cypress.yaml
│ ├── docker_build
│ ├── pack
│ └── update_version
├── .coveragerc
├── .dockerignore
├── .editorconfig
├── .github/
│ ├── ISSUE_TEMPLATE/
│ │ ├── ---bug_report.md
│ │ └── --anything_else.md
│ ├── PULL_REQUEST_TEMPLATE.md
│ ├── config.yml
│ ├── weekly-digest.yml
│ └── workflows/
│ ├── ci.yml
│ ├── periodic-snapshot.yml
│ ├── preview-image.yml
│ └── restyled.yml
├── .gitignore
├── .npmrc
├── .nvmrc
├── .pre-commit-config.yaml
├── .restyled.yaml
├── CHANGELOG.md
├── CONTRIBUTING.md
├── Dockerfile
├── LICENSE
├── LICENSE.borders
├── Makefile
├── README.md
├── SECURITY.md
├── bin/
│ ├── docker-entrypoint
│ ├── get_changes.py
│ ├── release_manager.py
│ └── run
├── client/
│ ├── .babelrc
│ ├── .eslintignore
│ ├── .eslintrc.js
│ ├── .gitignore
│ ├── app/
│ │ ├── .eslintrc.js
│ │ ├── __tests__/
│ │ │ ├── enzyme_setup.js
│ │ │ └── mocks.js
│ │ ├── assets/
│ │ │ ├── css/
│ │ │ │ └── login.css
│ │ │ ├── images/
│ │ │ │ └── illustrations/
│ │ │ │ └── readme.md
│ │ │ ├── less/
│ │ │ │ ├── STYLING-README.md
│ │ │ │ ├── ant.less
│ │ │ │ ├── inc/
│ │ │ │ │ ├── 404.less
│ │ │ │ │ ├── ace-editor.less
│ │ │ │ │ ├── alert.less
│ │ │ │ │ ├── ant-variables.less
│ │ │ │ │ ├── base.less
│ │ │ │ │ ├── bootstrap-overrides.less
│ │ │ │ │ ├── breadcrumb.less
│ │ │ │ │ ├── button.less
│ │ │ │ │ ├── carousel.less
│ │ │ │ │ ├── chart.less
│ │ │ │ │ ├── dropdown.less
│ │ │ │ │ ├── edit-in-place.less
│ │ │ │ │ ├── flex.less
│ │ │ │ │ ├── font.less
│ │ │ │ │ ├── form.less
│ │ │ │ │ ├── generics.less
│ │ │ │ │ ├── header.less
│ │ │ │ │ ├── ie-warning.less
│ │ │ │ │ ├── jumbotron.less
│ │ │ │ │ ├── label.less
│ │ │ │ │ ├── less-plugins/
│ │ │ │ │ │ └── for.less
│ │ │ │ │ ├── list-group.less
│ │ │ │ │ ├── list.less
│ │ │ │ │ ├── login.less
│ │ │ │ │ ├── media.less
│ │ │ │ │ ├── messages.less
│ │ │ │ │ ├── misc.less
│ │ │ │ │ ├── mixins.less
│ │ │ │ │ ├── modal.less
│ │ │ │ │ ├── panel.less
│ │ │ │ │ ├── photos.less
│ │ │ │ │ ├── popover.less
│ │ │ │ │ ├── pricing-table.less
│ │ │ │ │ ├── print.less
│ │ │ │ │ ├── profile.less
│ │ │ │ │ ├── progress-bar.less
│ │ │ │ │ ├── schema-browser.less
│ │ │ │ │ ├── sidebar.less
│ │ │ │ │ ├── table.less
│ │ │ │ │ ├── tile.less
│ │ │ │ │ ├── tooltips.less
│ │ │ │ │ ├── variables.less
│ │ │ │ │ ├── visualizations/
│ │ │ │ │ │ ├── box.less
│ │ │ │ │ │ ├── map.less
│ │ │ │ │ │ ├── misc.less
│ │ │ │ │ │ └── pivot-table.less
│ │ │ │ │ ├── well.less
│ │ │ │ │ └── widgets.less
│ │ │ │ ├── main.less
│ │ │ │ ├── redash/
│ │ │ │ │ ├── css-logo.less
│ │ │ │ │ ├── loading-indicator.less
│ │ │ │ │ ├── query.less
│ │ │ │ │ ├── redash-table.less
│ │ │ │ │ └── tags-control.less
│ │ │ │ └── server.less
│ │ │ └── robots.txt
│ │ ├── components/
│ │ │ ├── AceEditorInput.jsx
│ │ │ ├── AceEditorInput.less
│ │ │ ├── ApplicationArea/
│ │ │ │ ├── ApplicationLayout/
│ │ │ │ │ ├── DesktopNavbar.jsx
│ │ │ │ │ ├── DesktopNavbar.less
│ │ │ │ │ ├── MobileNavbar.jsx
│ │ │ │ │ ├── MobileNavbar.less
│ │ │ │ │ ├── VersionInfo.jsx
│ │ │ │ │ ├── index.jsx
│ │ │ │ │ └── index.less
│ │ │ │ ├── ErrorMessage.jsx
│ │ │ │ ├── ErrorMessage.less
│ │ │ │ ├── ErrorMessage.test.js
│ │ │ │ ├── ErrorMessageDetails.jsx
│ │ │ │ ├── Router.jsx
│ │ │ │ ├── handleNavigationIntent.js
│ │ │ │ ├── index.jsx
│ │ │ │ ├── navigateTo.js
│ │ │ │ ├── routeWithApiKeySession.jsx
│ │ │ │ └── routeWithUserSession.tsx
│ │ │ ├── BeaconConsent.jsx
│ │ │ ├── BigMessage.jsx
│ │ │ ├── CodeBlock.jsx
│ │ │ ├── CodeBlock.less
│ │ │ ├── Collapse.jsx
│ │ │ ├── CreateSourceDialog.jsx
│ │ │ ├── DateInput.jsx
│ │ │ ├── DateRangeInput.jsx
│ │ │ ├── DateTimeInput.jsx
│ │ │ ├── DateTimeRangeInput.jsx
│ │ │ ├── DialogWrapper.d.ts
│ │ │ ├── DialogWrapper.jsx
│ │ │ ├── DynamicComponent.jsx
│ │ │ ├── EditInPlace.jsx
│ │ │ ├── EditParameterSettingsDialog.jsx
│ │ │ ├── EditParameterSettingsDialog.less
│ │ │ ├── EditVisualizationButton/
│ │ │ │ ├── QueryControlDropdown.jsx
│ │ │ │ ├── QueryResultsLink.jsx
│ │ │ │ └── index.jsx
│ │ │ ├── EmailSettingsWarning.jsx
│ │ │ ├── FavoritesControl.jsx
│ │ │ ├── Filters.jsx
│ │ │ ├── HelpTrigger.jsx
│ │ │ ├── HelpTrigger.less
│ │ │ ├── InputWithCopy.jsx
│ │ │ ├── Link.tsx
│ │ │ ├── NoTaggedObjectsFound.jsx
│ │ │ ├── PageHeader/
│ │ │ │ ├── index.jsx
│ │ │ │ └── index.less
│ │ │ ├── Paginator.jsx
│ │ │ ├── ParameterApplyButton.jsx
│ │ │ ├── ParameterMappingInput.jsx
│ │ │ ├── ParameterMappingInput.less
│ │ │ ├── ParameterValueInput.jsx
│ │ │ ├── ParameterValueInput.less
│ │ │ ├── Parameters.jsx
│ │ │ ├── Parameters.less
│ │ │ ├── PermissionsEditorDialog/
│ │ │ │ ├── index.jsx
│ │ │ │ └── index.less
│ │ │ ├── PlainButton.less
│ │ │ ├── PlainButton.tsx
│ │ │ ├── PreviewCard.jsx
│ │ │ ├── QueryBasedParameterInput.jsx
│ │ │ ├── QueryLink.jsx
│ │ │ ├── QueryLink.less
│ │ │ ├── QuerySelector.jsx
│ │ │ ├── Resizable/
│ │ │ │ ├── index.jsx
│ │ │ │ └── index.less
│ │ │ ├── SelectItemsDialog.jsx
│ │ │ ├── SelectItemsDialog.less
│ │ │ ├── SelectWithVirtualScroll.tsx
│ │ │ ├── SettingsWrapper.jsx
│ │ │ ├── TagsList.less
│ │ │ ├── TagsList.tsx
│ │ │ ├── TimeAgo.jsx
│ │ │ ├── Timer.jsx
│ │ │ ├── Tooltip.tsx
│ │ │ ├── UserGroups.jsx
│ │ │ ├── UserGroups.less
│ │ │ ├── admin/
│ │ │ │ ├── Layout.jsx
│ │ │ │ ├── RQStatus.jsx
│ │ │ │ ├── StatusBlock.jsx
│ │ │ │ └── layout.less
│ │ │ ├── cards-list/
│ │ │ │ ├── CardsList.less
│ │ │ │ └── CardsList.tsx
│ │ │ ├── dashboards/
│ │ │ │ ├── AddWidgetDialog.jsx
│ │ │ │ ├── AutoHeightController.js
│ │ │ │ ├── CreateDashboardDialog.jsx
│ │ │ │ ├── DashboardGrid.jsx
│ │ │ │ ├── EditParameterMappingsDialog.jsx
│ │ │ │ ├── ExpandedWidgetDialog.jsx
│ │ │ │ ├── TextboxDialog.jsx
│ │ │ │ ├── TextboxDialog.less
│ │ │ │ ├── dashboard-grid.less
│ │ │ │ └── dashboard-widget/
│ │ │ │ ├── RestrictedWidget.jsx
│ │ │ │ ├── TextboxWidget.jsx
│ │ │ │ ├── VisualizationWidget.jsx
│ │ │ │ ├── Widget.jsx
│ │ │ │ ├── Widget.less
│ │ │ │ └── index.js
│ │ │ ├── dynamic-form/
│ │ │ │ ├── DynamicForm.jsx
│ │ │ │ ├── DynamicForm.less
│ │ │ │ ├── DynamicFormField.jsx
│ │ │ │ ├── dynamicFormHelper.js
│ │ │ │ ├── fields/
│ │ │ │ │ ├── AceEditorField.jsx
│ │ │ │ │ ├── CheckboxField.jsx
│ │ │ │ │ ├── ContentField.jsx
│ │ │ │ │ ├── FileField.jsx
│ │ │ │ │ ├── InputField.jsx
│ │ │ │ │ ├── NumberField.jsx
│ │ │ │ │ ├── SelectField.jsx
│ │ │ │ │ ├── TextAreaField.jsx
│ │ │ │ │ └── index.js
│ │ │ │ └── getFieldLabel.js
│ │ │ ├── dynamic-parameters/
│ │ │ │ ├── DateParameter.jsx
│ │ │ │ ├── DateRangeParameter.jsx
│ │ │ │ ├── DynamicButton.jsx
│ │ │ │ ├── DynamicButton.less
│ │ │ │ ├── DynamicDatePicker.jsx
│ │ │ │ ├── DynamicDateRangePicker.jsx
│ │ │ │ └── DynamicParameters.less
│ │ │ ├── empty-state/
│ │ │ │ ├── EmptyState.d.ts
│ │ │ │ ├── EmptyState.jsx
│ │ │ │ └── empty-state.less
│ │ │ ├── groups/
│ │ │ │ ├── CreateGroupDialog.jsx
│ │ │ │ ├── DeleteGroupButton.jsx
│ │ │ │ ├── DetailsPageSidebar.jsx
│ │ │ │ ├── GroupName.jsx
│ │ │ │ └── ListItemAddon.jsx
│ │ │ ├── items-list/
│ │ │ │ ├── ItemsList.tsx
│ │ │ │ ├── classes/
│ │ │ │ │ ├── ItemsFetcher.js
│ │ │ │ │ ├── ItemsSource.d.ts
│ │ │ │ │ ├── ItemsSource.js
│ │ │ │ │ ├── Paginator.js
│ │ │ │ │ ├── Sorter.js
│ │ │ │ │ └── StateStorage.js
│ │ │ │ ├── components/
│ │ │ │ │ ├── EmptyState.jsx
│ │ │ │ │ ├── ItemsTable.jsx
│ │ │ │ │ ├── LoadingState.jsx
│ │ │ │ │ └── Sidebar.jsx
│ │ │ │ └── hooks/
│ │ │ │ └── useItemsListExtraActions.js
│ │ │ ├── layouts/
│ │ │ │ ├── ContentWithSidebar.jsx
│ │ │ │ └── content-with-sidebar.less
│ │ │ ├── proptypes.js
│ │ │ ├── queries/
│ │ │ │ ├── AddToDashboardDialog.jsx
│ │ │ │ ├── ApiKeyDialog/
│ │ │ │ │ ├── index.jsx
│ │ │ │ │ └── index.less
│ │ │ │ ├── EmbedQueryDialog.jsx
│ │ │ │ ├── EmbedQueryDialog.less
│ │ │ │ ├── QueryEditor/
│ │ │ │ │ ├── AutoLimitCheckbox.jsx
│ │ │ │ │ ├── AutocompleteToggle.jsx
│ │ │ │ │ ├── QueryEditorControls.jsx
│ │ │ │ │ ├── QueryEditorControls.less
│ │ │ │ │ ├── ace.js
│ │ │ │ │ ├── index.jsx
│ │ │ │ │ └── index.less
│ │ │ │ ├── ScheduleDialog.css
│ │ │ │ ├── ScheduleDialog.jsx
│ │ │ │ ├── ScheduleDialog.test.js
│ │ │ │ ├── SchedulePhrase.jsx
│ │ │ │ ├── SchemaBrowser.jsx
│ │ │ │ ├── __snapshots__/
│ │ │ │ │ └── ScheduleDialog.test.js.snap
│ │ │ │ ├── add-to-dashboard-dialog.less
│ │ │ │ └── editor-components/
│ │ │ │ ├── databricks/
│ │ │ │ │ ├── DatabricksSchemaBrowser.jsx
│ │ │ │ │ ├── DatabricksSchemaBrowser.less
│ │ │ │ │ └── useDatabricksSchema.js
│ │ │ │ ├── editorComponents.js
│ │ │ │ └── index.js
│ │ │ ├── query-snippets/
│ │ │ │ └── QuerySnippetDialog.jsx
│ │ │ ├── tags-control/
│ │ │ │ ├── EditTagsDialog.jsx
│ │ │ │ └── TagsControl.jsx
│ │ │ └── visualizations/
│ │ │ ├── EditVisualizationDialog.jsx
│ │ │ ├── EditVisualizationDialog.less
│ │ │ ├── VisualizationName.jsx
│ │ │ ├── VisualizationName.less
│ │ │ ├── VisualizationRenderer.jsx
│ │ │ └── visualizationComponents.jsx
│ │ ├── config/
│ │ │ ├── antd-spinner.jsx
│ │ │ ├── dashboard-grid-options.js
│ │ │ └── index.js
│ │ ├── extensions/
│ │ │ └── .gitkeep
│ │ ├── index.html
│ │ ├── index.js
│ │ ├── lib/
│ │ │ ├── accessibility.ts
│ │ │ ├── calculateTextWidth.ts
│ │ │ ├── hooks/
│ │ │ │ ├── useFullscreenHandler.js
│ │ │ │ ├── useImmutableCallback.js
│ │ │ │ ├── useLazyRef.ts
│ │ │ │ ├── useSearchResults.js
│ │ │ │ └── useUniqueId.ts
│ │ │ ├── localOptions.js
│ │ │ ├── pagination/
│ │ │ │ ├── index.js
│ │ │ │ └── paginator.js
│ │ │ ├── queryFormat.test.js
│ │ │ ├── queryFormat.ts
│ │ │ ├── useQueryResultData.js
│ │ │ └── utils.js
│ │ ├── multi_org.html
│ │ ├── pages/
│ │ │ ├── admin/
│ │ │ │ ├── Jobs.jsx
│ │ │ │ ├── OutdatedQueries.jsx
│ │ │ │ ├── SystemStatus.jsx
│ │ │ │ └── system-status.less
│ │ │ ├── alert/
│ │ │ │ ├── Alert.jsx
│ │ │ │ ├── AlertEdit.jsx
│ │ │ │ ├── AlertNew.jsx
│ │ │ │ ├── AlertView.jsx
│ │ │ │ └── components/
│ │ │ │ ├── AlertDestinations.jsx
│ │ │ │ ├── AlertDestinations.less
│ │ │ │ ├── Criteria.jsx
│ │ │ │ ├── Criteria.less
│ │ │ │ ├── HorizontalFormItem.jsx
│ │ │ │ ├── MenuButton.jsx
│ │ │ │ ├── NotificationTemplate.jsx
│ │ │ │ ├── NotificationTemplate.less
│ │ │ │ ├── Query.jsx
│ │ │ │ ├── Query.less
│ │ │ │ ├── Rearm.jsx
│ │ │ │ ├── Rearm.less
│ │ │ │ ├── Title.jsx
│ │ │ │ └── Title.less
│ │ │ ├── alerts/
│ │ │ │ └── AlertsList.jsx
│ │ │ ├── dashboards/
│ │ │ │ ├── DashboardList.jsx
│ │ │ │ ├── DashboardPage.jsx
│ │ │ │ ├── DashboardPage.less
│ │ │ │ ├── PublicDashboardPage.jsx
│ │ │ │ ├── PublicDashboardPage.less
│ │ │ │ ├── components/
│ │ │ │ │ ├── DashboardHeader.jsx
│ │ │ │ │ ├── DashboardHeader.less
│ │ │ │ │ ├── DashboardListEmptyState.tsx
│ │ │ │ │ └── ShareDashboardDialog.jsx
│ │ │ │ ├── dashboard-list.css
│ │ │ │ └── hooks/
│ │ │ │ ├── useDashboard.js
│ │ │ │ ├── useDataSources.js
│ │ │ │ ├── useDuplicateDashboard.js
│ │ │ │ ├── useEditModeHandler.js
│ │ │ │ └── useRefreshRateHandler.js
│ │ │ ├── data-sources/
│ │ │ │ ├── DataSourcesList.jsx
│ │ │ │ └── EditDataSource.jsx
│ │ │ ├── destinations/
│ │ │ │ ├── DestinationsList.jsx
│ │ │ │ └── EditDestination.jsx
│ │ │ ├── groups/
│ │ │ │ ├── GroupDataSources.jsx
│ │ │ │ ├── GroupMembers.jsx
│ │ │ │ └── GroupsList.jsx
│ │ │ ├── home/
│ │ │ │ ├── Home.jsx
│ │ │ │ ├── Home.less
│ │ │ │ └── components/
│ │ │ │ └── FavoritesList.jsx
│ │ │ ├── index.js
│ │ │ ├── queries/
│ │ │ │ ├── QuerySource.jsx
│ │ │ │ ├── QuerySource.less
│ │ │ │ ├── QueryView.jsx
│ │ │ │ ├── QueryView.less
│ │ │ │ ├── VisualizationEmbed.jsx
│ │ │ │ ├── components/
│ │ │ │ │ ├── QueryExecutionMetadata.jsx
│ │ │ │ │ ├── QueryExecutionMetadata.less
│ │ │ │ │ ├── QueryExecutionStatus.jsx
│ │ │ │ │ ├── QueryMetadata.jsx
│ │ │ │ │ ├── QueryMetadata.less
│ │ │ │ │ ├── QueryPageHeader.jsx
│ │ │ │ │ ├── QueryPageHeader.less
│ │ │ │ │ ├── QuerySourceAlerts.jsx
│ │ │ │ │ ├── QuerySourceAlerts.less
│ │ │ │ │ ├── QuerySourceDropdown.jsx
│ │ │ │ │ ├── QuerySourceDropdownItem.jsx
│ │ │ │ │ ├── QuerySourceTypeIcon.jsx
│ │ │ │ │ ├── QueryViewButton.jsx
│ │ │ │ │ ├── QueryVisualizationTabs.jsx
│ │ │ │ │ ├── QueryVisualizationTabs.less
│ │ │ │ │ └── wrapQueryPage.jsx
│ │ │ │ └── hooks/
│ │ │ │ ├── useAddNewParameterDialog.js
│ │ │ │ ├── useAddToDashboardDialog.js
│ │ │ │ ├── useAddVisualizationDialog.js
│ │ │ │ ├── useApiKeyDialog.js
│ │ │ │ ├── useArchiveQuery.jsx
│ │ │ │ ├── useAutoLimitFlags.js
│ │ │ │ ├── useAutocompleteFlags.js
│ │ │ │ ├── useDataSourceSchema.js
│ │ │ │ ├── useDeleteVisualization.js
│ │ │ │ ├── useDuplicateQuery.js
│ │ │ │ ├── useEditScheduleDialog.js
│ │ │ │ ├── useEditVisualizationDialog.js
│ │ │ │ ├── useEmbedDialog.js
│ │ │ │ ├── usePermissionsEditorDialog.js
│ │ │ │ ├── usePublishQuery.js
│ │ │ │ ├── useQuery.js
│ │ │ │ ├── useQueryDataSources.js
│ │ │ │ ├── useQueryExecute.js
│ │ │ │ ├── useQueryFlags.js
│ │ │ │ ├── useQueryParameters.js
│ │ │ │ ├── useRenameQuery.js
│ │ │ │ ├── useUnpublishQuery.js
│ │ │ │ ├── useUnsavedChangesAlert.js
│ │ │ │ ├── useUpdateQuery.jsx
│ │ │ │ ├── useUpdateQueryDescription.js
│ │ │ │ ├── useUpdateQueryTags.js
│ │ │ │ └── useVisualizationTabHandler.js
│ │ │ ├── queries-list/
│ │ │ │ ├── QueriesList.jsx
│ │ │ │ ├── QueriesListEmptyState.jsx
│ │ │ │ └── queries-list.css
│ │ │ ├── query-snippets/
│ │ │ │ ├── QuerySnippetsList.jsx
│ │ │ │ └── QuerySnippetsList.less
│ │ │ ├── settings/
│ │ │ │ ├── OrganizationSettings.jsx
│ │ │ │ ├── components/
│ │ │ │ │ ├── AuthSettings/
│ │ │ │ │ │ ├── GoogleLoginSettings.jsx
│ │ │ │ │ │ ├── PasswordLoginSettings.jsx
│ │ │ │ │ │ ├── SAMLSettings.jsx
│ │ │ │ │ │ └── index.jsx
│ │ │ │ │ ├── GeneralSettings/
│ │ │ │ │ │ ├── BeaconConsentSettings.jsx
│ │ │ │ │ │ ├── FeatureFlagsSettings.jsx
│ │ │ │ │ │ ├── FormatSettings.jsx
│ │ │ │ │ │ ├── PlotlySettings.jsx
│ │ │ │ │ │ └── index.jsx
│ │ │ │ │ └── prop-types.js
│ │ │ │ └── hooks/
│ │ │ │ └── useOrganizationSettings.js
│ │ │ └── users/
│ │ │ ├── UserProfile.jsx
│ │ │ ├── UsersList.jsx
│ │ │ ├── components/
│ │ │ │ ├── ApiKeyForm.jsx
│ │ │ │ ├── CreateUserDialog.jsx
│ │ │ │ ├── EditableUserProfile.jsx
│ │ │ │ ├── PasswordForm/
│ │ │ │ │ ├── ChangePasswordDialog.jsx
│ │ │ │ │ ├── PasswordLinkAlert.jsx
│ │ │ │ │ ├── PasswordResetForm.jsx
│ │ │ │ │ ├── ResendInvitationForm.jsx
│ │ │ │ │ └── index.jsx
│ │ │ │ ├── ReadOnlyUserProfile.jsx
│ │ │ │ ├── ReadOnlyUserProfile.test.js
│ │ │ │ ├── ToggleUserForm.jsx
│ │ │ │ ├── UserInfoForm.jsx
│ │ │ │ └── __snapshots__/
│ │ │ │ └── ReadOnlyUserProfile.test.js.snap
│ │ │ ├── hooks/
│ │ │ │ └── useUserGroups.js
│ │ │ └── settings.less
│ │ ├── redash-font/
│ │ │ ├── style.less
│ │ │ └── variables.less
│ │ ├── services/
│ │ │ ├── KeyboardShortcuts.js
│ │ │ ├── alert-subscription.js
│ │ │ ├── alert.js
│ │ │ ├── auth.js
│ │ │ ├── auth.test.js
│ │ │ ├── axios.js
│ │ │ ├── dashboard.js
│ │ │ ├── data-source.js
│ │ │ ├── databricks-data-source.js
│ │ │ ├── destination.js
│ │ │ ├── getTags.js
│ │ │ ├── group.js
│ │ │ ├── location.js
│ │ │ ├── notification.d.ts
│ │ │ ├── notification.js
│ │ │ ├── notifications.js
│ │ │ ├── offline-listener.js
│ │ │ ├── organizationSettings.js
│ │ │ ├── organizationStatus.js
│ │ │ ├── parameters/
│ │ │ │ ├── DateParameter.js
│ │ │ │ ├── DateRangeParameter.js
│ │ │ │ ├── EnumParameter.js
│ │ │ │ ├── NumberParameter.js
│ │ │ │ ├── Parameter.js
│ │ │ │ ├── QueryBasedDropdownParameter.js
│ │ │ │ ├── TextParameter.js
│ │ │ │ ├── TextPatternParameter.js
│ │ │ │ ├── index.js
│ │ │ │ └── tests/
│ │ │ │ ├── DateParameter.test.js
│ │ │ │ ├── DateRangeParameter.test.js
│ │ │ │ ├── EnumParameter.test.js
│ │ │ │ ├── NumberParameter.test.js
│ │ │ │ ├── Parameter.test.js
│ │ │ │ ├── QueryBasedDropdownParameter.test.js
│ │ │ │ ├── TextParameter.test.js
│ │ │ │ └── TextPatternParameter.test.js
│ │ │ ├── policy/
│ │ │ │ ├── DefaultPolicy.js
│ │ │ │ └── index.js
│ │ │ ├── query-result.js
│ │ │ ├── query-result.test.js
│ │ │ ├── query-snippet.js
│ │ │ ├── query.js
│ │ │ ├── recordEvent.js
│ │ │ ├── resizeObserver.js
│ │ │ ├── restoreSession.jsx
│ │ │ ├── routes.ts
│ │ │ ├── sanitize.js
│ │ │ ├── settingsMenu.js
│ │ │ ├── settingsMenu.test.js
│ │ │ ├── url.js
│ │ │ ├── user.js
│ │ │ ├── utils.js
│ │ │ ├── visualization.js
│ │ │ └── widget.js
│ │ ├── styles/
│ │ │ ├── formStyle.less
│ │ │ └── formStyle.ts
│ │ ├── unsupported.html
│ │ ├── unsupportedRedirect.js
│ │ └── version.json
│ ├── cypress/
│ │ ├── .eslintrc.js
│ │ ├── cypress.js
│ │ ├── integration/
│ │ │ ├── alert/
│ │ │ │ ├── create_alert_spec.js
│ │ │ │ ├── edit_alert_spec.js
│ │ │ │ └── view_alert_spec.js
│ │ │ ├── dashboard/
│ │ │ │ ├── dashboard_list.js
│ │ │ │ ├── dashboard_spec.js
│ │ │ │ ├── dashboard_tags_spec.js
│ │ │ │ ├── filters_spec.js
│ │ │ │ ├── grid_compliant_widgets_spec.js
│ │ │ │ ├── parameter_spec.js
│ │ │ │ ├── sharing_spec.js
│ │ │ │ ├── textbox_spec.js
│ │ │ │ └── widget_spec.js
│ │ │ ├── data-source/
│ │ │ │ ├── create_data_source_spec.js
│ │ │ │ └── edit_data_source_spec.js
│ │ │ ├── destination/
│ │ │ │ └── create_destination_spec.js
│ │ │ ├── embed/
│ │ │ │ └── share_embed_spec.js
│ │ │ ├── group/
│ │ │ │ ├── edit_group_spec.js
│ │ │ │ └── group_list_spec.js
│ │ │ ├── query/
│ │ │ │ ├── create_query_spec.js
│ │ │ │ ├── filters_spec.js
│ │ │ │ ├── parameter_spec.js
│ │ │ │ └── query_tags_spec.js
│ │ │ ├── query-snippets/
│ │ │ │ └── create_query_snippet_spec.js
│ │ │ ├── settings/
│ │ │ │ ├── organization_settings_spec.js
│ │ │ │ └── settings_tabs_spec.js
│ │ │ ├── user/
│ │ │ │ ├── create_user_spec.js
│ │ │ │ ├── edit_profile_spec.js
│ │ │ │ ├── login_spec.js
│ │ │ │ ├── logout_spec.js
│ │ │ │ └── user_list_spec.js
│ │ │ └── visualizations/
│ │ │ ├── box_plot_spec.js
│ │ │ ├── chart_spec.js
│ │ │ ├── choropleth_spec.js
│ │ │ ├── cohort_spec.js
│ │ │ ├── counter_spec.js
│ │ │ ├── edit_visualization_dialog_spec.js
│ │ │ ├── funnel_spec.js
│ │ │ ├── map_spec.js
│ │ │ ├── pivot_spec.js
│ │ │ ├── sankey_sunburst_spec.js
│ │ │ ├── table/
│ │ │ │ ├── .mocks/
│ │ │ │ │ ├── all-cell-types.js
│ │ │ │ │ ├── large-dataset.js
│ │ │ │ │ ├── multi-column-sort.js
│ │ │ │ │ └── search-in-data.js
│ │ │ │ └── table_spec.js
│ │ │ └── word_cloud_spec.js
│ │ ├── seed-data.js
│ │ ├── support/
│ │ │ ├── commands.js
│ │ │ ├── dashboard/
│ │ │ │ └── index.js
│ │ │ ├── index.js
│ │ │ ├── parameters.js
│ │ │ ├── redash-api/
│ │ │ │ └── index.js
│ │ │ ├── tags/
│ │ │ │ └── index.js
│ │ │ └── visualizations/
│ │ │ ├── chart.js
│ │ │ └── table.js
│ │ └── tsconfig.json
│ ├── prettier.config.js
│ └── tsconfig.json
├── codecov.yml
├── compose.yaml
├── cypress.config.js
├── manage.py
├── migrations/
│ ├── 0001_warning.py
│ ├── README
│ ├── alembic.ini
│ ├── env.py
│ ├── script.py.mako
│ └── versions/
│ ├── 0ec979123ba4_.py
│ ├── 0f740a081d20_inline_tags.py
│ ├── 1038c2174f5d_make_case_insensitive_hash_of_query_text.py
│ ├── 1655999df5e3_default_alert_selector.py
│ ├── 1daa601d3ae5_add_columns_for_disabled_users.py
│ ├── 5ec5c84ba61e_.py
│ ├── 640888ce445d_.py
│ ├── 65fc9ede4746_add_is_draft_status_to_queries_and_.py
│ ├── 6b5be7e0a0ef_.py
│ ├── 71477dadd6ef_favorites_unique_constraint.py
│ ├── 7205816877ec_change_type_of_json_fields_from_varchar_.py
│ ├── 73beceabb948_bring_back_null_schedule.py
│ ├── 7671dca4e604_.py
│ ├── 7ce5925f832b_create_sqlalchemy_searchable_expressions.py
│ ├── 89bc7873a3e0_fix_multiple_heads.py
│ ├── 969126bd800f_.py
│ ├── 98af61feea92_add_encrypted_options_to_data_sources.py
│ ├── 9e8c841d1a30_fix_hash.py
│ ├── a92d92aa678e_inline_tags.py
│ ├── d1eae8b9893e_.py
│ ├── d4c798575877_create_favorites.py
│ ├── d7d747033183_encrypt_alert_destinations.py
│ ├── db0aca1ebd32_12_column_dashboard_layout.py
│ ├── e5c7a4e2df4d_remove_query_tracker_keys.py
│ ├── e7004224f284_add_org_id_to_favorites.py
│ ├── e7f8a917aa8e_add_user_details_json_column.py
│ └── fd4fc850d7ea_.py
├── netlify.toml
├── package.json
├── pnpm-workspace.yaml
├── pyproject.toml
├── pytest.ini
├── redash/
│ ├── __init__.py
│ ├── app.py
│ ├── authentication/
│ │ ├── __init__.py
│ │ ├── account.py
│ │ ├── google_oauth.py
│ │ ├── jwt_auth.py
│ │ ├── ldap_auth.py
│ │ ├── org_resolving.py
│ │ ├── remote_user_auth.py
│ │ └── saml_auth.py
│ ├── cli/
│ │ ├── __init__.py
│ │ ├── data_sources.py
│ │ ├── database.py
│ │ ├── groups.py
│ │ ├── organization.py
│ │ ├── queries.py
│ │ ├── rq.py
│ │ └── users.py
│ ├── destinations/
│ │ ├── __init__.py
│ │ ├── asana.py
│ │ ├── chatwork.py
│ │ ├── datadog.py
│ │ ├── discord.py
│ │ ├── email.py
│ │ ├── hangoutschat.py
│ │ ├── mattermost.py
│ │ ├── microsoft_teams_webhook.py
│ │ ├── pagerduty.py
│ │ ├── slack.py
│ │ ├── webex.py
│ │ └── webhook.py
│ ├── handlers/
│ │ ├── __init__.py
│ │ ├── admin.py
│ │ ├── alerts.py
│ │ ├── api.py
│ │ ├── authentication.py
│ │ ├── base.py
│ │ ├── dashboards.py
│ │ ├── data_sources.py
│ │ ├── databricks.py
│ │ ├── destinations.py
│ │ ├── embed.py
│ │ ├── events.py
│ │ ├── favorites.py
│ │ ├── groups.py
│ │ ├── organization.py
│ │ ├── permissions.py
│ │ ├── queries.py
│ │ ├── query_results.py
│ │ ├── query_snippets.py
│ │ ├── settings.py
│ │ ├── setup.py
│ │ ├── static.py
│ │ ├── users.py
│ │ ├── visualizations.py
│ │ ├── webpack.py
│ │ └── widgets.py
│ ├── metrics/
│ │ ├── __init__.py
│ │ ├── database.py
│ │ └── request.py
│ ├── models/
│ │ ├── __init__.py
│ │ ├── base.py
│ │ ├── changes.py
│ │ ├── mixins.py
│ │ ├── organizations.py
│ │ ├── parameterized_query.py
│ │ ├── types.py
│ │ └── users.py
│ ├── monitor.py
│ ├── permissions.py
│ ├── query_runner/
│ │ ├── __init__.py
│ │ ├── amazon_elasticsearch.py
│ │ ├── arango.py
│ │ ├── athena.py
│ │ ├── axibase_tsd.py
│ │ ├── azure_kusto.py
│ │ ├── big_query.py
│ │ ├── big_query_gce.py
│ │ ├── cass.py
│ │ ├── clickhouse.py
│ │ ├── cloudwatch.py
│ │ ├── cloudwatch_insights.py
│ │ ├── corporate_memory.py
│ │ ├── couchbase.py
│ │ ├── csv.py
│ │ ├── databend.py
│ │ ├── databricks.py
│ │ ├── db2.py
│ │ ├── dgraph.py
│ │ ├── drill.py
│ │ ├── druid.py
│ │ ├── duckdb.py
│ │ ├── e6data.py
│ │ ├── elasticsearch.py
│ │ ├── elasticsearch2.py
│ │ ├── exasol.py
│ │ ├── excel.py
│ │ ├── files/
│ │ │ ├── rds-combined-ca-bundle.pem
│ │ │ └── redshift-ca-bundle.crt
│ │ ├── google_analytics.py
│ │ ├── google_analytics4.py
│ │ ├── google_search_console.py
│ │ ├── google_spanner.py
│ │ ├── google_spreadsheets.py
│ │ ├── graphite.py
│ │ ├── hive_ds.py
│ │ ├── ignite.py
│ │ ├── impala_ds.py
│ │ ├── influx_db.py
│ │ ├── influx_db_v2.py
│ │ ├── jql.py
│ │ ├── json_ds.py
│ │ ├── kylin.py
│ │ ├── memsql_ds.py
│ │ ├── mongodb.py
│ │ ├── mssql.py
│ │ ├── mssql_odbc.py
│ │ ├── mysql.py
│ │ ├── nz.py
│ │ ├── oracle.py
│ │ ├── pg.py
│ │ ├── phoenix.py
│ │ ├── pinot.py
│ │ ├── presto.py
│ │ ├── prometheus.py
│ │ ├── python.py
│ │ ├── query_results.py
│ │ ├── risingwave.py
│ │ ├── rockset.py
│ │ ├── salesforce.py
│ │ ├── script.py
│ │ ├── snowflake.py
│ │ ├── sparql_endpoint.py
│ │ ├── sqlite.py
│ │ ├── tinybird.py
│ │ ├── treasuredata.py
│ │ ├── trino.py
│ │ ├── uptycs.py
│ │ ├── url.py
│ │ ├── vertica.py
│ │ ├── yandex_disk.py
│ │ └── yandex_metrica.py
│ ├── security.py
│ ├── serializers/
│ │ ├── __init__.py
│ │ └── query_result.py
│ ├── settings/
│ │ ├── __init__.py
│ │ ├── dynamic_settings.py
│ │ ├── helpers.py
│ │ └── organization.py
│ ├── tasks/
│ │ ├── __init__.py
│ │ ├── alerts.py
│ │ ├── databricks.py
│ │ ├── failure_report.py
│ │ ├── general.py
│ │ ├── queries/
│ │ │ ├── __init__.py
│ │ │ ├── execution.py
│ │ │ └── maintenance.py
│ │ ├── schedule.py
│ │ └── worker.py
│ ├── templates/
│ │ ├── _includes/
│ │ │ ├── signed_out_tail.html
│ │ │ └── tail.html
│ │ ├── emails/
│ │ │ ├── alert.html
│ │ │ ├── failures.html
│ │ │ ├── failures.txt
│ │ │ ├── invite.html
│ │ │ ├── invite.txt
│ │ │ ├── layout.html
│ │ │ ├── reset.html
│ │ │ ├── reset.txt
│ │ │ ├── reset_disabled.html
│ │ │ ├── reset_disabled.txt
│ │ │ ├── verify.html
│ │ │ └── verify.txt
│ │ ├── error.html
│ │ ├── forgot.html
│ │ ├── invite.html
│ │ ├── layouts/
│ │ │ └── signed_out.html
│ │ ├── login.html
│ │ ├── reset.html
│ │ ├── setup.html
│ │ └── verify.html
│ ├── utils/
│ │ ├── __init__.py
│ │ ├── configuration.py
│ │ ├── human_time.py
│ │ ├── pandas.py
│ │ ├── query_order.py
│ │ ├── requests_session.py
│ │ └── sentry.py
│ ├── version_check.py
│ ├── worker.py
│ └── wsgi.py
├── scripts/
│ └── README.md
├── setup/
│ └── README.md
├── tests/
│ ├── __init__.py
│ ├── destinations/
│ │ └── test_webhook.py
│ ├── factories.py
│ ├── handlers/
│ │ ├── __init__.py
│ │ ├── test_alerts.py
│ │ ├── test_authentication.py
│ │ ├── test_dashboards.py
│ │ ├── test_data_sources.py
│ │ ├── test_destinations.py
│ │ ├── test_embed.py
│ │ ├── test_favorites.py
│ │ ├── test_groups.py
│ │ ├── test_order_results.py
│ │ ├── test_paginate.py
│ │ ├── test_permissions.py
│ │ ├── test_queries.py
│ │ ├── test_query_results.py
│ │ ├── test_query_snippets.py
│ │ ├── test_settings.py
│ │ ├── test_users.py
│ │ ├── test_visualizations.py
│ │ └── test_widgets.py
│ ├── metrics/
│ │ ├── __init__.py
│ │ ├── test_database.py
│ │ └── test_request.py
│ ├── models/
│ │ ├── __init__.py
│ │ ├── test_alerts.py
│ │ ├── test_api_keys.py
│ │ ├── test_changes.py
│ │ ├── test_dashboards.py
│ │ ├── test_data_sources.py
│ │ ├── test_parameterized_query.py
│ │ ├── test_permissions.py
│ │ ├── test_queries.py
│ │ ├── test_query_results.py
│ │ └── test_users.py
│ ├── query_runner/
│ │ ├── __init__.py
│ │ ├── test_athena.py
│ │ ├── test_azure_kusto.py
│ │ ├── test_basequeryrunner.py
│ │ ├── test_basesql_queryrunner.py
│ │ ├── test_bigquery.py
│ │ ├── test_cass.py
│ │ ├── test_clickhouse.py
│ │ ├── test_databricks.py
│ │ ├── test_drill.py
│ │ ├── test_duckdb.py
│ │ ├── test_e6data.py
│ │ ├── test_elasticsearch2.py
│ │ ├── test_google_analytics4.py
│ │ ├── test_google_search_console.py
│ │ ├── test_google_spreadsheets.py
│ │ ├── test_http.py
│ │ ├── test_ignite.py
│ │ ├── test_influx_db.py
│ │ ├── test_influx_db_v2.py
│ │ ├── test_jql.py
│ │ ├── test_json_ds.py
│ │ ├── test_mongodb.py
│ │ ├── test_oracle.py
│ │ ├── test_pg.py
│ │ ├── test_prometheus.py
│ │ ├── test_python.py
│ │ ├── test_query_results.py
│ │ ├── test_script.py
│ │ ├── test_tinybird.py
│ │ ├── test_trino.py
│ │ ├── test_utils.py
│ │ ├── test_yandex_disk.py
│ │ └── test_yandex_metrica.py
│ ├── serializers/
│ │ ├── __init__.py
│ │ └── test_query_results.py
│ ├── tasks/
│ │ ├── __init__.py
│ │ ├── test_alerts.py
│ │ ├── test_empty_schedule.py
│ │ ├── test_failure_report.py
│ │ ├── test_queries.py
│ │ ├── test_refresh_queries.py
│ │ ├── test_refresh_schemas.py
│ │ ├── test_schedule.py
│ │ └── test_worker.py
│ ├── test_authentication.py
│ ├── test_cli.py
│ ├── test_configuration.py
│ ├── test_handlers.py
│ ├── test_migrations.py
│ ├── test_models.py
│ ├── test_monitor.py
│ ├── test_permissions.py
│ ├── test_utils.py
│ └── utils/
│ ├── __init__.py
│ └── test_json_dumps.py
├── viz-lib/
│ ├── .babelrc
│ ├── .gitignore
│ ├── CHANGELOG.md
│ ├── README.md
│ ├── __tests__/
│ │ ├── enzyme_setup.js
│ │ └── mocks.js
│ ├── package.json
│ ├── prettier.config.js
│ ├── src/
│ │ ├── components/
│ │ │ ├── ColorPicker/
│ │ │ │ ├── Input.tsx
│ │ │ │ ├── Label.tsx
│ │ │ │ ├── Swatch.tsx
│ │ │ │ ├── index.less
│ │ │ │ ├── index.tsx
│ │ │ │ ├── input.less
│ │ │ │ ├── label.less
│ │ │ │ ├── swatch.less
│ │ │ │ └── utils.ts
│ │ │ ├── ErrorBoundary.tsx
│ │ │ ├── HtmlContent.tsx
│ │ │ ├── TextAlignmentSelect/
│ │ │ │ ├── index.less
│ │ │ │ └── index.tsx
│ │ │ ├── json-view-interactive/
│ │ │ │ ├── JsonViewInteractive.tsx
│ │ │ │ └── json-view-interactive.less
│ │ │ ├── sortable/
│ │ │ │ ├── index.tsx
│ │ │ │ └── style.less
│ │ │ └── visualizations/
│ │ │ └── editor/
│ │ │ ├── ContextHelp.tsx
│ │ │ ├── Section.less
│ │ │ ├── Section.tsx
│ │ │ ├── Switch.less
│ │ │ ├── Switch.tsx
│ │ │ ├── TextArea.less
│ │ │ ├── TextArea.tsx
│ │ │ ├── context-help.less
│ │ │ ├── control-label.less
│ │ │ ├── createTabbedEditor.tsx
│ │ │ ├── index.ts
│ │ │ └── withControlLabel.tsx
│ │ ├── index.ts
│ │ ├── lib/
│ │ │ ├── chooseTextColorForBackground.ts
│ │ │ ├── hooks/
│ │ │ │ └── useMemoWithDeepCompare.ts
│ │ │ ├── referenceCountingCache.ts
│ │ │ ├── utils.ts
│ │ │ └── value-format.tsx
│ │ ├── services/
│ │ │ ├── resizeObserver.ts
│ │ │ └── sanitize.ts
│ │ └── visualizations/
│ │ ├── ColorPalette.ts
│ │ ├── Editor.tsx
│ │ ├── Renderer.tsx
│ │ ├── box-plot/
│ │ │ ├── Editor.tsx
│ │ │ ├── Renderer.tsx
│ │ │ ├── d3box.ts
│ │ │ ├── index.ts
│ │ │ └── renderer.less
│ │ ├── chart/
│ │ │ ├── Editor/
│ │ │ │ ├── AxisSettings.tsx
│ │ │ │ ├── ChartTypeSelect.tsx
│ │ │ │ ├── ColorsSettings.test.tsx
│ │ │ │ ├── ColorsSettings.tsx
│ │ │ │ ├── ColumnMappingSelect.tsx
│ │ │ │ ├── CustomChartSettings.tsx
│ │ │ │ ├── DataLabelsSettings.test.tsx
│ │ │ │ ├── DataLabelsSettings.tsx
│ │ │ │ ├── DefaultColorsSettings.tsx
│ │ │ │ ├── GeneralSettings.test.tsx
│ │ │ │ ├── GeneralSettings.tsx
│ │ │ │ ├── HeatmapColorsSettings.tsx
│ │ │ │ ├── PieColorsSettings.tsx
│ │ │ │ ├── SeriesSettings.test.tsx
│ │ │ │ ├── SeriesSettings.tsx
│ │ │ │ ├── XAxisSettings.test.tsx
│ │ │ │ ├── XAxisSettings.tsx
│ │ │ │ ├── YAxisSettings.test.tsx
│ │ │ │ ├── YAxisSettings.tsx
│ │ │ │ ├── __snapshots__/
│ │ │ │ │ ├── ColorsSettings.test.tsx.snap
│ │ │ │ │ ├── DataLabelsSettings.test.tsx.snap
│ │ │ │ │ ├── GeneralSettings.test.tsx.snap
│ │ │ │ │ ├── SeriesSettings.test.tsx.snap
│ │ │ │ │ ├── XAxisSettings.test.tsx.snap
│ │ │ │ │ └── YAxisSettings.test.tsx.snap
│ │ │ │ ├── editor.less
│ │ │ │ ├── index.test.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── Renderer/
│ │ │ │ ├── CustomPlotlyChart.tsx
│ │ │ │ ├── PlotlyChart.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ ├── initChart.ts
│ │ │ │ └── renderer.less
│ │ │ ├── fixtures/
│ │ │ │ └── getChartData/
│ │ │ │ ├── multiple-series-grouped.json
│ │ │ │ ├── multiple-series-multiple-y.json
│ │ │ │ ├── multiple-series-sorted.json
│ │ │ │ └── single-series.json
│ │ │ ├── getChartData.test.ts
│ │ │ ├── getChartData.ts
│ │ │ ├── getOptions.ts
│ │ │ ├── index.ts
│ │ │ └── plotly/
│ │ │ ├── customChartUtils.ts
│ │ │ ├── fixtures/
│ │ │ │ ├── prepareData/
│ │ │ │ │ ├── bar/
│ │ │ │ │ │ ├── default.json
│ │ │ │ │ │ ├── normalized.json
│ │ │ │ │ │ └── stacked.json
│ │ │ │ │ ├── box/
│ │ │ │ │ │ ├── default.json
│ │ │ │ │ │ └── with-points.json
│ │ │ │ │ ├── bubble/
│ │ │ │ │ │ └── default.json
│ │ │ │ │ ├── heatmap/
│ │ │ │ │ │ ├── default.json
│ │ │ │ │ │ ├── reversed.json
│ │ │ │ │ │ ├── sorted-reversed.json
│ │ │ │ │ │ ├── sorted.json
│ │ │ │ │ │ └── with-labels.json
│ │ │ │ │ ├── line-area/
│ │ │ │ │ │ ├── default.json
│ │ │ │ │ │ ├── keep-missing-values.json
│ │ │ │ │ │ ├── missing-values-0.json
│ │ │ │ │ │ ├── normalized-stacked.json
│ │ │ │ │ │ ├── normalized.json
│ │ │ │ │ │ └── stacked.json
│ │ │ │ │ ├── pie/
│ │ │ │ │ │ ├── custom-tooltip.json
│ │ │ │ │ │ ├── default.json
│ │ │ │ │ │ ├── without-labels.json
│ │ │ │ │ │ └── without-x.json
│ │ │ │ │ └── scatter/
│ │ │ │ │ ├── default.json
│ │ │ │ │ └── without-labels.json
│ │ │ │ └── prepareLayout/
│ │ │ │ ├── box-single-axis.json
│ │ │ │ ├── box-with-second-axis.json
│ │ │ │ ├── default-single-axis.json
│ │ │ │ ├── default-with-second-axis.json
│ │ │ │ ├── default-with-stacking.json
│ │ │ │ ├── default-without-legend.json
│ │ │ │ ├── pie-multiple-series.json
│ │ │ │ ├── pie-without-annotations.json
│ │ │ │ └── pie.json
│ │ │ ├── index.ts
│ │ │ ├── locales.ts
│ │ │ ├── prepareData.test.ts
│ │ │ ├── prepareData.ts
│ │ │ ├── prepareDefaultData.ts
│ │ │ ├── prepareHeatmapData.ts
│ │ │ ├── prepareLayout.test.ts
│ │ │ ├── prepareLayout.ts
│ │ │ ├── preparePieData.ts
│ │ │ ├── updateAxes.ts
│ │ │ ├── updateChartSize.ts
│ │ │ ├── updateData.ts
│ │ │ └── utils.ts
│ │ ├── choropleth/
│ │ │ ├── ColorPalette.ts
│ │ │ ├── Editor/
│ │ │ │ ├── BoundsSettings.tsx
│ │ │ │ ├── ColorsSettings.tsx
│ │ │ │ ├── FormatSettings.tsx
│ │ │ │ ├── GeneralSettings.tsx
│ │ │ │ ├── index.ts
│ │ │ │ └── utils.ts
│ │ │ ├── Renderer/
│ │ │ │ ├── Legend.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ ├── initChoropleth.tsx
│ │ │ │ ├── renderer.less
│ │ │ │ └── utils.ts
│ │ │ ├── getOptions.ts
│ │ │ ├── hooks/
│ │ │ │ └── useLoadGeoJson.ts
│ │ │ ├── index.ts
│ │ │ └── maps/
│ │ │ ├── convert-projection.ts
│ │ │ ├── countries.geo.json
│ │ │ ├── japan.prefectures.geo.json
│ │ │ ├── usa-albers.geo.json
│ │ │ └── usa.geo.json
│ │ ├── cohort/
│ │ │ ├── Cornelius.tsx
│ │ │ ├── Editor/
│ │ │ │ ├── AppearanceSettings.tsx
│ │ │ │ ├── ColorsSettings.tsx
│ │ │ │ ├── ColumnsSettings.tsx
│ │ │ │ ├── OptionsSettings.tsx
│ │ │ │ └── index.ts
│ │ │ ├── Renderer.tsx
│ │ │ ├── cornelius.less
│ │ │ ├── getOptions.ts
│ │ │ ├── index.ts
│ │ │ ├── prepareData.ts
│ │ │ └── renderer.less
│ │ ├── counter/
│ │ │ ├── Editor/
│ │ │ │ ├── FormatSettings.tsx
│ │ │ │ ├── GeneralSettings.tsx
│ │ │ │ └── index.ts
│ │ │ ├── Renderer.tsx
│ │ │ ├── index.ts
│ │ │ ├── render.less
│ │ │ ├── utils.test.ts
│ │ │ └── utils.ts
│ │ ├── details/
│ │ │ ├── Editor/
│ │ │ │ ├── ColumnEditor.tsx
│ │ │ │ ├── ColumnsSettings.test.tsx
│ │ │ │ ├── ColumnsSettings.tsx
│ │ │ │ ├── __snapshots__/
│ │ │ │ │ └── ColumnsSettings.test.tsx.snap
│ │ │ │ ├── editor.less
│ │ │ │ └── index.tsx
│ │ │ ├── Renderer.test.tsx
│ │ │ ├── Renderer.tsx
│ │ │ ├── details.less
│ │ │ ├── getOptions.test.ts
│ │ │ ├── getOptions.ts
│ │ │ └── index.ts
│ │ ├── funnel/
│ │ │ ├── Editor/
│ │ │ │ ├── AppearanceSettings.tsx
│ │ │ │ ├── GeneralSettings.tsx
│ │ │ │ └── index.ts
│ │ │ ├── Renderer/
│ │ │ │ ├── FunnelBar.tsx
│ │ │ │ ├── funnel-bar.less
│ │ │ │ ├── index.less
│ │ │ │ ├── index.tsx
│ │ │ │ └── prepareData.ts
│ │ │ ├── getOptions.ts
│ │ │ └── index.ts
│ │ ├── index.ts
│ │ ├── map/
│ │ │ ├── Editor/
│ │ │ │ ├── FormatSettings.tsx
│ │ │ │ ├── GeneralSettings.tsx
│ │ │ │ ├── GroupsSettings.tsx
│ │ │ │ ├── StyleSettings.tsx
│ │ │ │ └── index.ts
│ │ │ ├── Renderer.tsx
│ │ │ ├── getOptions.ts
│ │ │ ├── index.ts
│ │ │ ├── initMap.ts
│ │ │ └── prepareData.ts
│ │ ├── pivot/
│ │ │ ├── Editor.tsx
│ │ │ ├── Renderer.tsx
│ │ │ ├── index.ts
│ │ │ └── renderer.less
│ │ ├── prop-types.ts
│ │ ├── registeredVisualizations.ts
│ │ ├── sankey/
│ │ │ ├── Editor.tsx
│ │ │ ├── Renderer.tsx
│ │ │ ├── d3sankey.ts
│ │ │ ├── index.ts
│ │ │ ├── initSankey.ts
│ │ │ └── renderer.less
│ │ ├── shared/
│ │ │ ├── columnUtils.ts
│ │ │ ├── columns/
│ │ │ │ ├── __snapshots__/
│ │ │ │ │ ├── boolean.test.tsx.snap
│ │ │ │ │ ├── datetime.test.tsx.snap
│ │ │ │ │ ├── image.test.tsx.snap
│ │ │ │ │ ├── link.test.tsx.snap
│ │ │ │ │ ├── number.test.tsx.snap
│ │ │ │ │ └── text.test.tsx.snap
│ │ │ │ ├── boolean.test.tsx
│ │ │ │ ├── boolean.tsx
│ │ │ │ ├── datetime.test.tsx
│ │ │ │ ├── datetime.tsx
│ │ │ │ ├── image.test.tsx
│ │ │ │ ├── image.tsx
│ │ │ │ ├── index.ts
│ │ │ │ ├── json.tsx
│ │ │ │ ├── link.test.tsx
│ │ │ │ ├── link.tsx
│ │ │ │ ├── number.test.tsx
│ │ │ │ ├── number.tsx
│ │ │ │ ├── text.test.tsx
│ │ │ │ └── text.tsx
│ │ │ └── components/
│ │ │ ├── ColumnEditor.test.tsx
│ │ │ ├── ColumnEditor.tsx
│ │ │ └── ColumnsSettings.tsx
│ │ ├── sunburst/
│ │ │ ├── Editor.tsx
│ │ │ ├── Renderer.tsx
│ │ │ ├── index.ts
│ │ │ ├── initSunburst.ts
│ │ │ └── renderer.less
│ │ ├── table/
│ │ │ ├── Editor/
│ │ │ │ ├── ColumnEditor.tsx
│ │ │ │ ├── ColumnsSettings.test.tsx
│ │ │ │ ├── ColumnsSettings.tsx
│ │ │ │ ├── GridSettings.test.tsx
│ │ │ │ ├── GridSettings.tsx
│ │ │ │ ├── __snapshots__/
│ │ │ │ │ ├── ColumnsSettings.test.tsx.snap
│ │ │ │ │ └── GridSettings.test.tsx.snap
│ │ │ │ ├── editor.less
│ │ │ │ └── index.tsx
│ │ │ ├── Renderer.tsx
│ │ │ ├── getOptions.ts
│ │ │ ├── index.ts
│ │ │ ├── renderer.less
│ │ │ └── utils.tsx
│ │ ├── variables.less
│ │ ├── visualizationsSettings.tsx
│ │ └── word-cloud/
│ │ ├── Editor.tsx
│ │ ├── Renderer.tsx
│ │ ├── index.ts
│ │ └── renderer.less
│ ├── tsconfig.json
│ └── webpack.config.js
├── webpack.config.js
└── worker.conf
================================================
FILE CONTENTS
================================================
================================================
FILE: .ci/Dockerfile.cypress
================================================
FROM cypress/browsers:node-24.14.0-chrome-145.0.7632.116-1-ff-148.0-edge-145.0.3800.70-1
ENV APP /usr/src/app
WORKDIR $APP
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc $APP/
COPY viz-lib $APP/viz-lib
RUN npm install -g pnpm@10.30.3 && pnpm install --frozen-lockfile > /dev/null
COPY . $APP
RUN ./node_modules/.bin/cypress verify
================================================
FILE: .ci/compose.ci.yaml
================================================
services:
redash:
build: ../
command: manage version
depends_on:
- postgres
- redis
ports:
- "5000:5000"
environment:
PYTHONUNBUFFERED: 0
REDASH_LOG_LEVEL: "INFO"
REDASH_REDIS_URL: "redis://redis:6379/0"
POSTGRES_PASSWORD: "FmTKs5vX52ufKR1rd8tn4MoSP7zvCJwb"
REDASH_DATABASE_URL: "postgresql://postgres:FmTKs5vX52ufKR1rd8tn4MoSP7zvCJwb@postgres/postgres"
REDASH_COOKIE_SECRET: "2H9gNG9obnAQ9qnR9BDTQUph6CbXKCzF"
redis:
image: redis:7-alpine
restart: unless-stopped
postgres:
image: postgres:18-alpine
command: "postgres -c fsync=off -c full_page_writes=off -c synchronous_commit=OFF"
restart: unless-stopped
environment:
POSTGRES_HOST_AUTH_METHOD: "trust"
================================================
FILE: .ci/compose.cypress.yaml
================================================
x-redash-service: &redash-service
build:
context: ../
args:
install_groups: "main"
code_coverage: ${CODE_COVERAGE}
x-redash-environment: &redash-environment
REDASH_LOG_LEVEL: "INFO"
REDASH_REDIS_URL: "redis://redis:6379/0"
POSTGRES_PASSWORD: "FmTKs5vX52ufKR1rd8tn4MoSP7zvCJwb"
REDASH_DATABASE_URL: "postgresql://postgres:FmTKs5vX52ufKR1rd8tn4MoSP7zvCJwb@postgres/postgres"
REDASH_RATELIMIT_ENABLED: "false"
REDASH_ENFORCE_CSRF: "true"
REDASH_COOKIE_SECRET: "2H9gNG9obnAQ9qnR9BDTQUph6CbXKCzF"
services:
server:
<<: *redash-service
command: server
depends_on:
- postgres
- redis
ports:
- "5000:5000"
environment:
<<: *redash-environment
PYTHONUNBUFFERED: 0
scheduler:
<<: *redash-service
command: scheduler
depends_on:
- server
environment:
<<: *redash-environment
worker:
<<: *redash-service
command: worker
depends_on:
- server
environment:
<<: *redash-environment
PYTHONUNBUFFERED: 0
cypress:
ipc: host
build:
context: ../
dockerfile: .ci/Dockerfile.cypress
depends_on:
- server
- worker
- scheduler
environment:
CYPRESS_baseUrl: "http://server:5000"
CYPRESS_coverage: ${CODE_COVERAGE}
PERCY_TOKEN: ${PERCY_TOKEN}
PERCY_BRANCH: ${CIRCLE_BRANCH}
PERCY_COMMIT: ${CIRCLE_SHA1}
PERCY_PULL_REQUEST: ${CIRCLE_PR_NUMBER}
COMMIT_INFO_BRANCH: ${CIRCLE_BRANCH}
COMMIT_INFO_MESSAGE: ${COMMIT_INFO_MESSAGE}
COMMIT_INFO_AUTHOR: ${CIRCLE_USERNAME}
COMMIT_INFO_SHA: ${CIRCLE_SHA1}
COMMIT_INFO_REMOTE: ${CIRCLE_REPOSITORY_URL}
CYPRESS_PROJECT_ID: ${CYPRESS_PROJECT_ID}
CYPRESS_RECORD_KEY: ${CYPRESS_RECORD_KEY}
redis:
image: redis:7-alpine
restart: unless-stopped
postgres:
image: postgres:18-alpine
command: "postgres -c fsync=off -c full_page_writes=off -c synchronous_commit=OFF"
restart: unless-stopped
environment:
POSTGRES_HOST_AUTH_METHOD: "trust"
================================================
FILE: .ci/docker_build
================================================
#!/bin/bash
# This script only needs to run on the main Redash repo
if [ "${GITHUB_REPOSITORY}" != "getredash/redash" ]; then
echo "Skipping image build for Docker Hub, as this isn't the main Redash repository"
exit 0
fi
if [ "${GITHUB_REF_NAME}" != "master" ] && [ "${GITHUB_REF_NAME}" != "preview-image" ]; then
echo "Skipping image build for Docker Hub, as this isn't the 'master' nor 'preview-image' branch"
exit 0
fi
if [ "x${DOCKER_USER}" = "x" ] || [ "x${DOCKER_PASS}" = "x" ]; then
echo "Skipping image build for Docker Hub, as the login details aren't available"
exit 0
fi
set -e
VERSION=$(jq -r .version package.json)
VERSION_TAG="$VERSION.b${GITHUB_RUN_ID}.${GITHUB_RUN_NUMBER}"
export DOCKER_BUILDKIT=1
export COMPOSE_DOCKER_CLI_BUILD=1
docker login -u "${DOCKER_USER}" -p "${DOCKER_PASS}"
DOCKERHUB_REPO="redash/redash"
DOCKER_TAGS="-t redash/redash:preview -t redash/preview:${VERSION_TAG}"
# Build the docker container
docker build --build-arg install_groups="main,all_ds,dev" ${DOCKER_TAGS} .
# Push the container to the preview build locations
docker push "${DOCKERHUB_REPO}:preview"
docker push "redash/preview:${VERSION_TAG}"
echo "Built: ${VERSION_TAG}"
================================================
FILE: .ci/pack
================================================
#!/bin/bash
NAME=redash
VERSION=$(jq -r .version package.json)
FULL_VERSION=$VERSION+b$CIRCLE_BUILD_NUM
FILENAME=$NAME.$FULL_VERSION.tar.gz
mkdir -p /tmp/artifacts/
tar -zcv -f /tmp/artifacts/$FILENAME --exclude=".git" --exclude="optipng*" --exclude="cypress" --exclude="*.pyc" --exclude="*.pyo" --exclude="venv" *
================================================
FILE: .ci/update_version
================================================
#!/bin/bash
VERSION=$(jq -r .version package.json)
FULL_VERSION=${VERSION}+b${GITHUB_RUN_ID}.${GITHUB_RUN_NUMBER}
sed -ri "s/^__version__ = '([A-Za-z0-9.-]*)'/__version__ = '${FULL_VERSION}'/" redash/__init__.py
sed -i "s/dev/${GITHUB_SHA}/" client/app/version.json
================================================
FILE: .coveragerc
================================================
[run]
branch = True
source = redash
[report]
omit =
*/settings.py
*/python?.?/*
show_missing = True
================================================
FILE: .dockerignore
================================================
client/.tmp/
node_modules/
viz-lib/node_modules/
.tmp/
.venv/
venv/
.git/
/.codeclimate.yml
/.coverage
/coverage.xml
/.circleci/
/.github/
/netlify.toml
/setup/
================================================
FILE: .editorconfig
================================================
root = true
[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.py]
indent_style = space
indent_size = 4
[*.{js,jsx,css,less,html}]
indent_style = space
indent_size = 2
================================================
FILE: .github/ISSUE_TEMPLATE/---bug_report.md
================================================
---
name: "\U0001F41B Bug report"
about: Report reproducible software issues so we can improve
---
### Issue Summary
A summary of the issue and the browser/OS environment in which it occurs.
### Steps to Reproduce
1. This is the first step
2. This is the second step, etc.
Any other info e.g. Why do you consider this to be a bug? What did you expect to happen instead?
### Technical details:
* Redash Version:
* Browser/OS:
* How did you install Redash:
================================================
FILE: .github/ISSUE_TEMPLATE/--anything_else.md
================================================
---
name: "\U0001F4A1Anything else"
about: "For help, support, features & ideas - please use Discussions \U0001F46B "
labels: "Support Question"
---
We use GitHub only for bug reports 🐛
Anything else should be a discussion: https://github.com/getredash/redash/discussions/ 👫
🚨For support, help & questions use https://github.com/getredash/redash/discussions/categories/q-a
💡For feature requests & ideas use https://github.com/getredash/redash/discussions/categories/ideas
Alternatively, check out these resources below. Thanks! 😁.
- [Discussions](https://github.com/getredash/redash/discussions/)
- [Knowledge Base](https://redash.io/help)
================================================
FILE: .github/PULL_REQUEST_TEMPLATE.md
================================================
## What type of PR is this?
- [ ] Refactor
- [ ] Feature
- [ ] Bug Fix
- [ ] New Query Runner (Data Source)
- [ ] New Alert Destination
- [ ] Other
## Description
## How is this tested?
- [ ] Unit tests (pytest, jest)
- [ ] E2E Tests (Cypress)
- [ ] Manually
- [ ] N/A
## Related Tickets & Documents
## Mobile & Desktop Screenshots/Recordings (if there are UI changes)
================================================
FILE: .github/config.yml
================================================
# https://github.com/behaviorbot/request-info?installation_id=189571
requestInfoLabelToAdd: needs-more-info
requestInfoReplyComment: >
We would appreciate it if you could provide us with more info about this issue/pr!
================================================
FILE: .github/weekly-digest.yml
================================================
# Configuration for weekly-digest - https://github.com/apps/weekly-digest
publishDay: mon
canPublishIssues: true
canPublishPullRequests: true
canPublishContributors: true
canPublishStargazers: true
canPublishCommits: true
================================================
FILE: .github/workflows/ci.yml
================================================
name: Tests
on:
push:
branches:
- master
pull_request:
branches:
- master
env:
NODE_VERSION: 24
PNPM_VERSION: 10.30.3
jobs:
backend-lint:
runs-on: ubuntu-22.04
steps:
- if: github.event.pull_request.mergeable == 'false'
name: Exit if PR is not mergeable
run: exit 1
- uses: actions/checkout@v4
with:
fetch-depth: 1
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-python@v5
with:
python-version: '3.13'
- run: python -m pip install black==23.1.0 ruff==0.0.287
- run: ruff check .
- run: black --check .
backend-unit-tests:
runs-on: ubuntu-22.04
needs: backend-lint
env:
COMPOSE_FILE: .ci/compose.ci.yaml
COMPOSE_PROJECT_NAME: redash
COMPOSE_DOCKER_CLI_BUILD: 1
DOCKER_BUILDKIT: 1
steps:
- if: github.event.pull_request.mergeable == 'false'
name: Exit if PR is not mergeable
run: exit 1
- uses: actions/checkout@v4
with:
fetch-depth: 1
ref: ${{ github.event.pull_request.head.sha }}
- name: Build Docker Images
run: |
set -x
docker compose build --build-arg install_groups="main,all_ds,dev" --build-arg skip_frontend_build=true
docker compose up -d
sleep 10
- name: Create Test Database
run: docker compose -p redash run --rm postgres psql -h postgres -U postgres -c "create database tests;"
- name: List Enabled Query Runners
run: docker compose -p redash run --rm redash manage ds list_types
- name: Run Tests
run: docker compose -p redash run --name tests redash tests --junitxml=junit.xml --cov-report=xml --cov=redash --cov-config=.coveragerc tests/
- name: Copy Test Results
run: |
mkdir -p /tmp/test-results/unit-tests
docker cp tests:/app/coverage.xml ./coverage.xml
docker cp tests:/app/junit.xml /tmp/test-results/unit-tests/results.xml
# - name: Upload coverage reports to Codecov
# uses: codecov/codecov-action@v3
# with:
# token: ${{ secrets.CODECOV_TOKEN }}
- name: Store Test Results
uses: actions/upload-artifact@v4
with:
name: backend-test-results
path: /tmp/test-results
- name: Store Coverage Results
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage.xml
frontend-lint:
runs-on: ubuntu-22.04
steps:
- if: github.event.pull_request.mergeable == 'false'
name: Exit if PR is not mergeable
run: exit 1
- uses: actions/checkout@v4
with:
fetch-depth: 1
ref: ${{ github.event.pull_request.head.sha }}
- uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: Install Dependencies
run: pnpm install --frozen-lockfile
- name: Run Lint
run: pnpm run lint:ci
- name: Store Test Results
uses: actions/upload-artifact@v4
with:
name: frontend-test-results
path: /tmp/test-results
frontend-unit-tests:
runs-on: ubuntu-22.04
needs: frontend-lint
steps:
- if: github.event.pull_request.mergeable == 'false'
name: Exit if PR is not mergeable
run: exit 1
- uses: actions/checkout@v4
with:
fetch-depth: 1
ref: ${{ github.event.pull_request.head.sha }}
- uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: Install Dependencies
run: pnpm install --frozen-lockfile
- name: Run App Tests
run: pnpm run test
- name: Run Visualizations Tests
run: pnpm --filter @redash/viz test
- run: pnpm run lint
frontend-e2e-tests:
runs-on: ubuntu-22.04
needs: frontend-lint
env:
COMPOSE_FILE: .ci/compose.cypress.yaml
COMPOSE_PROJECT_NAME: cypress
CYPRESS_INSTALL_BINARY: 0
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: 1
# PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }}
# CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }}
# CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
steps:
- if: github.event.pull_request.mergeable == 'false'
name: Exit if PR is not mergeable
run: exit 1
- uses: actions/checkout@v4
with:
fetch-depth: 1
ref: ${{ github.event.pull_request.head.sha }}
- uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: Enable Code Coverage Report For Master Branch
if: endsWith(github.ref, '/master')
run: |
echo "CODE_COVERAGE=true" >> "$GITHUB_ENV"
- name: Install Dependencies
run: pnpm install --frozen-lockfile
- name: Setup Redash Server
run: |
set -x
pnpm run cypress build
pnpm run cypress start -- --skip-db-seed
docker compose run cypress pnpm run cypress db-seed
- name: Execute Cypress Tests
run: pnpm run cypress run-ci
- name: "Failure: output container logs to console"
if: failure()
run: docker compose logs
- name: Copy Code Coverage Results
run: docker cp cypress:/usr/src/app/coverage ./coverage || true
- name: Store Coverage Results
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage
================================================
FILE: .github/workflows/periodic-snapshot.yml
================================================
name: Periodic Snapshot
on:
schedule:
- cron: '10 0 1 * *' # 10 minutes after midnight on the first day of every month
workflow_dispatch:
inputs:
bump:
description: 'Bump the last digit of the version'
required: false
type: boolean
version:
description: 'Specific version to set'
required: false
default: ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
permissions:
actions: write
contents: write
jobs:
bump-version-and-tag:
runs-on: ubuntu-latest
if: github.ref_name == github.event.repository.default_branch
steps:
- uses: actions/checkout@v4
with:
ssh-key: ${{ secrets.ACTION_PUSH_KEY }}
- run: |
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
# Function to bump the version
bump_version() {
local version="$1"
local IFS=.
read -r major minor patch <<< "$version"
patch=$((patch + 1))
echo "$major.$minor.$patch-dev"
}
# Determine the new version tag
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
BUMP_INPUT="${{ github.event.inputs.bump }}"
SPECIFIC_VERSION="${{ github.event.inputs.version }}"
# Check if both bump and specific version are provided
if [ "$BUMP_INPUT" = "true" ] && [ -n "$SPECIFIC_VERSION" ]; then
echo "::error::Error: Cannot specify both bump and specific version."
exit 1
fi
if [ -n "$SPECIFIC_VERSION" ]; then
TAG_NAME="$SPECIFIC_VERSION-dev"
elif [ "$BUMP_INPUT" = "true" ]; then
CURRENT_VERSION=$(grep '"version":' package.json | awk -F\" '{print $4}')
TAG_NAME=$(bump_version "$CURRENT_VERSION")
else
echo "No version bump or specific version provided for manual dispatch."
exit 1
fi
else
TAG_NAME="$(date +%y.%m).0-dev"
fi
echo "New version tag: $TAG_NAME"
# Update version in files
gawk -i inplace -F: -v q=\" -v tag=${TAG_NAME} '/^ "version": / { print $1 FS, q tag q ","; next} { print }' package.json
gawk -i inplace -F= -v q=\" -v tag=${TAG_NAME} '/^__version__ =/ { print $1 FS, q tag q; next} { print }' redash/__init__.py
gawk -i inplace -F= -v q=\" -v tag=${TAG_NAME} '/^version =/ { print $1 FS, q tag q; next} { print }' pyproject.toml
git add package.json redash/__init__.py pyproject.toml
git commit -m "Snapshot: ${TAG_NAME}"
git tag ${TAG_NAME}
git push --atomic origin master refs/tags/${TAG_NAME}
# Run the 'preview-image' workflow if run this workflow manually
# For more information, please see the: https://docs.github.com/en/actions/security-guides/automatic-token-authentication
if [ "$BUMP_INPUT" = "true" ] || [ -n "$SPECIFIC_VERSION" ]; then
gh workflow run preview-image.yml --ref $TAG_NAME
fi
================================================
FILE: .github/workflows/preview-image.yml
================================================
name: Preview Image
on:
push:
tags:
- '*-dev'
workflow_dispatch:
inputs:
dockerRepository:
description: 'Docker repository'
required: true
default: 'preview'
type: choice
options:
- preview
- redash
env:
NODE_VERSION: 24
jobs:
build-skip-check:
runs-on: ubuntu-22.04
outputs:
skip: ${{ steps.skip-check.outputs.skip }}
steps:
- name: Skip?
id: skip-check
run: |
if [[ "${{ vars.DOCKER_USER }}" == '' ]]; then
echo 'Docker user is empty. Skipping build+push'
echo skip=true >> "$GITHUB_OUTPUT"
elif [[ "${{ secrets.DOCKER_PASS }}" == '' ]]; then
echo 'Docker password is empty. Skipping build+push'
echo skip=true >> "$GITHUB_OUTPUT"
elif [[ "${{ vars.DOCKER_REPOSITORY }}" == '' ]]; then
echo 'Docker repository is empty. Skipping build+push'
echo skip=true >> "$GITHUB_OUTPUT"
else
echo 'Docker user and password are set and branch is `master`.'
echo 'Building + pushing `preview` image.'
echo skip=false >> "$GITHUB_OUTPUT"
fi
build-docker-image:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
arch:
- amd64
- arm64
include:
- arch: amd64
os: ubuntu-22.04
- arch: arm64
os: ubuntu-22.04-arm
outputs:
VERSION_TAG: ${{ steps.version.outputs.VERSION_TAG }}
needs:
- build-skip-check
if: needs.build-skip-check.outputs.skip == 'false'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
ref: ${{ github.event.push.after }}
- uses: pnpm/action-setup@v4
with:
version: 10.30.3
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASS }}
- name: Install Dependencies
env:
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: true
run: pnpm install --frozen-lockfile
- name: Set version
id: version
run: |
set -x
.ci/update_version
VERSION_TAG=$(jq -r .version package.json)
echo "VERSION_TAG=$VERSION_TAG" >> "$GITHUB_OUTPUT"
- name: Build and push preview image to Docker Hub
id: build-preview
uses: docker/build-push-action@v4
if: ${{ github.event.inputs.dockerRepository == 'preview' || !github.event.workflow_run }}
with:
tags: |
${{ vars.DOCKER_REPOSITORY }}/redash
${{ vars.DOCKER_REPOSITORY }}/preview
context: .
build-args: |
test_all_deps=true
outputs: type=image,push-by-digest=true,push=true
cache-from: type=gha,scope=${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
env:
DOCKER_CONTENT_TRUST: true
- name: Build and push release image to Docker Hub
id: build-release
uses: docker/build-push-action@v4
if: ${{ github.event.inputs.dockerRepository == 'redash' }}
with:
tags: |
${{ vars.DOCKER_REPOSITORY }}/redash:${{ steps.version.outputs.VERSION_TAG }}
context: .
build-args: |
test_all_deps=true
outputs: type=image,push-by-digest=false,push=true
cache-from: type=gha,scope=${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
env:
DOCKER_CONTENT_TRUST: true
- name: "Failure: output container logs to console"
if: failure()
run: docker compose logs
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
if [[ "${{ github.event.inputs.dockerRepository }}" == 'preview' || !github.event.workflow_run ]]; then
digest="${{ steps.build-preview.outputs.digest}}"
else
digest="${{ steps.build-release.outputs.digest}}"
fi
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.arch }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
merge-docker-image:
runs-on: ubuntu-22.04
needs: build-docker-image
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASS }}
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Create and push manifest for the preview image
if: ${{ github.event.inputs.dockerRepository == 'preview' || !github.event.workflow_run }}
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create -t ${{ vars.DOCKER_REPOSITORY }}/redash:preview \
$(printf '${{ vars.DOCKER_REPOSITORY }}/redash:preview@sha256:%s ' *)
docker buildx imagetools create -t ${{ vars.DOCKER_REPOSITORY }}/preview:${{ needs.build-docker-image.outputs.VERSION_TAG }} \
$(printf '${{ vars.DOCKER_REPOSITORY }}/preview:${{ needs.build-docker-image.outputs.VERSION_TAG }}@sha256:%s ' *)
- name: Create and push manifest for the release image
if: ${{ github.event.inputs.dockerRepository == 'redash' }}
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create -t ${{ vars.DOCKER_REPOSITORY }}/redash:${{ needs.build-docker-image.outputs.VERSION_TAG }} \
$(printf '${{ vars.DOCKER_REPOSITORY }}/redash:${{ needs.build-docker-image.outputs.VERSION_TAG }}@sha256:%s ' *)
================================================
FILE: .github/workflows/restyled.yml
================================================
name: Restyled
on:
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
restyled:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- uses: restyled-io/actions/setup@v4
- id: restyler
uses: restyled-io/actions/run@v4
with:
fail-on-differences: true
- if: |
!cancelled() &&
steps.restyler.outputs.success == 'true' &&
github.event.pull_request.head.repo.full_name == github.repository
uses: peter-evans/create-pull-request@v6
with:
base: ${{ steps.restyler.outputs.restyled-base }}
branch: ${{ steps.restyler.outputs.restyled-head }}
title: ${{ steps.restyler.outputs.restyled-title }}
body: ${{ steps.restyler.outputs.restyled-body }}
labels: "restyled"
reviewers: ${{ github.event.pull_request.user.login }}
delete-branch: true
================================================
FILE: .gitignore
================================================
.venv
venv/
.cache
.coverage.*
.coveralls.yml
.idea
*.pyc
.nyc_output
coverage
.coverage
coverage.xml
client/dist
.DS_Store
.#*
\#*#
*~
_build
.vscode
.env
.tool-versions
dump.rdb
node_modules
.tmp
.sass-cache
npm-debug.log
client/cypress/screenshots
client/cypress/videos
================================================
FILE: .npmrc
================================================
engine-strict=true
auto-install-peers=true
shamefully-hoist=true
================================================
FILE: .nvmrc
================================================
v24
================================================
FILE: .pre-commit-config.yaml
================================================
repos:
- repo: https://github.com/psf/black
rev: 23.1.0
hooks:
- id: black
language_version: python3
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: "v0.0.287"
hooks:
- id: ruff
================================================
FILE: .restyled.yaml
================================================
enabled: true
auto: false
# Open Restyle PRs?
pull_requests: true
# Leave comments on the original PR linking to the Restyle PR?
comments: true
# Set commit statuses on the original PR?
statuses:
# Red status in the case of differences found
differences: true
# Green status in the case of no differences found
no_differences: true
# Red status if we encounter errors restyling
error: true
# Request review on the Restyle PR?
#
# Possible values:
#
# author: From the author of the original PR
# owner: From the owner of the repository
# none: Don't
#
# One value will apply to both origin and forked PRs, but you can also specify
# separate values.
#
# request_review:
# origin: author
# forked: owner
#
request_review: author
# Add labels to any created Restyle PRs
#
# These can be used to tell other automation to avoid our PRs.
#
labels:
- restyled
- "Skip CI"
# Labels to ignore
#
# PRs with any of these labels will be ignored by Restyled.
#
# ignore_labels:
# - restyled-ignore
# Restylers to run, and how
restylers:
- name: black
image: restyled/restyler-black:v24.4.2
include:
- redash
- tests
- migrations/versions
- name: prettier
image: restyled/restyler-prettier:v3.3.2-2
command:
- prettier
- --write
include:
- client/app/**/*.js
- client/app/**/*.jsx
- client/cypress/**/*.js
================================================
FILE: CHANGELOG.md
================================================
# Change Log
## 26.03.0
* Fix regular expression warning ([#7650](https://github.com/getredash/redash/pull/7650))
* Update Python version to 3.13 ([#7636](https://github.com/getredash/redash/pull/7636))
* Add last 2 years, last 3 years to the Date Range list ([#7635](https://github.com/getredash/redash/pull/7635))
* Update plotly.js to 3.3.1, react-pivottable to 0.11.0 ([#7634](https://github.com/getredash/redash/pull/7634))
* Add pivot chart ([#7632](https://github.com/getredash/redash/pull/7632))
* Aggregate y value for same x ([#7631](https://github.com/getredash/redash/pull/7631))
* cli: add --json option to users list command ([#7624](https://github.com/getredash/redash/pull/7624))
* Update packages for compatibility with setuptools 82 ([#7622](https://github.com/getredash/redash/pull/7622))
* Fix Elasticsearch connector configuration key mismatch ([#7607](https://github.com/getredash/redash/pull/7607))
* Support ipv6 for server in ipv6-only kubernetes cluster ([#7596](https://github.com/getredash/redash/pull/7596))
* fix(destinations): Handle unicode characters in webhook notifications ([#7586](https://github.com/getredash/redash/pull/7586))
* Add lineShape option for Line and Area charts ([#7582](https://github.com/getredash/redash/pull/7582))
* Feature/catch notsupported exception ([#7573](https://github.com/getredash/redash/pull/7573))
* Enhance dashboard parameter handling: persist updated values and apply saved parameters ([#7570](https://github.com/getredash/redash/pull/7570))
* Update queries.latest_query_data on save ([#7560](https://github.com/getredash/redash/pull/7560))
* Correct custom chart help text: use newPlot() ([#7557](https://github.com/getredash/redash/pull/7557))
* SchemaBrowser: on column comment tooltip, show newlines correctly ([#7552](https://github.com/getredash/redash/pull/7552))
* Query Serach: avoid concurrent search API request ([#7551](https://github.com/getredash/redash/pull/7551))
* Advanced query search syntax for multi byte search ([#7546](https://github.com/getredash/redash/pull/7546))
* Make details visualization configurable ([#7535](https://github.com/getredash/redash/pull/7535))
* Update ace-builds/react-ace to the latest versions ([#7532](https://github.com/getredash/redash/pull/7532))
* Fix/too many history replace state ([#7530](https://github.com/getredash/redash/pull/7530))
* Add range slider to the chart ([#7525](https://github.com/getredash/redash/pull/7525))
* Add "Missing and NULL values" option to scatter chart ([#7523](https://github.com/getredash/redash/pull/7523))
* fix: webpack missing source-map warning for @plotly/msgbox-gl ([#7522](https://github.com/getredash/redash/pull/7522))
* keep ordering on search ([#7520](https://github.com/getredash/redash/pull/7520))
* Fix: null is not shown for text with "Allow HTML content" ([#7519](https://github.com/getredash/redash/pull/7519))
* Fix stacking bar chart ([#7516](https://github.com/getredash/redash/pull/7516))
* Update plotly.js to 3.1.0 ([#7514](https://github.com/getredash/redash/pull/7514))
* Update Poetry to 2.1.4 ([#7509](https://github.com/getredash/redash/pull/7509))
* Update from webpack4 to webpack5 ([#7507](https://github.com/getredash/redash/pull/7507))
* Allow HTTP request line more than 4096 bytes ([#7506](https://github.com/getredash/redash/pull/7506))
* Add "Last 10 years" option for dynamic date range ([#7422](https://github.com/getredash/redash/pull/7422))
* Make favorite queries/dashboard order by starred at(favorited at) ([#7351](https://github.com/getredash/redash/pull/7351))
* Fix height for mobile safari not to overlap URL bar ([#7334](https://github.com/getredash/redash/pull/7334))
* Multi-org: format base path, not including protocol ([#7260](https://github.com/getredash/redash/pull/7260))
* PostgreSQL
* PostgreSQL: Rely on information_schema.columns for views and foreign tables ([#7521](https://github.com/getredash/redash/pull/7521))
* PostgreSQL: allow connection parameters to be specified ([#7579](https://github.com/getredash/redash/pull/7579))
* changed pg.py has_privileges function to include special characters a… ([#7574](https://github.com/getredash/redash/pull/7574))
* Use standard PostgreSQL image and drop clean-all target ([#7555](https://github.com/getredash/redash/pull/7555))
* MySQL
* MySQL: add column type, comment, and table comment on Schema Browser ([#7544](https://github.com/getredash/redash/pull/7544))
* Add charset option to RDS MySQL datasource ([#7616](https://github.com/getredash/redash/pull/7616))
* fix(mysql): Change default charset to utf8mb4 ([#7615](https://github.com/getredash/redash/pull/7615))
* BigQuery
* BigQuery: show table description tooltip in Schema Browser ([#7543](https://github.com/getredash/redash/pull/7543))
* BigQuery: Remove "Job ID" metadata on annotaton to avoid cache misses ([#7541](https://github.com/getredash/redash/pull/7541))
* BigQuery: support multiple dataset locations ([#7540](https://github.com/getredash/redash/pull/7540))
* BigQuery: show column description(comment) on Schema Browser ([#7538](https://github.com/getredash/redash/pull/7538))
* DuckDB
* Add duckdb support ([#7548](https://github.com/getredash/redash/pull/7548))
* duckdb: Show catalog (database) where applicable (e.g. Motherduck) ([#7599](https://github.com/getredash/redash/pull/7599))
* Trino
* Serialize Trino ROW types as JSON objects with field names ([#7644](https://github.com/getredash/redash/pull/7644))
* added passing client_tags option to Trino plugin ([#7633](https://github.com/getredash/redash/pull/7633))
* Add impersonation option in trino datasource ([#7605](https://github.com/getredash/redash/pull/7605))
* DB2
* Add ibm-db package to enable DB2 as datasource: ([#7581](https://github.com/getredash/redash/pull/7581))
* Jira
* Update jql.py (jira datasource) to use jira api v3 updated. ([#7527](https://github.com/getredash/redash/pull/7527))
* Snowflake
* Add private_key auth method to snowflake query runner ([#7371](https://github.com/getredash/redash/pull/7371))
## 25.08.0
* MongoDB: fix for empty username/password (Issue #7486) ([#7487](https://github.com/getredash/redash/pull/7487))
* clickhouse: display data types ([#7490](https://github.com/getredash/redash/pull/7490))
* Clickhouse: do not display INFORMATION_SCHEMA tables ([#7489](https://github.com/getredash/redash/pull/7489))
* Sort Dashboard and Query tags by name([#7484](https://github.com/getredash/redash/pull/7484))
* Add migration to set default alert selector([#7475](https://github.com/getredash/redash/pull/7475))
* Make refresh cookie name configurable([#7473](https://github.com/getredash/redash/pull/7473))
* Make NULL values visible([#7439](https://github.com/getredash/redash/pull/7439))
* Fix: saving empty query with auto limit crashes([#7430](https://github.com/getredash/redash/pull/7430))
* Push image using DOCKER_REPOSITORY([#7428](https://github.com/getredash/redash/pull/7428))
* Update assertion method in JSON dumps test([#7424](https://github.com/getredash/redash/pull/7424))
* Add translate="no" to html tag to prevent redash from translating and crashing([#7421](https://github.com/getredash/redash/pull/7421))
* Update Azure Data Explorer query runner to latest version([#7411](https://github.com/getredash/redash/pull/7411))
* Use 12-column layout for dashboard grid([#7396](https://github.com/getredash/redash/pull/7396))
* Change BigQuery super class from BaseQueryRunner to BaseSQLQueryRunner([#7378](https://github.com/getredash/redash/pull/7378))
* Upgrade prettier version to the same version that CI is using([#7367](https://github.com/getredash/redash/pull/7367))
* Fix table item list ordering([#7366](https://github.com/getredash/redash/pull/7366))
* Fix to make "show data labels" works([#7363](https://github.com/getredash/redash/pull/7363))
* Upgrade plotly.js to version 2 to fix the UI crashing issue([#7359](https://github.com/getredash/redash/pull/7359))
* TiDB: Exclude INFORMATION_SCHEMA([#7352](https://github.com/getredash/redash/pull/7352))
* Sanitize NaN, Infinite, -Infinite causing error when saving as PostgreSQL JSON([#7348](https://github.com/getredash/redash/pull/7348))
* Fix UnboundLocalError when checking alerts for query([#7346](https://github.com/getredash/redash/pull/7346))
* BigQuery: Avoid too long(10 seconds) interval for bigquery api to get results([#7342](https://github.com/getredash/redash/pull/7342))
* Add NULLS LAST option for query list ordering([#7341](https://github.com/getredash/redash/pull/7341))
* TypeScript sourcemap for viz-lib([#7336](https://github.com/getredash/redash/pull/7336))
* Fix the issue that chart(scatter, bubble, line...) having data with same x-value have wrong y-value([#7330](https://github.com/getredash/redash/pull/7330))
* Partiallly Revert "Remove workaround from check_csrf() (#6919)" ([#7327](https://github.com/getredash/redash/pull/7327))
* Make autocomplete always available([#7326](https://github.com/getredash/redash/pull/7326))
* Include Plotly.js localization([#7323](https://github.com/getredash/redash/pull/7323))
* Use absolute path for image resources([#7322](https://github.com/getredash/redash/pull/7322))
* Build/Test
* Require vars.DOCKER_REPOSITORY to publish image([#7400](https://github.com/getredash/redash/pull/7400))
* ci: snapshot only on default branch([#7355](https://github.com/getredash/redash/pull/7355))
* Push by tag name for Docker repository "redash"([#7321](https://github.com/getredash/redash/pull/7321))
* Dependencies
* Bump tar-fs from 2.1.1 to 2.1.2([#7385](https://github.com/getredash/redash/pull/7385))
## 25.01.0
* Support result reuse in Athena data sources ([\#7202](https://github.com/getredash/redash/pull/7202))
* Handle the case when query runner configuration is an empty dict ([\#7258](https://github.com/getredash/redash/pull/7258))
* BigQuery: add date, datetime type mapping ([\#7252](https://github.com/getredash/redash/pull/7252))
* Build/Test
* Create workflow trigger for publishing release image ([\#7259](https://github.com/getredash/redash/pull/7259))
* Dependencies
* Bump actions/upload-artifact from v3 to v4 ([\#7266](https://github.com/getredash/redash/pull/7266))
* Bump jinja2 from 3.1.4 to 3.1.5 ([\#7262](https://github.com/getredash/redash/pull/7262))
* Bump nanoid from 3.3.6 to 3.3.8 ([\#7249](https://github.com/getredash/redash/pull/7249))
* Bump to paramiko-3.4.1 ([\#7240](https://github.com/getredash/redash/pull/7240))
## 24.12.0
* Replace ptvsd with debugpy to match modern VS Code ([\#7234](https://github.com/getredash/redash/pull/7234))
* Alerts: Only evaluate the next state if there's a value ([\#7222](https://github.com/getredash/redash/pull/7222))
* Bring back version check & beacon reporting ([\#7211](https://github.com/getredash/redash/pull/7211))
## 24.11.0
* Alerts: don't crash when there is no data ([\#7208](https://github.com/getredash/redash/pull/7208))
* Fix issue with scheduled queries ([\#7111](https://github.com/getredash/redash/pull/7111))
* Correctly rehash queries in a migration ([\#7184](https://github.com/getredash/redash/pull/7184))
* Use correct redis connection ([\#7077](https://github.com/getredash/redash/pull/7077))
* Fix RQ wrongly moving jobs to FailedJobRegistry ([\#7186](https://github.com/getredash/redash/pull/7186))
* Build/Test
* Move restyled to a github action ([\#7191](https://github.com/getredash/redash/pull/7191))
* Docker build: use heredoc for multi-line actions ([\#7210](https://github.com/getredash/redash/pull/7210))
* Dependencies
* Bump cryptography from 42.0.8 to 43.0.1 ([\#7205](https://github.com/getredash/redash/pull/7205))
* Bump http-proxy-middleware from 2.0.6 to 2.0.7 ([\#7204](https://github.com/getredash/redash/pull/7204))
* Bump snowflake-connector-python from 3.12.0 to 3.12.3 ([\#7203](https://github.com/getredash/redash/pull/7203))
* Bump restrictedpython from 6.2 to 7.3 ([\#7181](https://github.com/getredash/redash/pull/7181))
* Bump elliptic from 6.5.7 to 6.6.0 ([\#7214](https://github.com/getredash/redash/pull/7214))
## 24.10.0
* Get rid of the strange looking 0 following "Running..." and "runtime" ([\#7099](https://github.com/getredash/redash/pull/7099))
* Automatically remove orphans when running make up ([\#7164](https://github.com/getredash/redash/pull/7164))
* Update `make up` to automatically initialize the db ([\#7161](https://github.com/getredash/redash/pull/7161))
* Better error msg for token validation ([\#7159](https://github.com/getredash/redash/pull/7159))
* Add REDASH_HOST to the docker compose file ([\#7157](https://github.com/getredash/redash/pull/7157))
* Make schema refresh timeout configurable via env var ([\#7114](https://github.com/getredash/redash/pull/7114))
* Dependencies
* Update pymssql to fix some problems with macOS ARM64 (`2.3.1`) ([\#7140](https://github.com/getredash/redash/pull/7140))
* Bump body-parser from 1.20.1 to 1.20.3 ([\#7156](https://github.com/getredash/redash/pull/7156))
* Bump express from 4.19.2 to 4.21.0 ([\#7155](https://github.com/getredash/redash/pull/7155))
* Bump path-to-regexp from 3.2.0 to 3.3.0 ([\#7154](https://github.com/getredash/redash/pull/7154))
* Bump dompurify from 2.0.17 to 2.5.4 in /viz-lib ([\#7163](https://github.com/getredash/redash/pull/7163))
## 24.09.0
* Add option to choose color scheme for charts ([\#7062](https://github.com/getredash/redash/pull/7062))
* Add data type to Athena query runner ([\#7112](https://github.com/getredash/redash/pull/7112))
* Add data type to Redshift query runner ([\#7109](https://github.com/getredash/redash/pull/7109))
* Fix a display order bug in MongoDB Query Runner ([\#7106](https://github.com/getredash/redash/pull/7106))
* Add the option to take new custom version for Snapshot ([\#7096](https://github.com/getredash/redash/pull/7096))
* Update certificates for RDS ([\#7100](https://github.com/getredash/redash/pull/7100))
* Fix columns duplication on MongoDB Query Runner [\#6640](https://github.com/getredash/redash/pull/6640) ([\#6641](https://github.com/getredash/redash/pull/6641))
* Get data size in memory for better logs ([\#7090](https://github.com/getredash/redash/pull/7090))
* Adding Evaluate button for alerts to test them ([\#7032](https://github.com/getredash/redash/pull/7032))
* Add min/max/first selector for alerts ([\#7076](https://github.com/getredash/redash/pull/7076))([\#7103](https://github.com/getredash/redash/pull/7103))
* Add support for 'linux/arm64' platforms ([\#7094](https://github.com/getredash/redash/pull/7094))
* Regressions
* Revert "Removed unused configuration class ([\#6682](https://github.com/getredash/redash/pull/6682))" ([\#7071](https://github.com/getredash/redash/pull/7071))
* Revert "Adding ability to fix table columns in place ([\#7019](https://github.com/getredash/redash/pull/7019))" ([\#7131](https://github.com/getredash/redash/pull/7131))
* Dependencies
* Fix mismatched poetry version ([\#7122](https://github.com/getredash/redash/pull/7122))
* Bump cryptography to 42.0.x & snowflake-connector-python to 3.12.0 ([\#7097](https://github.com/getredash/redash/pull/7097))
* Bump webpack from 5.88.2 to 5.94.0 in /viz-lib ([\#7135](https://github.com/getredash/redash/pull/7135))
* Bump bootstrap to 3.4.1
* Bump sentry-sdk from 1.28.1 to 2.8.0 ([\#7069](https://github.com/getredash/redash/pull/7069))
* Bump python-rapidjson to 1.20 ([\#7126](https://github.com/getredash/redash/pull/7126))
* Bump elliptic to version 6.5.7 to fix a Dependabot warning ([\#7120](https://github.com/getredash/redash/pull/7120))
## 24.08.0
* Remove defaults set during schema upgrade/downgrade [\#7068](https://github.com/getredash/redash/pull/7068)
* Athena: Support Arbitrary Catalog ID [\#7059](https://github.com/getredash/redash/pull/7059)
* Add option to toggle sort on pie charts [\#7055](https://github.com/getredash/redash/pull/7055)
* Conditionally render tooltip for Edit alert button [\#7054](https://github.com/getredash/redash/pull/7054)
* Make Redash FIPS compatible [\#7049](https://github.com/getredash/redash/pull/7049)
* Add a label for Restyler's PR and Bump component version [\#7037](https://github.com/getredash/redash/pull/7037)
* Add new text pattern parameter input type [\#7025](https://github.com/getredash/redash/pull/7025)
* Adding ability to fix table columns in place [\#7019](https://github.com/getredash/redash/pull/7019) (_reverted in [#7131](https://github.com/getredash/redash/pull/7131)_)
* Fixed frontend test deprecation warnings [\#7013](https://github.com/getredash/redash/pull/7013)
* Dependencies
* Bump setuptools from 69.0.3 to 70.0.0 [\#7060](https://github.com/getredash/redash/pull/7060)
* Bump requests to 2.32.3 [\#7057](https://github.com/getredash/redash/pull/7057)
* Bump zipp from 3.17.0 to 3.19.1 [\#7051](https://github.com/getredash/redash/pull/7051)
* Bump certifi from 2023.11.17 to 2024.7.4 [\#7047](https://github.com/getredash/redash/pull/7047)
* Bump ws from 5.2.3 to 5.2.4 in /viz-lib [\#7040](https://github.com/getredash/redash/pull/7040)
## 24.07.0
* Map() implementation fix for chart labels [\#7022](https://github.com/getredash/redash/pull/7022)
* PostgreSQL: Only list tables where schema has USAGE permission [\#7000](https://github.com/getredash/redash/pull/7000)
* Update to Python 3.10 / Debian 12 [\#6991](https://github.com/getredash/redash/pull/6991)
* Dependencies
* Bump ws from 5.2.3 to 5.2.4 [\#7021](https://github.com/getredash/redash/pull/7021)
* Bump urllib3 from 1.26.18 to 1.26.19 [\#7020](https://github.com/getredash/redash/pull/7020)
## 24.06.0 [broken]
* Sync .nvmrc with workflow [\#6958](https://github.com/getredash/redash/pull/6958)
* Fix 'str' object has no attribute 'pop' error when parsing query [\#6941](https://github.com/getredash/redash/pull/6941)
* pgautoupgrade now does multi-arch builds [\#6939](https://github.com/getredash/redash/pull/6939)
* Fix error serialization [\#6937](https://github.com/getredash/redash/pull/6937)
* Dependencies
* Bump requests from 2.31.0 to 2.32.0 [\#6981](https://github.com/getredash/redash/pull/6981)
* Bump pyodbc from 4.0.28 to 5.1.0 [\#6962](https://github.com/getredash/redash/pull/6962)
* Bump jinja2 from 3.1.3 to 3.1.4 [\#6951](https://github.com/getredash/redash/pull/6951)
## 24.05.0 [broken]
* Downgrade 'codecov-action' version from v4 to v3 [\#6930](https://github.com/getredash/redash/pull/6930)
* Update widgets.py [\#6926](https://github.com/getredash/redash/pull/6926)
* Use staticPath var to fetch unsupportedRedirect.js [\#6923](https://github.com/getredash/redash/pull/6923)
* Remove workaround from check\_csrf() [\#6919](https://github.com/getredash/redash/pull/6919)
* Bugfix: unable to parse elasticsearch index mappings [\#6918](https://github.com/getredash/redash/pull/6918)
* Consistent rq status naming and handling [\#6913](https://github.com/getredash/redash/pull/6913)
* Fix: aws elasticsearch typo [\#6899](https://github.com/getredash/redash/pull/6899)
* Add pydeps Makefile target for installing Python dependencies [\#6890](https://github.com/getredash/redash/pull/6890)
* Improve the text displayed when using the command line [\#6884](https://github.com/getredash/redash/pull/6884)
* Fixed local setup to run on ARM64 [\#6877](https://github.com/getredash/redash/pull/6877)
* Fix for coverage [\#6872](https://github.com/getredash/redash/pull/6872)
* Use default docker repo name if variable is not defined [\#6870](https://github.com/getredash/redash/pull/6870)
* Fix percy for a branch [\#6868](https://github.com/getredash/redash/pull/6868)
* Update yarn to current latest in 1.22.x series [\#6858](https://github.com/getredash/redash/pull/6858)
* Extend \`make up\` to automatically initialise the database [\#6855](https://github.com/getredash/redash/pull/6855)
* Remove version check and all of the data sharing [\#6852](https://github.com/getredash/redash/pull/6852)
* Automatically use the latest version of PostgreSQL [\#6851](https://github.com/getredash/redash/pull/6851)
* Remove Qubole query runner [\#6848](https://github.com/getredash/redash/pull/6848)
* Update "make clean" to remove Redash dev Docker images [\#6847](https://github.com/getredash/redash/pull/6847)
* Handle timedelta in query results [\#6846](https://github.com/getredash/redash/pull/6846)
* Autoformat hyperlinks in Slack alerts [\#6845](https://github.com/getredash/redash/pull/6845)
* Flatten all level for MongoDB data source [\#6844](https://github.com/getredash/redash/pull/6844)
* Filter widget results to fix tests during repeatable execution [\#6693](https://github.com/getredash/redash/pull/6693)
* Reuse built frontend in ci, merge compose files [\#6674](https://github.com/getredash/redash/pull/6674)
* Show pg and athena column comments and table descriptions as antd tooltip if they are defined [\#6582](https://github.com/getredash/redash/pull/6582)
* Dependencies
* Bump gunicorn to 22.0.0 [\#6900](https://github.com/getredash/redash/pull/6900)
* Bump sqlparse from 0.4.4 to 0.5.0 [\#6895](https://github.com/getredash/redash/pull/6895)
* Bump dnspython from 2.4.2 to 2.6.1 [\#6886](https://github.com/getredash/redash/pull/6886)
* Bump python-oracledb from 2.0.1 to 2.1.2 [\#6881](https://github.com/getredash/redash/pull/6881)
* Bump idna from 3.6 to 3.7 [\#6878](https://github.com/getredash/redash/pull/6878)
* Bump tar from 6.1.15 to 6.2.1 [\#6866](https://github.com/getredash/redash/pull/6866)
* Bump pymongo from 4.3.3 to 4.6.3 [\#6863](https://github.com/getredash/redash/pull/6863)
* Bump Rq from 1.9.0 to 1.16.1 [\#6902](https://github.com/getredash/redash/pull/6902)
## 24.04.0 [broken]
* Handle decimal types in query results [\#6837](https://github.com/getredash/redash/pull/6837)
* BigQuery: use default for useQueryAnnotation option [\#6824](https://github.com/getredash/redash/pull/6824)
* Uncaught rejection promise error in Edit Visualization Dialog Modal [\#6794](https://github.com/getredash/redash/pull/6794)
* Add RisingWave support [\#6776](https://github.com/getredash/redash/pull/6776)
* Schedule may not contain an until key [\#6771](https://github.com/getredash/redash/pull/6771)
* ClickHouse query runner: fixed error message [\#6764](https://github.com/getredash/redash/pull/6764)
* Dependencies
* Bump express from 4.18.2 to 4.19.2 [\#6838](https://github.com/getredash/redash/pull/6838)
* Bump webpack-dev-middleware from 5.3.3 to 5.3.4 [\#6829](https://github.com/getredash/redash/pull/6829)
* Bump jwcrypto from 1.5.1 to 1.5.6 [\#6816](https://github.com/getredash/redash/pull/6816)
* Bump follow-redirects from 1.15.5 to 1.15.6 in /viz-lib [\#6813](https://github.com/getredash/redash/pull/6813)
* Bump es5-ext from 0.10.53 to 0.10.63 in /viz-lib [\#6782](https://github.com/getredash/redash/pull/6782)
## 24.03.0
- Publish preview Docker image when release candidate is tagged [#6787](https://github.com/getredash/redash/pull/6787) [#6789](https://github.com/getredash/redash/pull/6789)
## 24.02.0
- Converted `text` columns to `jsonb` [#6687](https://github.com/getredash/redash/pull/6687) [#6713](https://github.com/getredash/redash/pull/6713)
- Update query hash with parameters applied [#6683](https://github.com/getredash/redash/pull/6683)
## 24.01.0
- Prometheus:
- Add ssl support [#6657](https://github.com/getredash/redash/pull/6657)
- Influxdb
- Add v2 query runner [#6646](https://github.com/getredash/redash/pull/6646)
## 23.12.0
- Add unarchive button to dashboard
- Add fork dashboard function
- Default to last used datasource
- Regression fix
- Revert "Switch from numeral to numbro)" [#6595](https://github.com/getredash/redash/pull/6595)
## 23.11.0
- Add query Runner for Yandex Disk
- New connection options for MySQL [#6538](https://github.com/getredash/redash/pull/6538)
- Regression fix
- Revert "Render counter widgets using relative font size" [#6566](https://github.com/getredash/redash/pull/6566)
## 23.10.0
- Avoid updating query result for archived queries [#6488](https://github.com/getredash/redash/pull/6488)
- Add Tinybird query runner [#5616](https://github.com/getredash/redash/pull/5616)
## 23.09.0
- Allow the X and Y tick format to be customized using a [D3 format string](https://redash.io/help/user-guide/visualizations/formatting-axis/) [#6370](https://github.com/getredash/redash/pull/6370)
- Add support for a default alert template [#5996](https://github.com/getredash/redash/pull/5996)
- New alert destination: Asana [#5753](https://github.com/getredash/redash/pull/5753)
- Fix query refresh if name contain control characters [#5602](https://github.com/getredash/redash/pull/5602)
- Allow RSA key used for JWT to be specified as a file path [#6271](https://github.com/getredash/redash/pull/6271)
- Add 'click' functionality for Chart visualization
## V10.1.0 - 2021-11-23
This release includes patches for three security vulnerabilities:
- Insecure default configuration affects installations where REDASH_COOKIE_SECRET is not set explicitly (CVE-2021-41192)
- SSRF vulnerability affects installations that enabled URL-loading data sources (CVE-2021-43780)
- Incorrect usage of state parameter in OAuth client code affects installations where Google Login is enabled (CVE-2021-43777)
And a couple features that didn't merge in time for 10.0.0
- Big Query: Speed up schema loading (#5632)
- Add support for Firebolt data source (#5606)
- Fix: Loading schema for Sqlite DB with "Order" column name fails (#5623)
## v10.0.0 - 2021-10-01
A few changes were merged during the V10 beta period.
- New Data Source: CSV/Excel Files
- Fix: Edit Source button disappeared for users without CanEdit permissions
- We pinned our docker base image to Python3.7-slim-buster to avoid build issues
- Fix: dashboard list pagination didn't work
## v10.0.0-beta - 2021-06-16
Just over a year since our last release, the V10 beta is ready. Since we never made a non-beta release of V9, we expect many users will upgrade directly from V8 -> V10. This will bring a lot of exciting features. Please check out the V9 beta release notes below to learn more.
This V10 beta incorporates fixes for the feedback we received on the V9 beta along with a few long-requested features (horizontal bar charts!) and other changes to improve UX and reliability.
This release was made possible by contributions from 35+ people (the Github API didn't let us pull handles this time around): Alex Kovar, Alexander Rusanov, Arik Fraimovich, Ben Amor, Christopher Grant, Đặng Minh Dũng, Daniel Lang, deecay, Elad Ossadon, Gabriel Dutra, iwakiriK, Jannis Leidel, Jerry, Jesse Whitehouse, Jiajie Zhong, Jim Sparkman, Jonathan Hult, Josh Bohde, Justin Talbot, koooge, Lei Ni, Levko Kravets, Lingkai Kong, max-voronov, Mike Nason, Nolan Nichols, Omer Lachish, Patrick Yang, peterlee, Rafael Wendel, Sebastian Tramp, simonschneider-db, Tim Gates, Tobias Macey, Vipul Mathur, and Vladislav Denisov
Our special thanks to [Sohail Ahmed](https://pk.linkedin.com/in/sohail-ahmed-755776184) for reporting a vulnerability in our "forgot password" page (#5425)
### Upgrading
(This section is duplicated from the previous release - since many users will upgrade directly from V8 -> V10)
Typically, if you are running your own instance of Redash and wish to upgrade, you would simply modify the Docker tag in your `docker-compose.yml` file. Since RQ has replaced Celery in this version, there are a couple extra modifications that need to be done in your `docker-compose.yml`:
1. Under `services/scheduler/environment`, omit `QUEUES` and `WORKERS_COUNT` (and omit `environment` altogether if it is empty).
2. Under `services`, add a new service for general RQ jobs:
```yaml
worker:
<<: *redash-service
command: worker
environment:
QUEUES: "periodic emails default"
WORKERS_COUNT: 1
```
Following that, force a recreation of your containers with `docker-compose up --force-recreate --build` and you should be good to go.
### UX
- Redash now uses a vertical navbar
- Dashboard list now includes “My Dashboards” filter
- Dashboard parameters can now be re-ordered
- Queries can now be executed with Shift + Enter on all platforms.
- Added New Dashboard/Query/Alert buttons to corresponding list pages
- Dashboard text widgets now prompt to confirm before closing the text editor
- A plus sign is now shown between tags used for search
- On the queries list view “My Queries” has moved above “Archived”
- Improved behavior for filtering by tags in list views
- When a user’s session expires for inactivity, they are prompted to log-in with a pop-up so they don’t lose their place in the app
- Numerous accessibility changes towards the a11y standard
- Hide the “Create” menu button if current user doesn’t have permission to any data sources
### Visualizations
- Feature: Added support for horizontal box plots
- Feature: Added support for horizontal bar charts
- Feature: Added “Reverse” option for Chart visualization legend
- Feature: Added option to align Chart Y-axes at zero
- Feature: The table visualization header is now fixed when scrolling
- Feature: Added USA map to choropleth visualization
- Fix: Selected filters were reset when switching visualizations
- Fix: Stacked bar chart showed the wrong Y-axis range in some cases
- Fix: Bar chart with second y axis overlapped data series
- Fix: Y-axis autoscale failed when min or max was set
- Fix: Custom JS visualization was broken because of a typo
- Fix: Too large visualization caused filters block to collapse
- Fix: Sankey visualization looked inconsistent if the data source returned VARCHAR instead of numeric types
### Structural Updates
- Redash now prevents CSRF attacks
- Migration to TypeScript
- Upgrade to Antd version 4
### Data Sources
- New Data Sources: SPARQL Endpoint, Eccenca Corporate Memory, TrinoDB
- Databricks
- Custom Schema Browser that allows switching between databases
- Option added to truncate large results
- Support for multiple-statement queries
- Schema browser can now use eventlet instead of RQ
- MongoDB:
- Moved Username and Password out of the connection string so that password can be stored secretly
- Oracle:
- Fix: Annotated queries always failed. Annotation is now disabled
- Postgres/CockroachDB:
- SSL certfile/keyfile fields are now handled as secret
- Python:
- Feature: Custom built-ins are now supported
- Fix: Query runner was not compatible with Python 3
- Snowflake:
- Data source now accepts a custom host address (for use with proxies)
- TreasureData:
- API key field is now handled as secret
- Yandex:
- OAuth token field is now handled as secret
### Alerts
- Feature: Added ability to mute alerts without deleting them
- Change: Non-email alert destination details are now obfuscated to avoid leaking sensitive information (webhook URLs, tokens etc.)
- Fix: numerical comparisons failed if value from query was a string
### Parameters
- Added “Last 12 months” option for dynamic date ranges
### Bug Fixes
- Fix: Private addresses were not allowed even when enforcing was disabled
- Fix: Python query runner wasn’t updated for Python 3
- Fix: Sorting queries by schedule returned the wrong order
- Fix: Counter visualization was enormous in some cases
- Fix: Dashboard URL will now change when the dashboard title changes
- Fix: URL parameters were removed when forking a query
- Fix: Create link on data sources page was broken
- Fix: Queries could be reassigned to read-only data sources
- Fix: Multi-select dropdown was very slow if there were 1k+ options
- Fix: Search Input couldn’t be focused or updated while editing a dashboard
- Fix: The CLI command for “status” did not work
- Fix: The dashboard list screen displayed too few items under certain pagination configurations
### Other
- Added an environment variable to disable public sharing links for queries and dashboards
- Alert destinations are now encrypted at the database
- The base query runner now has stubs to implement result truncating for other data sources
- Static SAML configuration and assertion encryption are now supported
- Adds new component for adding extra actions to the query and dashboard pages
- Non-admins with at least view_only permission on a dashboard can now make GET requests to the data source resource
- Added a BLOCKED_DOMAINS setting to prevent sign-ups from emails at specific domains
- Added a rate limit to the “forgot password” page
- RQ workers will now shutdown gracefully for known error codes
- Scheduled execution failure counter now resets following a successful ad hoc execution
- Redash now deletes locks for cancelled queries
- Upgraded Ace Editor from v6 to v9
- Added a periodic job to remove ghost locks
- Removed content width limit on all pages
- Introduce a React component
## v9.0.0-beta - 2020-06-11
This release was long time in the making and has several major changes:
- Our backend code was updated to support Python 3 and we no longer support Python 2. If you're using our Docker images, this should be a transparent change for you.
- We replaced Celery with RQ for background jobs processing. This will require some setup updates -- see instructions below.
- The frontend code is now 100% React and we removed all the Angular dependencies.
This release was made possible by contributions from over 50 people: @ari-e, @ariarijp, @arihantsurana, @arikfr, @atharvai, @cemremengu, @chulucninh09, @citrin, @daniellangnet, @DavidHernandez, @deecay, @dmudro, @erans, @erels, @ezkl, @gabrieldutra, @gstaykov, @ialeinikov, @ikenji, @Jakdaw, @jezdez, @juanvasquezreyes, @koooge, @kravets-levko, @kykrueger, @leibowitz, @leosunmo, @lihan, @loganprice, @mickeey2525, @mnoorenberghe, @monicagangwar, @NicolasLM, @p-yang, @Ralnoc, @ranbena, @randyzwitch, @rauchy, @rxin, @saravananselvamohan, @satyamkrishna, @shinsuke-nara, @stefan-mees, @stevebuckingham, @susodapop, @taminif, @thewarpaint, @tsuyoshizawa, @uncletimmy3, @wengkham.
### Upgrading
Typically, if you are running your own instance of Redash and wish to upgrade, you would simply modify the Docker tag in your `docker-compose.yml` file. Since RQ has replaced Celery in this version, there are a couple extra modifications that need to be done in your `docker-compose.yml`:
1. Under `services/scheduler/environment`, omit `QUEUES` and `WORKERS_COUNT` (and omit `environment` altogether if it is empty).
2. Under `services`, add a new service for general RQ jobs:
```yaml
worker:
<<: *redash-service
command: worker
environment:
QUEUES: "periodic emails default"
WORKERS_COUNT: 1
```
Following that, force a recreation of your containers with `docker-compose up --force-recreate --build` and you should be good to go.
### UX
- Redesigned Query Results page:
- Completely new layout is easier to read for non-technical Redash users.
- Empty query results are clearly displayed. User is now prompted to edit or execute the query.
- Mobile Experience Improvements:
- UI element spacing has been redesigned for clarity
- Admin pages now honor max-width. Tables scroll independent of the top menu.
- Large legends no longer shrink the visualization on small screens.
- Fix: it was sometimes impossible to scroll pages with dashboards because the visualizations captured every touch event.
- Fix: Visualizations on small screens would not always show horizontal scroll bars.
- Dashboards can now be un-archived using the API.
- Dashboard UI performance was improved.
- List pages were changed to show a user's name instead of avatar.
- Search-enabled tables now show a prompt for which columns will be searched.
- In the visualization editor, the settings pane now scrolls independent of the visualization preview.
- Tokens in the schema viewer now sort alphabetically.
- Links to settings panes that require Admin privileges are now hidden from non-Admins.
- The Admin page now remembers which tab you were viewing after a page reload.
### Visualizations
- Feature: Allow bubble size control with either coefficient or sizemode.
- Feature: Table visualization now treats Unix timestamps in query results as timestamps.
- Feature: It's now possible to provide a description to each Table column, appearing in UI as a tooltip.
- Feature: Added tooltip and popover templating to the map with markers visualization.
- Feature: Added an organization setting to hide the Plotly mode bar on all visualizations.
- Feature: Cohort visualization now has appearance settings.
- Feature: Add option to explicitly set Chart legend position.
- Change: Deprecated visualizations are now hidden.
- Change: Table settings editor now extends vertically instead of horizontally.
- Change: The maximum table pagination is now 500.
- Change: Pie chart labels maintain contrast against lighter slices.
- Fix: Chart series switched places when picking Y axis.
- Fix: Third column was not selectable for Bubble and Heatmap charts.
- Fix: On the counter visualizations, the “count rows” option showed an empty string instead of 0.
- Fix: Table visualization with column named "children" rendered +/- buttons.
- Fix: Sankey visualization now correctly occupies all available area even with fewer stages.
- Fix: Pie chart ignores series labels.
### Data Sources
- New Data Sources: Amazon Cloudwatch, Amazon CloudWatch Logs Insights, Azure Kusto, Exasol.
- Athena:
- Added the option to specify a base cost in settings, displaying a price for each query when executed.
- BigQuery:
- Fix: large jobs continued running after the user clicked “Cancel” query execution.
- Cassandra:
- Updated driver to 3.21.0 which dramatically reduces Docker build times.
- SSL options are now available.
- Clickhouse:
- You can now choose whether to verify the SSL certificate.
- Databricks:
- Databricks now use an ODBC-based connector.
- Fix: Date column was coerced to DateTime in the front-end.
- Druid:
- Added username and password authentication option.
- Microsoft SQL Server
- Added support for ODBC connections via pyodbc. There are now two MSSQL data source types. One using TDS. The other is using ODBC.
- MongoDB:
- Added support for running queries on secondary in replicaset mode.
- Fix: Connection test always succeeded.
- Oracle:
- Fix: Connection would fail if username or password contained special characters.
- Fix: Comparisons would fail if scale was None.
- RDS:
- Updated rds-combined-ca-bundle.pem to the latest CA.
- Redshift:
- Added the ability to use IAM Roles and Users.
- Fix: Redshift was unable to have its schema refreshed.
- Rockset:
- Fix: Allow Redash to load collections in all workspaces.
- Snowflake:
- You can now refresh the snowflake schema without waking the cluster.
- Added support for all of Snowflake’s datetime types. Otherwise certain timestamps would only appear as strings in the front-end.
- TreasureData:
- Fix: API calls would fail when setting a non-default region.
### Alerts
- Feature: Added ability to mute alerts without deleting them.
- Fix: numerical comparisons failed if value from query was a string.
### Parameters
- Added Last x Days options for date range parameters.
- Fix: Parameters added in empty queries were always added as text parameters
### Bug Fixes
- Fix: Alembic migration schema was preventing v4 users from upgrading. In v5 we started encrypting data source credentials in the database.
- Fix: System admin dashboard would not show correct database size if non-default name was used.
- Fix: refresh_queries job would break if any query had a bad schedule object.
- Fix: Orgs with LDAP enabled couldn’t disable password login.
- Fix: SSL mode was sometimes sent as an empty string to the database instead of omitted entirely.
- Fix: When creating new Map visualization with clustering disabled, map would crash on save.
- Fix: It was possible on the New Query page to click “Save” multiple times, causing multiple new query records to be created.
- Fix: Visualization render errors on a dashboard would crash the entire page.
- Fix: A scheduled execution failure would modify the query’s “updated_at” timestamp.
- Fix: Parameter UI would wrap awkwardly during some drag operations.
- Fix: In dashboard edit mode, users couldn’t modify widgets.
- Fix: Frontend error when parsing a NaN float.
### Other
- Added TSV as a download format (in addition to CSV and Excel).
- Added maildev settings (helps with automated settings).
- Refine permissions usage in Redash to allow for guest users
- The query results API now explicitly handles 404 errors.
- Forked queries now retain the tags of the original query.
- We now allow setting custom Sentry environments.
- Started using Black linter for our Python source code
- Added CLI command to re-encrypt data source details with new secret key.
- Favorites list is now loaded on menu click instead of on page load.
- Administrators can now allow connections to private IP addresses.
## v8.0.0 - 2019-10-27
There were no changes in this release since `v8.0.0-beta.2`. This is just to mark a stable release.
## v8.0.0-beta.2 - 2019-09-16
This is an update to the previous beta release, which includes:
- Add options for users to share anonymous usage information with us (see [docs](https://redash.io/help/open-source/admin-guide/usage-data) for details).
- Visualizations:
- Allow the user to decide how to handle null values in charts.
- Upgrade Sentry-SDK to latest version.
- Make horizontal table scroll visible in dashboard widgets without scrolling.
- Data Sources:
- Add support for Azure Data Explorer (Kusto).
- MySQL: fix connections without SSL configuration failing.
- Amazon Redshift: option to set query group for adhoc/scheduled queries.
- Hive: make error message more friendly.
- Qubole: add support to run Quantum queries.
- Display data source icon in query editor.
- Fix: allow users with view only acces to use the queries in Query Results
- Dashboard: when updating parameters refersh only widgets that use those parameters.
This release had contributions from 12 people: @arikfr, @cclauss, @gabrieldutra, @justinclift, @kravets-levko, @ranbena, @rauchy, @sandeepV2, @shinsuke-nara, @spacentropy, @sphenlee, @swfz.
## v8.0.0-beta - 2019-08-18
After months of being heads down with hard work, it's finally time to wrap up the V8 release 🤩 This release includes many long awaited improvements to parameters, UX improvements, further React migration and other changes, fixes and improvements.
While this version is already running on the hosted platform to make sure it's stable, we're excited to put this in the hands of our Open Source users.
Starting from this release we will no longer build a tarball distribution of the codebase and recommend everyone to switch over to using our Docker images. We're planning on dropping Python 2 support towards its EOL this year and switching over to the Docker image will make this transition much simpler.
This release was made possible by contributions from over 40 people: @aidarbek, @AntonZarutsky, @ariarijp, @arikfr, @combineads, @deecay, @fmy, @gabrieldutra, @guwenqing, @guyco33, @ialeinikov, @Jakdaw, @jezdez, @justinclift, @k-tomoyasu, @katty0324, @koooge, @kravets-levko, @ktmud, @KumanoTanaka, @kyoshidajp, @nason, @oldPadavan, @openjck, @osule, @otsaloma, @ranbena, @rauchy, @rueian, @sekiyama58, @shinsuke-nara, @taminif, @The-Alchemist, @vv-p, @washort, @wudi-ayuan, @ygrishaev, @yoavbls, @yoshiken, @yusukegoto and the support of over 500 organizations who subscribed to our hosted version and by that sponsor the team's work.
### Parameters
- Parameter UI improvements:
- Support for multi-select in dropdown (and query dropdown) parameters.
- Support for dynamic values in date and date-range parameters.
- Search dropdown parameter values.
- New UX for applying parameter changes in queries and dashboards.
- Allow using Safe Parameters in visualization embeds and public dashboards. Safe Parameters are any parameter type except for the a text parameter (dropdowns are safe).
### Data Sources
- New Data Sources: Couchbase, Phoenix and Dgraph.
- New JSON data source (and deprecated old URL data source).
- Snowflake: update connector to latest version.
- PostgreSQL: show only accessible tables in schema.
- BigQuery:
- Correctly handle NaN values.
- Treat repeated fields as rrays.
- [BigQuery] Fix: in some queries there is no mode field
- DynamoDB:
- Support for Unicode in queries.
- Safe loading of schema.
- Rockset: better handling of query errors.
- Google Sheets:
- Support for Team Drive.
- Friendlier error message in case of an API error and more reliable test connection.
- MySQL:
- Support for calling Stored Procedures and better handling of query cancellation.
- Switch to using `mysqlclient` (a maintained fork of `Python-MySQL`).
- MongoDB: Support serializing Decimal128 values.
- Presto: support for passwords in connection settings.
- Amazon Athena: allow to specify custom work group.
- Query Results: querying a column with a dictionary or array fails
- Clickhouse: make sure we don't show password in error messages.
- Enable Cassandra support by default.
### Visualizations
- Charts:
- Fix: legend overlapping chart on small screens.
- Fix: Pie chart not rendering when series doesn't exist in options.
- Pie Chart: add option to set direction of slices.
- WordCloud: rewritten to support new options (provide frequency in query, limits), scale when resizing, handle long words and more.
- Pivot Table: support hiding totals.
- Counters: apply formatting to target value.
- Maps:
- Ability to customize marker icon and color.
- Customization options for Choropleth maps.
- New Visualization: Details View.
### **UX**
- Replace blank screen with a loading indicator when the application is doing its first load.
- Multiple improvements to dashboards editing: auto-save, grid markings and better refresh indicator.
- Admin can now edit user's groups from the user page.
- Add keyboard shortcut (Ctrl/Cmd+Shift+F) to trigger query formatting.
### API
- Query Result API response minimized to only required fields when called with a non user API key.
- Prefer API key over cookies in authentication.
- User can now regenerate Query API Key.
### Other Changes
- Sends CSP headers to prevent various kinds of security attacks via the browser. Might break unusual usages and embeds of Redash.
- New Failed Scheduled Queries email report (can be enabled from organization settings screen).
- Deprecated HipChat Alert Destination.
- Add options to hide different parts of a Visualization embed UI (parameters, title, link to query).
- Support multi-byte search for query names and descriptions (needs to be enabled in Organization settings screen).
- CSV query results download: correctly serialize booleans and date values.
- Dashboard filters now collect values from all widgets with the same filter.
- Support for custom message and description in alert notifications (currently disabled behind a feature flag until we improve the alert UX).
### Bug Fixes
- Fix: adding widget to dashboard from a query page is broken.
- Fix: default time format option was wrong.
- Fix: when too many errors of a scheduled queries occur it causes an OverflowError.
- Fix: when forking a query maintain the same visualizations order.
## v7.0.0 - 2019-03-17
We're trying a new format for the CHANGELOG in this release. Focusing on the bigger changes, but for whoever interested, you can see all the changes [here](https://github.com/getredash/redash/compare/v6.0.0...master).
Besides all the features, bug fixes and improvements listed below we managed to convert a large portion of Redash's frontend code from Angular.js to React. You can see status in [#3071](https://github.com/getredash/redash/issues/3071).
This release was made possible with the help of 34 contributors. 🙇♂️
### Data Sources
- **All data source options are now encrypted in the database.** By default the encryption uses the `REDASH_COOKIE_SECRET` value (`redash.settings.COOKIE_SECRET`), but you can specify a different value by setting the `REDASH_SECRET_KEY` environment variable value. Note that you need to set this _before_ doing the upgrade.
- New Data Sources: Uptycs and Apache Drill.
- Snowplow: is now enabled by default & supports region setting.
- Elasticsearch: add support for Amazon Elasticsearch IAM authentication (with IAM profile or key/secret pair).
- PostgreSQL: add support for serializing range values.
- Redshift: remove duplicate column information for late-binding views.
- Athena: load all databases (using pagination).
- BigQuery: correctly handle temp tables with no schema field.
- Jira (JQL): support for fetching all records with pagination.
- Prometheus: fix schema loading and add support for query range.
### In-app Help
You can now open the [Knowledge Base](https://redash.io/help) inside the application. We also added a few "help triggers" in the app, that will open the Knowledge Base in context of what you're currently doing.
### Parameters
- **Dashboard Parameters**: We improved the flow of adding queries with parameters to dashboards and now give you full control over how parameters are mapped. You no longer have to make sure all parameters have the same name or use the `Global` checkbox. We also added new options, like keeping the parameter local to the widget or setting a static value. [Read more in our Knowledge Base →](https://redash.io/help/user-guide/querying/query-parameters#Parameter-Mapping-on-Dashboards)
- We added server side validation of parameter values for all parameter types, except for parameters of `text` type. All validated parameter types are considered safe. When a query is using safe parameters (or no parameters at all), View Only users can refresh it.
- Refreshing safe queries is done using the new results API endpoint, which takes only a query ID (and optionally parameter values) and does not need the query text.
### Query Editor Improvements
- Run only the highlighted query text: hit Execute after highlighting a portion of your query and only the selected portion will be sent to the database. This is useful for testing sub-SELECT statements and CTE's.
- Improved auto complete: add a dot . after a table name in the query editor and auto complete will only suggest columns on that table.
- Autosave parameter configuration changes.
- YAML syntax support (for data sources like Yandex Metrica).
### Improved Query Scheduler
The Query Scheduler got a face lift and some new options: you can pick a day for a weekly schedule to run on and also set an end date after which the query will no longer execute on schedule.
### Data Sources
We added Apache Drill, Uptycs and a new JSON data source. Also fixed a few bugs in Athena's query runner and others.
### User Management
The users page got revamped with a new look and feel and few new features:
- An indication when a user was last active.
- Show if an invited user hasn't finished the setup process yet (Pending Invitations section).
- You can now generate a new API key for users, if there's a concern it was compromised.
### Admin
- New Celery queues status screens, replacing the old Queries Status and better reflecting the status of running queries.
- Make the queue name for schema refresh job configurable. The default used to be hard coded `schemas`, which is not available on all setups. Now it's `celery`.
- The `gevent` library is installed by default, and you can now setup gunicorn to use `gevent` based workers.
- New Docker entrypoint command to do a health check for a worker process.
- Flask-Admin is no longer setup or supported.
### Other Changes
- New Alert destination: Google Hangouts Chat.
- When downloading results from the results API it will set a user friendly filename for the downloaded file.
- Archived Queries section added to the queries list.
### Bug Fixes
- Fixed: fork query does not fork tables but instead adds default table.
- Fixed: when deleting a visualization, any widget using it was left empty on the dashboard.
- Fixed: issues with Query Editor resizing on new versions of Chrome.
- Fixed: issues with exporting dictionaries to Excel.
- Fixed: Cohort visualization gets stuck when passing string values.
- Fixed: use series name for Pie chart label.
- Make sure Flask app created in Celery's worker process (could cause some query runners to get stuck while running queries).
## v6.0.0 - 2018-12-16
v6.0.0 release version. Mainly includes fixes for regressions from the beta version.
This release had contributions from 5 people: @rauchy, @denisov-vlad, @arikfr, @ariarijp, and @gabrieldutra. Thank you, everyone 🙏
### Changed
- #3183 Make refresh_queries less noisey in logs. @arikfr
### Fixed
- #3163 Include correct version in production builds. @rauchy
- #3161 Clickhouse: fix int() conversion error. @denisov-vlad
- #3166 Directly using record_event task requires timestamp. @arikfr
- #3167 Alert.evaluate failing when the column is missing. @arikfr
- ##3162 Remove API permissions for users who have been disabled. @rauchy
- #3171 Reject empty query name. @ariarijp
- #3175, #3186 Fix disable error message. @rauchy, @gabrieldutra
- #3182 [Redshift] support for schema names with dots. @arikfr
- #3187 Safely create_app in Celery code (try to fetch current_app first). @arikfr
### Other
- #3155 Add DB Seed to Cypress and setup Percy. @gabrieldutra
- #3180 Remove coverage from pytest terminal output. @rauchy
## v6.0.0-beta - 2018-12-03
This release was 2 months in the making and it is full with good stuff!
- We have 5 new data sources: Databricks, IBM DB2, Kylin, Druid and Rockset. ⌗
- There are fixes and improvements to 11 existing data sources (MySQL, Redshift, Postgres, MongoDB, Google BigQuery, Vertica, TreasureData, Presto, ClickHouse, Google Sheets and Google Analytics).
- The Query Results data source can now load cached results, just use the `cached_query_` prefix instead of `query_`.
- On the visualizations front we added a Heatmap visualization and did updated the table and counter visualizations.
- Alerts got some fixes and a new destination: PagerDuty.
- If the live autocomplete in the code editor annoys you, you can disable it now (although we're working to make it better, see #3092).
- Fast queries will now load faster. 🏃♂️
- We improved the layout of visualizations and content on smaller screen sizes. 📱
- For those of you who like sharing, you can now enable the ability to share ownership of queries and dashboards and let others to edit them. Check the Settings page to enable this feature.
There were also important changes to the code and infrastructure:
- More components moved to React.
- We switched to Webpack 4 with the help of @dmonego.
- We upgraded to Celery 4 with the help of @emtwo, @jezdez, @mashrikt and @atharvai.
- We started moving towards Python 3 for our backend. The first step was to make sure our code pass basic sanity tests with Flake 8, which was implemented by @cclauss.
- We improved our testing on the frontend by adding setup for Jest tests and E2E testing using Cypress (@gabrieldutra).
- Each pull request now gets a deploy preview using Netlify to easily test frontend changes.
This is just a summary, you're welcome to review the full list below. ⬇
This release had contributions from 38 people: @arikfr, @kravets-levko, @jezdez, @kyoshidajp, @kocsmy, @alison985, @gabrieldutra, @washort, @GitSumito, @emtwo, @rauchy, @alexanderlz, @denisov-vlad, @ariarijp, @yoavbls, @zhujunsan, @sjakthol, @koooge, @SakuradaJun, @dmonego, @Udomomo, @cclauss, @combineads, @zaimy, @Trigl, @ralphilius, @jodevsa, @deecay, @igorcanadi, @pashaxp, @hoangphuoc25, @toph, @burnash, @wankdanker, @Yossi-a, @Rovel, @kadrach, and @nicof38. Thank you, everyone 🙏
### Added
- #2747, #3143 Add a new Databricks query runner. @alison985, @jezdez, @arikfr
- #2767 Add ability to add viz to dashboard from query edit page. @alison985, @jezdez
- #2780 Add a query autocomplete toggle. @alison985, @jezdez, @arikfr
- #2768 Add authentication via JWT providers. @SakuradaJun
- #2790 Add the ability to sort favorited queries, paginate the dashboard list and improve UI inconsistencies. @jezdez
- #2681 Add ability to search table column names in schema browser. @alison985
- #2855 Add option to query cached results. @yoavbls
- #2740 Add ability for extensions to add periodic tasks. @emtwo
- #2924 Google Spreadsheets: Add support for opening by URL. @alexanderlz
- #2903 Add PagerDuty as an Alert Destination. @alexanderlz
- #2824 Add support for expanding dashboard visualizations. @sjakthol
- #2900 Add ability to specify a counter label. @ralphilius
- #2565 Add frontend extension capabilities. @emtwo
- #2848 Add IBM Db2 as a data source using the ibm-db Python package. @nicof38
- #2959 Add option to auto reload widget data in shared dashboards. @arikfr
- #2993 Add page size settings. @kyoshidajp
- #2080 New Heatmap chart visualization with Plotly. @deecay
- #2991 Show users in CLI group list. @GitSumito
- #2342 New SQLPARSE_FORMAT_OPTIONS setting to configure query formatter. @ariarijp
- #3031 Add some tests for Query Results. @ariarijp
- #2936 Add Kylin data source. @Trigl
- #3047 Add Druid data source. @rauchy
- #3077 New user interface for the feature flag of the share edit permissions feature. @arikfr
- #3007 Add permissions to the result of "manage.py groups list" command. @Udomomo
- #3088 Add get_current_user() fuction for the Python query runner. @kyoshidajp
- #3114 Add event tracking to autocomplete toggle. @arikfr
- #3068 Add Rockset query runner. @igorcanadi, @arikfr
- #3105 Display frontend version. @rauchy
### Changed
- #2636 Rewrite query editor with React. @washort, @arikfr
- #2637 Convert edit-in-place component to React. @washort, @arikfr
- #2766 Suitable events are now being recorded server side instead of in the frontend. @alison985, @jezdez
- #2796 Change placement (right/bottom) of chart legend depending on chart width. @kravets-levko
- #2833 Uses server side sort order for tag list and show count of tagged items. @jezdez
- #2318 Support authentication for the URL data source. @jezdez
- #2884 Rename Yandex Metrika to Metrica. @jezdez
- #2909 MySQL: hide sys tables. @arikfr
- #2817 Consistently use simplejson for loading and dumping JSON. @jezdez
- #2872 Use Plotly's function to clean y-values (x may be category or date/time). @kravets-levko
- #2938 Auto focus tag input. @kyoshidajp
- #2927 Design refinements for queries pages. @kocsmy
- #2950 Show activity status in CLI user list. @GitSumito
- #2968 Presto data source: setting protocol (http/https), safe loading of error messages. @arikfr
- #2967 Show groups in CLI user list. @GitSumito
- #2603 MongoDB: Update requirements to support srv. @arikfr
- #2961 MongoDB: Skip system collections when loading schema. @arikfr
- #2960 Add timeout to various HTTP requests. @arikfr
- #2983 Databricks: New logo, updated name and enabled by default. @arikfr
- #2982 Table visualization: change default size to 25 and add more size options. @arikfr
- #2866 Redshift: Hide tables the configured user cannot access. @sjakthol
- #3058 Mustache: don't html-escape query parameters values. @kravets-levko
- #3079 Always use basic autocomplete, as well as the live autocomplete. @arikfr
- #3084 Support tel://, sms://, mailto:// links in query results. @zhujunsan
- #3083 Clickhouse: Add WITH TOTALS option support. @denisov-vlad
- #3063 Allow setting colors for bubble charts. @toph
- #3085 BigQuery: Switch to Standard SQL as the default. @kyoshidajp
- #3094 Tags autocomplete: Show note when creating a new label. @kravets-levko
- #2984 Autocomplete toggle improvements. @arikfr
- #3089 Open new tab when forking a query. @kyoshidajp
- #3126 MongoDB: add support for sorting columns. @arikfr
- #3128 Improve backoff algorithm of query results polling to speed it up. @arikfr
- #3125 Vertica: update driver & add support for connection timeout. @arikfr
- #3124 Support unicode in Postgres/Redshift schema. @arikfr
- #3138 Migrate all tags components to React. @kravets-levko
- #3139 Better manage permissions modal. @kocsmy
- #3149 Improve tag link colors and fix group tags on Users page. @kocsmy
- #3146 Update, replace and fix new alert destination logos so it fits better. @kocsmy
- #3147 Add and improve recent db logos that didn't fit in size properly. @kocsmy
- #3148 Fix label positioning on no found screen. @kocsmy
- #3156 json_dumps: add support for serializing buffer objects. @arikfr
### Fixed
- #2849 Fix invalid reference to alert.to_dict() in webhook. @wankdanker
- #2840 Improve counter visualization text scaling. @kravets-levko
- #2854 Widget titles are no longer rendered wrong on public dashboards. @kravets-levko
- #2318 Removed redundant exception handling in data sources since that's handled in the query backend. @jezdez
- #2886 Fix Javascript build that broke because registerAll tried to run EditInPlace component. @arikfr
- #2911 Don’t show “Add to dashboard” in dropdown to unsaved queries. @jezdez
- #2916 Fix export query results output file name. @gabrieldutra
- #2917 Fix output file name not changing after rename query. @gabrieldutra
- #2868 Address edge case when retrieving Glue schemas for Athena data source. @kadrach
- #2929 Fix: date value in a filter is duplicated. @combineads
- #2875 Unbreak charts with long legend break in horizontal mode. Update plotly.js. @kravets-levko
- #2937 Fix event recording in admin API backend. @kyoshidajp
- #2953 Minor fixes for the Clickhouse data source. @denisov-vlad
- #2941 Bring back fix to Box plot hover. @arikfr
- #2957 Apply missing CSS classes to EditInPlace component. @arikfr
- #2897 Show "Add description" only after saving the query. @arikfr
- #2922 Query page layout improvements for small screens. @kravets-levko
- #2956 Clickhouse: move timeout to params. @denisov-vlad
- #2964 Fix no tags shown when having empty set. @gabrieldutra
- #2757 Use full text search ranking when searching in list views. @jezdez
- #2969 Query Results data source: improved errors, quoted column names. @arikfr
- #2906 Preventing open redirection in loging process. @kyoshidajp
- #2867 TreasureData: Deduplicate column names. @zaimy
- #2994 Fix scheme of various URLs from http to https. @kyoshidajp
- #2992 Fix an invalid prop type warning in new version notifier. @kyoshidajp
- #3022 Fix Toolbox covering part of a chart. @kravets-levko
- #2998 Fix charts losing responsive features after refreshing the dashboard. @kravets-levko
- #3034 Postgres: handle NaN/Infinity values. @kravets-levko
- #2745 Sort columns with undefined values. @Yossi-a
- #3041 Sort CLI output of lists. @GitSumito
- #2803, #3006 Address various tag display issues on query list page. @kocsmy, @alison985
- #3049 Fix edit-in-place component which ignored isEditable flag and didn't work on Groups page. @kravets-levko
- #2965 Google Analytics: Fix crash when no results are returned. @alexanderlz
- #3061 Fix table visualization so that the horizontal scrollbar is not be always visible. @kravets-levko
- #3076 Add white-space padding to separators in the footer. @burnash
- #2919 Fix URL data source to not require a URL. @arikfr
- #3098 Force AngularJS to update query editor properly. @washort
- #3100 Delete redundant regex segment in query result frontend. @zhujunsan
- #2978 Prevent the query update timestamp from changing when it is linked to new query results. @rauchy
- #3046 Fix query page header. @kravets-levko
- #3097 Mongo: Fix collection fields retreival bug when Views are present. @jodevsa
- #3107 Keep query text in local state for now. @washort
- #3111 Fix mobile padding issues on Query results. @kocsmy
- #3122 Show menu divider only if query is archived. @jezdez
- #3120 Fix tag counts for dashboards and queries. @jezdez
- #3141 Fix schema refresh to work on MySQL 8. @hoangphuoc25
- #3142 Fix: editing dashboard title results in the visualizations being replaced by the loading markers. @kravets-levko
### Other
- #2850 The setup scripts are now based on Ubuntu 18.04 LTS and Docker. @pashaxp, @arikfr
- #2985 Add Jest based tests to our stack. @arikfr
- #2999 Add netlify configuration. @arikfr
- #3000 Initial Cypress based E2E test infrastructure. @gabrieldutra
- #2898 Move Ant styles into a central location. @arikfr
- #2910 Fix webpack build error about BigMessage. @jezdez
- #2928 Speed up builds by skipping installing requirements_all_ds.txt in CI unit tests. @arikfr
- #2963 Fix tarball build failure. @emtwo
- #2996 Fix setup.sh failures when run as root. @arikfr
- #2989 Rearrange make targets. @koooge
- #3036 Update Flask-Admin to 1.5.2. @yoavbls
- #2901 Fix documentation links. @kravets-levko
- #3073 Remove only Redash containers in clean Make task. @ariarijp
- #3048 Remove pytest-watch dependency to workaround an issue with watchdog. @rauchy
- #2905 Update development docker-compose.yml file to use latest Redis and Postgres servers and specify working volume explictly. @Rovel
- #3032 Makefile: Add make targets for test. @koooge
- #2933 Switch to Webpack 4. @dmonego
- #2908 Update setup files. @arikfr
- #2946 Update snowflake_connector_python version. @arikfr
- #2773 Upgrade to Celery 4.2.1. @emtwo, @jezdez
- #2881 CircleCI: Make flake8 tests pass on Legacy Python and Python 3. @cclauss
- #2907 Remove unused dependencies (honcho, wsgiref). @arikfr
- #3039 Build docker image on master branch. @arikfr
- #3106 Fix registerAll failures after minification. @arikfr
## v5.0.2 - 2018-10-18
### Security
- Fix: prevent Open Redirect vulnerability.
## v5.0.1 - 2018-09-27
### Added
- Added support for JWT authentication (for services like Cloudflare Access or Google IAP).
### Changed
- Upgraded Celery version to 3.1.26 to make upgrade to Celery 4 easier.
## v5.0.0 - 2018-09-21
Final release for V5. Most of the changes were already in the beta release of V5, but this includes several fixes along
with UI improvements.
🙏 Thanks to @arikfr, @jezdez, @kravets-levko, @alison985, @kocsmy, @yossi-a, @tdsmith, @nasmithan, @jrbenny35, @sjakthol, @ariarijp and @combineads who contributed to this release.
### Security
- Fix: don't expose Google OAuth client secret. @arikfr
### Changed
- Improve mobile rendering of dashboards and queries. @kocsmy
- UI improvements for favorites and empty state. @arikfr
- Remove unnecessary X at the end of the query search. @kocsmy
- Add server-side sorting to dashboard list. @jezdez
- Sort queries in descending order. @jezdez
- Throw error when non-owner tries to add a user to dashboard permissions. @alison985
- Propagate query execution errors from Celery tasks properly. @alison985
- Reload the route when using the app header search input. @jezdez
### Fixed
- Fix: BigQuery default location is null and not US. @arikfr
- Fix: query embeds are broken. @arikfr
- Fix: typo in Celery log foramt. @ariarijp
- Use QuerySerializer in outdated queries list. @jezdez
- Fix: sometimes widgets are getting zero height. @kravets-levko
- Athena: Switch to simple_json to serialize NaN/Infinity values as nulls. @kravets-levko, @jezdez
- Fix: queries with parameters with no value breaking the scheduler. @arikfr
- Fix: MongoDB query results parser didn't support unicode keys. @arikfr
- Fix: Google Analytics schema wasn't loading in some cases. @arikfr
- Fix: date/time parameters not working as global param @kravets-levko.
- Fix: Widgets crumble when trying to move / resize a widget. @kravets-levko
- Fix: handling rows with "length" field with forOwn method. @yossi-a
- Fix: query selection not working on alert page. @sjakthol
- Fix: query_results for Embedded Parameters (removed deprecated to_dict function). @nasmithan
- Fix: unicode not supported in dashboard search. @combineads
- Fix: unicode not supported in users search. @arikfr
### Other
- Add test for using saved parameters in scheduled queries. @alison985
- Minor code smell cleanup. @jezdez
- Update QueryResultListResource docstring. @tdsmith
- Switch to CirlceCI 2.0 @jrbenny35, @arikfr
- Remove unnecessary init methods. @jezdez
## v5.0.0-Beta - 2018-08-06
This is the first beta of the V5 release (and hopefully the last one). This version includes a lot of exciting new additions along with bug fixes and other changes.
Some notable changes:
- Extensive work on parameters UI:
- New Date Range parameter type.
- UI for creating new parameters.
- Support for Now/Today as default value of date/time parameter.
- Tagging and favorites ⭐️ support for queries and dashboards.
- Users list page was improved (search, additional information) and you can now disable users.
- Query editor improvements: additional keyboard shortcuts and support for searching in query text.
- Visualizations improvements: option to select colors of pie chart sectors, X Axis type auto detect and option to format values, labels and tooltips.
- Data Sources:
- Support for Yandex Metrika and AppMetrika.
- BigQuery: location property support and schema will load all tables now.
- Elasticsearch: stop sending source_content_type parameter which wasn't supported in older versions.
- Started migrating the frontend codebase to React.
And much more!
🙏 Thanks to @kravets-levko, @arikfr, @ariarijp, @alison985, @kyoshidajp, @kocsmy, @denisov-vlad, @deecay, @yuua, @emtwo, @Pablohn26, @sieben, @atharvai, @matsumo, @tdawber, @innovia, @gabrieldutra, @coreyhuinker, @maxv, @sjakthol, @mtrbean and @washort who contributed to this release!
### Added
- #2712: Date/Time Range parameter type (@kravets-levko)
- #2482: Add support for ChatWork Alert Destination. (@matsumo)
- #2678: Explicit "Add Parameter" Button in Query Editor. (@kravets-levko)
- #2513: Add location property to BigQuery data source settings. (@kyoshidajp)
- #2616: Pie chart: support setting pie chart sector colors. (@kravets-levko)
- #2697: Date/Time parameters: support for "Now" as default value. (@kravets-levko)
- #2693: Enable search function in Query Editor. (@arikfr)
- #2573: Tagging and favorites for Queries and Dashboards (@arikfr)
- #2640: Keyboard shortcut to collapse query editor/schema browser (@kravets-levko)
- #2674: Add support for the Chrome Logger extension (@arikfr)
- #2653: Add redash db size to status page (@alison985)
- #2669: Store Athena query id with result metadata (@tdawber)
- #2546: Configuration for incorporating React components (@washort)
- #2533: New datasource: Yandex Metrika & AppMetrika (@denisov-vlad)
- #2536: Chart: formats for values, labels and tooltips (@kravets-levko)
- #2560: Introduce Policy object (@arikfr)
- #2380: Admin should be able to disable a user (@kravets-levko)
- #2509: Show custom date format on settings page (@kyoshidajp)
### Changed
- #2715: Improve users list page (@arikfr)
- #2710: Update Ant variables to fit Redash's style (@kocsmy)
- #2709: Move format button next Add New Param button. (@arikfr)
- #2664: Dashboard shows a spinner when query failed to load (@kravets-levko)
- #2626: Show real status when loading cached query result (@kravets-levko)
- #2663: Set column name implicitly when column name is blank (@ariarijp)
- #2695: Improve Date/DateTime type parameters (@kravets-levko)
- #2694: Block users with disposable email addresses (@arikfr)
- #2687: YAML: changed load to safe_load (@denisov-vlad)
- #2514: Update value parsing for google spreadsheets source (@atharvai)
- #2570: fixes query pagination alignment (@alison985)
- #2584: keep query result pagination out of scroll (@alison985)
- #2647: Improve Script Query Runner (@ariarijp)
- #2583: Query header improvements on widgets (@kocsmy)
- #2671: Save some space (@kocsmy)
- #2658: delaying schema filtering to improve responsiveness (@alison985)
- #2648: Update datasource documentation links (@Pablohn26)
- #2613: Improve Script Query Runner (@ariarijp)
- #2619: data source sort case insensitive (@alison985)
- #2604: Improve Google Spreadsheets Query Runner (@ariarijp)
- #2542: Closes #2541: x-axis improvements. (@emtwo)
- #2590: Remove redundant variables (@ariarijp)
- #2585: Show data only mode: allow to add and delete visualizations (@kravets-levko)
- #2549: Allow get_tables to see views and v10-style partitioned tables (@coreyhuinker)
- #2568: sort datasources alphabetically (@alison985)
- #2444: feat: show error if saml response cannot be parsed (@sjakthol)
- #2554: Display name to be delete (@kyoshidajp)
- #2510: Display confirmation dialog when deleting a item (@kyoshidajp)
- #2518: Design improvements (@kocsmy)
- #2520: Filter data sources in a data source input area (@kyoshidajp)
### Fixed
- #2722: Elasticsearch: Don't send source_content_type parameter. (@arikfr)
- #2719: Remove closing input tags (@maxv)
- #2458: Get all tables in the BigQuery (@kyoshidajp)
- #2698: Make sure we return distinct data source values (@arikfr)
- #2315: Fix: pyHive type matches (@yuua)
- #2638: Dashboard stops rendering when adding widget with empty query (@kravets-levko)
- #2610: Fix export query results output file name (@gabrieldutra)
- #2574: commit query result to db before evaluating alerts (@mtrbean)
- #2580: add break-word wrap to add/edit text box on dashboard (@alison985)
- #2578: Fix connection error when you run "create_tables" (@ariarijp)
- #2572: remove extra menu line if query is archived (@alison985)
- #2526: Fix pivot hide control in dashboards (@deecay)
- #2511: Fixing signed_out.html template (@kocsmy)
- #2523: Frontend: fix boolean field with null value display as null. (@innovia)
### Other
- #2682: Add Zeit's now support to have preview builds for every PR (@arikfr)
- #2668: Upgrade bootstrap script to Redash 4.0.1 (@ariarijp)
- #2639: Add tests for SpreadSheets (@ariarijp)
- #2635: Add tests for Query Results (@ariarijp)
- #2537: Remove trailing semicolon (@sieben)
## v4.0.1 - 2018-05-02
### Added
- Log user's screen resolution. @arikfr
### Changed
- [Redshift] fix the order of columns in the schema browser. @akiray03
- Improve dashboard refresh UX: show previous data while refreshing. @arikfr
### Fixed
- Disable fork button to view_only users. @tonyjiangh
- Hide overflowing data source and alert destination names. @kocsmy
- Login pages were broken on mobile. @kocsmy
- Cohort visualization wasn't rendering if value wasn't properly detected as date. @kravets-levko
- Dashboard filters setting wasn't persisting. @arikfr
- Display nulls and empty values as blank in table numeric fields. @chriszs
- Date column on alerts page is labeled "Created By". @dbravender
- Bootstrap script was breaking due to incompatability with pip 10. @ariarijp
### Other
- Updated README. @kocsmy
## v4.0.0 - 2018-04-16
### Added
- MatterMost alert destination. @alon710
- Full screen view on map visualizations. @deecay
- Choropleth map visualization 🗺. @kravets-levko
- Report Celery queue size. @arikfr
- Load dashboard refresh rate from URL. @arikfr
- Configuration for query refresh intervals. @arikfr
### Changed
- TreasureData: improve query failure message. @toru-takahashi
- Update botocore version (fixes an issue with loading Athena tables). @arikfr
- Changed Map visualization name to "Map (Markers)" to distinguish from the Choropleth one. @arikfr
- Use MongoClient for ReplicaSet connections. @fmy
- Update pymongo version to support newer MongoDB versions. @arikfr
- Changed "his" to "their" in user creation form success message. @tnetennba3
- Show friendly names in dynamic forms labels. @arikfr
- Render safe HTML by default in tables to remain backward compatible. @arikfr
- Apply time limit to alert status checking task. @arikfr
- Plotly: increase Y value accuracy. @arikfr
- close metadata database connection early in the execute query Celery task. @arikfr
### Fixed
- Query page layout gets messed up when clicking on "cancel" in "Do you want to leave this page?" dialog. @kravets-levko
- docker-entrypoint broke for other database names than "postgres". @valentin2105
- (BigQuery) UDF URI was used even if empty. @arikfr
- Show correct Box Plot chart hover data. @deeccay
- Fork button shows in data only view, but not working. @arikfr
- Saving widget sends too much data to the server, sometimes making dashboard save fail. @arikfr
- DynamoDB: always return counter as a number rather than string. @arikfr
- MSSQL: UUID fields were detected as booleans. @arikfr
- The whole dashboard page reloads when clicking on refresh. @arikfr
- Line chart with category x-axis: when some values missing, wrong hints displayed on hover. @kravets-levko
- Second Y-axis not displayed when stacking enabled. @kravets-levko
- Widget with empty contents had extra 40px of white space (paddings of container). @kravets-levko
- Add scrollbars to pivot table widgets. @kravets-levko
- Multiple performance, usability and auto-height related fixes to the dashboard rendering engine (also switched to GridStack). @kravets-levko
- Login form missing on LDAP logging page. @idalin
- Empty state: show connect data source link only to admins. @arikfr
- Dashboard "dancing" widgets (when auto-height enabled). @kravets-levko
### Other
- Webpack: ignore vim swap files. @deecay
## v4.0.0-rc.1 - 2018-03-05
### Added
- Configuration for query refresh intervals.
- [Prometheus] Support for range queries. @jubel-han
- Extensions system based on Python entrypoints. @jezdez
- Funnel visualization. @tonyjiangh
- UI to edit allowed Google OAuth domains. @arikfr
- Empty state for homepage, alerts, queries and dashboards pages. @kocsmy, @arikfr
### Changed
- Maintain widget's auto-height state until it's been resized by the user. @kravets-levko
- Change default table viz width from 4 to 3 columns. @kravets-levko
- When saving dashboard adding or removing widgets, save only modified widgets (with changed size and/or position). @kravets-levko
- Don't allow disabling Password based login if no SSO is enabled. @arikfr
- Always show login page, even if password based login disabled. @arikfr
- Upgrade `sqlparse` to 0.2.4. @ariarijp
- Make sure datetime/number columns in table visualization don't wrap. @kravets-levko
- Explicitly set order of tabs in settings page. @kravets-levko
- User can no longer change the type of a saved visualization. @kravets-levko
- Update docker-compose.yml to restart postgres/redis containers `unless-stopped`. @benmanns
- New default colors for chart visualizations. @kocsmy
- Updated design of all the authentication pages (login, forgot password, etc). @kravets-levko
### Fixed
- Glue schemas with more than 100 tables were showing only first 100 tables. @jezdez
- Long visualizations dind't render scrollbars on some browsers. @kravets-levko
- When the dataset was returning some columns name as non strings, table couldn't be rendered. @kravets-levko
- Missing logos for Prometheus and Snowflake. @kocsmy
- Render correct link to LDAP login on login page. @arikfr
- Sort widgets by column/row to make sure they are placed correctly. @arikfr
- Public dashboards were not rendered due to Javascript error. @kravets-levko
## v4.0.0-beta - 2018-02-14
### Added
- Massive update to the UI/UX of the whole application. @kocsmy, @kravets-levko, @arikfr
- Flexible dashboard layout: resize widgets both vertically and horizonally. @kravets-levko
- Configuration and new options for the table visualization. @kravets-levko
- API to return internal usage events. @arikfr
- Add an option to set a common prefix to the backend logs. @arikfr
- [MongoDB] support nested fields in results. @arikfr
- Cohort visualization: add options and fix rendering logic. @kravets-levko
- Table visualization: `URL` column type. @kravets-levko
- Table visualization: `Image` column type. @kravets-levko
- [BigQuery] show amount of data scanned. @arikfr
- Make dashboard refresh intervals configurable. @arikfr
- Button to insert table/column name from schema into the query text. @kravets-levko
- [Athena] show amount of data scanned. @washort
- [Salesforce] Add setting to set the API version. @mayconbordin
- UI for configuration options (auth, date format, etc). @arikfr
- CLI command to create the root user. @kyoshidajp
- [Redshift] support for loading late binding views in schema browser. @tonyjiangh
- Show user's profile picture and load it from Google when using Google OAuth. @kyoshidajp
- CockroachDB query runner. @yershalom
- MAPD query runner. @cdessanti
- Pie chart: show subplot titles. @deecay
### Changed
- Make trusted header authentication compatible with multiorg mode. @sjakthol
- Update AWS RDS certificate bundle. @arikfr
- Add Prometheus to the default query runners list. @arikfr
- [Athena] update botocore version to support Glue. @arikfr
- Support for quotes passwords in the Redis and Postgres connection URLs. @javier-sanz
- Change the way static assets are served. @arikfr
- [BigQuery] Properly handle RECORD fields in schema (show the nested fields). @arikfr
- Upgrade to Celery 3.1.25 in preparation to Celery 4. @jezdez
- Remove loading indicator when updating query parameter value (before executing). @kravets-levko
- Improvements to the chart visualization (see #2156 for details). @kravets-levko
- Start searching for queries immediately instead of waiting for 3 characters. @kyoshidajp
- Make all references to Elasticsearch be properly capitalized. @kakakakakku
- Use PostgreSQL's FTS/tsvector type for query searches. @jezdez
- [Redshift] Make sslmode configurable. @sjakthol
- Allow passing options to tests Docker command. @arikfr
- Improve error handling mechanism and make error pages friendlier. @kravets-levko, @kocsmy, @arikfr
- Make LDAP settings names more consistent. @gramakri
- [Oracle] support for non SELECT queries. @doddjc21
- Admin can no longer remove themselves from the built-in groups. @negibouze
- Update pie charts font style. @deecay
- Upgrade psycopg2 for support PostgreSQL 10.0. @kyoshidajp
- Convert all stylesheets to LESS. @kravets-levko
- [Elasticsearch] Collect doc_count field from aggregation. @arjan
- Switch to pytest. @jezdez
- Ensure email is case-insensitive. @miketheman
- [Redshift] change default SSL mode to prefer. @arikfr
- Return Redis memory usage in bytes for easier monitoring. @kakakakakku
- create_db command in docker-entrypoint waits for Postgres to become available first. @ariarijp
- [Elasticsearch] set source_content_type on ES queries to support Elasticsearch 6.0. @alexdrans
- Show `-` instead of `Invalid Date` for null values given to `dateTime` filter. @kyoshidajp
### Fixed
- Parameters list was resetting when adding a new parameter. @arikfr
- Don't escape values in non-html columns. @kravets-levko
- Commit SAML user group assignment to the database. @sjakthol
- Update correct settings in SAML settings form. @sjakthol
- Fix Google OAuth login in MULTIORG mode. @shinji19
- Strip annotation from query when path is specified in Script query runner. @ariarijp
- Fix filter headers when there are multiple rows of filters. @kocsmy
- Update query version when changing query data source. @washort
- Fix upgrade script to support changes in CircleCI. @rgjodekerken
- Don't show error indicators after submitting the user form. @bamboo-yujiro
- [Query Results] support unicode column names. @tonyjiangh
- Issue with Google OAuth caused by old pyOpenSSL version. @crooy
- Fix layout of outdated queries admin view. @bamboo-yujiro
- User can't download query results of a new query. @arikfr
- Typo in celery logs format. @ariarijp
- Handling whitespace characters in Query Results data source. @ariarijp
- [MySQL] Close cursor when cancellig the query. @jasonsmithj
## v3.0.0 - 2017-11-13
### 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
================================================
FILE: CONTRIBUTING.md
================================================
# Contributing Guide
Thank you for taking the time to contribute! :tada::+1:
The following is a set of guidelines for contributing to Redash. These are guidelines, not rules, please use your best judgement and feel free to propose changes to this document in a pull request.
:star: If you're already here and love the project, please make sure to press the Star button. :star:
## Table of Contents
[How can I contribute?](#how-can-i-contribute)
- [Reporting Bugs](#reporting-bugs)
- [Suggesting Enhancements / Feature Requests](#suggesting-enhancements--feature-requests)
- [Pull Requests](#pull-requests)
- [Documentation](#documentation)
- Design?
[Additional Notes](#additional-notes)
- [Release Method](#release-method)
- [Code of Conduct](#code-of-conduct)
## Quick Links:
- [User Forum](https://github.com/getredash/redash/discussions)
- [Documentation](https://redash.io/help/)
---
## How can I contribute?
### Reporting Bugs
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 [Q&A discussion](https://github.com/getredash/redash/discussions/new?category=q-a) first. Unless you can provide clear steps to reproduce, it's probably better to start with a discussion 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:
- Please check [the Ideas discussions](https://github.com/getredash/redash/discussions/categories/ideas) for existing threads about 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 open thread, you're welcome to start one to have 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 is 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.
#### Criteria for Review / Merging
When you open your pull request, please follow this repository’s PR template carefully:
- Indicate the type of change
- If you implement multiple unrelated features, bug fixes, or refactors please split them into individual pull requests.
- Describe the change
- If fixing a bug, please describe the bug or link to an existing github issue / forum discussion
- Include UI screenshots / GIFs whenever possible
- Please add [documentation](#documentation) for new features or changes in functionality along with the code.
- Please follow existing code style:
- Python: we use [Black](https://github.com/psf/black) to auto format the code.
- Javascript: we use [Prettier](https://github.com/prettier/prettier) to auto-format the code.
#### Initial Review (1 week)
During this phase, a team member will apply the “Team Review” label if a pull request meets our criteria or a “Needs More Information” label if not. If more information is required, the team member will comment which criteria have not been met.
If your pull request receives the “Needs More Information” label, please make the requested changes and then remove the label. This resets the 1 week timer for an initial review.
Stale pull requests that remain untouched in “Needs More Information” for more than 4 weeks will be closed.
If a team member closes your pull request, you may reopen it after you have made the changes requested during initial review. After you make these changes, remove the “Needs More Information” label. This again resets the timer for another initial review.
#### Full Review (2 weeks)
After the “Team Review” label is applied, a member of the core team will review the PR within 2 weeks.
Reviews will approve, request changes, or ask questions to discuss areas of uncertainty. After you’ve responded, a member of the team will re-review within one week.
#### Merging (1 week)
After your pull request has been approved, a member of the core team will merge the pull request within a week.
### 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/src/pages/kb) 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.
## Additional Notes
### Release Method
We publish a stable release every ~3-4 months, although the goal is to get to a stable release every month.
Every build of the master branch updates the *redash/redash:preview* Docker Image. These releases are usually stable, but might contain regressions and therefore recommended for "advanced users" only.
When we release a new stable release, we also update the *latest* Docker image tag, the EC2 AMIs and GCE images.
## Code of Conduct
This project adheres to the Contributor Covenant [code of conduct](https://redash.io/community/code_of_conduct). By participating, you are expected to uphold this code. Please report unacceptable behavior to team@redash.io.
================================================
FILE: Dockerfile
================================================
FROM node:24-bookworm AS frontend-builder
RUN npm install --global pnpm@10.30.3
# Controls whether to build the frontend assets
ARG skip_frontend_build
ENV CYPRESS_INSTALL_BINARY=0
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1
RUN useradd -m -d /frontend redash
USER redash
WORKDIR /frontend
COPY --chown=redash package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc /frontend/
COPY --chown=redash viz-lib /frontend/viz-lib
COPY --chown=redash scripts /frontend/scripts
# Controls whether to instrument code for coverage information
ARG code_coverage
ENV BABEL_ENV=${code_coverage:+test}
# Use BuildKit cache mount for pnpm store to speed rebuilds
RUN --mount=type=cache,id=pnpm-store,target=/frontend/.cache/pnpm,uid=1001,gid=1001 \
pnpm config set store-dir /frontend/.cache/pnpm && \
if [ "x$skip_frontend_build" = "x" ] ; then pnpm install --frozen-lockfile; fi
COPY --chown=redash client /frontend/client
COPY --chown=redash webpack.config.js /frontend/
# Use the same cache mount for the build step
RUN --mount=type=cache,id=pnpm-store,target=/frontend/.cache/pnpm,uid=1001,gid=1001 < /etc/apt/sources.list.d/mssql-release.list
apt-get update
ACCEPT_EULA=Y apt-get install -y --no-install-recommends msodbcsql18
apt-get clean
rm -rf /var/lib/apt/lists/*
curl "$databricks_odbc_driver_url" --location --output /tmp/simba_odbc.zip
chmod 600 /tmp/simba_odbc.zip
unzip /tmp/simba_odbc.zip -d /tmp/simba
dpkg -i /tmp/simba/*.deb
printf "[Simba]\nDriver = /opt/simba/spark/lib/64/libsparkodbc_sb64.so" >> /etc/odbcinst.ini
rm /tmp/simba_odbc.zip
rm -rf /tmp/simba
fi
EOF
WORKDIR /app
ENV POETRY_VERSION=2.1.4
ENV POETRY_HOME=/etc/poetry
ENV POETRY_VIRTUALENVS_CREATE=false
RUN curl -sSL --retry 3 --retry-delay 5 https://install.python-poetry.org | python3 -
# Avoid crashes, including corrupted cache artifacts, when building multi-platform images with GitHub Actions.
RUN /etc/poetry/bin/poetry cache clear pypi --all
COPY pyproject.toml poetry.lock ./
ARG POETRY_OPTIONS="--no-root --no-interaction --no-ansi"
# for LDAP authentication, install with `ldap3` group
# disabled by default due to GPL license conflict
ARG install_groups="main,all_ds,dev"
RUN /etc/poetry/bin/poetry install --only $install_groups $POETRY_OPTIONS
COPY --chown=redash . /app
COPY --from=frontend-builder --chown=redash /frontend/client/dist /app/client/dist
RUN chown redash /app
USER redash
ENTRYPOINT ["/app/bin/docker-entrypoint"]
CMD ["server"]
================================================
FILE: LICENSE
================================================
Copyright (c) 2013-2020, Arik Fraimovich.
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation and/or
other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS
BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
================================================
FILE: LICENSE.borders
================================================
The Bahrain map data used in Redash was downloaded from
https://cartographyvectors.com/map/857-bahrain-detailed-boundary in PR #6192.
* Free for personal and commercial purpose with attribution.
================================================
FILE: Makefile
================================================
.PHONY: compose_build up test_db create_database clean down tests lint backend-unit-tests frontend-unit-tests test build watch start redis-cli bash
compose_build: .env
COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose build
up:
docker compose up -d redis postgres --remove-orphans
docker compose exec -u postgres postgres psql postgres --csv \
-1tqc "SELECT table_name FROM information_schema.tables WHERE table_name = 'organizations'" 2> /dev/null \
| grep -q "organizations" || make create_database
COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose up -d --build --remove-orphans
test_db:
@for i in `seq 1 5`; do \
if (docker compose exec postgres sh -c 'psql -U postgres -c "select 1;"' 2>&1 > /dev/null) then break; \
else echo "postgres initializing..."; sleep 5; fi \
done
docker compose exec postgres sh -c 'psql -U postgres -c "drop database if exists tests;" && psql -U postgres -c "create database tests;"'
create_database: .env
docker compose run server create_db
clean:
docker compose down
docker compose --project-name cypress down
docker compose rm --stop --force
docker compose --project-name cypress rm --stop --force
docker image rm --force \
cypress-server:latest cypress-worker:latest cypress-scheduler:latest \
redash-server:latest redash-worker:latest redash-scheduler:latest
docker container prune --force
docker image prune --force
docker volume prune --force
down:
docker compose down
.env:
printf "REDASH_COOKIE_SECRET=`pwgen -1s 32`\nREDASH_SECRET_KEY=`pwgen -1s 32`\n" >> .env
env: .env
format:
pre-commit run --all-files
tests:
docker compose run server tests
lint:
ruff check .
black --check . --diff
backend-unit-tests: up test_db
docker compose run --rm --name tests server tests
frontend-unit-tests:
CYPRESS_INSTALL_BINARY=0 PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1 pnpm install --frozen-lockfile
pnpm test
test: backend-unit-tests frontend-unit-tests lint
build:
pnpm run build
watch:
pnpm run watch
start:
pnpm start
redis-cli:
docker compose run --rm redis redis-cli -h redis
bash:
docker compose run --rm server bash
================================================
FILE: README.md
================================================
[](https://redash.io/help/)
[](https://github.com/getredash/redash/actions)
Redash is designed to enable anyone, regardless of the level of technical sophistication, to harness the power of data big and small. SQL users leverage Redash to explore, query, visualize, and share data from any data source. Their work, in turn, enables anybody in their organization to use the data. Every day, millions of users at thousands of organizations around the world use Redash to develop insights and make data-driven decisions.
Redash features:
1. **Browser-based**: Everything in your browser, with a shareable URL.
2. **Ease-of-use**: Become immediately productive with data without the need to master complex software.
3. **Query editor**: Quickly compose SQL and NoSQL queries with a schema browser and auto-complete.
4. **Visualization and dashboards**: Create [beautiful visualizations](https://redash.io/help/user-guide/visualizations/visualization-types) with drag and drop, and combine them into a single dashboard.
5. **Sharing**: Collaborate easily by sharing visualizations and their associated queries, enabling peer review of reports and queries.
6. **Schedule refreshes**: Automatically update your charts and dashboards at regular intervals you define.
7. **Alerts**: Define conditions and be alerted instantly when your data changes.
8. **REST API**: Everything that can be done in the UI is also available through the REST API.
9. **Broad support for data sources**: An extensible data source API with native support for a long list of common databases and platforms.
## Getting Started
* [Setting up a Redash instance](https://redash.io/help/open-source/setup) (includes links to ready-made AWS/GCE images).
* [Documentation](https://redash.io/help/).
## Supported Data Sources
Redash supports more than 35 SQL and NoSQL [data sources](https://redash.io/help/data-sources/supported-data-sources). It can also be extended to support more. Below is a list of built-in sources:
- Amazon Athena
- Amazon CloudWatch / Insights
- Amazon DynamoDB
- Amazon Redshift
- ArangoDB
- Axibase Time Series Database
- Apache Cassandra
- ClickHouse
- CockroachDB
- Couchbase
- CSV
- Databricks
- DB2 by IBM
- Dgraph
- Apache Drill
- Apache Druid
- e6data
- Eccenca Corporate Memory
- Elasticsearch
- Exasol
- Microsoft Excel
- Firebolt
- Databend
- Google Analytics
- Google BigQuery
- Google Spreadsheets
- Graphite
- Greenplum
- Apache Hive
- Apache Impala
- InfluxDB
- InfluxDBv2
- IBM Netezza Performance Server
- JIRA (JQL)
- JSON
- Apache Kylin
- OmniSciDB (Formerly MapD)
- MariaDB
- MemSQL
- Microsoft Azure Data Warehouse / Synapse
- Microsoft Azure SQL Database
- Microsoft Azure Data Explorer / Kusto
- Microsoft SQL Server
- MongoDB
- MySQL
- Oracle
- Apache Phoenix
- Apache Pinot
- PostgreSQL
- Presto
- Prometheus
- Python
- Qubole
- Rockset
- RisingWave
- Salesforce
- ScyllaDB
- Shell Scripts
- Snowflake
- SPARQL
- SQLite
- TiDB
- Tinybird
- TreasureData
- Trino
- Uptycs
- Vertica
- Yandex AppMetrica
- Yandex Metrica
## Getting Help
* Issues: https://github.com/getredash/redash/issues
* Discussion Forum: https://github.com/getredash/redash/discussions/
* Development Discussion: https://discord.gg/tN5MdmfGBp
## 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://github.com/getredash/redash/wiki/Local-development-setup) and make a pull request. We need all the help we can get!
## Security
Please email security@redash.io to report any security vulnerabilities. We will acknowledge receipt of your vulnerability and strive to send you regular updates about our progress. If you're curious about the status of your disclosure please feel free to email us again. If you want to encrypt your disclosure email, you can use [this PGP key](https://keybase.io/arikfr/key.asc).
## License
BSD-2-Clause.
================================================
FILE: SECURITY.md
================================================
# Security Policy
## Reporting a Vulnerability
Please email security@redash.io to report any security vulnerabilities. We will acknowledge receipt of your vulnerability and strive to send you regular updates about our progress. If you're curious about the status of your disclosure please feel free to email us again. If you want to encrypt your disclosure email, you can use [this PGP key](https://keybase.io/arikfr/key.asc).
================================================
FILE: bin/docker-entrypoint
================================================
#!/bin/bash
set -e
scheduler() {
echo "Starting RQ scheduler..."
exec /app/manage.py rq scheduler
}
dev_scheduler() {
echo "Starting dev RQ scheduler..."
exec watchmedo auto-restart --directory=./redash/ --pattern=*.py --recursive -- ./manage.py rq scheduler
}
worker() {
echo "Starting RQ worker..."
export WORKERS_COUNT=${WORKERS_COUNT:-2}
export QUEUES=${QUEUES:-}
exec supervisord -c worker.conf
}
workers_healthcheck() {
WORKERS_COUNT=${WORKERS_COUNT}
echo "Checking active workers count against $WORKERS_COUNT..."
ACTIVE_WORKERS_COUNT=`echo $(rq info --url $REDASH_REDIS_URL -R | grep workers | grep -oP ^[0-9]+)`
if [ "$ACTIVE_WORKERS_COUNT" -lt "$WORKERS_COUNT" ]; then
echo "$ACTIVE_WORKERS_COUNT workers are active, Exiting"
exit 1
else
echo "$ACTIVE_WORKERS_COUNT workers are active"
exit 0
fi
}
dev_worker() {
echo "Starting dev RQ worker..."
exec watchmedo auto-restart --directory=./redash/ --pattern=*.py --recursive -- ./manage.py rq worker $QUEUES
}
server() {
# Recycle gunicorn workers every n-th request. See http://docs.gunicorn.org/en/stable/settings.html#max-requests for more details.
MAX_REQUESTS=${MAX_REQUESTS:-1000}
MAX_REQUESTS_JITTER=${MAX_REQUESTS_JITTER:-100}
TIMEOUT=${REDASH_GUNICORN_TIMEOUT:-60}
BIND_ADDRESS=${REDASH_GUNICORN_BIND:-[::]:5000}
exec /usr/local/bin/gunicorn -b "$BIND_ADDRESS" --name redash -w${REDASH_WEB_WORKERS:-4} redash.wsgi:app --max-requests $MAX_REQUESTS --max-requests-jitter $MAX_REQUESTS_JITTER --timeout $TIMEOUT --limit-request-line ${REDASH_GUNICORN_LIMIT_REQUEST_LINE:-0}
}
create_db() {
exec /app/manage.py database create_tables
}
help() {
echo "Redash Docker."
echo ""
echo "Usage:"
echo ""
echo "server -- start Redash server (with gunicorn)"
echo "worker -- start a single RQ worker"
echo "dev_worker -- start a single RQ worker with code reloading"
echo "scheduler -- start an rq-scheduler instance"
echo "dev_scheduler -- start an rq-scheduler instance with code reloading"
echo ""
echo "shell -- open shell"
echo "dev_server -- start Flask development server with debugger and auto reload"
echo "debug -- start Flask development server with remote debugger via debugpy"
echo "create_db -- create database tables"
echo "manage -- CLI to manage redash"
echo "tests -- run tests"
}
tests() {
export REDASH_DATABASE_URL="postgresql://postgres@postgres/tests"
if [ $# -eq 0 ]; then
TEST_ARGS=tests/
else
TEST_ARGS=$@
fi
exec pytest $TEST_ARGS
}
case "$1" in
worker)
shift
worker
;;
workers_healthcheck)
shift
workers_healthcheck
;;
server)
shift
server
;;
scheduler)
shift
scheduler
;;
dev_scheduler)
shift
dev_scheduler
;;
dev_worker)
shift
dev_worker
;;
celery_healthcheck)
shift
echo "DEPRECATED: Celery has been replaced with RQ and now performs healthchecks autonomously as part of the 'worker' entrypoint."
;;
dev_server)
export FLASK_DEBUG=1
exec /app/manage.py runserver --debugger --reload -h 0.0.0.0
;;
debug)
export FLASK_DEBUG=1
export REMOTE_DEBUG=1
exec /app/manage.py runserver --debugger --no-reload -h 0.0.0.0
;;
shell)
exec /app/manage.py shell
;;
create_db)
create_db
;;
manage)
shift
exec /app/manage.py $*
;;
tests)
shift
tests $@
;;
help)
shift
help
;;
*)
exec "$@"
;;
esac
================================================
FILE: bin/get_changes.py
================================================
#!/bin/env python3
import re
import subprocess
import sys
def get_change_log(previous_sha):
args = [
"git",
"--no-pager",
"log",
"--merges",
"--grep",
"Merge pull request",
'--pretty=format:"%h|%s|%b|%p"',
"master...{}".format(previous_sha),
]
log = subprocess.check_output(args)
changes = []
for line in log.split("\n"):
try:
sha, subject, body, parents = line[1:-1].split("|")
except ValueError:
continue
try:
pull_request = re.match(r"Merge pull request #(\d+)", subject).groups()[0]
pull_request = " #{}".format(pull_request)
except Exception:
pull_request = ""
author = subprocess.check_output(["git", "log", "-1", '--pretty=format:"%an"', parents.split(" ")[-1]])[1:-1]
changes.append("{}{}: {} ({})".format(sha, pull_request, body.strip(), author))
return changes
if __name__ == "__main__":
previous_sha = sys.argv[1]
changes = get_change_log(previous_sha)
for change in changes:
print(change)
================================================
FILE: bin/release_manager.py
================================================
#!/usr/bin/env python3
import os
import re
import subprocess
import sys
from urllib.parse import urlparse
import requests
import simplejson
github_token = os.environ["GITHUB_TOKEN"]
auth = (github_token, "x-oauth-basic")
repo = "getredash/redash"
def _github_request(method, path, params=None, headers={}):
if urlparse(path).hostname != "api.github.com":
url = "https://api.github.com/{}".format(path)
else:
url = path
if params is not None:
params = simplejson.dumps(params)
response = requests.request(method, url, data=params, auth=auth)
return response
def exception_from_error(message, response):
return Exception("({}) {}: {}".format(response.status_code, message, response.json().get("message", "?")))
def rc_tag_name(version):
return "v{}-rc".format(version)
def get_rc_release(version):
tag = rc_tag_name(version)
response = _github_request("get", "repos/{}/releases/tags/{}".format(repo, tag))
if response.status_code == 404:
return None
elif response.status_code == 200:
return response.json()
raise exception_from_error("Unknown error while looking RC release: ", response)
def create_release(version, commit_sha):
tag = rc_tag_name(version)
params = {
"tag_name": tag,
"name": "{} - RC".format(version),
"target_commitish": commit_sha,
"prerelease": True,
}
response = _github_request("post", "repos/{}/releases".format(repo), params)
if response.status_code != 201:
raise exception_from_error("Failed creating new release", response)
return response.json()
def upload_asset(release, filepath):
upload_url = release["upload_url"].replace("{?name,label}", "")
filename = filepath.split("/")[-1]
with open(filepath) as file_content:
headers = {"Content-Type": "application/gzip"}
response = requests.post(
upload_url, file_content, params={"name": filename}, headers=headers, auth=auth, verify=False
)
if response.status_code != 201: # not 200/201/...
raise exception_from_error("Failed uploading asset", response)
return response
def remove_previous_builds(release):
for asset in release["assets"]:
response = _github_request("delete", asset["url"])
if response.status_code != 204:
raise exception_from_error("Failed deleting asset", response)
def get_changelog(commit_sha):
latest_release = _github_request("get", "repos/{}/releases/latest".format(repo))
if latest_release.status_code != 200:
raise exception_from_error("Failed getting latest release", latest_release)
latest_release = latest_release.json()
previous_sha = latest_release["target_commitish"]
args = [
"git",
"--no-pager",
"log",
"--merges",
"--grep",
"Merge pull request",
'--pretty=format:"%h|%s|%b|%p"',
"{}...{}".format(previous_sha, commit_sha),
]
log = subprocess.check_output(args)
changes = ["Changes since {}:".format(latest_release["name"])]
for line in log.split("\n"):
try:
sha, subject, body, parents = line[1:-1].split("|")
except ValueError:
continue
try:
pull_request = re.match(r"Merge pull request #(\d+)", subject).groups()[0]
pull_request = " #{}".format(pull_request)
except Exception:
pull_request = ""
author = subprocess.check_output(["git", "log", "-1", '--pretty=format:"%an"', parents.split(" ")[-1]])[1:-1]
changes.append("{}{}: {} ({})".format(sha, pull_request, body.strip(), author))
return "\n".join(changes)
def update_release_commit_sha(release, commit_sha):
params = {
"target_commitish": commit_sha,
}
response = _github_request("patch", "repos/{}/releases/{}".format(repo, release["id"]), params)
if response.status_code != 200:
raise exception_from_error("Failed updating commit sha for existing release", response)
return response.json()
def update_release(version, build_filepath, commit_sha):
try:
release = get_rc_release(version)
if release:
release = update_release_commit_sha(release, commit_sha)
else:
release = create_release(version, commit_sha)
print("Using release id: {}".format(release["id"]))
remove_previous_builds(release)
response = upload_asset(release, build_filepath)
changelog = get_changelog(commit_sha)
response = _github_request("patch", release["url"], {"body": changelog})
if response.status_code != 200:
raise exception_from_error("Failed updating release description", response)
except Exception as ex:
print(ex)
if __name__ == "__main__":
commit_sha = sys.argv[1]
version = sys.argv[2]
filepath = sys.argv[3]
# TODO: make sure running from git directory & remote = repo
update_release(version, filepath, commit_sha)
================================================
FILE: bin/run
================================================
#!/usr/bin/env bash
# Ideally I would use stdin with source, but in older bash versions this
# wasn't supported properly.
TEMP_ENV_FILE=`mktemp /tmp/redash_env.XXXXXX`
sed 's/^REDASH/export REDASH/' .env > $TEMP_ENV_FILE
source $TEMP_ENV_FILE
rm $TEMP_ENV_FILE
exec "$@"
================================================
FILE: client/.babelrc
================================================
{
"presets": [
[
"@babel/preset-env",
{
"exclude": ["@babel/plugin-transform-async-to-generator", "@babel/plugin-transform-arrow-functions"],
"corejs": "2",
"useBuiltIns": "usage"
}
],
"@babel/preset-react",
"@babel/preset-typescript"
],
"plugins": [
"@babel/plugin-transform-class-properties",
"@babel/plugin-transform-object-assign",
[
"babel-plugin-transform-builtin-extend",
{
"globals": ["Error"]
}
]
],
"env": {
"test": {
"plugins": ["istanbul"]
}
}
}
================================================
FILE: client/.eslintignore
================================================
build/*.js
dist
config/*.js
client/dist
================================================
FILE: client/.eslintrc.js
================================================
module.exports = {
root: true,
parser: "@typescript-eslint/parser",
extends: [
"react-app",
"plugin:compat/recommended",
"prettier",
"plugin:jsx-a11y/recommended",
],
plugins: ["jest", "compat", "no-only-tests", "@typescript-eslint", "jsx-a11y"],
settings: {
"import/resolver": "webpack",
polyfills: [
"document.body",
"Notification",
],
},
env: {
browser: true,
node: true,
},
rules: {
// allow debugger during development
"no-debugger": process.env.NODE_ENV === "production" ? 2 : 0,
// Pre-existing patterns - anonymous default exports are used throughout
"import/no-anonymous-default-export": "off",
// Some tests verify no-throw behavior without explicit assertions
"jest/expect-expect": "off",
"jsx-a11y/anchor-is-valid": [
// TMP
"off",
{
components: ["Link"],
aspects: ["noHref", "invalidHref", "preferButton"],
},
],
"jsx-a11y/no-redundant-roles": "error",
"jsx-a11y/no-autofocus": "off",
"jsx-a11y/click-events-have-key-events": "off", // TMP
"jsx-a11y/no-static-element-interactions": "off", // TMP
"jsx-a11y/no-noninteractive-element-interactions": "off", // TMP
"no-console": ["warn", { allow: ["warn", "error"] }],
"no-restricted-imports": [
"error",
{
paths: [
{
name: "antd",
message: "Please use 'import XXX from antd/lib/XXX' import instead.",
},
{
name: "antd/lib",
message: "Please use 'import XXX from antd/lib/XXX' import instead.",
},
],
},
],
},
overrides: [
{
// Only run typescript-eslint on TS files
files: ["*.ts", "*.tsx", ".*.ts", ".*.tsx"],
extends: ["plugin:@typescript-eslint/recommended"],
rules: {
// Do not require functions (especially react components) to have explicit returns
"@typescript-eslint/explicit-function-return-type": "off",
// Do not require to type every import from a JS file to speed up development
"@typescript-eslint/no-explicit-any": "off",
// Do not complain about useless contructors in declaration files
"no-useless-constructor": "off",
"@typescript-eslint/no-useless-constructor": "error",
// Many API fields and generated types use camelcase
camelcase: "off",
// Allow {} type - used extensively in existing codebase
"@typescript-eslint/ban-types": ["error", { types: { "{}": false } }],
},
},
{
// Cypress test files
files: ["**/cypress/**/*.js"],
extends: ["plugin:cypress/recommended"],
plugins: ["cypress", "chai-friendly"],
env: {
"cypress/globals": true,
},
rules: {
"no-redeclare": "off",
"cypress/unsafe-to-chain-command": "off",
"func-names": ["error", "never"],
"no-unused-expressions": "off",
"chai-friendly/no-unused-expressions": "error",
},
},
],
};
================================================
FILE: client/.gitignore
================================================
dist
================================================
FILE: client/app/.eslintrc.js
================================================
module.exports = {
extends: ["plugin:jest/recommended"],
plugins: ["jest"],
env: {
"jest/globals": true,
},
rules: {
"jest/no-focused-tests": "off",
},
};
================================================
FILE: client/app/__tests__/enzyme_setup.js
================================================
import { configure } from "enzyme";
import Adapter from "enzyme-adapter-react-16";
configure({ adapter: new Adapter() });
================================================
FILE: client/app/__tests__/mocks.js
================================================
import MockDate from "mockdate";
const date = new Date("2000-01-01T02:00:00.000");
MockDate.set(date);
================================================
FILE: client/app/assets/css/login.css
================================================
body {
padding-top: 0px !important;
background-color: #FFFFFF;
}
.logo-container {
background-color: #668899;
display: table;
width: 100%;
padding: 10px;
}
.content-container {
background-color: white;
display: table;
width: 100%;
padding: 10px;
height: calc(100% - 116px);
}
@media (min-width: 992px) {
.content-container {
height: 100%;
width: 60%;
float: left;
}
.logo-container {
height: 100%;
width: 40%;
float: right;
}
}
.login-or {
position: relative;
font-size: 18px;
color: #aaa;
margin-top: 20px;
margin-bottom: 20px;
padding-top: 10px;
padding-bottom: 10px;
}
.span-or {
display: block;
position: absolute;
left: 50%;
top: -2px;
margin-left: -25px;
background-color: #fff;
width: 50px;
text-align: center;
}
.hr-or {
background-color: #cdcdcd;
height: 1px;
margin-top: 0px !important;
margin-bottom: 0px !important;
}
img.login-button {
width: 250px;
display: block;
margin-left: auto;
margin-right: auto;
}
================================================
FILE: client/app/assets/images/illustrations/readme.md
================================================
The illustrations shared in this folder are covered by the CC-BY-SA-NC-ND License v4.0 (or newer). You are allowed to use them when using unmodified versions of the Redash source code for non-commercial purposes (meaning for yourself), but you are not allowed to use for any other use or to distribute them as part of your software or fork of Redash.
For any questions, please contact us.
================================================
FILE: client/app/assets/less/STYLING-README.md
================================================
# Styling readme
Some general rules before you add stuff.
- Avoid using inline css
- If possible, use classes that are already in place instead of adding new
- Keep less/inc folder untouched, rewrite things in less/redash respectively
- Try following BEM naming conventions: http://getbem.com/naming/
================================================
FILE: client/app/assets/less/ant.less
================================================
@import "~antd/lib/style/core/iconfont";
@import "~antd/lib/style/core/motion";
@import "~antd/lib/alert/style/index";
@import "~antd/lib/input/style/index";
@import "~antd/lib/input-number/style/index";
@import "~antd/lib/date-picker/style/index";
@import "~antd/lib/modal/style/index";
@import "~antd/lib/tooltip/style/index";
@import "~antd/lib/select/style/index";
@import "~antd/lib/checkbox/style/index";
@import "~antd/lib/upload/style/index";
@import "~antd/lib/form/style/index";
@import "~antd/lib/button/style/index";
@import "~antd/lib/radio/style/index";
@import "~antd/lib/time-picker/style/index";
@import "~antd/lib/pagination/style/index";
@import "~antd/lib/table/style/index";
@import "~antd/lib/popover/style/index";
@import "~antd/lib/tag/style/index";
@import "~antd/lib/grid/style/index";
@import "~antd/lib/switch/style/index";
@import "~antd/lib/empty/style/index";
@import "~antd/lib/drawer/style/index";
@import "~antd/lib/card/style/index";
@import "~antd/lib/steps/style/index";
@import "~antd/lib/divider/style/index";
@import "~antd/lib/dropdown/style/index";
@import "~antd/lib/menu/style/index";
@import "~antd/lib/list/style/index";
@import "~antd/lib/badge/style/index";
@import "~antd/lib/card/style/index";
@import "~antd/lib/spin/style/index";
@import "~antd/lib/skeleton/style/index";
@import "~antd/lib/tabs/style/index";
@import "~antd/lib/notification/style/index";
@import "~antd/lib/collapse/style/index";
@import "~antd/lib/progress/style/index";
@import "~antd/lib/typography/style/index";
@import "~antd/lib/descriptions/style/index";
@import "inc/ant-variables";
// Increase z-indexes to avoid conflicts with some other libraries (e.g. Plotly)
@zindex-modal: 2000;
@zindex-modal-mask: 2000;
@zindex-message: 2010;
@zindex-notification: 2010;
@zindex-popover: 2030;
@zindex-dropdown: 2050;
@zindex-picker: 2050;
@zindex-tooltip: 2060;
@item-hover-bg: #e5f8ff;
.@{drawer-prefix-cls} {
&.help-drawer {
z-index: @zindex-tooltip; // help drawer should be topmost
}
}
// Remove bold in labels for Ant checkboxes and radio buttons
.ant-checkbox-wrapper,
.ant-radio-wrapper {
font-weight: normal;
}
.ant-select-dropdown-menu-item em {
color: @input-color-placeholder;
font-size: 11px;
}
// Fix for disabled button styles inside Tooltip component.
// Tooltip wraps disabled buttons with `` and moves all styles
// and classes to that ``. This resets all button styles and
// turns it into simple inline element (because now it's wrapper is a button)
.btn {
button[disabled] {
-moz-appearance: none !important;
-webkit-appearance: none !important;
appearance: none !important;
border: 0 !important;
outline: none !important;
background: transparent !important;
margin: 0 !important;
padding: 0 !important;
}
}
// Button overrides
.@{btn-prefix-cls} {
transition-duration: 150ms;
&.icon-button {
width: 32px;
padding: 0 10px;
}
}
// Fix ant input number showing duplicate arrows
.ant-input-number-input::-webkit-outer-spin-button,
.ant-input-number-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
// Pagination overrides (based on existing Bootstrap overrides)
.@{pagination-prefix-cls} {
display: inline-block;
margin-top: 18px;
margin-bottom: 18px;
vertical-align: top;
&-item {
background-color: @pagination-bg;
border-color: transparent;
color: @pagination-color;
font-size: 14px;
margin-right: 5px;
a {
color: inherit;
}
&:focus,
&:hover {
background-color: @pagination-hover-bg;
border-color: transparent;
color: @pagination-hover-color;
a {
color: inherit;
}
}
&-active {
&,
&:hover,
&:focus {
background-color: @pagination-active-bg;
color: @pagination-active-color;
border-color: transparent;
pointer-events: none;
cursor: default;
a {
color: inherit;
}
}
}
}
&-disabled {
&,
&:hover,
&:focus {
opacity: 0.5;
pointer-events: none;
}
}
&-prev,
&-next {
.@{pagination-prefix-cls}-item-link {
background-color: @pagination-bg;
border-color: transparent;
color: @pagination-color;
line-height: @pagination-item-size - 2px;
.@{pagination-prefix-cls}.mini & {
line-height: @pagination-item-size-sm - 2px;
}
}
&:focus .@{pagination-prefix-cls}-item-link,
&:hover .@{pagination-prefix-cls}-item-link {
background-color: @pagination-hover-bg;
border-color: transparent;
color: @pagination-hover-color;
}
}
&-prev,
&-jump-prev,
&-jump-next {
margin-right: 5px;
}
&-jump-prev,
&-jump-next {
.@{pagination-prefix-cls}-item-container {
.@{pagination-prefix-cls}-item-link-icon {
color: @pagination-color;
}
}
}
}
// Table
.@{table-prefix-cls} {
color: inherit;
tr,
th,
td {
transition: none !important;
}
&-thead > tr > th {
padding: @table-padding-vertical * 2 @table-padding-horizontal;
}
.@{table-prefix-cls}-column-sorters {
&:before,
&:hover:before {
content: none;
}
}
&-thead > tr > th {
.@{table-prefix-cls}-column-sorter {
&-up,
&-down {
&.on {
color: @table-header-icon-active-color;
}
}
}
}
&-tbody > tr&-row {
&:hover,
&:focus,
&:focus-within {
& > td {
background: @table-row-hover-bg;
}
}
}
// Custom styles
&-headerless &-tbody > tr:first-child > td {
border-top: @border-width-base @border-style-base @border-color-split;
}
}
// List
.@{list-prefix-cls} {
&-item {
// custom rule
&.selected {
background-color: #f6f8f9;
}
&.disabled {
background-color: fade(#f6f8f9, 40%);
& > * {
opacity: 0.4;
}
}
}
}
.@{dialog-prefix-cls} {
// styling for short modals (no lines)
&.shortModal {
.@{dialog-prefix-cls} {
&-header,
&-footer {
border: none;
padding: 16px;
}
&-body {
padding: 10px 16px;
}
&-close-x {
width: 46px;
height: 46px;
line-height: 46px;
}
}
}
// fullscreen modals
&-fullscreen {
.@{dialog-prefix-cls} {
position: absolute;
left: 15px;
top: 15px;
right: 15px;
bottom: 15px;
width: auto !important;
height: auto !important;
max-width: none;
max-height: none;
margin: 0;
padding: 0;
.@{dialog-prefix-cls}-content {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
width: auto;
height: auto;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.@{dialog-prefix-cls}-body {
flex: 1 1 auto;
overflow: auto;
}
}
}
}
// description in modal header
.modal-header-desc {
font-size: @font-size-base;
color: @text-color-secondary;
font-weight: normal;
margin-top: 4px;
}
// Notification overrides
.@{notification-prefix-cls} {
// vertical centering
&-notice-close {
top: 20px;
right: 20px;
}
&-notice-description {
max-width: 484px;
}
}
.@{btn-prefix-cls} .@{iconfont-css-prefix}-ellipsis {
margin: 0 -7px 0 -8px;
}
// Collapse
.@{collapse-prefix-cls} {
&&-headerless {
border: 0;
background: none;
.@{collapse-prefix-cls}-header {
display: none;
}
.@{collapse-prefix-cls}-item,
.@{collapse-prefix-cls}-content {
border: 0;
}
.@{collapse-prefix-cls}-content-box {
padding: 0;
}
}
}
// overrides for tall form components such as ace editor
.@{form-prefix-cls}-item {
&-children {
display: block; // so feeback icon positions correctly
}
// no change for short components, sticks to body for tall ones
&-children-icon {
top: auto !important;
bottom: 8px;
// makes the icon white instead of see-through
& svg {
background: white;
border-radius: 50%;
}
}
// for form items that contain text
&.form-item-line-height-normal .@{form-prefix-cls}-item-control {
line-height: 20px;
margin-top: 9px;
}
}
.@{menu-prefix-cls} {
// invert stripe position with class .invert-stripe-position
&-inline.invert-stripe-position {
.@{menu-prefix-cls}-item {
&::after {
right: auto;
left: 0;
}
}
&:focus,
&:focus-within {
color: @menu-highlight-color;
}
}
}
.@{dropdown-prefix-cls}-menu-item {
&:focus,
&:focus-within {
background-color: @item-hover-bg;
}
}
// overrides for checkbox
@checkbox-prefix-cls: ~"@{ant-prefix}-checkbox";
.@{checkbox-prefix-cls}-wrapper + span,
.@{checkbox-prefix-cls} + span {
padding-right: 0;
}
// make sure Multiple select has room for icons
.@{select-prefix-cls}-multiple {
&.@{select-prefix-cls}-show-arrow,
&.@{select-prefix-cls}-show-search,
&.@{select-prefix-cls}-loading {
.@{select-prefix-cls}-selector {
padding-right: 30px;
}
}
}
================================================
FILE: client/app/assets/less/inc/404.less
================================================
.four-zero {
background: @white;
box-shadow: 0 1px 11px rgba(0, 0, 0, 0.27);
border-radius: 2px;
position: absolute;
top: 50%;
margin-top: -150px;
text-align: center;
padding: 15px;
height: 300px;
width: 500px;
left: 50%;
color: #333;
margin-left: -250px;
h2 {
font-size: 130px;
}
@media (max-width: @screen-xs-max) {
width: ~"calc(100% - 40px)";
left: 20px;
margin-left: 0;
height: 260px;
margin-top: -130px;
h2 {
font-size: 90px;
}
}
h2 {
line-height: 100%;
font-weight: 100;
}
small {
display: block;
font-size: 26px;
margin-top: -10px
}
footer {
background: @ace;
position: absolute;
left: 0;
bottom: 0;
width: 100%;
padding: 10px;
& > a {
font-size: 21px;
display: inline-block;
color: #333;
margin: 0 1px;
line-height: 40px;
width: 40px;
height: 40px;
background: rgba(0, 0, 0, 0.09);
border-radius: 50%;
text-align: center;
&:hover {
background: rgba(0, 0, 0, 0.2);
}
}
}
}
================================================
FILE: client/app/assets/less/inc/ace-editor.less
================================================
.ace_editor {
border: 1px solid fade(@redash-gray, 15%);
height: 100%;
margin-bottom: 10px;
&.ace_autocomplete .ace_completion-highlight {
text-shadow: none !important;
background: #ffff005e;
font-weight: 600;
}
&.ace-tm {
.ace_gutter {
background: #fff !important;
}
.ace_gutter-active-line {
background-color: fade(@redash-gray, 20%) !important;
}
.ace_marker-layer .ace_active-line {
background: fade(@redash-gray, 9%) !important;
}
}
}
================================================
FILE: client/app/assets/less/inc/alert.less
================================================
.alert-page h3 {
flex-grow: 1;
input {
margin: -0.2em 0;
width: 100%;
min-width: 170px;
}
}
.btn-create-alert[disabled] {
display: block;
margin-top: -20px;
}
.alert-state {
border-bottom: 1px solid @input-border;
padding-bottom: 30px;
.alert-state-indicator {
text-transform: uppercase;
font-size: 14px;
padding: 5px 8px;
}
.ant-form-item-explain {
margin-top: 10px;
}
.alert-last-triggered {
color: @headings-color;
}
}
.alert-query-selector {
min-width: 250px;
width: auto !important;
}
// allow form item labels to gracefully break line
.alert-form-item label {
white-space: initial;
padding-right: 8px;
line-height: 21px;
&::after {
margin-right: 0 !important;
}
}
================================================
FILE: client/app/assets/less/inc/ant-variables.less
================================================
/* --------------------------------------------------------
Colors
-----------------------------------------------------------*/
@lightblue: #03a9f4;
@primary-color: #2196f3;
@redash-gray: rgba(102, 136, 153, 1);
@redash-orange: rgba(255, 120, 100, 1);
@redash-black: rgba(0, 0, 0, 1);
@redash-yellow: rgba(252, 252, 161, 0.75);
/* --------------------------------------------------------
Font
-----------------------------------------------------------*/
@redash-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue",
sans-serif;
@font-family-no-number: @redash-font;
@font-family: @redash-font;
@code-family: @redash-font;
@font-size-base: 13px;
/* --------------------------------------------------------
Borders
-----------------------------------------------------------*/
@border-color-split: #f0f0f0;
/* --------------------------------------------------------
Typograpgy
-----------------------------------------------------------*/
@text-color: #595959;
/* --------------------------------------------------------
Form
-----------------------------------------------------------*/
@input-height-base: 35px;
@input-color: #595959;
@input-color-placeholder: #b4b4b4;
@border-radius-base: 2px;
@border-color-base: #e8e8e8;
/* --------------------------------------------------------
Pagination
-----------------------------------------------------------*/
@pagination-item-size: 33px;
@pagination-font-family: @redash-font;
@pagination-font-weight-active: normal;
@pagination-bg: fade(@redash-gray, 15%);
@pagination-color: #7e7e7e;
@pagination-active-bg: @lightblue;
@pagination-active-color: #fff;
@pagination-disabled-bg: fade(@redash-gray, 15%);
@pagination-hover-color: #333;
@pagination-hover-bg: fade(@redash-gray, 25%);
/* --------------------------------------------------------
Table
-----------------------------------------------------------*/
@table-border-radius-base: 0;
@table-header-color: #333;
@table-header-bg: fade(@redash-gray, 3%);
@table-header-icon-color: fade(@text-color, 20%);
@table-header-icon-active-color: @text-color;
@table-header-sort-bg: @table-header-bg;
@table-header-sort-active-bg: @table-header-bg;
@table-header-filter-active-bg: @table-header-bg;
@table-body-sort-bg: transparent;
@table-row-hover-bg: fade(@redash-gray, 5%);
@table-padding-vertical: 7px;
@table-padding-horizontal: 10px;
/* --------------------------------------------------------
Notification
-----------------------------------------------------------*/
@notification-padding: @notification-padding-vertical 48px @notification-padding-vertical 17px;
@notification-width: auto;
================================================
FILE: client/app/assets/less/inc/base.less
================================================
*,
button,
input,
i,
a {
-webkit-font-smoothing: antialiased;
}
*,
*:active,
*:hover {
outline: none !important;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0) !important;
}
html {
overflow-x: ~"hidden\0/";
-ms-overflow-style: auto;
}
html,
body {
height: 100%;
}
body {
padding-top: 0;
background: #f6f8f9;
font-family: @redash-font;
position: relative;
#application-root {
padding-bottom: 15px;
}
}
#application-root {
height: 100%;
}
#application-root,
#app-content {
display: flex;
flex-direction: column;
flex-grow: 1;
}
strong {
font-weight: 500;
}
#content {
position: relative;
padding-top: 30px;
padding-bottom: 30px;
@media (min-width: (@screen-sm-min + 1)) {
padding-right: 15px;
padding-left: 15px;
}
@media (min-width: (@screen-lg-min + 80px)) {
margin-left: @sidebar-left-width;
}
@media (min-width: @screen-sm-min) and (max-width: (@screen-md-max + 80px)) {
margin-left: @sidebar-left-mid-width;
}
@media (max-width: (@screen-sm-min)) {
margin-left: 0;
}
}
.container {
&.c-boxed {
max-width: @boxed-width;
}
}
.settings-screen,
.home-page,
.page-dashboard-list,
.page-queries-list,
.page-alerts-list,
.alert-page,
.admin-page-layout {
.container {
width: 100%;
max-width: none;
}
}
.scrollbox {
overflow: auto;
position: relative;
}
.clickable {
cursor: pointer;
button&:disabled {
cursor: not-allowed;
}
}
.resize-vertical {
resize: vertical !important;
transition: height 0s !important;
}
.resize-horizontal {
resize: horizontal !important;
transition: width 0s !important;
}
.resize-both,
.resize-vertical.resize-horizontal {
resize: both !important;
transition: height 0s, width 0s !important;
}
.bg-ace {
background-color: fade(@redash-gray, 12%) !important;
}
// resizeable
.rg-top span,
.rg-bottom span {
height: 3px;
border-color: #b1c1ce; // TODO: variable
}
.rg-bottom {
bottom: 15px;
span {
margin: 1.5px 0 0 -10px;
}
}
// Plotly
text.slicetext {
text-shadow: 1px 1px 5px #333;
}
// markdown
.markdown strong {
font-weight: bold;
}
.markdown img {
max-width: 100%;
}
.dropdown-menu > li > a:hover,
.dropdown-menu > li > a:focus {
background-color: fade(@redash-gray, 15%);
color: #111;
}
.profile__image--sidebar {
border-radius: 100%;
margin-right: 3px;
margin-top: -2px;
}
.profile__image--settings {
border-radius: 100%;
}
.profile__image_thumb {
border-radius: 100%;
margin-right: 3px;
margin-top: -2px;
width: 20px;
height: 20px;
}
// Error state
.error-state {
display: flex;
flex-direction: column;
justify-content: flex-start;
text-align: center;
margin-top: 25vh;
padding: 35px;
font-size: 14px;
line-height: 21px;
.error-state__icon {
.zmdi {
font-size: 64px;
color: @redash-gray;
}
}
@media (max-width: 767px) {
margin-top: 10vh;
}
}
.warning-icon-danger {
color: @red !important;
}
// page
.page-title {
display: flex;
align-items: center;
.label {
margin-top: 3px;
display: inline-block;
}
.favorites-control {
font-size: 19px;
margin-right: 10px;
}
}
.page-header--new {
h3 {
margin: 0.2em 0;
line-height: 1.3;
font-weight: 500;
}
}
.select-option-divider {
margin: 10px 0 !important;
}
================================================
FILE: client/app/assets/less/inc/bootstrap-overrides.less
================================================
/** Media - Overriding the Media object to 3.2 version in order to prevent issues like text overflow. **/
.media {
margin-top: 0;
.clearfix();
& > .pull-left {
padding-right: 15px;
}
& > .pull-right {
padding-left: 15px;
}
overflow: visible;
}
.media-heading {
font-size: 14px;
margin-bottom: 10px;
}
.media-body {
zoom: 1;
display: block;
width: auto;
}
.media-object {
border-radius: 2px;
}
.collapsing,
.collapse.in {
padding: 0;
transition: all 0.35s ease;
}
/** LIST **/
.list-inline > li {
vertical-align: top;
margin-left: 0;
}
// Hide URLs next to links when printing (override `bootstrap` rules)
@media print {
a[href]:after {
content: none !important;
}
}
================================================
FILE: client/app/assets/less/inc/breadcrumb.less
================================================
.breadcrumb {
border-bottom: 1px solid #E5E5E5;
border-radius: 0;
padding-top: 10px;
padding-right: 33px;
padding-bottom: 11px;
@media (min-width: (@screen-lg-min + 80px)) {
padding-left: (@sidebar-left-width + @grid-gutter-width);
}
@media (min-width: @screen-sm-min) and (max-width: (@screen-md-max + 80px)) {
padding-left: (@sidebar-left-mid-width + @grid-gutter-width);
}
@media (max-width: (@screen-sm-min)) {
padding-left: @grid-gutter-width/2;
}
& > li {
& > a {
color: #A9A9A9;
&:hover {
color: @breadcrumb-active-color;
}
}
}
}
================================================
FILE: client/app/assets/less/inc/button.less
================================================
.btn {
&:not(.btn-alt) {
border: 0;
}
&[class*="bg-"]:not(.bg-white) {
color: #fff;
}
.caret {
margin-top: -3px;
}
&:not(.btn-link) {
&:active,
&.active,
&:hover {
}
}
}
.btn-default {
.button-variant(#333, #eee, transparent);
}
.btn-inverse {
.button-variant(#fff, #454545, transparent);
}
.btn-link {
color: #333;
}
.btn-icon {
border-radius: 50%;
width: 40px;
height: 40px;
padding: 0;
text-align: center;
.zmdi {
font-size: 17px;
}
}
.btn-icon-text {
& > .zmdi {
font-size: 15px;
vertical-align: top;
display: inline-block;
margin-top: 2px;
line-height: 100%;
margin-right: 5px;
}
}
.open .btn {
outline: none !important;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0) !important;
&:focus, &:active {
outline: none !important;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0) !important;
}
}
/** ALTERNATIVE BUTTONS **/
.btn-alt(@color) {
border-color: @color;
color: @color;
&:not(.btn-white) {
&:hover,
&:active,
&:focus {
color: #fff;
background: @color;
}
}
&.btn-white {
&:hover,
&:active,
&:focus {
color: #333;
background: @color;
}
}
}
.btn-alt {
background: transparent;
&.btn-default {
.btn-alt(darken(@brand-default, 30%));
}
&.btn-info {
.btn-alt(@brand-info);
}
&.btn-primary {
.btn-alt(@brand-primary);
}
&.btn-success {
.btn-alt(@brand-success);
}
&.btn-warning {
.btn-alt(@brand-warning);
}
&.btn-danger {
.btn-alt(@brand-danger);
}
}
.btn-xs > .fa {
font-size: 14px;
top: 1px;
position: relative;
}
.btn-default {
background-color: fade(@redash-gray, 15%);
}
.btn-transparent {
background-color: transparent !important;
}
.btn-default:hover, .btn-default:focus, .btn-default.focus, .btn-default:active, .btn-default.active, .open > .dropdown-toggle.btn-default {
background-color: fade(@redash-gray, 25%);
}
.btn-default:active:hover, .btn-default.active:hover, .open > .dropdown-toggle.btn-default:hover, .btn-default:active:focus, .btn-default.active:focus, .open > .dropdown-toggle.btn-default:focus, .btn-default:active.focus, .btn-default.active.focus, .open > .dropdown-toggle.btn-default.focus {
color: #333;
background-color: fade(@redash-gray, 45%);
}
================================================
FILE: client/app/assets/less/inc/carousel.less
================================================
.carousel-caption {
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.6);
h3 {
margin-top: 0;
margin-bottom: 3px;
color: #fff;
}
}
.carousel-indicators {
bottom: 10px;
& > li:not(.active) {
border: 0;
background: #000;
}
}
.carousel-control {
width: 50px;
background: none;
.fa {
font-size: 50px;
height: 52px;
margin-top: -26px;
position: absolute;
top: 50%;
.margin-left(-9px);
}
}
@media @max-768 {
.carousel-indicators, .carousel-caption {
display: none;
}
}
================================================
FILE: client/app/assets/less/inc/chart.less
================================================
/* --------------------------------------------------------
Chart Helper Classes
-----------------------------------------------------------*/
.main-chart {
margin: 0px -8px 0 -10px;
overflow: hidden;
position: relative;
bottom: -10px;
}
.mc-item {
width: 100%;
height: 250px;
}
.mc-pie {
width: 100%;
height: 300px;
}
@media (min-width: @screen-sm-min) {
.mc-info {
position: absolute;
bottom: 10px;
z-index: 1;
padding: 10px 20px 15px;
left: 10px;
background-color: rgba(0, 150, 136, 0.55);
color: #fff;
max-width: 270px;
span {
font-size: 33px;
}
small {
display: block;
margin-top: -3px;
margin-left: 3px;
line-height: 130%;
}
}
}
/* --------------------------------------------------------
Overview Small Charts
-----------------------------------------------------------*/
.o-item {
padding: 0 20px 15px 20px;
color: #fff;
margin-bottom: @grid-gutter-width;
box-shadow: @tile-shadow;
}
.oi-number {
font-size: 23px;
display: block;
margin-top: 6px;
margin-bottom: 3px;
line-height: 100%;
}
.oi-title {
text-transform: uppercase;
.text-overflow();
line-height: 100%;
padding: 10px 15px;
width: auto;
margin: 0 -21px 20px;
}
/* --------------------------------------------------------
Count Box
-----------------------------------------------------------*/
.count-box {
padding: 20px 23px 0;
[class*="col-"] {
padding-left: 8px;
padding-right: 8px;
}
}
.cb-item {
background: rgba(255,255,255,0.22);
padding: 10px 0;
text-align: center;
margin-bottom: 16px;
& > h3 {
margin: 0;
line-height: 100%;
color: #fff;
font-weight: normal;
}
& > small {
line-height: 100%;
margin-top: 1px;
display: block;
font-size: 11px;
color: #fff;
}
}
/* --------------------------------------------------------
Flot Charts
-----------------------------------------------------------*/
.flot-legend {
text-align: center;
margin: 10px 0 5px;
table {
display: inline-block;
}
.legendColorBox {
& > div {
border: #fff !important;
& > div {
border-radius: 50%;
}
}
}
.legendLabel {
padding: 0 8px 0 3px;
}
}
[class*="flc-"] {
text-align: center;
margin: 20px 0 5px;
table {
display: inline-block;
}
.legendColorBox {
& > div {
border: #fff !important;
& > div {
border-radius: 50%;
}
}
}
.legendLabel {
padding: 0 8px 0 3px;
}
}
/* --------------------------------------------------------
Easy Pie Charts
-----------------------------------------------------------*/
.pie-overviews {
margin-bottom: -15px;
}
.po-item {
display: inline-block;
position: relative;
margin: 0 5px 10px;
padding-bottom: 13px;
color: #fff;
}
.poi-percent {
position: absolute;
text-align: center;
width: 100%;
margin-top: 32px;
font-size: 27px;
text-shadow: none;
padding-left: 2px;
&:after {
content: '%';
font-size: 11px;
}
}
.poi-title {
position: absolute;
bottom: 0;
width: 100%;
text-align: center;
font-size: 12px;
i {
font-size: 15px;
font-weight: normal;
-webkit-font-smoothing: antialiased;
.opacity(0.5);
&:hover {
.opacity(1);
cursor: pointer;
}
}
}
/* --------------------------------------------------------
Chart Tooltips
-----------------------------------------------------------*/
#jqstooltip,
.chart-tooltip {
min-width: 21px;
min-height: 23px;
text-align: center;
border: 0;
background: #333;
}
#jqstooltip .jqsfield,
.chart-tooltip {
font-size: 12px;
font-weight: 500;
font-family: inherit;
text-align: center;
color: #fff;
}
#jqstooltip .jqsfield {
& > span {
display: none;
}
}
.chart-tooltip {
position: absolute;
padding: 6px 10px 5px;
}
================================================
FILE: client/app/assets/less/inc/dropdown.less
================================================
.dropdown-menu {
z-index: 1000000000;
box-shadow: @dropdown-shadow;
margin-top: 1px;
border-width: 0;
.animated(fadeIn, 300ms);
> .disabled{
cursor: not-allowed;
// The real magic ;)
> a {
pointer-events: none;
color: @dropdown-link-disabled-color;
}
}
& > li > a {
padding: 8px 17px;
}
&.dm-icon {
& > li > a > .zmdi {
line-height: 100%;
vertical-align: top;
font-size: 18px;
width: 28px;
}
}
&:not([class*="bg-"]) {
& > li > a {
&:hover {
color: #000;
}
}
}
&[class*="bg-"] {
& > li > a {
font-weight: 300;
color: #fff;
}
}
}
.dropdown-header {
padding: 10px 15px 9px;
text-transform: uppercase;
font-weight: normal;
border-radius: 1px 1px 0 0;
line-height: 100%;
border-radius: 2px 2px 0 0;
&[class*="bg-"] {
color: #fff;
}
.actions {
top: 0;
right: 0;
& > li > a {
display: block;
padding: 6px 0 5px;
width: 33px;
text-align: center;
&:hover {
background: rgba(0,0,0,0.08);
}
}
}
}
.dropdown-menu {
>span {
>li {
>a {
display: block;
padding: 3px 20px;
clear: both;
font-weight: normal;
line-height: 1.428571429;
color: #333333;
white-space: nowrap;
&:hover, &:focus {
color: #ffffff;
text-decoration: none;
background-color: #428bca;
}
}
}
}
}
================================================
FILE: client/app/assets/less/inc/edit-in-place.less
================================================
.edit-in-place {
white-space: pre-line;
display: inline-block;
p {
margin-bottom: 0;
}
.editable {
display: inline-block;
cursor: pointer;
&:hover {
background: @redash-yellow;
border-radius: @redash-radius;
}
}
&.active input,
&.active textarea {
display: inline-block;
}
}
================================================
FILE: client/app/assets/less/inc/flex.less
================================================
.d-flex { display: flex !important; }
.d-inline-flex { display: inline-flex !important; }
.flex-row { flex-direction: row !important; }
.flex-column { flex-direction: column !important; }
.flex-row-reverse { flex-direction: row-reverse !important; }
.flex-column-reverse { flex-direction: column-reverse !important; }
.flex-wrap { flex-wrap: wrap !important; }
.flex-nowrap { flex-wrap: nowrap !important; }
.flex-wrap-reverse { flex-wrap: wrap-reverse !important; }
.flex-fill { flex: 1 1 auto !important; }
.justify-content-start { justify-content: flex-start !important; }
.justify-content-end { justify-content: flex-end !important; }
.justify-content-center { justify-content: center !important; }
.justify-content-between { justify-content: space-between !important; }
.justify-content-around { justify-content: space-around !important; }
.align-items-start { align-items: flex-start !important; }
.align-items-end { align-items: flex-end !important; }
.align-items-center { align-items: center !important; }
.align-items-baseline { align-items: baseline !important; }
.align-items-stretch { align-items: stretch !important; }
.align-content-start { align-content: flex-start !important; }
.align-content-end { align-content: flex-end !important; }
.align-content-center { align-content: center !important; }
.align-content-between { align-content: space-between !important; }
.align-content-around { align-content: space-around !important; }
.align-content-stretch { align-content: stretch !important; }
.align-self-auto { align-self: auto !important; }
.align-self-start { align-self: flex-start !important; }
.align-self-end { align-self: flex-end !important; }
.align-self-center { align-self: center !important; }
.align-self-baseline { align-self: baseline !important; }
.align-self-stretch { align-self: stretch !important; }
================================================
FILE: client/app/assets/less/inc/font.less
================================================
///* --------------------------------------------------------
// Roboto Light - 300
//-----------------------------------------------------------*/
//.font-face(roboto, 'Roboto-Light-webfont', 300, normal);
//
//
///* --------------------------------------------------------
// Roboto Regular - 400
//-----------------------------------------------------------*/
//.font-face(roboto, 'Roboto-Regular-webfont', 400, normal);
//
//
///* --------------------------------------------------------
// Roboto Medium - 500
//-----------------------------------------------------------*/
//.font-face(roboto, 'Roboto-Medium-webfont', 500, normal);
//
//
///* --------------------------------------------------------
// Roboto Bold - 700
//-----------------------------------------------------------*/
//.font-face(roboto, 'Roboto-Bold-webfont', 700, normal);
================================================
FILE: client/app/assets/less/inc/form.less
================================================
label {
font-weight: 500;
}
textarea.v-resizable {
resize: vertical;
}
.form-group {
&.required {
.control-label {
&:after {
content: " *";
color: inherit;
}
}
}
&.has-error {
.help-block {
&.error {
display: block;
}
}
}
.help-block {
&.error {
display: none;
}
}
}
/* --------------------------------------------------------
Input Fields
-----------------------------------------------------------*/
.form-control {
.transition(all);
.transition-duration(300ms);
resize: none;
box-shadow: 0 0 0 40px rgba(0, 0, 0, 0) !important;
border-radius: @redash-input-radius;
&:focus {
box-shadow: none !important;
border-color: @blue;
}
&:hover {
border-color: @blue;
}
}
/* --------------------------------------------------------
Custom Checkbox + Radio
-----------------------------------------------------------*/
.cra-validatation(@color) {
input[type="checkbox"],
input[type="radio"] {
& + .input-helper {
border-color: @color;
}
&:checked + .input-helper:before {
background: @color;
}
}
}
.cr-alt {
position: relative;
padding-top: 0;
margin: 0;
label {
position: relative;
padding-left: 28px;
}
&.has-success {
.cra-validatation(@green);
}
&.has-warning {
.cra-validatation(@orange);
}
&.has-error {
.cra-validatation(@red);
}
input[type="checkbox"],
input[type="radio"] {
.opacity(0);
width: 20px;
height: 20px;
position: absolute;
z-index: 10;
margin: 0;
top: 0;
left: 0;
cursor: pointer;
& + .input-helper {
border: 1px solid @input-border;
width: 19px;
height: 19px;
background: #fff;
position: absolute;
left: 0;
top: -1px;
cursor: pointer;
}
&:checked + .input-helper:before {
content: "";
width: 9px;
height: 9px;
background: #31acff;
position: absolute;
left: 4px;
top: 4px;
}
}
input[type="radio"] {
& + i {
border-radius: 50%;
}
&:checked + i:before {
border-radius: 50%;
}
}
&.disabled {
.opacity(0.7);
}
}
.checkbox-inline,
.radio-inline {
padding-left: 27px;
}
/* --------------------------------------------------------
Input Addon
-----------------------------------------------------------*/
.input-group {
.input-group-addon {
min-width: 40px;
color: #333;
padding: 0;
}
&:not([class*="input-group-"]) {
.input-group-addon {
font-size: 15px;
}
}
}
/* --------------------------------------------------------
Toggle Switch
-----------------------------------------------------------*/
.ts-color(@color) {
input {
&:not(:disabled) {
&:checked {
& + .ts-helper {
background: fade(@color, 50%);
&:before {
background: @color;
}
&:active {
&:before {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.28), 0 0 0 20px fade(@color, 20%);
}
}
}
}
}
}
}
.toggle-switch {
display: inline-block;
vertical-align: top;
.user-select(none);
.ts-label {
display: inline-block;
margin: 0 20px 0 0;
vertical-align: top;
-webkit-transition: color 0.56s cubic-bezier(0.4, 0, 0.2, 1);
transition: color 0.56s cubic-bezier(0.4, 0, 0.2, 1);
}
.ts-helper {
display: inline-block;
position: relative;
width: 40px;
height: 16px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.26);
-webkit-transition: background 0.28s cubic-bezier(0.4, 0, 0.2, 1);
transition: background 0.28s cubic-bezier(0.4, 0, 0.2, 1);
vertical-align: middle;
cursor: pointer;
&:before {
content: "";
position: absolute;
top: -4px;
left: -4px;
width: 24px;
height: 24px;
background: #fafafa;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.28);
border-radius: 50%;
webkit-transition: left 0.28s cubic-bezier(0.4, 0, 0.2, 1), background 0.28s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.28s cubic-bezier(0.4, 0, 0.2, 1);
transition: left 0.28s cubic-bezier(0.4, 0, 0.2, 1), background 0.28s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.28s cubic-bezier(0.4, 0, 0.2, 1);
}
}
&:not(.disabled) {
.ts-helper {
&:active {
&:before {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.28), 0 0 0 20px rgba(128, 128, 128, 0.1);
}
}
}
}
input {
position: absolute;
z-index: 1;
width: 46px;
margin: 0 0 0 -4px;
height: 24px;
.opacity(0);
cursor: pointer;
&:checked {
& + .ts-helper {
&:before {
left: 20px;
}
}
}
}
&:not([data-ts-color]) {
.ts-color(@teal);
}
&.disabled {
.opacity(0.6);
}
&[data-ts-color="red"] {
.ts-color(@red);
}
&[data-ts-color="blue"] {
.ts-color(@blue);
}
&[data-ts-color="amber"] {
.ts-color(@amber);
}
&[data-ts-color="purple"] {
.ts-color(@purple);
}
&[data-ts-color="pink"] {
.ts-color(@pink);
}
&[data-ts-color="lime"] {
.ts-color(@lime);
}
&[data-ts-color="cyan"] {
.ts-color(@cyan);
}
&[data-ts-color="green"] {
.ts-color(@green);
}
}
================================================
FILE: client/app/assets/less/inc/generics.less
================================================
/* --------------------------------------------------------
Generate Margin Classes (0px - 25px)
margin, margin-top, margin-bottom, margin-left, margin-right
-----------------------------------------------------------*/
.margin (@label, @size: 1, @key:1) when (@size =< 30) {
.m-@{key} {
margin: @size !important;
}
.m-t-@{key} {
margin-top: @size !important;
}
.m-b-@{key} {
margin-bottom: @size !important;
}
.m-l-@{key} {
margin-left: @size !important;
}
.m-r-@{key} {
margin-right: @size !important;
}
.margin(@label - 5; @size + 5; @key + 5);
}
.margin(25, 0px, 0);
.m-2 {
margin: 2px;
}
/* --------------------------------------------------------
Generate Padding Classes (0px - 25px)
padding, padding-top, padding-bottom, padding-left, padding-right
-----------------------------------------------------------*/
.padding (@label, @size: 1, @key:1) when (@size =< 30) {
.p-@{key} {
padding: @size !important;
}
.p-t-@{key} {
padding-top: @size !important;
}
.p-b-@{key} {
padding-bottom: @size !important;
}
.p-l-@{key} {
padding-left: @size !important;
}
.p-r-@{key} {
padding-right: @size !important;
}
.padding(@label - 5; @size + 5; @key + 5);
}
.padding(25, 0px, 0);
/* --------------------------------------------------------
Generate Font-Size Classes (8px - 20px)
-----------------------------------------------------------*/
.font-size (@label, @size: 8, @key:10) when (@size =< 20) {
.f-@{key} {
font-size: @size !important;
}
.font-size(@label - 1; @size + 1; @key + 1);
}
.font-size(20, 8px, 8);
.f-inherit {
font-size: inherit !important;
}
/* --------------------------------------------------------
Font Weight
-----------------------------------------------------------*/
.f-300 {
font-weight: 300 !important;
}
.f-400 {
font-weight: 400 !important;
}
.f-500 {
font-weight: 500 !important;
}
.f-700 {
font-weight: 700 !important;
}
/* --------------------------------------------------------
Position
-----------------------------------------------------------*/
.p-relative {
position: relative !important;
}
.p-absolute {
position: absolute !important;
}
.p-fixed {
position: fixed !important;
}
.p-static {
position: static !important;
}
/* --------------------------------------------------------
Overflow
-----------------------------------------------------------*/
.o-hidden {
overflow: hidden !important;
}
.o-visible {
overflow: visible !important;
}
.o-auto {
overflow: auto !important;
}
/* --------------------------------------------------------
Display
-----------------------------------------------------------*/
.di-block {
display: inline-block !important;
}
.d-block {
display: block;
}
/* --------------------------------------------------------
Background Colors and Colors
-----------------------------------------------------------*/
@array: c-white bg-white @white, c-ace bg-ace @ace, c-black bg-black @black, c-brown bg-brown @brown,
c-pink bg-pink @pink, c-red bg-red @red, c-blue bg-blue @blue, c-purple bg-purple @purple,
c-deeppurple bg-deeppurple @deeppurple, c-lightblue bg-lightblue @lightblue, c-cyan bg-cyan @cyan,
c-teal bg-teal @teal, c-green bg-green @green, c-lightgreen bg-lightgreen @lightgreen, c-lime bg-lime @lime,
c-yellow bg-yellow @yellow, c-amber bg-amber @amber, c-orange bg-orange @orange,
c-deeporange bg-deeporange @deeporange, c-gray bg-gray @gray, c-bluegray bg-bluegray @bluegray,
c-indigo bg-indigo @indigo;
.for(@array);
.-each(@value) {
@name: extract(@value, 1);
@name2: extract(@value, 2);
@color: extract(@value, 3);
&.@{name2} {
background-color: @color !important;
}
&.@{name} {
color: @color !important;
}
}
/* --------------------------------------------------------
Background Colors
-----------------------------------------------------------*/
.bg-brand {
background-color: @brand-bg;
}
.bg-black-trp {
background-color: rgba(0, 0, 0, 0.12) !important;
}
/* --------------------------------------------------------
Borders
-----------------------------------------------------------*/
.b-0 {
border: 0 !important;
}
/* --------------------------------------------------------
Width
-----------------------------------------------------------*/
.w-100 {
width: 100% !important;
}
.w-50 {
width: 50% !important;
}
.w-25 {
width: 25% !important;
}
/* --------------------------------------------------------
Border Radius
-----------------------------------------------------------*/
.brd-2 {
border-radius: 2px;
}
/* --------------------------------------------------------
Alignment
-----------------------------------------------------------*/
.va-top {
vertical-align: top;
}
/* --------------------------------------------------------
Screen readers
-----------------------------------------------------------*/
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
================================================
FILE: client/app/assets/less/inc/header.less
================================================
#header {
width: 100%;
z-index: 10;
top: 0;
left: 0;
background-color: #fff;
height: @header-height;
&.affix {
box-shadow: 0 0 20px rgba(0, 0, 0, 0.23);
}
&:not(.affix) {
box-shadow: @tile-shadow;
position: fixed;
}
}
/* --------------------------------------------------------
Top Menu
-----------------------------------------------------------*/
.header-inner {
padding: 0;
margin: 0;
width: 100%;
list-style: none;
& > li {
&:not(.pull-right) {
float: left;
}
@media (max-width: @screen-sm-min) {
&:not(.top-search) {
position: static;
}
.dropdown-menu {
width: ~"calc(100% - 30px)";
margin-left: 15px;
}
}
& > a {
height: @header-height;
color: #333;
min-width: 45px;
display: block;
position: relative;
& > .zmdi {
font-size: 22px;
line-height: @header-height;
}
}
&:not(.logo) {
text-align: center;
}
&.open > a:not([class*="hi-"]):before {
content: "";
width: 40px;
height: 40px;
position: absolute;
top: 50%;
left: 50%;
margin-top: -21px;
margin-left: -20px;
background: #eee;
border-radius: 50%;
z-index: -1;
}
}
.dropdown-menu {
margin-top: -5px;
}
.open {
& > .hi-messages { color: @green; }
& > .hi-notifications { color: @orange; }
& > .hi-projects { color: @green; }
& > .hi-events { color: @blue; }
.hi-count {
display: none;
}
}
}
.hi-count {
position: absolute;
font-style: normal;
background-color: @red;
padding: 0 4px;
font-size: 10px;
color: #fff;
line-height: 17px;
height: 17px;
top: 11px;
right: 6px;
border-radius: 50%;
width: 17px;
}
.hi-dropdown {
padding: 0;
@media (min-width: @screen-sm-min) {
width: 350px;
}
}
/* --------------------------------------------------------
Logo
-----------------------------------------------------------*/
.logo {
position: relative;
z-index: 2;
height: @logo-height;
@media (min-width: (@screen-lg-min + 80px)) {
width: @logo-width;
background-color: #000;
margin-right: 15px;
& > a {
padding: 15px 22px;
}
}
@media (max-width: (@screen-md-max + 80px)) {
width: @sidebar-left-mid-width;
& > a {
display: none !important;
}
}
@media (max-width: (@screen-sm-min)) {
padding: 12px;
}
}
/* --------------------------------------------------------
Sidebar Trigger for mobile
-----------------------------------------------------------*/
#menu-trigger {
font-size: 21px;
text-align: center;
color: #fff;
cursor: pointer;
display: none;
background: #000;
height: 100%;
&.toggled i:before {
content: '\f2ea';
}
@media (min-width: (@screen-sm-min + 1)) {
line-height: @header-height;
}
@media (max-width: (@screen-md-max + 80px)) {
display: block;
}
@media (max-width: (@screen-sm-min)) {
border-radius: 2px;
line-height: 39px;
}
}
/* --------------------------------------------------------
Top Search
-----------------------------------------------------------*/
.top-search {
position: relative;
background: #fff;
height: @header-height;
&:not(.toggled) {
width: 80px;
margin-left: 15px;
&:before {
font-family: @font-icon;
content: "\f1c3";
position: absolute;
left: 0;
top: 15px;
font-size: 22px;
z-index: 1;
color: #333;
}
.ts-reset {
display: none;
}
.ts-input {
cursor: pointer;
}
@media (max-width: (@screen-xs-min - 150px)) {
width: 20px;
}
}
.ts-input {
height: @header-height - 2px;
padding-left: 25px;
width: 100%;
border: 0;
position: relative;
background: transparent;
z-index: 1;
}
&.toggled {
position: absolute;
top: 0;
font-size: 20px;
font-weight: normal;
z-index: 1;
width: 100%;
left: 0;
@media (min-width: (@screen-lg-min + 80px)) {
padding-left: @sidebar-left-width;
}
@media (min-width: (@screen-sm-min + 1px)) and (max-width: (@screen-md-max + 80px)) {
padding-left: @sidebar-left-mid-width;
}
.ts-input {
background: #fff;
}
.ts-reset {
font-size: 11px;
color: #fff;
position: absolute;
top: 50%;
right: 15px;
z-index: 2;
width: 20px;
height: 20px;
background-color: #8E8E8E;
line-height: 20px;
text-align: center;
border-radius: 50%;
margin-top: -10px;
&:hover {
cursor: pointer;
background: #333;
}
}
}
}
/* --------------------------------------------------------
Events
-----------------------------------------------------------*/
.event-time {
width: 67px;
height: 50px;
text-align: center;
padding: 9px 0;
color: #fff;
border-radius: 2px;
margin-top: 2px;
& > h2 {
margin: 0;
line-height: 100%;
font-size: 17px;
margin-bottom: -1px;
color: #fff;
font-weight: normal;
}
}
/* --------------------------------------------------------
Apps
-----------------------------------------------------------*/
@media (min-width: @screen-sm-min) {
#launch-apps {
padding: 0;
text-align: center;
width: 300px;
}
.la-body {
padding: 20px 10px;
}
.lab-item {
width: 60px;
display: inline-block;
margin: 10px;
&:hover {
& > a {
.opacity(0.8);
}
& > small {
color: #333;
}
}
& > a {
height: 60px;
display: block;
color: #fff;
line-height: 70px;
border-radius: 50%;
.transition(opacity);
& > i {
font-size: 25px;
}
}
& > small {
color: #969696;
display: block;
margin-top: 5px;
.transition(color);
}
}
}
/* --------------------------------------------------------
Time
-----------------------------------------------------------*/
#time {
font-size: 18px;
font-weight: 400;
background-color: @sidebar;
color: #FBFBFB;
padding: 4px 11px;
border-radius: 2px;
margin: 14px;
span {
&:not(:last-child):after {
content: ":";
position: relative;
top: -1px;
right: -1px;
}
}
}
================================================
FILE: client/app/assets/less/inc/ie-warning.less
================================================
.ie-warning {
position: fixed;
top: 0;
left: 0;
z-index: 9999;
background: @black;
width: 100%;
height: 100%;
text-align: center;
color: #fff;
font-family: "Courier New", Courier, monospace;
padding: 50px 0;
p {
font-size: 17px;
}
.iew-container {
min-width: 1024px;
width: 100%;
height: 200px;
background: #fff;
margin: 50px 0;
}
.iew-download {
list-style: none;
padding: 30px 0;
margin: 0 auto;
width: 720px;
& > li {
float: left;
vertical-align: top;
& > a {
display: block;
color: #000;
width: 140px;
font-size: 15px;
padding: 15px 0;
& > div {
margin-top: 10px;
}
&:hover {
background-color: #eee;
}
}
}
}
}
================================================
FILE: client/app/assets/less/inc/jumbotron.less
================================================
.jumbotron {
padding-left: 60px;
padding-right: 60px;
}
================================================
FILE: client/app/assets/less/inc/label.less
================================================
.label {
border-radius: 2px;
padding: 3px 6px 4px;
font-weight: 500;
font-size: 11px;
}
.badge {
border-radius: 1px;
}
.label-default {
background: fade(@redash-gray, 85%);
}
.label-tag-unpublished {
background: fade(@redash-gray, 85%);
}
.label-tag-archived {
.label-warning();
}
.label-tag {
background: fade(@redash-gray, 10%);
color: fade(@redash-gray, 75%);
}
.label-tag-unpublished,
.label-tag-archived,
.label-tag {
margin-right: 3px;
display: inline;
margin-top: 2px;
max-width: 24ch;
.text-overflow();
}
================================================
FILE: client/app/assets/less/inc/less-plugins/for.less
================================================
.for(@i, @n) {.-each(@i)}
.for(@n) when (isnumber(@n)) {.for(1, @n)}
.for(@i, @n) when not (@i = @n) {
.for((@i + (@n - @i) / abs(@n - @i)), @n);
}
.for(@array) when (default()) {.for-impl_(length(@array))}
.for-impl_(@i) when (@i > 1) {.for-impl_((@i - 1))}
.for-impl_(@i) when (@i > 0) {.-each(extract(@array, @i))}
================================================
FILE: client/app/assets/less/inc/list-group.less
================================================
.list-group {
margin-bottom: 0;
&.lg-alt .list-group-item {
border: 0;
}
&:not(.lg-alt) {
&.lg-listview .list-group-item {
border-left: 0;
border-right: 0;
&:last-child {
border-bottom: 0;
}
}
}
}
.max-character {
.text-overflow();
}
.list-group-item {
&.active {
button {
color: white;
}
}
.cr-alt {
line-height: 100%;
margin-top: 2px;
}
&.active, &.active:hover, &.active:focus {
background-color: #fff;
box-shadow: inset 3px 0px 0px @brand-primary;
}
}
.list-group-item-heading {
margin-bottom: 2px;
color: #333;
& > small {
font-size: 11px;
color: #C5C5C5;
margin-left: 10px;
}
}
.list-group-item-heading,
.list-group-item-text {
.text-overflow();
}
.list-group-item-text {
display: block;
&:not(:last-child) {
margin-bottom: 4px;
}
}
.list-group-img {
width: 38px;
height: 38px;
border-radius: 2px;
}
.ui-select-choices-row.disabled > span {
background-color: inherit !important;
}
.list-group-item.inactive,
.ui-select-choices-row.disabled {
background-color: #eee !important;
border-color: transparent;
opacity: 0.5;
box-shadow: none;
color: #333;
pointer-events: none;
cursor: not-allowed;
}
================================================
FILE: client/app/assets/less/inc/list.less
================================================
.clist {
list-style: none;
& > li {
&:before {
font-family: @font-icon;
margin: 0 10px 0 -20px;
vertical-align: middle;
}
}
&.clist-angle > li:before {
content: "\f2fb";
}
&.clist-check > li:before {
content: "\f26b";
}
&.clist-star > li:before {
content: "\f27d";
}
}
================================================
FILE: client/app/assets/less/inc/login.less
================================================
.login-content {
overflow: hidden;
height: 100%;
background: @brand-bg;
padding: 0;
text-align: center;
&:after {
content: "";
vertical-align: middle;
display: inline-block;
width: 1px;
height: 100%;
}
}
.lc-block {
background: #fff;
box-shadow: 0 1px 11px rgba(0, 0, 0, 0.27);
border-radius: 2px;
width: 300px;
display: inline-block;
vertical-align: middle;
position: relative;
padding: 45px 30px 30px;
&:not(.toggled) {
display: none;
}
&.toggled {
.animated(fadeInUp, 300ms);
z-index: 10;
}
@media (max-width: @screen-xs-max) {
padding: 15px 35px 25px 20px;
width: ~"calc(100% - 60px)";
}
.form-control {
text-align: center;
}
}
.lcb-float {
width: 60px;
height: 60px;
background: #ffffff;
border-radius: 50%;
box-shadow: 0 -10px 19px rgba(0, 0, 0, 0.38);
position: absolute;
top: -35px;
left: 50%;
margin-left: -30px;
img {
width: 100%;
height: 100%;
border-radius: 50%;
padding: 4px;
}
i {
color: #333;
font-size: 25px;
line-height: 60px;
}
}
.lcb-lockscreen {
position: relative;
.form-control {
padding-right: 35px;
}
.lcbl-btn {
background-color: #2196F3;
position: absolute;
top: 0;
right: 0;
width: 30px;
color: #fff;
font-size: 15px;
height: 27px;
margin: 4px;
line-height: 26px;
border-radius: 2px;
}
}
.login-navigation {
list-style: none;
padding: 0;
margin: 0;
position: absolute;
width: 100%;
text-align: center;
left: 0%;
bottom: -45px;
& > li {
display: inline-block;
margin: 0 2px;
.transition(all);
.transition-duration(150ms);
cursor: pointer;
vertical-align: top;
color: #fff;
line-height: 16px;
min-width: 16px;
min-height: 16px;
text-transform: uppercase;
.backface-visibility(hidden);
& > span {
.opacity(0);
}
&:not(:hover) {
font-size: 0px;
border-radius: 100%;
}
&:hover {
border-radius: 10px;
padding: 0 5px;
font-size: 8px;
& > span {
.opacity(1);
}
}
}
}
================================================
FILE: client/app/assets/less/inc/media.less
================================================
/* --------------------------------------------------------
Thumbnail
-----------------------------------------------------------*/
.thumbnail {
a&:hover,
a&:focus,
a&.active {
border-color: @thumbnail-border;
}
}
/* --------------------------------------------------------
Lightbox
-----------------------------------------------------------*/
.lightbox {
& > a {
position: relative;
.transition(opacity);
.transition-duration(300ms);
& > img {
width: 100%;
}
&:hover {
.opacity(0.8);
}
}
& > a:not(.p-item) { //Not for photo items
margin-bottom: 20px;
}
}
/* --------------------------------------------------------
Carousel
-----------------------------------------------------------*/
.carousel {
.carousel-control {
.transition(all);
.transition-duration(250ms);
.opacity(0);
.zmdi {
position: absolute;
top: 50%;
left: 50%;
line-height: 100%;
@media screen and (min-width: @screen-sm-min) {
font-size: 60px;
width: 60px;
height: 60px;
margin-top: -30px;
margin-left: -30px;
}
@media screen and (max-width: @screen-sm-max) {
width: 24px;
height: 24px;
margin-top: -12px;
margin-left: -12px;
}
}
}
&:hover {
.carousel-control {
.opacity(1);
}
}
.carousel-caption {
background: rgba(0,0,0,0.6);
left: 0;
right: 0;
bottom: 0;
width: 100%;
padding-bottom: 50px;
& > h3 {
color: #fff;
margin: 0 0 5px;
font-weight: 300;
}
& > p {
margin: 0;
}
@media screen and (max-width: @screen-sm-max) {
display: none;
}
}
.carousel-indicators {
bottom: 10px;
margin: 0;
left: 0;
bottom: 0;
width: 100%;
padding: 0 0 6px;
background: rgba(0,0,0,0.6);
li {
border-radius: 0;
width: 15px;
border: 0;
background: #fff;
height: 3px;
margin: 0;
.transition(all);
.transition-duration(250ms);
&.active {
width: 25px;
height: 3px;
background: @orange;
}
}
}
}
================================================
FILE: client/app/assets/less/inc/messages.less
================================================
#messages-main {
position: relative;
margin: 0 auto;
.clearfix();
.ms-menu {
position: absolute;
left: 0;
top: 0;
border-right: 1px solid #eee;
padding-bottom: 50px;
height: 100%;
width: 240px;
background: #fff;
@media (max-width: @screen-xs-max) {
height: ~"calc(100% - 58px)";
display: none;
z-index: 1;
top: 58px;
&.toggled {
display: block;
}
}
}
.ms-body {
@media (min-width: @screen-sm-min) {
padding-left: 240px;
}
@media (max-width: @screen-xs-max) {
overflow: hidden;
}
}
.ms-user {
padding: 15px;
background: @ace;
& > div {
overflow: hidden;
padding: 3px 5px 0px 15px;
font-size: 11px;
}
}
#ms-compose {
position: fixed;
bottom: 120px;
z-index: 1;
right: 30px;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.14),0 4px 8px rgba(0, 0, 0, 0.28);
}
}
#ms-menu-trigger {
.user-select(none);
position: absolute;
left: 0;
top: 0;
width: 50px;
height: 100%;
text-align: right;
padding-right: 10px;
padding-top: 19px;
i {
font-size: 21px;
}
&.toggled {
i:before{
content: '\f2ea';
}
}
}
/* --------------------------------------------------------
For Message
-----------------------------------------------------------*/
.message-feed {
padding: 20px;
&.right {
text-align: right;
& > .pull-right {
margin-left: 15px;
}
}
&:not(.right) {
.mf-content {
background: @amber;
color: #fff;
}
}
&.right .mf-content {
background: #eee;
}
}
.mf-content {
padding: 12px 17px 13px;
border-radius: 2px;
display: inline-block;
max-width: 80%;
}
.mf-date {
display: block;
color: #B3B3B3;
margin-top: 7px;
& > i {
font-size: 14px;
line-height: 100%;
position: relative;
top: 1px;
}
}
.msb-reply {
box-shadow: 0 -20px 20px -5px #fff;
position: relative;
margin-top: 30px;
border-top: 1px solid #eee;
background: @ace;
textarea {
width: 100%;
font-size: 13px;
border: 0;
padding: 10px 15px;
resize: none;
height: 60px;
background: transparent;
}
button {
position: absolute;
top: 0;
right: 0;
border: 0;
height: 100%;
width: 60px;
font-size: 25px;
color: @blue;
background: transparent;
&:hover {
background: #f2f2f2;
}
}
}
================================================
FILE: client/app/assets/less/inc/misc.less
================================================
/* --------------------------------------------------------
Actions
-----------------------------------------------------------*/
.actions {
position: absolute;
list-style: none;
padding: 0;
margin: 0;
& > li {
display: inline-block;
& > a {
display: block;
padding: 0 10px;
& > i {
font-size: 20px;
}
}
}
.dropdown-menu {
min-width: 140px;
margin-top: -8px;
margin-right: -1px;
}
&:not(.a-alt) {
& > li > a > i {
color: #939393;
}
& > li.open > a > i,
& > li > a:hover > i {
color: #000;
}
}
&.a-alt {
& > li > a > i {
color: #fff;
}
}
}
/* --------------------------------------------------------
View More
-----------------------------------------------------------*/
.view-more {
display: block;
padding: 5px 10px;
text-align: center;
border-top: 1px solid darken(@light-gray, 3%);
font-size: 12px;
margin-top: 15px;
color: #777777;
&:hover {
color: #333;
background-color: @light-gray;
}
}
/* --------------------------------------------------------
Page Header
-----------------------------------------------------------*/
.page-header {
padding: 0 22px;
font-weight: normal;
font-size: 19px;
margin: 0 0 20px 0;
small {
text-transform: none;
display: block;
font-size: 12px;
color: #9C9C9C;
margin-top: 7px;
line-height: 140%;
}
h3 {
margin: 0;
font-weight: normal;
font-size: 15px;
color: #333;
}
}
/* --------------------------------------------------------
Close
-----------------------------------------------------------*/
.close {
font-weight: normal;
text-shadow: none;
.opacity(0.5);
}
/* --------------------------------------------------------
Action Header
-----------------------------------------------------------*/
.action-header {
position: relative;
background: @ace;
padding: 15px 13px 15px 17px;
}
.ah-actions {
z-index: 3;
float: right;
margin-top: 7px;
position: relative;
}
.ah-label {
color: #818181;
display: inline-block;
margin: 0;
font-size: 14px;
font-weight: normal;
padding: 0 6px;
line-height: 33px;
vertical-align: middle;
float: left;
}
.ah-search {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
z-index: 4;
background: #fff;
display: none;
&:before {
content: "\f1c3";
font-family: 'Material-Design-Iconic-Font';
position: absolute;
left: 24px;
top: 17px;
font-size: 22px;
}
}
.ahs-input {
border: 0;
padding: 0 26px 0 55px;
height: 63px;
font-size: 18px;
width: 100%;
font-weight: 100;
background: #fff;
border-bottom: 1px solid #EEE;
}
.ahs-close {
font-style: normal;
position: absolute;
top: 23px;
right: 22px;
font-size: 17px;
width: 18px;
height: 18px;
background-color: #ADADAD;
line-height: 100%;
color: #fff;
text-align: center;
cursor: pointer;
border-radius: 50%;
&:hover {
background: #333;
}
}
/* --------------------------------------------------------
Load More
-----------------------------------------------------------*/
.load-more {
text-align: center;
margin-top: 30px;
a {
padding: 5px 10px 3px;
display: inline-block;
background-color: @red;
color: #FFF;
border-radius: 2px;
white-space: nowrap;
i {
font-size: 20px;
vertical-align: middle;
position: relative;
margin-top: -2px;
}
&:hover {
background-color: darken(@red, 10%);
}
}
}
/* --------------------------------------------------------
Data List
-----------------------------------------------------------*/
.dl-horizontal dt {
text-align: left;
}
/* --------------------------------------------------------
User Avatar
-----------------------------------------------------------*/
.img-avatar {
height: 37px;
border-radius: 2px;
width: 37px;
}
/* --------------------------------------------------------
Percy
-----------------------------------------------------------*/
@media only percy {
.hide-in-percy, .pace {
visibility: hidden;
}
// hide tooltips in Percy
.ant-tooltip {
display: none !important;
}
}
================================================
FILE: client/app/assets/less/inc/mixins.less
================================================
/* --------------------------------------------------------
Font Face
-----------------------------------------------------------*/
.font-face(@family, @name, @weight: 300, @style){
@font-face{
font-family: @family;
src:url('../fonts/@{family}/@{name}.eot');
src:url('../fonts/@{family}/@{name}.eot?#iefix') format('embedded-opentype'),
url('../fonts/@{family}/@{name}.woff') format('woff'),
url('../fonts/@{family}/@{name}.ttf') format('truetype'),
url('../fonts/@{family}/@{name}.svg#icon') format('svg');
font-weight: @weight;
font-style: @style;
}
}
/* --------------------------------------------------------
Button Varients
-----------------------------------------------------------*/
.button-variant(@color; @background; @border) {
color: @color;
background-color: @background;
border-color: @border;
&:hover,
&:focus,
&.focus,
&:active,
&.active,
.open > .dropdown-toggle& {
color: @color;
background-color: darken(@background, 2%);
border-color: darken(@border, 1%);
}
&:active,
&.active,
.open > .dropdown-toggle& {
background-image: none;
}
&.disabled,
&[disabled],
fieldset[disabled] & {
&,
&:hover,
&:focus,
&.focus,
&:active,
&.active {
background-color: @background;
border-color: @border;
}
}
.badge {
color: @background;
background-color: @color;
}
}
/* --------------------------------------------------------
CSS Transform - Scale and Rotate
-----------------------------------------------------------*/
.scale-rotate(@scale, @rotate) {
-webkit-transform: scale(@scale) rotate(@rotate);
-ms-transform: scale(@scale) rotate(@rotate);
-o-transform: scale(@scale) rotate(@rotate);
transform: scale(@scale) rotate(@rotate);
}
/* --------------------------------------------------------
CSS Animations based on animate.css
-----------------------------------------------------------*/
.animated(@name, @duration) {
-webkit-animation-name: @name;
animation-name: @name;
-webkit-animation-duration: @duration;
animation-duration: @duration;
-webkit-animation-fill-mode: both;
animation-fill-mode: both;
}
================================================
FILE: client/app/assets/less/inc/modal.less
================================================
.modal-header {
padding: 23px 26px;
}
.modal-body {
padding: 0 26px 10px;
}
.modal-content {
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.31);
}
.modal-footer {
padding: 20px 26px;
}
.modal-xl {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
overflow: hidden;
.modal-dialog {
position: fixed;
margin: 0;
width: 100%;
height: 100%;
padding: 0;
}
.modal-content {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
border: 2px solid #3c7dcf;
border-radius: 0;
box-shadow: none;
}
.modal-header {
position: absolute;
top: 0;
right: 0;
left: 0;
height: 50px;
padding: 10px;
border: 0;
}
.modal-body {
position: absolute;
top: 50px;
bottom: 60px;
width: 100%;
overflow: auto;
}
.modal-footer {
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 60px;
padding: 10px;
}
}
================================================
FILE: client/app/assets/less/inc/panel.less
================================================
.panel {
box-shadow: none;
border: 0;
}
.panel-heading {
padding: 0;
>p {
&:last-child {
margin-bottom: 0px;
}
}
>a, .query-link {
color: inherit;
}
.query-link {
&:hover {
text-decoration: underline;
}
}
}
.panel-title {
& > a {
padding: 10px 15px;
display: block;
font-size: 13px;
}
}
.panel-collapse {
.panel-heading {
position: relative;
.panel-title {
& > a {
padding: 8px 5px 16px 30px;
color: #000;
position: relative;
border-bottom: 2px solid #eee;
}
}
&:before {
font-family: @font-icon;
font-size: 17px;
position: absolute;
left: 0;
top: 4px;
content: "\f278";
}
&.active {
&:before {
content: "\f273";
}
}
}
.panel-body {
border-top: 0 !important;
padding-left: 5px;
padding-right: 5px;
}
}
.panel-collapse-color(@color) {
.panel-collapse {
.panel-heading {
&.active .panel-title > a {
border-bottom-color: @color;
}
}
}
}
.panel-group {
&:not([data-collapse-color]) {
.panel-collapse-color(@blue);
}
&[data-collapse-color="red"] {
.panel-collapse-color(@red);
}
&[data-collapse-color="green"] {
.panel-collapse-color(@green);
}
&[data-collapse-color="amber"] {
.panel-collapse-color(@amber);
}
&[data-collapse-color="teal"] {
.panel-collapse-color(@teal);
}
&[data-collapse-color="black"] {
.panel-collapse-color(@black);
}
&[data-collapse-color="cyan"] {
.panel-collapse-color(@cyan);
}
}
================================================
FILE: client/app/assets/less/inc/photos.less
================================================
.photos {
&:not(.pmb-block) {
margin: 10px 5px 0;
}
.p-item {
padding: 0 3px;
margin-bottom: 6px;
}
}
.p-item {
& > img {
border-radius: 2px;
}
}
.p-timeline {
position: relative;
padding-left: 80px;
margin-bottom: 75px;
.p-item {
float: left;
width: 70px;
height: 70px;
margin: 0 3px 3px 0;
}
&:last-child .pt-line:before {
height: 100%;
}
}
.ptb-title {
font-size: 15px;
font-weight: 400;
margin-bottom: 20px;
}
.pt-line {
position: absolute;
left: 0;
top: 0;
height: 100%;
line-height: 14px;
&:before,
&:after {
content: "";
position: absolute;
}
&:before {
width: 1px;
height: ~"calc(100% + 63px)";
background: #E2E2E2;
top: 14px;
right: -20px;
}
&:after {
top: 2px;
right: -26px;
width: 13px;
height: 13px;
border: 1px solid #C1C1C1;
border-radius: 50%;
}
}
================================================
FILE: client/app/assets/less/inc/popover.less
================================================
.popover {
box-shadow: fade(@redash-gray, 25%) 0px 0px 15px 0px;
}
.popover-title {
border-bottom: 0;
padding: 15px;
font-size: 12px;
text-transform: uppercase;
& + .popover-content {
padding-top: 0;
}
}
.popover-content {
padding: 15px;
p {
margin-bottom: 0;
}
}
================================================
FILE: client/app/assets/less/inc/pricing-table.less
================================================
.pricing-table {
margin-top: 50px;
}
.pt-inner {
text-align: center;
.pti-header {
padding: 45px 10px 70px;
color: #fff;
position: relative;
margin-bottom: 15px;
& > h2 {
margin: 0;
line-height: 100%;
color: #fff;
font-weight: 100;
font-size: 50px;
small {
color: #fff;
letter-spacing: 0;
vertical-align: top;
font-size: 16px;
font-weight: 100;
}
}
.ptih-title {
background-color: rgba(0, 0, 0, 0.1);
padding: 8px 10px 9px;
text-transform: uppercase;
margin: 0 -10px;
position: absolute;
width: 100%;
bottom: 0;
}
}
.pti-body {
padding: 0 23px;
.ptib-item {
padding: 15px 0;
font-weight: 400;
&:not(:last-child) {
border-bottom: 1px solid #eee;
}
}
}
.pti-footer {
padding: 10px 20px 30px;
& > a {
width: 60px;
height: 60px;
border-radius: 50%;
text-align: center;
color: #fff;
display: inline-block;
line-height: 60px;
font-size: 30px;
&:hover {
.opacity(0.85);
}
}
}
}
================================================
FILE: client/app/assets/less/inc/print.less
================================================
@media print {
@page {
margin: 0;
padding: 0;
size: auto;
}
body, #content, .container {
margin: 0mm 0mm 0mm 0mm !important;
padding: 0mm !important;
}
#header,
#sidebar,
#chat,
.growl-animated,
[data-action="print"] {
display: none !important;
}
/* --------------------------------------------------------
Invoice
-----------------------------------------------------------*/
.invoice {
padding: 30px !important;
-webkit-print-color-adjust: exact !important;
.card-header {
background: #eee !important;
padding: 20px;
margin-bottom: 20px;
margin: -60px -30px 25px -30px;
}
.page-header {
display: none;
}
.highlight {
background: #eee !important;
}
}
}
================================================
FILE: client/app/assets/less/inc/profile.less
================================================
#profile-main {
min-height: 500px;
position: relative;
}
.pm-overview {
overflow: hidden !important;
@media (min-width: 1200px) {
width: 300px;
}
@media (min-width: @screen-sm-min) and (max-width: 1200px) {
width: 250px;
}
@media (min-width: @screen-sm-min) {
position: absolute;
left: 0;
top: 0;
height: 100%;
background: #f8f8f8;
border-right: 1px solid #eee;
}
@media (max-width: @screen-xs-max) {
width: 100%;
background: #333;
text-align: center;
}
&:hover {
.pmop-edit {
.opacity(1);
color: #fff;
}
}
}
.pm-body {
@media (min-width: 1200px) {
padding-left: 300px;
}
@media (min-width: @screen-sm-min) and (max-width: 1200px) {
padding-left: 250px;
}
@media (max-width: @screen-xs-max) {
padding-left: 0;
}
}
.pmo-pic {
position: relative;
margin: 20px;
img {
@media(min-width: @screen-sm-min) {
width: 100%;
border-radius: 2px 2px 0 0;
}
@media(max-width: @screen-xs-max) {
width: 180px;
display: inline-block;
height: 180px;
border-radius: 50%;
border: 4px solid #fff;
}
}
}
.pmo-stat {
border-radius: 0 0 2px 2px;
color: #fff;
text-align: center;
padding: 30px 5px 0;
@media(min-width: @screen-sm-min) {
background: @amber;
padding-bottom: 15px;
}
}
.pmop-edit {
position: absolute;
top: 0;
left: 0;
color: #fff;
background: rgba(0, 0, 0, 0.38);
text-align: center;
padding: 10px 10px 11px;
&:hover {
background: rgba(0, 0, 0, 0.8);
}
i {
font-size: 18px;
vertical-align: middle;
margin-top: -3px;
}
@media (min-width: @screen-sm-min) {
width: 100%;
.opacity(0);
i {
margin-right: 4px;
}
}
}
.pmop-message {
position: absolute;
bottom: 27px;
left: 50%;
margin-left: -25px;
.dropdown-menu {
padding: 5px 0 55px;
left: -90px;
width: 228px;
height: 150px;
top: -74px;
textarea {
width: 100%;
height: 95px;
border: 0;
resize: none;
padding: 10px 19px;
}
button {
position: absolute;
bottom: 5px;
left: 93px;
}
}
}
.pmb-block {
margin-bottom: 20px;
@media (min-width: 1200px) {
padding: 40px 42px 0;
}
@media (max-width: 1199px) {
padding: 30px 20px 0;
}
&:last-child {
margin-bottom: 50px;
}
&.toggled {
.pmbb-edit {
display: block;
}
.pmbb-view {
display: none;
}
}
}
.pmbb-header {
margin-bottom: 25px;
position: relative;
.actions {
position: absolute;
top: -2px;
right: 0;
}
h2 {
margin: 0;
font-weight: 100;
font-size: 20px;
}
}
.pmbb-edit {
position: relative;
z-index: 1;
display: none;
}
.pmo-block {
padding: 25px;
& > h2 {
font-size: 16px;
margin: 0 0 15px;
}
}
.pmo-items {
.pmob-body {
padding: 0 10px;
}
a {
display: block;
padding: 4px;
img {
width: 100%;
}
}
}
.pmopm-send {
background-color: #fff;
width: 50px;
height: 50px;
font-size: 24px;
line-height: 53px;
border-radius: 50%;
position: absolute;
color: #333;
bottom: -50px;
box-shadow: 0px 3px 10px rgba(0, 0, 0, 0.16);
text-align: center;
&:hover {
color: #000;
}
}
.pmo-contact {
ul {
list-style: none;
margin: 0;
padding: 0;
li {
position: relative;
padding: 8px 0 8px 35px;
i {
font-size: 18px;
vertical-align: top;
line-height: 100%;
position: absolute;
left: 0;
width: 18px;
text-align: center;
color: #333;
}
}
}
}
.pmo-map {
margin: 20px -21px -18px;
display: block;
img {
width: 100%;
}
}
.p-header {
position: relative;
margin: 0 -7px;
.actions {
position: absolute;
top: -18px;
right: 0;
}
}
.p-menu {
list-style: none;
padding: 0 8px;
margin: 0 0 30px;
& > li {
display: inline-block;
vertical-align: top;
& > a {
display: block;
padding: 5px 20px 5px 0;
font-weight: 500;
text-transform: uppercase;
font-size: 15px;
& > i {
margin-right: 4px;
font-size: 20px;
vertical-align: middle;
margin-top: -5px;
}
}
&:not(.active) > a {
color: #4285F4;
&:hover {
color: #333;
}
}
&.active > a {
color: #000;
}
}
.pm-search {
@media(max-width: @screen-sm-max) {
margin: 20px 2px 30px;
display: block;
input[type="text"] {
width: 100%;
border: 1px solid #ccc;
}
}
}
.pms-inner {
margin: -2px 0 0;
position: relative;
top: -2px;
overflow: hidden;
white-space: nowrap;
i {
vertical-align: top;
font-size: 20px;
line-height: 100%;
position: absolute;
left: 9px;
top: 8px;
color: #333;
}
input[type="text"] {
height: 35px;
border-radius: 2px;
padding: 0 10px 0 40px;
@media(min-width: @screen-sm-min) {
border: 1px solid #fff;
width: 50px;
background: transparent;
position: relative;
z-index: 1;
.transition(all);
.transition-duration(300ms);
&:focus {
border-color: #DFDFDF;
width: 200px;
}
}
}
}
}
================================================
FILE: client/app/assets/less/inc/progress-bar.less
================================================
.progress {
box-shadow: none;
border-radius: 0;
height: 5px;
margin-bottom: 0;
.progress-bar {
box-shadow: none;
}
}
================================================
FILE: client/app/assets/less/inc/schema-browser.less
================================================
.schema-container {
height: 100%;
z-index: 10;
background-color: white;
.schema-browser {
overflow: hidden;
border: none;
padding-top: 10px;
position: relative;
height: 100%;
.schema-loading-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.collapse.in {
background: transparent;
}
.copy-to-editor {
visibility: hidden;
color: fade(@redash-gray, 90%);
width: 20px;
display: flex;
align-items: center;
justify-content: center;
transition: none;
}
.schema-list-item {
display: flex;
border-radius: @redash-radius;
height: 22px;
.table-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
padding: 2px 22px 2px 10px;
}
&:hover,
&:focus,
&:focus-within {
background: fade(@redash-gray, 10%);
.copy-to-editor {
visibility: visible;
}
}
}
.table-open {
.table-open-item {
display: flex;
height: 18px;
width: calc(100% - 22px);
padding-left: 22px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: none;
div:first-child {
flex: 1;
}
.column-type {
color: fade(@text-color, 80%);
font-size: 10px;
margin-left: 2px;
text-transform: uppercase;
}
&:hover,
&:focus,
&:focus-within {
background: fade(@redash-gray, 10%);
.copy-to-editor {
visibility: visible;
}
}
}
}
}
.schema-control {
display: flex;
flex-wrap: nowrap;
padding: 0;
.ant-btn {
height: auto;
}
}
.parameter-label {
display: block;
}
}
================================================
FILE: client/app/assets/less/inc/sidebar.less
================================================
#sidebar {
background-color: @sidebar;
position: fixed;
left: 0;
top: @header-height;
z-index: 9;
height: ~"calc(100% - 62px)";
@media (min-width: (@screen-lg-min + 80px)), (max-width: (@screen-sm-min)) {
width: @sidebar-left-width;
overflow: auto;
}
@media (min-width: @screen-sm-min) and (max-width: (@screen-md-max + 80px)) {
&:not(.toggled) {
width: @sidebar-left-mid-width;
overflow: visible;
}
&.toggled {
width: @sidebar-left-width;
overflow: auto;
}
}
@media (max-width: @screen-sm-min) {
display: none;
&.toggled {
display: block;
z-index: 12;
}
}
}
/* --------------------------------------------------------
Profile Menu
-----------------------------------------------------------*/
.sms-profile {
margin: 12px 0 10px;
& > a {
padding: 15px;
display: block;
color: @color-dark;
& > img {
width: 28px;
height: 28px;
border-radius: 50%;
float: left;
margin-right: 10px;
margin-top: 3px;
}
}
}
/* --------------------------------------------------------
Sidebar Menu
-----------------------------------------------------------*/
.side-menu {
list-style: none;
padding: 0;
a {
color: @color-dark;
}
& > li {
width: 100%;
display: block;
& > a {
display: block;
padding: 9px 10px 9px 16px;
position: relative;
white-space: nowrap;
.transition(color);
& > .zmdi {
font-size: 13px;
width: 28px;
height: 28px;
border-radius: 50%;
background-color: #000;
line-height: 29px;
margin-right: 7px;
text-align: center;
}
.label {
position: absolute;
top: 15px;
right: 12px;
}
}
&.active > a,
&:hover > a {
color: #fff;
}
&.active > a {
background: @sidebar-active-bg;
.zmdi {
background: #2C313A;
color: #fff;
}
}
}
}
.sm-sub {
position: relative;
&:not(.active) {
& > ul {
display: none;
}
}
& > ul {
position: relative;
width: 100%;
padding: 0 0 0 27px;
background: darken(@sidebar, 1.5%);
margin-bottom: 0;
border: 0;
list-style: none;
&:before {
content: "";
height: 100%;
width: 1px;
position: absolute;
background: #1f2229;
left: 30px;
top: 0;
}
& > li {
& > a {
padding: 7px 18px 7px 28px;
font-size: 12px;
display: block;
position: relative;
white-space: nowrap;
.transition(color);
&:hover {
color: #fff;
}
&:before {
content: "";
width: 8px;
height: 1px;
background: #22252d;
position: absolute;
left: 4px;
top: 14px;
}
}
&.active > a {
color: #fff;
}
&:first-child > a {
&:before {
top: 20px;
}
padding-top: 13px;
}
&:last-child > a {
padding-bottom: 13px;
}
}
}
}
/* --------------------------------------------------------
Sidebar for mid size screens
-----------------------------------------------------------*/
@media (min-width: @screen-sm-min) and (max-width: (@screen-md-max + 80px)) {
#sidebar:not(.toggled) {
.side-menu > li {
& > a {
& > span {
position: absolute;
left: @sidebar-left-mid-width;
background-color: @sidebar-active-bg;
width: 180px;
padding: 14px 18px;
display: none;
text-transform: uppercase;
.animated(fadeIn, 300ms);
}
.label {
display: none;
}
}
&.sms-bottom > a > span {
bottom: 0;
}
&:not(.sms-bottom) > a > span {
top: 0;
}
&:hover {
& a > span {
display: block;
}
}
}
.sm-sub {
& > ul {
display: none !important;
position: absolute;
left: @sidebar-left-mid-width;
width: 180px;
padding-left: 0;
.animated(fadeIn, 300ms);
&:before {
display: none;
}
& > li > a {
padding-left: 18px;
&:before {
display: none;
}
}
}
&:not(.sms-bottom) > ul {
top: 46px;
border-top: 1px solid lighten(@sidebar, 5%);
}
&.sms-bottom > ul {
bottom: 46px;
border-bottom: 1px solid lighten(@sidebar, 5%);
}
&:hover {
& > ul {
display: block !important;
}
}
}
}
}
================================================
FILE: client/app/assets/less/inc/table.less
================================================
.table {
margin-bottom: 0;
th.sortable-column {
cursor: pointer;
}
&:not(.table-striped) > thead > tr > th {
background-color: #fafafa;
}
[class*="bg-"] {
& > tr > th {
color: #fff;
border-bottom: 0;
background: transparent !important;
}
& + tbody > tr:first-child > td {
border-top: 0;
}
}
& > thead > tr > th {
vertical-align: middle;
font-weight: 500;
color: #333;
border-width: 1px;
text-transform: uppercase;
padding: 15px 10px;
}
& > thead > tr,
& > tbody > tr,
& > tfoot > tr {
& > th,
& > td {
&:first-child {
padding-left: 30px;
}
&:last-child {
padding-right: 30px;
}
}
}
tbody > tr:last-child > td {
padding-bottom: 20px;
}
}
.table-bordered {
border: 0;
& > tbody > tr {
& > td,
& > th {
border-bottom: 0;
border-left: 0;
&:last-child {
border-right: 0;
}
}
}
& > thead > tr > th {
border-left: 0;
&:last-child {
border-right: 0;
}
}
}
.table-vmiddle {
td {
vertical-align: middle !important;
}
}
.table-responsive {
border: 0;
}
.tile .table {
& > thead:not([class*="bg-"]) > tr > th {
border-top: 1px solid @table-border-color;
}
}
.table-hover > tbody > tr:hover {
background-color: #f4f4f4;
}
.table-data {
thead > tr > th {
white-space: nowrap;
}
tbody > tr > td {
padding-top: 5px !important;
}
.btn-favorite,
.btn-archive {
font-size: 15px;
}
}
.table-main-title {
font-weight: 500;
line-height: 1.7 !important;
}
.btn-favorite {
color: #d4d4d4;
transition: all 0.25s ease-in-out;
.fa-star {
color: @yellow-darker;
}
&:hover,
&:focus {
color: @yellow-darker;
cursor: pointer;
.fa-star {
filter: saturate(75%);
opacity: 0.75;
}
}
}
.btn-archive {
color: #d4d4d4;
transition: all 0.25s ease-in-out;
&:hover,
&:focus {
color: @gray-light;
}
.fa-archive {
color: @gray-light;
}
}
.table > thead > tr > th {
text-transform: none;
}
.table-data .label-tag {
display: inline-block;
max-width: 135px;
}
================================================
FILE: client/app/assets/less/inc/tile.less
================================================
.tile {
background-color: #fff;
margin-bottom: @grid-gutter-width;
position: relative;
border-radius: 3px;
box-shadow: fade(@redash-gray, 15%) 0px 4px 9px -3px;
&[class*="bg-"] {
color: #fff;
}
@media (max-width: @screen-sm-min) {
margin-bottom: @grid-gutter-width/2;
}
}
.tiled {
border-radius: 3px;
box-shadow: fade(@redash-gray, 15%) 0px 4px 9px -3px;
}
.t-header {
.th-title {
line-height: 100%;
}
&:not(.th-alt) {
padding: 20px 23px;
.th-title {
font-size: 17px;
font-weight: 400;
color: #333;
small {
font-size: 12px;
color: #9C9C9C;
margin-top: 3px;
display: block;
}
}
}
&.widget {
padding: 5px;
}
&.th-alt {
padding: 10px 15px 9px;
.actions {
& > a {
color: #fff;
}
}
&[class*="bg-"] {
.th-title {
color: #fff;
}
}
}
.actions {
right: 0;
top: 0;
& > a {
font-size: 24px;
line-height: 100%;
padding: 4px 10px 3px;
display: block;
}
& > a:hover,
&.open > a {
background-color: rgba(0, 0, 0, 0.1);
}
}
}
.t-header:not(.th-alt) {
padding: 15px;
ul {
margin-bottom: 0;
line-height: 2.2;
}
}
.tb-padding {
padding: 20px 23px 30px;
}
.t-body a.actions {
font-size: 24px;
line-height: 100%;
padding: 4px 10px 3px;
display: block;
}
.t-body a.actions:hover,
.t-body a.actions.open > a {
background-color: rgba(0, 0, 0, 0.1);
}
================================================
FILE: client/app/assets/less/inc/tooltips.less
================================================
.tooltip-inner {
border-radius: 1px;
padding: 5px 10px;
font-size: 12px;
}
================================================
FILE: client/app/assets/less/inc/variables.less
================================================
/* --------------------------------------------------------
Paths
-----------------------------------------------------------*/
@imgpath: ~'../img';
@fontpath: ~'../fonts';
/* --------------------------------------------------------
Container
-----------------------------------------------------------*/
@container-tablet: 100%;
@container-desktop: 100%;
@container-large-desktop: 100%;
/* --------------------------------------------------------
Template Variables
-----------------------------------------------------------*/
@header-height: 60px;
@sidebar-left-width: 240px;
@sidebar-left-mid-width: 64px;
@logo-width: @sidebar-left-width;
@logo-height: @header-height;
@boxed-width: 1170px;
@body-bg: #edecec;
@spacing: 15px;
@redash-radius: 3px;
/* --------------------------------------------------------
Branding
-----------------------------------------------------------*/
@brand-bg: #191C22;
@sidebar: @brand-bg;
@sidebar-active-bg: #121419;
@color-dark: #9BA1B1;
/* --------------------------------------------------------
Font
-----------------------------------------------------------*/
@font-icon: 'Material-Design-Iconic-Font';
@font-family-sans-serif: 'Roboto', sans-serif;
@redash-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
@font-size-base: 13px;
/* --------------------------------------------------------
Typograpgy
-----------------------------------------------------------*/
@text-color: #767676;
@link: #02a4c4;
@link-hover-decoration: none;
@headings-color: #333;
/* --------------------------------------------------------
Form
-----------------------------------------------------------*/
@input-color: #595959;
@input-color-placeholder: #b4b4b4;
@input-border: #e8e8e8;
@input-border-radius: 0;
@input-border-radius-large: 0px;
@redash-input-radius: 2px;
@input-height-large: 40px;
@input-height-base: 35px;
@input-height-small: 30px;
@input-border-focus: #79c2ff;
@input-group-addon-bg: @light-gray;
/* --------------------------------------------------------
Colors
-----------------------------------------------------------*/
@white: #ffffff;
@black: #000000;
@blue: #2196F3;
@red: #F44336;
@purple: #9C27B0;
@deeppurple: #673AB7;
@lightblue: #03A9F4;
@cyan: #00BCD4;
@teal: #009688;
@green: #4CAF50;
@lightgreen: #8BC34A;
@lime: #CDDC39;
@yellow: #FFEB3B;
@yellow-darker: #fbd208;
@amber: #FFC107;
@orange: #FF9800;
@deeporange: #FF5722;
@gray: #9E9E9E;
@bluegray: #607D8B;
@indigo: #3F51B5;
@pink: #E91E63;
@brown: #795548;
@light-gray: #FCFCFC;
@gray-light: #828282;
@ace: #f8f8f8;
@redash-gray: rgba(102, 136, 153, 1);
@redash-orange: rgba(255, 120, 100, 1);
@redash-black: rgba(0, 0, 0, 1);
@redash-yellow: rgba(252, 252, 161, 0.75);
/** Form States **/
@state-success-text: @green;
@state-info-text: @blue;
@state-danger-text: lighten(@red, 5%);
/* --------------------------------------------------------
Alert
-----------------------------------------------------------*/
@alert-success-border: transparent;
@alert-info-border: transparent;
@alert-danger-border: transparent;
@alert-inverse-border: transparent;
@alert-success-bg: fade(@green, 70%);
@alert-info-bg: fade(@blue, 70%);
@alert-danger-bg: fade(@red, 70%);
@alert-inverse-bg: #333;
@alert-success-text: #fff;
@alert-info-text: #fff;
@alert-danger-text: #fff;
@alert-inverse-text: #fff;
/* --------------------------------------------------------
Bootstrap Brands
-----------------------------------------------------------*/
@brand-default: #eee;
@brand-primary: @blue;
@brand-info: @cyan;
@brand-success: @green;
@brand-warning: @orange;
@brand-danger: @red;
/* --------------------------------------------------------
Border Radius
-----------------------------------------------------------*/
@border-radius-base: 2px;
@border-radius-large: 2px;
@border-radius-small: 2px;
/* --------------------------------------------------------
Dropdown
-----------------------------------------------------------*/
@dropdown-fallback-border: transparent;
@dropdown-border: transparent;
@dropdown-divider-bg: '';
@dropdown-link-hover-bg: rgba(0,0,0,0.075);
@dropdown-link-color: #333;
@dropdown-link-hover-color: #333;
@dropdown-link-disabled-color: #e4e4e4;
@dropdown-divider-bg: rgba(0,0,0,0.08);
@dropdown-link-active-color: #333;
@dropdown-link-active-bg: rgba(0, 0, 0, 0.075);
@zindex-dropdown: 9;
@dropdown-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
/* --------------------------------------------------------
Page Header
-----------------------------------------------------------*/
@page-header-border-color: transparent;
/* --------------------------------------------------------
Buttons
-----------------------------------------------------------*/
@btn-default-border: @input-border;
@btn-font-weight: 400;
/* --------------------------------------------------------
Tables
-----------------------------------------------------------*/
@table-bg: #fff;
@table-border-color: #f0f0f0;
@table-cell-padding: 10px;
@table-condensed-cell-padding: 7px;
@table-bg-accent: @light-gray;
@table-bg-active: #FFFCBE;
@table-bg-hover: lighten(@light-gray, 2%);
/* --------------------------------------------------------
Pagination
-----------------------------------------------------------*/
@pagination-bg: #E2E2E2;
@pagination-border: #fff;
@pagination-color: #7E7E7E;
@pagination-active-bg: @lightblue;
@pagination-active-border: @pagination-border;
@pagination-disabled-bg: #E2E2E2;
@pagination-disabled-border: @pagination-border;
@pagination-hover-color: #333;
@pagination-hover-bg: #d7d7d7;
@pagination-hover-border: @pagination-border;
/* --------------------------------------------------------
Thumbnail
-----------------------------------------------------------*/
@thumbnail-bg: #fff;
@thumbnail-border: #eee;
/* --------------------------------------------------------
Carousel
-----------------------------------------------------------*/
@carousel-caption-color: #fff;
/* --------------------------------------------------------
Modal
-----------------------------------------------------------*/
@modal-content-fallback-border-color: transparent;
@modal-content-border-color: transparent;
@modal-backdrop-bg: #000;
@modal-header-border-color: transparent;
@modal-title-line-height: transparent;
@modal-footer-border-color: transparent;
@zindex-modal-background: 10;
/* --------------------------------------------------------
Tooltips
-----------------------------------------------------------*/
@tooltip-bg: #333;
@tooltip-opacity: 1;
/* --------------------------------------------------------
Popobver
-----------------------------------------------------------*/
@zindex-popover: 9;
@popover-title-bg: #fff;
@popover-border-color: #fff;
@popover-fallback-border-color: #fff;
/* --------------------------------------------------------
Breacrumb
-----------------------------------------------------------*/
@breadcrumb-bg: transparent;
@breadcrumb-padding-horizontal: 20px;
@breadcrumb-active-color: #7c7c7c;
/* --------------------------------------------------------
Jumbotron
-----------------------------------------------------------*/
@jumbotron-bg: #F7F7F7;
/* --------------------------------------------------------
List Group
-----------------------------------------------------------*/
@list-group-border: #f4f4f4;
@list-group-active-color: #000;
@list-group-active-bg: #f5f5f5;
@list-group-active-border: @list-group-border;
@list-group-disabled-color: #B5B4B4;
@list-group-disabled-bg: #fff;
@list-group-disabled-text-color: #B5B4B4;
/* --------------------------------------------------------
Badges
-----------------------------------------------------------*/
@badge-color: #fff;
@badge-bg: @brand-primary;
@badge-border-radius: 2px;
@badge-font-weight: 400;
@badge-active-color: #fff;
@badge-active-bg: @brand-primary;
/* --------------------------------------------------------
Misc
-----------------------------------------------------------*/
@code-bg: transparent;
@tile-shadow: 0 1px 1px rgba(0,0,0,0.07);
================================================
FILE: client/app/assets/less/inc/visualizations/box.less
================================================
.box {
font: 10px sans-serif;
line, rect, circle {
fill: #fff;
stroke: #000;
stroke-width: 1.5px;
}
.center {
stroke-dasharray: 3, 3;
}
.outlier {
fill: none;
stroke: #000;
}
}
.axis text {
font: 10px sans-serif;
}
.axis path,
.axis line {
fill: none;
stroke: #000;
shape-rendering: crispEdges;
}
.grid-background {
fill: #ddd;
}
.grid path,
.grid line {
fill: none;
stroke: #fff;
shape-rendering: crispEdges;
}
.grid .minor line {
stroke-opacity: .5;
}
.grid text {
display: none;
}
================================================
FILE: client/app/assets/less/inc/visualizations/map.less
================================================
.map-visualization-container {
height: 500px;
> div:first-child {
width: 100%;
height: 100%;
z-index: 0;
}
}
.leaflet-popup-content img {
max-width: 100%;
height: auto;
}
================================================
FILE: client/app/assets/less/inc/visualizations/misc.less
================================================
.visualization-renderer {
display: block;
.pagination,
.ant-pagination {
margin: 0;
}
}
================================================
FILE: client/app/assets/less/inc/visualizations/pivot-table.less
================================================
.pivot-table-visualization-container > table,
.visualization-renderer > .visualization-renderer-wrapper {
overflow: auto;
}
================================================
FILE: client/app/assets/less/inc/well.less
================================================
.well {
border-radius: 0;
background: #fff;
box-shadow: none;
}
================================================
FILE: client/app/assets/less/inc/widgets.less
================================================
/* --------------------------------------------------------
User Signups
-----------------------------------------------------------*/
.rounded-thumbs {
padding: 15px 25px 0;
}
.rt-item {
display: block;
padding-top: 10px;
padding-bottom: 10px;
img {
width: 100%;
height: 100%;
border-radius: 50%;
}
small {
.text-overflow();
text-align: center;
display: block;
color: #777;
margin-top: 3px;
}
&:hover {
background-color: @light-gray;
}
}
================================================
FILE: client/app/assets/less/main.less
================================================
/** LESS Plugins **/
@import "inc/less-plugins/for";
/** Load Main Bootstrap LESS files **/
@import "~bootstrap/less/bootstrap";
/** Load Vendors Dependencies **/
@import "~font-awesome/less/font-awesome";
@import "~material-design-iconic-font/dist/css/material-design-iconic-font.css";
@import "inc/variables";
@import "inc/mixins";
@import "inc/font";
@import "inc/print";
@import "inc/bootstrap-overrides";
@import "inc/base";
@import "inc/generics";
@import "inc/form";
@import "inc/button";
@import "inc/list";
@import "inc/header";
@import "inc/tile";
@import "inc/label";
@import "inc/dropdown";
@import "inc/list-group";
@import "inc/misc";
@import "inc/progress-bar";
@import "inc/widgets";
@import "inc/table";
@import "inc/alert";
@import "inc/media";
@import "inc/modal";
@import "inc/panel";
@import "inc/tooltips";
@import "inc/popover";
@import "inc/breadcrumb";
@import "inc/jumbotron";
@import "inc/profile";
@import "inc/404";
@import "inc/ie-warning";
@import "inc/edit-in-place";
@import "inc/flex";
@import "inc/ace-editor";
@import "inc/schema-browser";
@import "inc/visualizations/box";
@import "inc/visualizations/pivot-table";
@import "inc/visualizations/map";
@import "inc/visualizations/misc";
/** REDASH STYLING **/
@import "redash/redash-table";
@import "redash/query";
@import "redash/tags-control";
@import "redash/css-logo";
@import "redash/loading-indicator";
================================================
FILE: client/app/assets/less/redash/css-logo.less
================================================
// based on https://github.com/outbrain/tech-companies-logos-in-css/pull/28
@primary: #ff7964;
@shadow: #ef6c58;
@bar: white;
#css-logo {
width: 100px;
height: 100px;
position: relative;
#circle {
width: 79px;
height: 79px;
background-color: @shadow;
border-radius: 50%;
margin: auto;
overflow: hidden;
position: relative;
& > div {
width: 79px;
height: 73px;
background-color: @primary;
border-radius: 50%;
position: absolute;
top: 0;
}
}
#bars {
position: absolute;
left: 0;
top: 24px;
right: 0;
height: 33px;
display: flex;
padding: 0 22px 0;
.bar {
background: @bar;
box-shadow: 0px 2px 0 0 @shadow;
display: inline-block;
border-radius: 1px;
align-self: flex-end;
flex: 1;
margin: 0 2px;
border-radius: 3px;
&:nth-child(1) {
height: 32%;
}
&:nth-child(2) {
height: 71%;
}
&:nth-child(3) {
height: 50%;
}
&:nth-child(4) {
height: 100%;
}
}
}
#point,
#point > div {
position: absolute;
width: 0;
height: 0;
border: 17px solid @shadow;
border-right-color: transparent !important;
border-bottom-color: transparent !important;
bottom: 0;
left: 48px;
transform: scaleX(0.87);
transform-origin: left;
}
#point > div {
bottom: -12px;
border-color: @primary;
transform: scaleX(1.04);
left: -17px;
}
}
================================================
FILE: client/app/assets/less/redash/loading-indicator.less
================================================
.loading-indicator {
position: fixed;
top: 50%;
left: 50%;
margin: -50px 0 0 -50px; // center
width: 100px;
height: 100px;
transition-duration: 150ms;
transition-timing-function: linear;
transition-property: opacity, transform;
#css-logo {
animation: hover 2s infinite;
}
#shadow {
width: 33px;
height: 12px;
border-radius: 50%;
background-color: black;
opacity: 0.25;
display: block;
position: absolute;
left: 34px;
top: 115px;
animation: shadow 2s infinite;
}
@keyframes hover {
50% {
transform: translateY(-5px);
}
}
@keyframes shadow {
50% {
transform: scaleX(0.9);
opacity: 0.2;
}
}
}
// hide indicator when application has content
#application-root:not(:empty) ~ .loading-indicator {
opacity: 0;
transform: scale(0.9);
pointer-events: none;
* {
animation: none !important;
}
}
================================================
FILE: client/app/assets/less/redash/query.less
================================================
body.fixed-layout {
padding: 0;
overflow: hidden;
#application-root {
display: flex;
flex-direction: row;
padding-bottom: 0;
width: 100vw;
height: 100%;
.application-layout-content > div {
display: flex;
}
}
}
.p-b-60 {
padding-bottom: 60px !important;
}
.bottom-controller-container {
box-shadow: 0 0 9px 0 rgba(102, 136, 153, 0.15);
z-index: 1;
border: none !important;
flex-shrink: 0;
}
// Editor
.filter-container {
margin-bottom: 5px;
}
.schema-container {
background: transparent;
flex-grow: 1;
display: flex;
flex-direction: column;
}
.editor__left {
height: 100% !important;
width: calc(~"25% - 10px");
margin-right: 10px;
.form-control {
height: 30px;
}
}
.query-alerts {
.alert {
margin-bottom: 15px;
}
}
.query-log-line {
font-family: monospace;
white-space: pre;
margin: 0;
}
.paginator-container {
text-align: center;
}
.tile {
.paginator-container {
text-align: center;
margin-top: 10px;
}
}
.query__vis {
table {
border: 1px solid #f0f0f0;
}
.paginator-container {
text-align: center;
margin-top: 10px;
li:first-of-type {
margin-left: 0;
}
}
}
.embed__vis {
display: flex;
flex-flow: column;
height: calc(~'100% - 25px');
> .embed-heading {
flex: 0 0 auto;
}
> .query__vis {
flex: 1 1 auto;
.chart-visualization-container, .visualization-renderer-wrapper, .visualization-renderer {
height: 100%
}
}
> .tile__bottom-control {
flex: 0 0 auto;
}
width: 100%;
}
.embed-heading {
h3 {
line-height: 1.75;
margin: 0;
}
}
.widget-wrapper {
.body-container {
.filters-wrapper {
display: block;
padding-left: 15px;
}
}
}
// Don't let filters take all visualization space on query fixed layout
.query-fixed-layout {
.filters-wrapper {
max-height: 40%;
overflow: auto;
}
}
.page-header--new {
.query-tags,
.query-tags__mobile {
.label-default,
.label-warning {
margin-right: 3px;
}
}
}
.label-tag {
background: fade(@redash-gray, 15%);
color: darken(@redash-gray, 15%);
&:hover,
&:focus,
&:active {
color: darken(@redash-gray, 15%);
background: fade(@redash-gray, 25%);
}
}
.query-page-wrapper {
display: flex;
flex-direction: column;
flex-grow: 1;
position: relative;
}
.query-fullscreen {
background: #fff;
padding: 0;
box-shadow: rgba(102, 136, 153, 0.15) 0 4px 9px -3px;
flex-grow: 1;
display: flex;
.resizable-component.react-resizable {
.react-resizable-handle-horizontal {
border-right: 1px solid #efefef;
}
.react-resizable-handle-vertical {
border-bottom: 1px solid #efefef;
}
}
.query-metadata.query-metadata-horizontal {
border-bottom: 1px solid #efefef;
}
.tile,
.tiled {
box-shadow: none;
padding: 15px 0 !important;
}
nav {
position: relative;
display: flex;
flex-flow: column;
flex-basis: 25%;
flex-shrink: 0;
max-width: 600px;
min-width: 10px;
overflow-x: hidden;
.editor__left__data-source,
.schema-control,
.editor {
flex-shrink: 0;
}
.editor__left__schema,
.editor__left__data-source {
padding: 15px;
}
.editor__left__data-source {
.ant-select {
.ant-select-selection-selected-value {
img,
span {
vertical-align: middle;
}
}
}
}
.editor__left__schema {
min-height: 120px;
flex-grow: 1;
display: flex;
flex-direction: column;
padding-bottom: 0;
padding-top: 0 !important;
position: relative;
.schema-container {
position: absolute;
left: 15px;
top: 0;
right: 15px;
bottom: 0;
}
}
}
.content {
background: #fff;
flex-grow: 1;
display: flex;
flex-flow: column nowrap;
justify-content: space-around;
align-content: space-around;
padding: 0;
overflow-x: hidden;
}
.row {
background: #fff;
min-height: 50px;
&.resizable {
flex: 0 0 300px;
}
&.editor {
display: flex;
flex-flow: row nowrap;
justify-content: space-around;
align-content: space-around;
overflow: hidden;
min-height: 10px;
max-height: 70vh;
flex: 0 0 300px;
}
.row {
display: block;
min-height: 0;
}
}
section {
box-sizing: border-box;
flex: 1;
min-width: 30px;
&.resizable {
flex: 0 0 300px;
}
}
// **********************************************************************
// directive styles
// **********************************************************************
.resizable {
position: relative;
&.no-transition {
transition: none !important;
}
}
.rg-right,
.rg-left,
.rg-top,
.rg-bottom {
display: block;
width: 10px;
height: 10px;
line-height: @spacing;
position: absolute;
z-index: 99;
span {
position: absolute;
box-sizing: border-box;
display: block;
border: 1px solid #ccc;
}
}
.rg-right,
.rg-left {
span {
border-width: 0 1px;
top: 50%;
margin: -10px 0 0 @spacing / 4;
height: 20px;
width: 3px;
}
}
.rg-top,
.rg-bottom {
span {
border-width: 1px 0;
left: 50%;
margin: @spacing / 4 0 0 -10px;
width: 20px;
height: 3px;
}
}
.rg-top {
cursor: row-resize;
width: 100%;
top: 0;
left: 0;
margin-top: -@spacing / 2;
}
.rg-right {
cursor: col-resize;
border-right: 1px solid #efefef;
height: 100%;
right: 0;
top: 0;
margin-right: 0px;
&:hover {
background: fade(@redash-gray, 6%);
}
}
.rg-bottom {
cursor: row-resize;
background: #fff;
width: 100%;
bottom: 0;
left: 0;
margin-bottom: 0;
&:hover {
background: fade(@redash-gray, 6%);
}
}
.rg-left {
cursor: col-resize;
height: 100%;
left: 0;
top: 0;
margin-left: -@spacing;
}
}
.datasource-small {
visibility: hidden;
}
// Visualization editor
.modal-xl .modal-content {
border: none;
}
.visualization-editor {
.modal-title {
font-weight: 600;
font-size: 20px;
}
.modal-body {
bottom: 50px;
}
.modal-footer {
height: auto;
}
.visualization-editor__right {
margin-top: 23px;
border: 1px solid #eee;
border-radius: 3px;
.parameter-container {
padding-left: 25px;
margin-top: 10px;
}
}
}
// Left nav fixes for filling all the space
nav .rg-bottom {
visibility: hidden;
}
.query-tags {
display: inline-block;
vertical-align: middle;
}
.query-tags__mobile {
display: none;
}
.table--permission {
.profile__image {
margin-right: 0;
}
}
.mp__permission-type {
text-transform: capitalize;
}
.edit-visualization {
margin-right: 5px;
}
@media (min-width: 880px) {
.query-fullscreen {
.query-metadata.query-metadata-horizontal {
display: none;
}
}
}
// Smaller screens
@media (max-width: 880px) {
.btn--showhide,
.query-actions-menu .dropdown-toggle {
margin-bottom: 5px;
}
.btn-publish {
display: none;
}
.query-fullscreen {
flex-direction: column;
overflow: hidden;
nav {
display: none;
}
.schema-container {
display: none;
}
main {
flex-direction: column-reverse;
nav {
width: 100%;
max-width: 100%;
border-right: none;
.editor__left__schema {
height: 300px !important;
}
.rg-right {
display: none;
}
}
}
.content {
width: 100%;
height: 100%;
.static-position__mobile {
position: static !important;
}
}
.bottom-controller-container {
z-index: 9;
}
}
.datasource-small {
visibility: visible;
}
}
@media (max-width: 768px) {
.editor__left__schema,
.editor__left__data-source {
display: none;
}
.filter-container {
padding-right: 0;
}
}
================================================
FILE: client/app/assets/less/redash/redash-table.less
================================================
.table {
margin-bottom: 0;
[class*="bg-"] {
& > tr > th {
color: #fff;
border-bottom: 0;
background: transparent !important;
}
& + tbody > tr:first-child > td {
border-top: 0;
}
}
& > thead > tr > th {
vertical-align: middle;
font-weight: 500;
color: #333;
border-width: 1px;
text-transform: none;
padding: 15px 10px;
}
& > thead > tr,
& > tbody > tr,
& > tfoot > tr {
& > th, & > td {
&:first-child {
padding-left: 15px;
}
&:last-child {
padding-right: 15px;
}
}
}
tbody > tr:last-child > td {
padding-bottom: 10px;
}
}
.table.table-condensed {
tbody > tr:last-child > td {
padding-bottom: 7px;
}
}
.table-bordered {
border: 0;
& > tbody > tr {
& > td, & > th {
border-bottom: 0;
border-left: 0;
&:last-child {
border-right: 0;
}
}
}
& > thead > tr > th {
border-left: 0;
&:last-child {
border-right: 0;
}
}
}
.table-vmiddle {
td {
vertical-align: middle !important;
}
}
.table-responsive {
border: 0;
}
.tile .table {
& > thead:not([class*="bg-"]) > tr > th {
border-top: 1px solid @table-border-color;
}
}
.table-hover > tbody > tr:hover {
background-color: #fff !important;
background-color: fade(@redash-gray, 5%) !important;
}
.table:not(.table-striped) > thead > tr > th {
background-color: #fff !important;
background-color: fade(@redash-gray, 3%) !important;
}
.table > thead > tr > th,
.table > tbody > tr > th,
.table > tfoot > tr > th,
.table > thead > tr > td,
.table > tbody > tr > td,
.table > tfoot > tr > td {
vertical-align: middle;
}
.table-condensed > tbody > tr > td {
padding: 7px 10px;
}
.table-border {
border: 1px solid rgb(240, 240, 240);
}
================================================
FILE: client/app/assets/less/redash/tags-control.less
================================================
.tags-control {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: stretch;
justify-content: flex-start;
line-height: 1em;
&.inline-tags-control {
display: inline-block;
}
.tag-separator {
margin: 4px 3px 0 0;
}
&.disabled {
opacity: 0.4;
}
}
================================================
FILE: client/app/assets/less/server.less
================================================
/** LESS Plugins **/
@import 'inc/less-plugins/for';
/** Load Main Bootstrap LESS files **/
@import '~bootstrap/less/bootstrap';
@import '~material-design-iconic-font/dist/css/material-design-iconic-font.css';
@import 'inc/variables';
@import 'inc/mixins';
@import 'inc/font';
@import 'inc/print';
@import 'inc/bootstrap-overrides';
@import 'inc/base';
@import 'inc/generics';
@import 'inc/form';
@import 'inc/button';
@import 'inc/404';
@import 'inc/ie-warning';
@import 'inc/flex';
html, body {
height: 100%;
margin: 0;
padding: 0;
background: #F6F8F9;
}
.signed-out {
}
hr {
border-top-width: 2px;
margin: 25px 0;
}
.tiled {
padding: 25px;
}
.header {
margin-top: 25px;
img {
height: 40px;
}
}
.fixed-width-page {
width: 500px;
}
@media (max-width: 767px) {
.fixed-width-page {
width: 80vw;
}
}
.login-button {
display: flex;
align-items: center;
justify-content: center;
margin: 20px 0;
&:first-of-type {
margin-top: 0;
}
&:last-of-type {
margin-bottom: 0;
}
img {
height: 25px;
margin-right: 5px;
}
&:before {
content: "";
display: inline-block;
height: 25px;
}
}
================================================
FILE: client/app/assets/robots.txt
================================================
# robotstxt.org
User-agent: *
================================================
FILE: client/app/components/AceEditorInput.jsx
================================================
import React, { forwardRef } from "react";
import AceEditor from "react-ace";
import "./AceEditorInput.less";
function AceEditorInput(props, ref) {
return (
);
}
export default forwardRef(AceEditorInput);
================================================
FILE: client/app/components/AceEditorInput.less
================================================
.ace-editor-input {
// hide ghost cursor when not focused
.ace_hidden-cursors {
opacity: 0;
}
// allow Ant Form feedback icon to hover scrollbar
.ace_scrollbar {
z-index: auto;
}
}
================================================
FILE: client/app/components/ApplicationArea/ApplicationLayout/DesktopNavbar.jsx
================================================
import React, { useMemo } from "react";
import { first, includes } from "lodash";
import Menu from "antd/lib/menu";
import Link from "@/components/Link";
import PlainButton from "@/components/PlainButton";
import HelpTrigger from "@/components/HelpTrigger";
import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog";
import { useCurrentRoute } from "@/components/ApplicationArea/Router";
import { Auth, currentUser } from "@/services/auth";
import settingsMenu from "@/services/settingsMenu";
import logoUrl from "@/assets/images/redash_icon_small.png";
import DesktopOutlinedIcon from "@ant-design/icons/DesktopOutlined";
import CodeOutlinedIcon from "@ant-design/icons/CodeOutlined";
import AlertOutlinedIcon from "@ant-design/icons/AlertOutlined";
import PlusOutlinedIcon from "@ant-design/icons/PlusOutlined";
import QuestionCircleOutlinedIcon from "@ant-design/icons/QuestionCircleOutlined";
import SettingOutlinedIcon from "@ant-design/icons/SettingOutlined";
import VersionInfo from "./VersionInfo";
import "./DesktopNavbar.less";
function NavbarSection({ children, ...props }) {
return (
{children}
);
}
function useNavbarActiveState() {
const currentRoute = useCurrentRoute();
return useMemo(
() => ({
dashboards: includes(
[
"Dashboards.List",
"Dashboards.Favorites",
"Dashboards.My",
"Dashboards.ViewOrEdit",
"Dashboards.LegacyViewOrEdit",
],
currentRoute.id
),
queries: includes(
[
"Queries.List",
"Queries.Favorites",
"Queries.Archived",
"Queries.My",
"Queries.View",
"Queries.New",
"Queries.Edit",
],
currentRoute.id
),
dataSources: includes(["DataSources.List"], currentRoute.id),
alerts: includes(["Alerts.List", "Alerts.New", "Alerts.View", "Alerts.Edit"], currentRoute.id),
}),
[currentRoute.id]
);
}
export default function DesktopNavbar() {
const firstSettingsTab = first(settingsMenu.getAvailableItems());
const activeState = useNavbarActiveState();
const canCreateQuery = currentUser.hasPermission("create_query");
const canCreateDashboard = currentUser.hasPermission("create_dashboard");
const canCreateAlert = currentUser.hasPermission("list_alerts");
return (
{currentUser.hasPermission("list_dashboards") && (
Dashboards
)}
{currentUser.hasPermission("view_query") && (
Queries
)}
{currentUser.hasPermission("list_alerts") && (
Alerts
)}
{(canCreateQuery || canCreateDashboard || canCreateAlert) && (
Create
}>
{canCreateQuery && (
New Query
)}
{canCreateDashboard && (
CreateDashboardDialog.showModal()}>
New Dashboard
)}
{canCreateAlert && (
New Alert
)}
)}
Help
{firstSettingsTab && (
Settings
)}
}>
Profile
{currentUser.hasPermission("super_admin") && (
System Status
)}
Auth.logout()}>
Log out
);
}
================================================
FILE: client/app/components/ApplicationArea/ApplicationLayout/DesktopNavbar.less
================================================
@backgroundColor: #001529;
@dividerColor: rgba(255, 255, 255, 0.5);
@textColor: rgba(255, 255, 255, 0.75);
@brandColor: #ff7964; // Redash logo color
@activeItemColor: @brandColor;
@iconSize: 26px;
.desktop-navbar {
background: @backgroundColor;
display: flex;
flex-direction: column;
height: 100%;
width: 80px;
overflow: hidden;
&-spacer {
flex: 1 1 auto;
}
&-logo.ant-menu {
padding-top: 20px;
padding-bottom: 20px;
text-align: center;
img {
height: 40px;
transition: all 270ms;
}
}
.help-trigger {
font: inherit;
}
.ant-menu {
.ant-menu-item,
.ant-menu-submenu {
font-weight: 500;
color: @textColor;
&.navbar-active-item {
box-shadow: inset 3px 0 0 @activeItemColor;
.anticon {
color: @activeItemColor;
}
}
&.ant-menu-submenu-open,
&.ant-menu-submenu-active,
&:hover,
&:active,
&:focus,
&:focus-within {
color: #fff;
}
.anticon {
font-size: @iconSize;
margin: 0;
}
.desktop-navbar-label {
margin-top: 4px;
font-size: 11px;
}
a,
span,
.anticon {
color: inherit;
}
}
.ant-menu-submenu-arrow {
display: none;
}
.ant-menu-item,
.ant-menu-submenu {
padding: 0;
height: 60px;
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
}
.ant-menu-submenu-title {
width: 100%;
padding: 0;
}
a,
&.ant-menu-vertical > .ant-menu-submenu > .ant-menu-submenu-title,
.ant-menu-submenu-title {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
line-height: normal;
height: auto;
background: none;
color: inherit;
}
}
.desktop-navbar-profile-menu {
.desktop-navbar-profile-menu-title {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
.profile__image_thumb {
margin: 0;
vertical-align: middle;
width: @iconSize;
height: @iconSize;
}
}
}
}
.desktop-navbar-submenu {
.ant-menu {
.ant-menu-item-divider {
background: @dividerColor;
}
.ant-menu-item {
font-weight: 500;
color: @textColor;
&:hover,
&:active,
&:focus,
&:focus-within {
color: #fff;
}
a,
span,
.anticon {
color: inherit;
}
.zmdi,
.fa {
margin-right: 5px;
}
&.version-info {
height: auto;
line-height: normal;
padding-top: 12px;
padding-bottom: 12px;
a {
color: rgba(255, 255, 255, 0.8);
&:hover,
&:active,
&:focus,
&:focus-within {
color: rgba(255, 255, 255, 1);
}
}
}
}
}
}
================================================
FILE: client/app/components/ApplicationArea/ApplicationLayout/MobileNavbar.jsx
================================================
import { first } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import Button from "antd/lib/button";
import MenuOutlinedIcon from "@ant-design/icons/MenuOutlined";
import Dropdown from "antd/lib/dropdown";
import Menu from "antd/lib/menu";
import Link from "@/components/Link";
import { Auth, currentUser } from "@/services/auth";
import settingsMenu from "@/services/settingsMenu";
import logoUrl from "@/assets/images/redash_icon_small.png";
import "./MobileNavbar.less";
export default function MobileNavbar({ getPopupContainer }) {
const firstSettingsTab = first(settingsMenu.getAvailableItems());
return (
{currentUser.hasPermission("list_dashboards") && (
Dashboards
)}
{currentUser.hasPermission("view_query") && (
Queries
)}
{currentUser.hasPermission("list_alerts") && (
Alerts
)}
Edit Profile
{firstSettingsTab && (
Settings
)}
{currentUser.hasPermission("super_admin") && (
System Status
)}
{currentUser.hasPermission("super_admin") && }
{/* eslint-disable-next-line react/jsx-no-target-blank */}
Help
Auth.logout()}>
Log out
}>
);
}
MobileNavbar.propTypes = {
getPopupContainer: PropTypes.func,
};
MobileNavbar.defaultProps = {
getPopupContainer: null,
};
================================================
FILE: client/app/components/ApplicationArea/ApplicationLayout/MobileNavbar.less
================================================
@backgroundColor: #001529;
@dividerColor: rgba(255, 255, 255, 0.5);
@textColor: rgba(255, 255, 255, 0.75);
.mobile-navbar {
display: flex;
justify-content: space-between;
align-items: center;
background: @backgroundColor;
box-shadow: 0 4px 9px -3px rgba(102, 136, 153, 0.15);
padding: 0 15px;
height: 100%;
&-logo {
img {
height: 40px;
width: 40px;
}
}
.ant-btn.mobile-navbar-toggle-button {
padding: 0 10px;
}
}
.mobile-navbar-menu {
.ant-dropdown-menu-item {
font-weight: 500;
color: @textColor;
}
.ant-dropdown-menu-item-divider {
background: @dividerColor;
}
}
================================================
FILE: client/app/components/ApplicationArea/ApplicationLayout/VersionInfo.jsx
================================================
import React from "react";
import Link from "@/components/Link";
import { clientConfig, currentUser } from "@/services/auth";
import frontendVersion from "@/version.json";
export default function VersionInfo() {
return (
Version: {clientConfig.version}
{frontendVersion !== clientConfig.version && ` (${frontendVersion.substring(0, 8)})`}
{clientConfig.newVersionAvailable && currentUser.hasPermission("super_admin") && (
{/* eslint-disable react/jsx-no-target-blank */}
Update Available
(opens in a new tab)
)}
);
}
================================================
FILE: client/app/components/ApplicationArea/ApplicationLayout/index.jsx
================================================
import React, { useRef, useCallback } from "react";
import PropTypes from "prop-types";
import DynamicComponent from "@/components/DynamicComponent";
import DesktopNavbar from "./DesktopNavbar";
import MobileNavbar from "./MobileNavbar";
import "./index.less";
export default function ApplicationLayout({ children }) {
const mobileNavbarContainerRef = useRef();
const getMobileNavbarPopupContainer = useCallback(() => mobileNavbarContainerRef.current, []);
return (
{children}
);
}
ApplicationLayout.propTypes = {
children: PropTypes.node,
};
ApplicationLayout.defaultProps = {
children: null,
};
================================================
FILE: client/app/components/ApplicationArea/ApplicationLayout/index.less
================================================
@mobileBreakpoint: ~"(max-width: 767px)";
body #application-root {
@topMenuHeight: 49px;
display: flex;
flex-direction: row;
justify-content: stretch;
padding-bottom: 0 !important;
height: 100%;
.application-layout-side-menu {
height: 100%;
position: relative;
@media @mobileBreakpoint {
display: none;
}
}
.application-layout-top-menu {
height: @topMenuHeight;
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
box-sizing: border-box;
z-index: 1000;
@media @mobileBreakpoint {
display: block;
}
}
.application-layout-content {
display: flex;
flex-direction: column;
overflow-y: auto;
flex: 1 1 auto;
padding-bottom: 15px;
@media @mobileBreakpoint {
margin-top: @topMenuHeight; // compensate for app header fixed position
}
}
}
body > section {
height: 100%;
}
body.fixed-layout #application-root {
.application-layout-content {
padding-bottom: 0;
}
}
body.headless #application-root {
.application-layout-side-menu,
.application-layout-top-menu {
display: none !important;
}
.application-layout-content {
margin-top: 0;
}
}
// Fixes for proper snapshots in Percy (move vertical scroll to body level
// to capture entire page, otherwise it wll be cut by viewport)
@media only percy {
body #application-root {
height: auto;
.application-layout-side-menu {
height: auto;
}
.application-layout-content {
overflow: visible;
}
}
}
================================================
FILE: client/app/components/ApplicationArea/ErrorMessage.jsx
================================================
import { get, isObject } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import "./ErrorMessage.less";
import DynamicComponent from "@/components/DynamicComponent";
import { ErrorMessageDetails } from "@/components/ApplicationArea/ErrorMessageDetails";
function getErrorMessageByStatus(status, defaultMessage) {
switch (status) {
case 404:
return "It seems like the page you're looking for cannot be found.";
case 401:
case 403:
return "It seems like you don’t have permission to see this page.";
default:
return defaultMessage;
}
}
function getErrorMessage(error) {
const message = "It seems like we encountered an error. Try refreshing this page or contact your administrator.";
if (isObject(error)) {
// HTTP errors
if (error.isAxiosError && isObject(error.response)) {
return getErrorMessageByStatus(error.response.status, get(error, "response.data.message", message));
}
// Router errors
if (error.status) {
return getErrorMessageByStatus(error.status, message);
}
}
return message;
}
export default function ErrorMessage({ error, message }) {
if (!error) {
return null;
}
console.error(error);
const errorDetailsProps = {
error,
message: message || getErrorMessage(error),
};
return (
}
{...errorDetailsProps}
/>
);
}
ErrorMessage.propTypes = {
error: PropTypes.object.isRequired,
message: PropTypes.string,
};
================================================
FILE: client/app/components/ApplicationArea/ErrorMessage.less
================================================
.error-message-container {
width: 100%;
padding: 0 15px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
.error-state {
max-width: 1200px;
width: 100%;
@media (min-width: 768px) {
width: 65%;
}
}
}
================================================
FILE: client/app/components/ApplicationArea/ErrorMessage.test.js
================================================
import React from "react";
import { mount } from "enzyme";
import ErrorMessage from "./ErrorMessage";
const ErrorMessages = {
UNAUTHORIZED: "It seems like you don’t have permission to see this page.",
NOT_FOUND: "It seems like the page you're looking for cannot be found.",
GENERIC: "It seems like we encountered an error. Try refreshing this page or contact your administrator.",
};
function mockAxiosError(status = 500, response = {}) {
const error = new Error(`Failed with code ${status}.`);
error.isAxiosError = true;
error.response = { status, ...response };
return error;
}
describe("Error Message", () => {
const spyError = jest.spyOn(console, "error");
beforeEach(() => {
spyError.mockReset();
});
function expectErrorMessageToBe(error, errorMessage) {
const component = mount( );
expect(component.find(".error-state__details h4").text()).toBe(errorMessage);
expect(spyError).toHaveBeenCalledWith(error);
}
test("displays a generic message on adhoc errors", () => {
expectErrorMessageToBe(new Error("technical information"), ErrorMessages.GENERIC);
});
test("displays a not found message on axios errors with 404 code", () => {
expectErrorMessageToBe(mockAxiosError(404), ErrorMessages.NOT_FOUND);
});
test("displays a unauthorized message on axios errors with 401 code", () => {
expectErrorMessageToBe(mockAxiosError(401), ErrorMessages.UNAUTHORIZED);
});
test("displays a unauthorized message on axios errors with 403 code", () => {
expectErrorMessageToBe(mockAxiosError(403), ErrorMessages.UNAUTHORIZED);
});
test("displays a generic message on axios errors with 500 code", () => {
expectErrorMessageToBe(mockAxiosError(500), ErrorMessages.GENERIC);
});
});
================================================
FILE: client/app/components/ApplicationArea/ErrorMessageDetails.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
export function ErrorMessageDetails(props) {
return {props.message} ;
}
ErrorMessageDetails.propTypes = {
error: PropTypes.instanceOf(Error).isRequired,
message: PropTypes.string.isRequired,
};
================================================
FILE: client/app/components/ApplicationArea/Router.jsx
================================================
import { isFunction, startsWith, trimStart, trimEnd } from "lodash";
import React, { useState, useEffect, useRef, useContext } from "react";
import PropTypes from "prop-types";
import UniversalRouter from "universal-router";
import ErrorBoundary from "@redash/viz/lib/components/ErrorBoundary";
import location from "@/services/location";
import url from "@/services/url";
import ErrorMessage from "./ErrorMessage";
function generateRouteKey() {
return Math.random()
.toString(32)
.substr(2);
}
export const CurrentRouteContext = React.createContext(null);
export function useCurrentRoute() {
return useContext(CurrentRouteContext);
}
export function stripBase(href) {
// Resolve provided link and '' (root) relative to document's base.
// If provided href is not related to current document (does not
// start with resolved root) - return false. Otherwise
// strip root and return relative url.
const baseHref = trimEnd(url.normalize(""), "/") + "/";
href = url.normalize(href);
if (startsWith(href, baseHref)) {
return "/" + trimStart(href.substr(baseHref.length), "/");
}
return false;
}
export default function Router({ routes, onRouteChange }) {
const [currentRoute, setCurrentRoute] = useState(null);
const currentPathRef = useRef(null);
const errorHandlerRef = useRef();
useEffect(() => {
let isAbandoned = false;
const router = new UniversalRouter(routes, {
resolveRoute({ route }, routeParams) {
if (isFunction(route.render)) {
return { ...route, routeParams };
}
},
});
function resolve(action) {
if (!isAbandoned) {
if (errorHandlerRef.current) {
errorHandlerRef.current.reset();
}
const pathname = stripBase(location.path) || "/";
// This is a optimization for route resolver: if current route was already resolved
// from this path - do nothing. It also prevents router from using outdated route in a case
// when user navigated to another path while current one was still resolving.
// Note: this lock uses only `path` fragment of URL to distinguish routes because currently
// all pages depend only on this fragment and handle search/hash on their own. If router
// should reload page on search/hash change - this fragment (and few checks below) should be updated
if (pathname === currentPathRef.current) {
return;
}
currentPathRef.current = pathname;
// Don't reload controller if URL was replaced
if (action === "REPLACE") {
return;
}
router
.resolve({ pathname })
.then(route => {
if (!isAbandoned && currentPathRef.current === pathname) {
setCurrentRoute({ ...route, key: generateRouteKey() });
}
})
.catch(error => {
if (!isAbandoned && currentPathRef.current === pathname) {
setCurrentRoute({
render: currentRoute => ,
routeParams: { error },
});
}
});
}
}
resolve("PUSH");
const unlisten = location.listen((unused, action) => resolve(action));
return () => {
isAbandoned = true;
currentPathRef.current = null;
unlisten();
};
}, [routes]);
useEffect(() => {
onRouteChange(currentRoute);
}, [currentRoute, onRouteChange]);
if (!currentRoute) {
return null;
}
return (
}>
{currentRoute.render(currentRoute)}
);
}
Router.propTypes = {
routes: PropTypes.arrayOf(
PropTypes.shape({
path: PropTypes.string.isRequired,
render: PropTypes.func, // (routeParams: PropTypes.object; currentRoute; location) => PropTypes.node
// Additional props to be injected into route component.
// Object keys are props names. Object values will become prop values:
// - if value is a function - it will be called without arguments, and result will be used; otherwise value will be used;
// - after previous step, if value is a promise - router will wait for it to resolve; resolved value then will be used;
// otherwise value will be used directly.
resolve: PropTypes.objectOf(PropTypes.any),
})
),
onRouteChange: PropTypes.func,
};
Router.defaultProps = {
routes: [],
onRouteChange: () => {},
};
================================================
FILE: client/app/components/ApplicationArea/handleNavigationIntent.js
================================================
import { isString } from "lodash";
import navigateTo from "./navigateTo";
export default function handleNavigationIntent(event) {
let element = event.target;
while (element) {
if (element.tagName === "A") {
break;
}
element = element.parentNode;
}
if (!element || !element.hasAttribute("href") || element.hasAttribute("download") || element.dataset.skipRouter) {
return;
}
// Keep some default behaviour
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
return;
}
const target = element.getAttribute("target");
if (isString(target) && target.toLowerCase() === "_blank") {
return;
}
event.preventDefault();
navigateTo(element.href);
}
================================================
FILE: client/app/components/ApplicationArea/index.jsx
================================================
import React, { useState, useEffect } from "react";
import routes from "@/services/routes";
import Router from "./Router";
import handleNavigationIntent from "./handleNavigationIntent";
import ErrorMessage from "./ErrorMessage";
export default function ApplicationArea() {
const [currentRoute, setCurrentRoute] = useState(null);
const [unhandledError, setUnhandledError] = useState(null);
useEffect(() => {
if (currentRoute && currentRoute.title) {
document.title = currentRoute.title;
}
}, [currentRoute]);
useEffect(() => {
function globalErrorHandler(event) {
event.preventDefault();
if (event.message === "Uncaught SyntaxError: Unexpected token '<'") {
// if we see a javascript error on unexpected token where the unexpected token is '<', this usually means that a fallback html file (like index.html)
// was served as content of script rather than the expected script, give a friendlier message in the console on what could be going on
console.error(
`[Uncaught SyntaxError: Unexpected token '<'] usually means that a fallback html file was returned from server rather than the expected script. Check that the server is properly serving the file ${event.filename}.`
);
}
setUnhandledError(event.error);
}
document.body.addEventListener("click", handleNavigationIntent, false);
window.addEventListener("error", globalErrorHandler, false);
return () => {
document.body.removeEventListener("click", handleNavigationIntent, false);
window.removeEventListener("error", globalErrorHandler, false);
};
}, []);
if (unhandledError) {
return ;
}
return ;
}
================================================
FILE: client/app/components/ApplicationArea/navigateTo.js
================================================
import location from "@/services/location";
import url from "@/services/url";
import { stripBase } from "./Router";
// When `replace` is set to `true` - it will just replace current URL
// without reloading current page (router will skip this location change)
export default function navigateTo(href, replace = false) {
// Allow calling chain to roll up, and then navigate
setTimeout(() => {
const isExternal = stripBase(href) === false;
if (isExternal) {
window.location = href;
return;
}
href = url.parse(href);
location.update(
{
path: href.pathname,
search: href.search,
hash: href.hash,
},
replace
);
}, 10);
}
================================================
FILE: client/app/components/ApplicationArea/routeWithApiKeySession.jsx
================================================
import React, { useEffect, useState, useContext } from "react";
import PropTypes from "prop-types";
import { ErrorBoundaryContext } from "@redash/viz/lib/components/ErrorBoundary";
import { Auth, clientConfig } from "@/services/auth";
// This wrapper modifies `route.render` function and instead of passing `currentRoute` passes an object
// that contains:
// - `currentRoute.routeParams`
// - `pageTitle` field which is equal to `currentRoute.title`
// - `onError` field which is a `handleError` method of nearest error boundary
// - `apiKey` field
function ApiKeySessionWrapper({ apiKey, currentRoute, renderChildren }) {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const { handleError } = useContext(ErrorBoundaryContext);
useEffect(() => {
let isCancelled = false;
Auth.setApiKey(apiKey);
Auth.loadConfig()
.then(() => {
if (!isCancelled) {
setIsAuthenticated(true);
}
})
.catch(() => {
if (!isCancelled) {
setIsAuthenticated(false);
}
});
return () => {
isCancelled = true;
};
}, [apiKey]);
if (!isAuthenticated || clientConfig.disablePublicUrls) {
return null;
}
return (
{renderChildren({ ...currentRoute.routeParams, pageTitle: currentRoute.title, onError: handleError, apiKey })}
);
}
ApiKeySessionWrapper.propTypes = {
apiKey: PropTypes.string.isRequired,
renderChildren: PropTypes.func,
};
ApiKeySessionWrapper.defaultProps = {
renderChildren: () => null,
};
export default function routeWithApiKeySession({ render, getApiKey, ...rest }) {
return {
...rest,
render: currentRoute => (
),
};
}
================================================
FILE: client/app/components/ApplicationArea/routeWithUserSession.tsx
================================================
import React, { useEffect, useState } from "react";
import ErrorBoundary, { ErrorBoundaryContext } from "@redash/viz/lib/components/ErrorBoundary";
import { Auth } from "@/services/auth";
import { policy } from "@/services/policy";
import { CurrentRoute } from "@/services/routes";
import organizationStatus from "@/services/organizationStatus";
import DynamicComponent from "@/components/DynamicComponent";
import ApplicationLayout from "./ApplicationLayout";
import ErrorMessage from "./ErrorMessage";
export type UserSessionWrapperRenderChildrenProps = {
pageTitle?: string;
onError: (error: Error) => void;
} & P;
export interface UserSessionWrapperProps
{
render: (props: UserSessionWrapperRenderChildrenProps
) => React.ReactNode;
currentRoute: CurrentRoute
;
bodyClass?: string;
}
// This wrapper modifies `route.render` function and instead of passing `currentRoute` passes an object
// that contains:
// - `currentRoute.routeParams`
// - `pageTitle` field which is equal to `currentRoute.title`
// - `onError` field which is a `handleError` method of nearest error boundary
export function UserSessionWrapper
({ bodyClass, currentRoute, render }: UserSessionWrapperProps
) {
const [isAuthenticated, setIsAuthenticated] = useState(!!Auth.isAuthenticated());
useEffect(() => {
let isCancelled = false;
Promise.all([Auth.requireSession(), organizationStatus.refresh(), policy.refresh()])
.then(() => {
if (!isCancelled) {
setIsAuthenticated(!!Auth.isAuthenticated());
}
})
.catch(() => {
if (!isCancelled) {
setIsAuthenticated(false);
}
});
return () => {
isCancelled = true;
};
}, []);
useEffect(() => {
if (bodyClass) {
document.body.classList.toggle(bodyClass, true);
return () => {
document.body.classList.toggle(bodyClass, false);
};
}
}, [bodyClass]);
if (!isAuthenticated) {
return null;
}
return (
{/* @ts-expect-error FIXME */}
}>
{(
{
handleError,
} /* : { handleError: UserSessionWrapperRenderChildrenProps["onError"] } FIXME bring back type */
) => render({ ...currentRoute.routeParams, pageTitle: currentRoute.title, onError: handleError })}
);
}
export type RouteWithUserSessionOptions
= {
render: (props: UserSessionWrapperRenderChildrenProps
) => React.ReactNode;
bodyClass?: string;
title: string;
path: string;
};
export const UserSessionWrapperDynamicComponentName = "UserSessionWrapper";
export default function routeWithUserSession
({
render: originalRender,
bodyClass,
...rest
}: RouteWithUserSessionOptions
) {
return {
...rest,
render: (currentRoute: CurrentRoute
) => {
const props = {
render: originalRender,
bodyClass,
currentRoute,
};
return (
}
/>
);
},
};
}
================================================
FILE: client/app/components/BeaconConsent.jsx
================================================
import React, { useState } from "react";
import Card from "antd/lib/card";
import Button from "antd/lib/button";
import Typography from "antd/lib/typography";
import { clientConfig } from "@/services/auth";
import Link from "@/components/Link";
import HelpTrigger from "@/components/HelpTrigger";
import DynamicComponent from "@/components/DynamicComponent";
import OrgSettings from "@/services/organizationSettings";
const Text = Typography.Text;
function BeaconConsent() {
const [hide, setHide] = useState(false);
if (!clientConfig.showBeaconConsentMessage || hide) {
return null;
}
const hideConsentCard = () => {
clientConfig.showBeaconConsentMessage = false;
setHide(true);
};
const confirmConsent = (confirm) => {
let message = "🙏 Thank you.";
if (!confirm) {
message = "Settings Saved.";
}
OrgSettings.save({ beacon_consent: confirm }, message)
// .then(() => {
// // const settings = get(response, 'settings');
// // this.setState({ settings, formValues: { ...settings } });
// })
.finally(hideConsentCard);
};
return (
Would you be ok with sharing anonymous usage data with the Redash team?{" "}
>
}
bordered={false}
>
Help Redash improve by automatically sending anonymous usage data:
Number of users, queries, dashboards, alerts, widgets and visualizations.
Types of data sources, alert destinations and visualizations.
All data is aggregated and will never include any sensitive or private data.
confirmConsent(true)}>
Yes
confirmConsent(false)}>
No
You can change this setting anytime from the Settings page.
);
}
export default BeaconConsent;
================================================
FILE: client/app/components/BigMessage.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
import { useUniqueId } from "@/lib/hooks/useUniqueId";
import cx from "classnames";
function BigMessage({ message, icon, children, className }) {
const messageId = useUniqueId("bm-message");
return (
{message}
{children}
);
}
BigMessage.propTypes = {
message: PropTypes.string,
icon: PropTypes.string.isRequired,
children: PropTypes.node,
className: PropTypes.string,
};
BigMessage.defaultProps = {
message: "",
children: null,
className: "tiled bg-white",
};
export default BigMessage;
================================================
FILE: client/app/components/CodeBlock.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
import Button from "antd/lib/button";
import Tooltip from "@/components/Tooltip";
import CopyOutlinedIcon from "@ant-design/icons/CopyOutlined";
import "./CodeBlock.less";
export default class CodeBlock extends React.Component {
static propTypes = {
copyable: PropTypes.bool,
children: PropTypes.node,
};
static defaultProps = {
copyable: false,
children: null,
};
state = { copied: null };
constructor(props) {
super(props);
this.ref = React.createRef();
this.copyFeatureEnabled = props.copyable && document.queryCommandSupported("copy");
this.resetCopyState = null;
}
componentWillUnmount() {
if (this.resetCopyState) {
clearTimeout(this.resetCopyState);
}
}
copy = () => {
// select text
window.getSelection().selectAllChildren(this.ref.current);
// copy
try {
const success = document.execCommand("copy");
if (!success) {
throw new Error();
}
this.setState({ copied: "Copied!" });
} catch (err) {
this.setState({
copied: "Copy failed",
});
}
// reset selection
window.getSelection().removeAllRanges();
// reset tooltip
this.resetCopyState = setTimeout(() => this.setState({ copied: null }), 2000);
};
render() {
const { copyable, children, ...props } = this.props;
const copyButton = (
} type="dashed" size="small" onClick={this.copy} />
);
return (
{children}
{this.copyFeatureEnabled && copyButton}
);
}
}
================================================
FILE: client/app/components/CodeBlock.less
================================================
@import (reference, less) "~@/assets/less/ant";
.code-block {
background: rgba(0, 0, 0, 0.06);
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 2px;
padding: 3px 27px 3px 3px;
position: relative;
min-height: 32px;
code {
padding: 0;
font-size: 85%;
}
.@{btn-prefix-cls} {
position: absolute;
right: 3px;
bottom: 3px;
padding-left: 3px !important;
padding-right: 3px !important;
}
}
================================================
FILE: client/app/components/Collapse.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
import cx from "classnames";
import AntCollapse from "antd/lib/collapse";
export default function Collapse({ collapsed, children, className, ...props }) {
return (
{children}
);
}
Collapse.propTypes = {
collapsed: PropTypes.bool,
children: PropTypes.node,
className: PropTypes.string,
};
Collapse.defaultProps = {
collapsed: true,
children: null,
className: "",
};
================================================
FILE: client/app/components/CreateSourceDialog.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
import { isEmpty, toUpper, includes, get, uniqueId } from "lodash";
import Button from "antd/lib/button";
import List from "antd/lib/list";
import Modal from "antd/lib/modal";
import Input from "antd/lib/input";
import Steps from "antd/lib/steps";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import Link from "@/components/Link";
import { PreviewCard } from "@/components/PreviewCard";
import EmptyState from "@/components/items-list/components/EmptyState";
import DynamicForm from "@/components/dynamic-form/DynamicForm";
import helper from "@/components/dynamic-form/dynamicFormHelper";
import HelpTrigger, { TYPES as HELP_TRIGGER_TYPES } from "@/components/HelpTrigger";
const { Step } = Steps;
const { Search } = Input;
const StepEnum = {
SELECT_TYPE: 0,
CONFIGURE_IT: 1,
DONE: 2,
};
class CreateSourceDialog extends React.Component {
static propTypes = {
dialog: DialogPropType.isRequired,
types: PropTypes.arrayOf(PropTypes.object),
sourceType: PropTypes.string.isRequired,
imageFolder: PropTypes.string.isRequired,
helpTriggerPrefix: PropTypes.string,
onCreate: PropTypes.func.isRequired,
};
static defaultProps = {
types: [],
helpTriggerPrefix: null,
};
state = {
searchText: "",
selectedType: null,
savingSource: false,
currentStep: StepEnum.SELECT_TYPE,
};
formId = uniqueId("sourceForm");
selectType = selectedType => {
this.setState({ selectedType, currentStep: StepEnum.CONFIGURE_IT });
};
resetType = () => {
if (this.state.currentStep === StepEnum.CONFIGURE_IT) {
this.setState({ searchText: "", selectedType: null, currentStep: StepEnum.SELECT_TYPE });
}
};
createSource = (values, successCallback, errorCallback) => {
const { selectedType, savingSource } = this.state;
if (!savingSource) {
this.setState({ savingSource: true, currentStep: StepEnum.DONE });
this.props
.onCreate(selectedType, values)
.then(data => {
successCallback("Saved.");
this.props.dialog.close({ success: true, data });
})
.catch(error => {
this.setState({ savingSource: false, currentStep: StepEnum.CONFIGURE_IT });
errorCallback(get(error, "response.data.message", "Failed saving."));
});
}
};
renderTypeSelector() {
const { types } = this.props;
const { searchText } = this.state;
const filteredTypes = types.filter(
type => isEmpty(searchText) || includes(type.name.toLowerCase(), searchText.toLowerCase())
);
return (
this.setState({ searchText: e.target.value })}
autoFocus
data-test="SearchSource"
/>
{isEmpty(filteredTypes) ? (
) : (
this.renderItem(item)} />
)}
);
}
renderForm() {
const { imageFolder, helpTriggerPrefix } = this.props;
const { selectedType } = this.state;
const fields = helper.getFields(selectedType);
const helpTriggerType = `${helpTriggerPrefix}${toUpper(selectedType.type)}`;
return (
{selectedType.name}
{HELP_TRIGGER_TYPES[helpTriggerType] && (
Setup Instructions
(help)
)}
{selectedType.type === "databricks" && (
By using the Databricks Data Source you agree to the Databricks JDBC/ODBC{" "}
Driver Download Terms and Conditions
.
)}
);
}
renderItem(item) {
const { imageFolder } = this.props;
return (
this.selectType(item)}>
);
}
render() {
const { currentStep, savingSource } = this.state;
const { dialog, sourceType } = this.props;
return (
dialog.dismiss()} data-test="CreateSourceCancelButton">
Cancel
,
Create
,
]
: [
Previous
,
Create
,
]
}>
{currentStep === StepEnum.CONFIGURE_IT ? (
Type Selection} className="clickable" onClick={this.resetType} />
) : (
)}
{currentStep === StepEnum.SELECT_TYPE && this.renderTypeSelector()}
{currentStep !== StepEnum.SELECT_TYPE && this.renderForm()}
);
}
}
export default wrapDialog(CreateSourceDialog);
================================================
FILE: client/app/components/DateInput.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
import DatePicker from "antd/lib/date-picker";
import { clientConfig } from "@/services/auth";
import { Moment } from "@/components/proptypes";
const DateInput = React.forwardRef(({ defaultValue, value, onSelect, className, ...props }, ref) => {
const format = clientConfig.dateFormat || "YYYY-MM-DD";
const additionalAttributes = {};
if (defaultValue && defaultValue.isValid()) {
additionalAttributes.defaultValue = defaultValue;
}
if (value === null || (value && value.isValid())) {
additionalAttributes.value = value;
}
return (
);
});
DateInput.propTypes = {
defaultValue: Moment,
value: Moment,
onSelect: PropTypes.func,
className: PropTypes.string,
};
DateInput.defaultProps = {
defaultValue: null,
value: undefined,
onSelect: () => {},
className: "",
};
export default DateInput;
================================================
FILE: client/app/components/DateRangeInput.jsx
================================================
import { isArray } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import DatePicker from "antd/lib/date-picker";
import { clientConfig } from "@/services/auth";
import { Moment } from "@/components/proptypes";
const { RangePicker } = DatePicker;
const DateRangeInput = React.forwardRef(({ defaultValue, value, onSelect, className, ...props }, ref) => {
const format = clientConfig.dateFormat || "YYYY-MM-DD";
const additionalAttributes = {};
if (isArray(defaultValue) && defaultValue[0].isValid() && defaultValue[1].isValid()) {
additionalAttributes.defaultValue = defaultValue;
}
if (value === null || (isArray(value) && value[0].isValid() && value[1].isValid())) {
additionalAttributes.value = value;
}
return (
);
});
DateRangeInput.propTypes = {
defaultValue: PropTypes.arrayOf(Moment),
value: PropTypes.arrayOf(Moment),
onSelect: PropTypes.func,
className: PropTypes.string,
};
DateRangeInput.defaultProps = {
defaultValue: null,
value: undefined,
onSelect: () => {},
className: "",
};
export default DateRangeInput;
================================================
FILE: client/app/components/DateTimeInput.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
import DatePicker from "antd/lib/date-picker";
import { clientConfig } from "@/services/auth";
import { Moment } from "@/components/proptypes";
const DateTimeInput = React.forwardRef(({ defaultValue, value, withSeconds, onSelect, className, ...props }, ref) => {
const format = (clientConfig.dateFormat || "YYYY-MM-DD") + (withSeconds ? " HH:mm:ss" : " HH:mm");
const additionalAttributes = {};
if (defaultValue && defaultValue.isValid()) {
additionalAttributes.defaultValue = defaultValue;
}
if (value === null || (value && value.isValid())) {
additionalAttributes.value = value;
}
return (
);
});
DateTimeInput.propTypes = {
defaultValue: Moment,
value: Moment,
withSeconds: PropTypes.bool,
onSelect: PropTypes.func,
className: PropTypes.string,
};
DateTimeInput.defaultProps = {
defaultValue: null,
value: undefined,
withSeconds: false,
onSelect: () => {},
className: "",
};
export default DateTimeInput;
================================================
FILE: client/app/components/DateTimeRangeInput.jsx
================================================
import { isArray } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import DatePicker from "antd/lib/date-picker";
import { clientConfig } from "@/services/auth";
import { Moment } from "@/components/proptypes";
const { RangePicker } = DatePicker;
const DateTimeRangeInput = React.forwardRef(
({ defaultValue, value, withSeconds, onSelect, className, ...props }, ref) => {
const format = (clientConfig.dateFormat || "YYYY-MM-DD") + (withSeconds ? " HH:mm:ss" : " HH:mm");
const additionalAttributes = {};
if (isArray(defaultValue) && defaultValue[0].isValid() && defaultValue[1].isValid()) {
additionalAttributes.defaultValue = defaultValue;
}
if (value === null || (isArray(value) && value[0].isValid() && value[1].isValid())) {
additionalAttributes.value = value;
}
return (
);
}
);
DateTimeRangeInput.propTypes = {
defaultValue: PropTypes.arrayOf(Moment),
value: PropTypes.arrayOf(Moment),
withSeconds: PropTypes.bool,
onSelect: PropTypes.func,
className: PropTypes.string,
};
DateTimeRangeInput.defaultProps = {
defaultValue: null,
value: undefined,
withSeconds: false,
onSelect: () => {},
className: "",
};
export default DateTimeRangeInput;
================================================
FILE: client/app/components/DialogWrapper.d.ts
================================================
import { ModalProps } from "antd/lib/modal/Modal";
export interface DialogProps {
props: ModalProps;
close: (result: ROk) => void;
dismiss: (result: RCancel) => void;
}
export type DialogWrapperChildProps = {
dialog: DialogProps;
};
export type DialogComponentType = React.ComponentType<
DialogWrapperChildProps & P
>;
export function wrap(
DialogComponent: DialogComponentType
): {
Component: DialogComponentType;
showModal: (
props?: P
) => {
update: (props: P) => void;
onClose: (handler: (result: ROk) => Promise | void) => void;
onDismiss: (handler: (result: RCancel) => Promise | void) => void;
close: (result: ROk) => void;
dismiss: (result: RCancel) => void;
};
};
================================================
FILE: client/app/components/DialogWrapper.jsx
================================================
import { isFunction } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import ReactDOM from "react-dom";
/**
Wrapper for dialogs based on Ant's component.
Using wrapped dialogs
=====================
Wrapped component is an object with two fields:
{
showModal: (dialogProps) => object({
close: (result) => void,
dismiss: (reason) => void,
onClose: (handler) => this,
onDismiss: (handler) => this,
}),
Component: React.Component, // wrapped dialog component
}
To open dialog, use `showModal` method; optionally you can pass additional properties that
will be expanded on wrapped component:
const dialog = SomeWrappedDialog.showModal()
const dialog = SomeWrappedDialog.showModal({ greeting: 'Hello' })
To get result of modal, use `onClose`/`onDismiss` setters:
dialog
.onClose(result => { ... }) // pressed OK button or used `close` method
.onDismiss(result => { ... }) // pressed Cancel button or used `dismiss` method
If `onClose`/`onDismiss` returns a promise - dialog wrapper will stop handling further close/dismiss
requests and will show loader on a corresponding button until that promise is fulfilled (either resolved or
rejected). If that promise will be rejected - dialog close/dismiss will be abandoned. Use promise returned
from `close`/`dismiss` methods to handle errors (if needed).
Also, dialog has `close` and `dismiss` methods that allows to close dialog by caller. Passed arguments
will be passed to a corresponding handler. Both methods will return the promise returned from `onClose` and
`onDismiss` callbacks. `update` method allows to pass new properties to dialog.
Creating a dialog
================
1. Add imports:
import { wrap as wrapDialog, DialogPropType } from 'path/to/DialogWrapper';
2. define a `dialog` property on your component:
propTypes = {
dialog: DialogPropType.isRequired,
};
`dialog` property is an object:
{
props: object, // properties for component;
close: (result) => void, // method to confirm dialog; `result` will be returned to caller
dismiss: (reason) => void, // method to reject dialog; `reason` will be returned to caller
}
3. expand additional properties on component:
render() {
const { dialog } = this.props;
return (
);
}
4. wrap your component and export it:
export default wrapDialog(YourComponent).
Your component is ready to use. Wrapper will manage 's visibility and events.
If you want to override behavior of `onOk`/`onCancel` - don't forget to close dialog:
customOkHandler() {
this.saveData().then(() => {
this.props.dialog.close({ success: true }); // or dismiss();
});
}
render() {
const { dialog } = this.props;
return (
this.customOkHandler()}>
);
}
*/
export const DialogPropType = PropTypes.shape({
props: PropTypes.shape({
visible: PropTypes.bool,
onOk: PropTypes.func,
onCancel: PropTypes.func,
afterClose: PropTypes.func,
}).isRequired,
close: PropTypes.func.isRequired,
dismiss: PropTypes.func.isRequired,
});
function openDialog(DialogComponent, props) {
const dialog = {
props: {
visible: true,
okButtonProps: {},
cancelButtonProps: {},
onOk: () => {},
onCancel: () => {},
afterClose: () => {},
},
close: () => {},
dismiss: () => {},
};
let pendingCloseTask = null;
const handlers = {
onClose: () => {},
onDismiss: () => {},
};
const container = document.createElement("div");
document.body.appendChild(container);
function render() {
ReactDOM.render( , container);
}
function destroyDialog() {
// Allow calling chain to roll up, and then destroy component
setTimeout(() => {
ReactDOM.unmountComponentAtNode(container);
document.body.removeChild(container);
}, 10);
}
function processDialogClose(result, setAdditionalDialogProps) {
dialog.props.okButtonProps = { disabled: true };
dialog.props.cancelButtonProps = { disabled: true };
setAdditionalDialogProps();
render();
return Promise.resolve(result)
.then(() => {
dialog.props.visible = false;
})
.finally(() => {
dialog.props.okButtonProps = {};
dialog.props.cancelButtonProps = {};
render();
});
}
function closeDialog(result) {
if (!pendingCloseTask) {
pendingCloseTask = processDialogClose(handlers.onClose(result), () => {
dialog.props.okButtonProps.loading = true;
}).finally(() => {
pendingCloseTask = null;
});
}
return pendingCloseTask;
}
function dismissDialog(result) {
if (!pendingCloseTask) {
pendingCloseTask = processDialogClose(handlers.onDismiss(result), () => {
dialog.props.cancelButtonProps.loading = true;
}).finally(() => {
pendingCloseTask = null;
});
}
return pendingCloseTask;
}
dialog.props.onOk = closeDialog;
dialog.props.onCancel = dismissDialog;
dialog.props.afterClose = destroyDialog;
dialog.close = closeDialog;
dialog.dismiss = dismissDialog;
const result = {
close: closeDialog,
dismiss: dismissDialog,
update: newProps => {
props = { ...props, ...newProps };
render();
},
onClose: handler => {
if (isFunction(handler)) {
handlers.onClose = handler;
}
return result;
},
onDismiss: handler => {
if (isFunction(handler)) {
handlers.onDismiss = handler;
}
return result;
},
};
render(); // show it only when all structures initialized to avoid unnecessary re-rendering
return result;
}
export function wrap(DialogComponent) {
return {
Component: DialogComponent,
showModal: props => openDialog(DialogComponent, props),
};
}
export default {
DialogPropType,
wrap,
};
================================================
FILE: client/app/components/DynamicComponent.jsx
================================================
import { isFunction, isString, isUndefined } from "lodash";
import React from "react";
import PropTypes from "prop-types";
const componentsRegistry = new Map();
const activeInstances = new Set();
export function registerComponent(name, component) {
if (isString(name) && name !== "") {
componentsRegistry.set(name, isFunction(component) ? component : null);
// Refresh active DynamicComponent instances which use this component
activeInstances.forEach(dynamicComponent => {
if (dynamicComponent.props.name === name) {
dynamicComponent.forceUpdate();
}
});
}
}
export function unregisterComponent(name) {
registerComponent(name, null);
}
export default class DynamicComponent extends React.Component {
static propTypes = {
name: PropTypes.string.isRequired,
fallback: PropTypes.node,
children: PropTypes.node,
};
static defaultProps = {
children: null,
};
componentDidMount() {
activeInstances.add(this);
}
componentWillUnmount() {
activeInstances.delete(this);
}
render() {
const { name, children, fallback, ...props } = this.props;
const RealComponent = componentsRegistry.get(name);
if (!RealComponent) {
// return fallback if any, otherwise return children
return isUndefined(fallback) ? children : fallback;
}
return {children} ;
}
}
================================================
FILE: client/app/components/EditInPlace.jsx
================================================
import { trim } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import cx from "classnames";
import Input from "antd/lib/input";
export default class EditInPlace extends React.Component {
static propTypes = {
ignoreBlanks: PropTypes.bool,
isEditable: PropTypes.bool,
placeholder: PropTypes.string,
value: PropTypes.string,
onDone: PropTypes.func.isRequired,
onStopEditing: PropTypes.func,
multiline: PropTypes.bool,
editorProps: PropTypes.object,
defaultEditing: PropTypes.bool,
};
static defaultProps = {
ignoreBlanks: false,
isEditable: true,
placeholder: "",
value: "",
onStopEditing: () => {},
multiline: false,
editorProps: {},
defaultEditing: false,
};
constructor(props) {
super(props);
this.state = {
editing: props.defaultEditing,
};
}
componentDidUpdate(_, prevState) {
if (!this.state.editing && prevState.editing) {
this.props.onStopEditing();
}
}
startEditing = () => {
if (this.props.isEditable) {
this.setState({ editing: true });
}
};
stopEditing = currentValue => {
const newValue = trim(currentValue);
const ignorableBlank = this.props.ignoreBlanks && newValue === "";
if (!ignorableBlank && newValue !== this.props.value) {
this.props.onDone(newValue);
}
this.setState({ editing: false });
};
handleKeyDown = event => {
if (event.keyCode === 13 && !event.shiftKey) {
event.preventDefault();
this.stopEditing(event.target.value);
} else if (event.keyCode === 27) {
this.setState({ editing: false });
}
};
renderNormal = () =>
this.props.value ? (
{this.props.value}
) : (
{this.props.placeholder}
);
renderEdit = () => {
const { multiline, value, editorProps } = this.props;
const InputComponent = multiline ? Input.TextArea : Input;
return (
this.stopEditing(e.target.value)}
onKeyDown={this.handleKeyDown}
autoFocus
{...editorProps}
/>
);
};
render() {
return (
{this.state.editing ? this.renderEdit() : this.renderNormal()}
);
}
}
================================================
FILE: client/app/components/EditParameterSettingsDialog.jsx
================================================
import { includes, words, capitalize, clone, isNull } from "lodash";
import React, { useState, useEffect } from "react";
import PropTypes from "prop-types";
import Checkbox from "antd/lib/checkbox";
import Modal from "antd/lib/modal";
import Form from "antd/lib/form";
import Button from "antd/lib/button";
import Select from "antd/lib/select";
import Input from "antd/lib/input";
import Divider from "antd/lib/divider";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import QuerySelector from "@/components/QuerySelector";
import { Query } from "@/services/query";
import { useUniqueId } from "@/lib/hooks/useUniqueId";
import "./EditParameterSettingsDialog.less";
const { Option } = Select;
const formItemProps = { labelCol: { span: 6 }, wrapperCol: { span: 16 } };
function getDefaultTitle(text) {
return capitalize(words(text).join(" ")); // humanize
}
function isTypeDateRange(type) {
return /-range/.test(type);
}
function joinExampleList(multiValuesOptions) {
const { prefix, suffix } = multiValuesOptions;
return ["value1", "value2", "value3"].map((value) => `${prefix}${value}${suffix}`).join(",");
}
function NameInput({ name, type, onChange, existingNames, setValidation }) {
let helpText = "";
let validateStatus = "";
if (!name) {
helpText = "Choose a keyword for this parameter";
setValidation(false);
} else if (includes(existingNames, name)) {
helpText = "Parameter with this name already exists";
setValidation(false);
validateStatus = "error";
} else {
if (isTypeDateRange(type)) {
helpText = (
Appears in query as{" "}
{`{{${name}.start}} {{${name}.end}}`}
);
}
setValidation(true);
}
return (
onChange(e.target.value)} autoFocus />
);
}
NameInput.propTypes = {
name: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
existingNames: PropTypes.arrayOf(PropTypes.string).isRequired,
setValidation: PropTypes.func.isRequired,
type: PropTypes.string.isRequired,
};
function EditParameterSettingsDialog(props) {
const [param, setParam] = useState(clone(props.parameter));
const [isNameValid, setIsNameValid] = useState(true);
const [initialQuery, setInitialQuery] = useState();
const [userInput, setUserInput] = useState(param.regex || "");
const [isValidRegex, setIsValidRegex] = useState(true);
const isNew = !props.parameter.name;
// fetch query by id
useEffect(() => {
const queryId = props.parameter.queryId;
if (queryId) {
Query.get({ id: queryId }).then(setInitialQuery);
}
}, [props.parameter.queryId]);
function isFulfilled() {
// name
if (!isNameValid) {
return false;
}
// title
if (param.title === "") {
return false;
}
// query
if (param.type === "query" && !param.queryId) {
return false;
}
return true;
}
function onConfirm() {
// update title to default
if (!param.title) {
// forced to do this cause param won't update in time for save
param.title = getDefaultTitle(param.name);
setParam(param);
}
props.dialog.close(param);
}
const paramFormId = useUniqueId("paramForm");
const handleRegexChange = (e) => {
setUserInput(e.target.value);
try {
new RegExp(e.target.value);
setParam({ ...param, regex: e.target.value });
setIsValidRegex(true);
} catch (error) {
setIsValidRegex(false);
}
};
return (
Cancel
,
{isNew ? "Add Parameter" : "OK"}
,
]}
>
);
}
EditParameterSettingsDialog.propTypes = {
parameter: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
dialog: DialogPropType.isRequired,
existingParams: PropTypes.arrayOf(PropTypes.string),
};
EditParameterSettingsDialog.defaultProps = {
existingParams: [],
};
export default wrapDialog(EditParameterSettingsDialog);
================================================
FILE: client/app/components/EditParameterSettingsDialog.less
================================================
.input-error {
border-color: red !important;
}
================================================
FILE: client/app/components/EditVisualizationButton/QueryControlDropdown.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
import Dropdown from "antd/lib/dropdown";
import Menu from "antd/lib/menu";
import Button from "antd/lib/button";
import PlainButton from "@/components/PlainButton";
import { clientConfig } from "@/services/auth";
import PlusCircleFilledIcon from "@ant-design/icons/PlusCircleFilled";
import ShareAltOutlinedIcon from "@ant-design/icons/ShareAltOutlined";
import FileOutlinedIcon from "@ant-design/icons/FileOutlined";
import FileExcelOutlinedIcon from "@ant-design/icons/FileExcelOutlined";
import EllipsisOutlinedIcon from "@ant-design/icons/EllipsisOutlined";
import QueryResultsLink from "./QueryResultsLink";
export default function QueryControlDropdown(props) {
const menu = (
{!props.query.isNew() && (!props.query.is_draft || !props.query.is_archived) && (
props.openAddToDashboardForm(props.selectedTab)}>
Add to Dashboard
)}
{!clientConfig.disablePublicUrls && !props.query.isNew() && (
props.showEmbedDialog(props.query, props.selectedTab)}
data-test="ShowEmbedDialogButton">
Embed Elsewhere
)}
Download as CSV File
Download as TSV File
Download as Excel File
);
return (
);
}
QueryControlDropdown.propTypes = {
query: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
queryResult: PropTypes.object, // eslint-disable-line react/forbid-prop-types
queryExecuting: PropTypes.bool.isRequired,
showEmbedDialog: PropTypes.func.isRequired,
embed: PropTypes.bool,
apiKey: PropTypes.string,
selectedTab: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
openAddToDashboardForm: PropTypes.func.isRequired,
};
QueryControlDropdown.defaultProps = {
queryResult: {},
embed: false,
apiKey: "",
selectedTab: "",
};
================================================
FILE: client/app/components/EditVisualizationButton/QueryResultsLink.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
import Link from "@/components/Link";
export default function QueryResultsLink(props) {
let href = "";
const { query, queryResult, fileType } = props;
const resultId = queryResult.getId && queryResult.getId();
const resultData = queryResult.getData && queryResult.getData();
if (resultId && resultData && query.name) {
if (query.id) {
href = `api/queries/${query.id}/results/${resultId}.${fileType}${props.embed ? `?api_key=${props.apiKey}` : ""}`;
} else {
href = `api/query_results/${resultId}.${fileType}`;
}
}
return (
{props.children}
);
}
QueryResultsLink.propTypes = {
query: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
queryResult: PropTypes.object, // eslint-disable-line react/forbid-prop-types
fileType: PropTypes.string,
disabled: PropTypes.bool.isRequired,
embed: PropTypes.bool,
apiKey: PropTypes.string,
children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node]).isRequired,
};
QueryResultsLink.defaultProps = {
queryResult: {},
fileType: "csv",
embed: false,
apiKey: "",
};
================================================
FILE: client/app/components/EditVisualizationButton/index.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
import Button from "antd/lib/button";
import FormOutlinedIcon from "@ant-design/icons/FormOutlined";
export default function EditVisualizationButton(props) {
return (
props.openVisualizationEditor(props.selectedTab)}>
Edit Visualization
);
}
EditVisualizationButton.propTypes = {
openVisualizationEditor: PropTypes.func.isRequired,
selectedTab: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
};
EditVisualizationButton.defaultProps = {
selectedTab: "",
};
================================================
FILE: client/app/components/EmailSettingsWarning.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
import { clientConfig, currentUser } from "@/services/auth";
import Tooltip from "@/components/Tooltip";
import Alert from "antd/lib/alert";
import HelpTrigger from "@/components/HelpTrigger";
import { useUniqueId } from "@/lib/hooks/useUniqueId";
export default function EmailSettingsWarning({ featureName, className, mode, adminOnly }) {
const messageDescriptionId = useUniqueId("sr-mail-description");
if (!clientConfig.mailSettingsMissing) {
return null;
}
if (adminOnly && !currentUser.isAdmin) {
return null;
}
const message = (
Your mail server isn't configured correctly, and is needed for {featureName} to work.{" "}
);
if (mode === "icon") {
return (
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */}
);
}
return ;
}
EmailSettingsWarning.propTypes = {
featureName: PropTypes.string.isRequired,
className: PropTypes.string,
mode: PropTypes.oneOf(["alert", "icon"]),
adminOnly: PropTypes.bool,
};
EmailSettingsWarning.defaultProps = {
className: null,
mode: "alert",
adminOnly: false,
};
================================================
FILE: client/app/components/FavoritesControl.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
import PlainButton from "@/components/PlainButton";
export default class FavoritesControl extends React.Component {
static propTypes = {
item: PropTypes.shape({
is_favorite: PropTypes.bool.isRequired,
}).isRequired,
onChange: PropTypes.func,
};
static defaultProps = {
onChange: () => {},
};
toggleItem(event, item, callback) {
const action = item.is_favorite ? item.unfavorite.bind(item) : item.favorite.bind(item);
const savedIsFavorite = item.is_favorite;
action().then(() => {
item.is_favorite = !savedIsFavorite;
this.forceUpdate();
callback();
});
}
render() {
const { item, onChange } = this.props;
const icon = item.is_favorite ? "fa fa-star" : "fa fa-star-o";
const title = item.is_favorite ? "Remove from favorites" : "Add to favorites";
return (
this.toggleItem(event, item, onChange)}>
);
}
}
================================================
FILE: client/app/components/Filters.jsx
================================================
import { isArray, indexOf, get, map, includes, every, some, toNumber } from "lodash";
import moment from "moment";
import React from "react";
import PropTypes from "prop-types";
import Select from "antd/lib/select";
import { formatColumnValue } from "@/lib/utils";
const ALL_VALUES = "###Redash::Filters::SelectAll###";
const NONE_VALUES = "###Redash::Filters::Clear###";
export const FilterType = PropTypes.shape({
name: PropTypes.string.isRequired,
friendlyName: PropTypes.string.isRequired,
multiple: PropTypes.bool,
current: PropTypes.oneOfType([PropTypes.any, PropTypes.arrayOf(PropTypes.any)]),
values: PropTypes.arrayOf(PropTypes.any).isRequired,
});
export const FiltersType = PropTypes.arrayOf(FilterType);
function createFilterChangeHandler(filters, onChange) {
return (filter, values) => {
if (isArray(values)) {
values = map(values, value => filter.values[toNumber(value.key)] || value.key);
} else {
const _values = filter.values[toNumber(values.key)];
values = _values !== undefined ? _values : values.key;
}
if (filter.multiple && includes(values, ALL_VALUES)) {
values = [...filter.values];
}
if (filter.multiple && includes(values, NONE_VALUES)) {
values = [];
}
filters = map(filters, f => (f.name === filter.name ? { ...filter, current: values } : f));
onChange(filters);
};
}
export function filterData(rows, filters = []) {
if (!isArray(rows)) {
return [];
}
let result = rows;
if (isArray(filters) && filters.length > 0) {
// "every" field's value should match "some" of corresponding filter's values
result = result.filter(row =>
every(filters, filter => {
const rowValue = row[filter.name];
const filterValues = isArray(filter.current) ? filter.current : [filter.current];
return some(filterValues, filterValue => {
if (moment.isMoment(rowValue)) {
return rowValue.isSame(filterValue);
}
// We compare with either the value or the String representation of the value,
// because Select2 casts true/false to "true"/"false".
return filterValue === rowValue || String(rowValue) === filterValue;
});
})
);
}
return result;
}
function Filters({ filters, onChange }) {
if (filters.length === 0) {
return null;
}
onChange = createFilterChangeHandler(filters, onChange);
return (
{map(filters, filter => {
const options = map(filter.values, (value, index) => (
{formatColumnValue(value, get(filter, "column.type"))}
));
return (
{filter.friendlyName}
{options.length === 0 && }
{options.length > 0 && (
({
key: `${indexOf(filter.values, value)}`,
label: formatColumnValue(value),
}))
: { key: `${indexOf(filter.values, filter.current)}`, label: formatColumnValue(filter.current) }
}
allowClear={filter.multiple}
optionFilterProp="children"
showSearch
maxTagCount={3}
maxTagTextLength={10}
maxTagPlaceholder={num => `+${num.length} more`}
onChange={values => onChange(filter, values)}>
{!filter.multiple && options}
{filter.multiple && [
Clear
,
Select All
,
{options}
,
]}
)}
);
})}
);
}
Filters.propTypes = {
filters: FiltersType.isRequired,
onChange: PropTypes.func, // (name, value) => void
};
Filters.defaultProps = {
onChange: () => {},
};
export default Filters;
================================================
FILE: client/app/components/HelpTrigger.jsx
================================================
import { startsWith, get, some, mapValues } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import cx from "classnames";
import Tooltip from "@/components/Tooltip";
import Drawer from "antd/lib/drawer";
import Link from "@/components/Link";
import PlainButton from "@/components/PlainButton";
import CloseOutlinedIcon from "@ant-design/icons/CloseOutlined";
import BigMessage from "@/components/BigMessage";
import DynamicComponent, { registerComponent } from "@/components/DynamicComponent";
import "./HelpTrigger.less";
const DOMAIN = "https://redash.io";
const HELP_PATH = "/help";
const IFRAME_TIMEOUT = 20000;
const IFRAME_URL_UPDATE_MESSAGE = "iframe_url";
export const TYPES = mapValues(
{
HOME: ["", "Help"],
VALUE_SOURCE_OPTIONS: ["/user-guide/querying/query-parameters#Value-Source-Options", "Guide: Value Source Options"],
SHARE_DASHBOARD: ["/user-guide/dashboards/sharing-dashboards", "Guide: Sharing and Embedding Dashboards"],
AUTHENTICATION_OPTIONS: ["/user-guide/users/authentication-options", "Guide: Authentication Options"],
USAGE_DATA_SHARING: ["/open-source/admin-guide/usage-data", "Help: Anonymous Usage Data Sharing"],
DS_ATHENA: ["/data-sources/amazon-athena-setup", "Guide: Help Setting up Amazon Athena"],
DS_BIGQUERY: ["/data-sources/bigquery-setup", "Guide: Help Setting up BigQuery"],
DS_URL: ["/data-sources/querying-urls", "Guide: Help Setting up URL"],
DS_MONGODB: ["/data-sources/mongodb-setup", "Guide: Help Setting up MongoDB"],
DS_GOOGLE_SPREADSHEETS: [
"/data-sources/querying-a-google-spreadsheet",
"Guide: Help Setting up Google Spreadsheets",
],
DS_GOOGLE_ANALYTICS: ["/data-sources/google-analytics-setup", "Guide: Help Setting up Google Analytics"],
DS_AXIBASETSD: ["/data-sources/axibase-time-series-database", "Guide: Help Setting up Axibase Time Series"],
DS_RESULTS: ["/user-guide/querying/query-results-data-source", "Guide: Help Setting up Query Results"],
ALERT_SETUP: ["/user-guide/alerts/setting-up-an-alert", "Guide: Setting Up a New Alert"],
MAIL_CONFIG: ["/open-source/setup/#Mail-Configuration", "Guide: Mail Configuration"],
ALERT_NOTIF_TEMPLATE_GUIDE: ["/user-guide/alerts/custom-alert-notifications", "Guide: Custom Alerts Notifications"],
FAVORITES: ["/user-guide/querying/favorites-tagging/#Favorites", "Guide: Favorites"],
MANAGE_PERMISSIONS: [
"/user-guide/querying/writing-queries#Managing-Query-Permissions",
"Guide: Managing Query Permissions",
],
NUMBER_FORMAT_SPECS: ["/user-guide/visualizations/formatting-numbers", "Formatting Numbers"],
GETTING_STARTED: ["/user-guide/getting-started", "Guide: Getting Started"],
DASHBOARDS: ["/user-guide/dashboards", "Guide: Dashboards"],
QUERIES: ["/user-guide/querying", "Guide: Queries"],
ALERTS: ["/user-guide/alerts", "Guide: Alerts"],
},
([url, title]) => [DOMAIN + HELP_PATH + url, title]
);
const HelpTriggerPropTypes = {
type: PropTypes.string,
href: PropTypes.string,
title: PropTypes.node,
className: PropTypes.string,
showTooltip: PropTypes.bool,
renderAsLink: PropTypes.bool,
children: PropTypes.node,
};
const HelpTriggerDefaultProps = {
type: null,
href: null,
title: null,
className: null,
showTooltip: true,
renderAsLink: false,
children: ,
};
export function helpTriggerWithTypes(types, allowedDomains = [], drawerClassName = null) {
return class HelpTrigger extends React.Component {
static propTypes = {
...HelpTriggerPropTypes,
type: PropTypes.oneOf(Object.keys(types)),
};
static defaultProps = HelpTriggerDefaultProps;
iframeRef = React.createRef();
iframeLoadingTimeout = null;
state = {
visible: false,
loading: false,
error: false,
currentUrl: null,
};
componentDidMount() {
window.addEventListener("message", this.onPostMessageReceived, false);
}
componentWillUnmount() {
window.removeEventListener("message", this.onPostMessageReceived);
clearTimeout(this.iframeLoadingTimeout);
}
loadIframe = (url) => {
clearTimeout(this.iframeLoadingTimeout);
this.setState({ loading: true, error: false });
this.iframeRef.current.src = url;
this.iframeLoadingTimeout = setTimeout(() => {
this.setState({ error: url, loading: false });
}, IFRAME_TIMEOUT); // safety
};
onIframeLoaded = () => {
this.setState({ loading: false });
clearTimeout(this.iframeLoadingTimeout);
};
onPostMessageReceived = (event) => {
if (!some(allowedDomains, (domain) => startsWith(event.origin, domain))) {
return;
}
const { type, message: currentUrl } = event.data || {};
if (type !== IFRAME_URL_UPDATE_MESSAGE) {
return;
}
this.setState({ currentUrl });
};
getUrl = () => {
const helpTriggerType = get(types, this.props.type);
return helpTriggerType ? helpTriggerType[0] : this.props.href;
};
openDrawer = (e) => {
// keep "open in new tab" behavior
if (!e.shiftKey && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
this.setState({ visible: true });
// wait for drawer animation to complete so there's no animation jank
setTimeout(() => this.loadIframe(this.getUrl()), 300);
}
};
closeDrawer = (event) => {
if (event) {
event.preventDefault();
}
this.setState({ visible: false });
this.setState({ visible: false, currentUrl: null });
};
render() {
const targetUrl = this.getUrl();
if (!targetUrl) {
return null;
}
const tooltip = get(types, `${this.props.type}[1]`, this.props.title);
const className = cx("help-trigger", this.props.className);
const url = this.state.currentUrl;
const isAllowedDomain = some(allowedDomains, (domain) => startsWith(url || targetUrl, domain));
const shouldRenderAsLink = this.props.renderAsLink || !isAllowedDomain;
return (
{tooltip}
{shouldRenderAsLink && (
<>
{" "}
(opens in a new tab)
>
)}
>
) : null
}
>
{} : this.openDrawer}
>
{this.props.children}
{url && (
{/* eslint-disable-next-line react/jsx-no-target-blank */}
(opens in a new tab)
)}
{/* iframe */}
{!this.state.error && (
)}
{/* loading indicator */}
{this.state.loading && (
)}
{/* error message */}
{this.state.error && (
Something went wrong.
{/* eslint-disable-next-line react/jsx-no-target-blank */}
Click here
{" "}
to open the page in a new window.
)}
{/* extra content */}
);
}
};
}
registerComponent("HelpTrigger", helpTriggerWithTypes(TYPES, [DOMAIN]));
export default function HelpTrigger(props) {
return ;
}
HelpTrigger.propTypes = HelpTriggerPropTypes;
HelpTrigger.defaultProps = HelpTriggerDefaultProps;
================================================
FILE: client/app/components/HelpTrigger.less
================================================
@import (reference, less) "~@/assets/less/ant";
@help-doc-bg: #f7f7f7; // according to https://github.com/getredash/website/blob/13daff2d8b570956565f482236f6245042e8477f/src/scss/_components/_variables.scss#L15
.help-trigger {
font-size: 15px;
&:hover {
cursor: pointer;
}
}
.help-drawer {
.ant-drawer-body {
padding: 0;
height: 100%; // to allow iframe full dimensions
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
.drawer-wrapper {
flex: 1;
display: flex;
align-items: center;
width: 100%;
justify-content: center;
}
.drawer-menu {
position: fixed;
z-index: 1;
top: 13px;
right: 13px;
border-radius: 3px;
background: rgba(@help-doc-bg, 0.75); // makes it dissolve over help doc bg
border: 2px solid @help-doc-bg;
display: flex;
a,
.plain-button {
height: 26px;
width: 26px;
display: flex;
align-items: center;
justify-content: center;
color: @text-color-secondary;
transition: color @animation-duration-slow;
position: relative;
cursor: pointer;
&:hover {
color: @icon-color-hover;
text-decoration: none;
}
.anticon {
font-size: 15px;
}
.fa-external-link {
position: relative;
top: 1px;
font-size: 14px;
}
// divider
&:not(:first-child):before {
content: "";
position: absolute;
width: 1px;
height: 9px;
left: 0;
top: 9px;
border-left: 1px dotted rgba(0, 0, 0, 0.12);
}
}
}
iframe {
width: 0;
visibility: hidden;
}
iframe.ready {
border: 0;
width: 100%;
height: 100%;
visibility: visible;
}
}
================================================
FILE: client/app/components/InputWithCopy.jsx
================================================
import React from "react";
import Input from "antd/lib/input";
import CopyOutlinedIcon from "@ant-design/icons/CopyOutlined";
import Tooltip from "@/components/Tooltip";
import PlainButton from "./PlainButton";
export default class InputWithCopy extends React.Component {
constructor(props) {
super(props);
this.state = { copied: null };
this.ref = React.createRef();
this.copyFeatureSupported = document.queryCommandSupported("copy");
this.resetCopyState = null;
}
componentWillUnmount() {
if (this.resetCopyState) {
clearTimeout(this.resetCopyState);
}
}
copy = () => {
// select text
this.ref.current.select();
// copy
try {
const success = document.execCommand("copy");
if (!success) {
throw new Error();
}
this.setState({ copied: "Copied!" });
} catch (err) {
this.setState({
copied: "Copy failed",
});
}
// reset tooltip
this.resetCopyState = setTimeout(() => this.setState({ copied: null }), 2000);
};
render() {
const copyButton = (
{/* TODO: lacks visual feedback */}
);
return ;
}
}
================================================
FILE: client/app/components/Link.tsx
================================================
import React from "react";
import Button, { ButtonProps as AntdButtonProps } from "antd/lib/button";
function DefaultLinkComponent({ children, ...props }: React.AnchorHTMLAttributes) {
return {children} ;
}
Link.Component = DefaultLinkComponent;
interface LinkProps extends Omit, "role" | "type" | "target"> {
href: string;
}
function Link({ children, ...props }: LinkProps) {
return {children} ;
}
interface LinkWithIconProps extends LinkProps {
children: string;
icon: JSX.Element;
alt: string;
target?: "_self" | "_blank" | "_parent" | "_top";
}
function LinkWithIcon({ icon, alt, children, ...props }: LinkWithIconProps) {
return (
{children} {icon} {alt}
);
}
Link.WithIcon = LinkWithIcon;
function ExternalLink({
icon = ,
alt = "(opens in a new tab)",
...props
}: Omit) {
return ;
}
Link.External = ExternalLink;
// Ant Button will render an if href is present.
function DefaultButtonLinkComponent(props: ButtonProps) {
return ;
}
ButtonLink.Component = DefaultButtonLinkComponent;
interface ButtonProps extends AntdButtonProps {
href: string;
}
function ButtonLink(props: ButtonProps) {
return ;
}
Link.Button = ButtonLink;
export default Link;
================================================
FILE: client/app/components/NoTaggedObjectsFound.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
import BigMessage from "@/components/BigMessage";
import { TagsControl } from "@/components/tags-control/TagsControl";
export default function NoTaggedObjectsFound({ objectType, tags }) {
return (
No {objectType} found tagged with
.
);
}
NoTaggedObjectsFound.propTypes = {
objectType: PropTypes.string.isRequired,
tags: PropTypes.oneOfType([PropTypes.array, PropTypes.objectOf(Set)]).isRequired,
};
================================================
FILE: client/app/components/PageHeader/index.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
import "./index.less";
export default function PageHeader({ title, actions }) {
return (
{title}
{actions &&
{actions}
}
);
}
PageHeader.propTypes = {
title: PropTypes.string,
actions: PropTypes.node,
};
PageHeader.defaultProps = {
title: "",
actions: null,
};
================================================
FILE: client/app/components/PageHeader/index.less
================================================
.page-header-wrapper {
margin: 15px 0 10px 0;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
justify-content: stretch;
h3 {
margin: 0;
line-height: 1.3;
font-weight: 500;
flex: 1 1 auto;
}
.page-header-actions {
flex: 0 0 auto;
padding: 0 0 0 15px;
}
}
================================================
FILE: client/app/components/Paginator.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
import Pagination from "antd/lib/pagination";
const MIN_ITEMS_PER_PAGE = 5;
export default function Paginator({ page, showPageSizeSelect, pageSize, onPageSizeChange, totalCount, onChange }) {
if (totalCount <= (showPageSizeSelect ? MIN_ITEMS_PER_PAGE : pageSize)) {
return null;
}
return (
onPageSizeChange(size)}
defaultCurrent={page}
pageSize={pageSize}
total={totalCount}
onChange={onChange}
/>
);
}
Paginator.propTypes = {
page: PropTypes.number.isRequired,
showPageSizeSelect: PropTypes.bool,
pageSize: PropTypes.number.isRequired,
totalCount: PropTypes.number.isRequired,
onPageSizeChange: PropTypes.func,
onChange: PropTypes.func,
};
Paginator.defaultProps = {
showPageSizeSelect: false,
onChange: () => {},
onPageSizeChange: () => {},
};
================================================
FILE: client/app/components/ParameterApplyButton.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
import Button from "antd/lib/button";
import Badge from "antd/lib/badge";
import Tooltip from "@/components/Tooltip";
import KeyboardShortcuts from "@/services/KeyboardShortcuts";
function ParameterApplyButton({ paramCount, onClick }) {
// show spinner when count is empty so the fade out is consistent
const icon = !paramCount ? (
Loading...
) : (
);
return (
{icon} Apply Changes
);
}
ParameterApplyButton.propTypes = {
onClick: PropTypes.func.isRequired,
paramCount: PropTypes.number.isRequired,
};
export default ParameterApplyButton;
================================================
FILE: client/app/components/ParameterMappingInput.jsx
================================================
/* eslint-disable react/no-multi-comp */
import { isString, extend, each, has, map, includes, findIndex, find, fromPairs, clone, isEmpty } from "lodash";
import React, { Fragment } from "react";
import PropTypes from "prop-types";
import classNames from "classnames";
import Select from "antd/lib/select";
import Table from "antd/lib/table";
import Popover from "antd/lib/popover";
import Button from "antd/lib/button";
import Tag from "antd/lib/tag";
import Input from "antd/lib/input";
import Radio from "antd/lib/radio";
import Form from "antd/lib/form";
import Tooltip from "@/components/Tooltip";
import ParameterValueInput from "@/components/ParameterValueInput";
import { ParameterMappingType } from "@/services/widget";
import { Parameter, cloneParameter } from "@/services/parameters";
import HelpTrigger from "@/components/HelpTrigger";
import QuestionCircleFilledIcon from "@ant-design/icons/QuestionCircleFilled";
import EditOutlinedIcon from "@ant-design/icons/EditOutlined";
import CloseOutlinedIcon from "@ant-design/icons/CloseOutlined";
import CheckOutlinedIcon from "@ant-design/icons/CheckOutlined";
import "./ParameterMappingInput.less";
export const MappingType = {
DashboardAddNew: "dashboard-add-new",
DashboardMapToExisting: "dashboard-map-to-existing",
WidgetLevel: "widget-level",
StaticValue: "static-value",
};
export function parameterMappingsToEditableMappings(mappings, parameters, existingParameterNames = []) {
return map(mappings, (mapping) => {
const result = extend({}, mapping);
const alreadyExists = includes(existingParameterNames, mapping.mapTo);
result.param = find(parameters, (p) => p.name === mapping.name);
switch (mapping.type) {
case ParameterMappingType.DashboardLevel:
result.type = alreadyExists ? MappingType.DashboardMapToExisting : MappingType.DashboardAddNew;
result.value = null;
break;
case ParameterMappingType.StaticValue:
result.type = MappingType.StaticValue;
result.param = cloneParameter(result.param);
result.param.setValue(result.value);
break;
case ParameterMappingType.WidgetLevel:
result.type = MappingType.WidgetLevel;
result.value = null;
break;
// no default
}
return result;
});
}
export function editableMappingsToParameterMappings(mappings) {
return fromPairs(
map(
// convert to map
mappings,
(mapping) => {
const result = extend({}, mapping);
switch (mapping.type) {
case MappingType.DashboardAddNew:
result.type = ParameterMappingType.DashboardLevel;
result.value = null;
break;
case MappingType.DashboardMapToExisting:
result.type = ParameterMappingType.DashboardLevel;
result.value = null;
break;
case MappingType.StaticValue:
result.type = ParameterMappingType.StaticValue;
result.param = cloneParameter(mapping.param);
result.param.setValue(result.value);
result.value = result.param.value;
break;
case MappingType.WidgetLevel:
result.type = ParameterMappingType.WidgetLevel;
result.value = null;
break;
// no default
}
delete result.param;
return [result.name, result];
}
)
);
}
export function synchronizeWidgetTitles(sourceMappings, widgets) {
const affectedWidgets = [];
each(sourceMappings, (sourceMapping) => {
if (sourceMapping.type === ParameterMappingType.DashboardLevel) {
each(widgets, (widget) => {
const widgetMappings = widget.options.parameterMappings;
each(widgetMappings, (widgetMapping) => {
// check if mapped to the same dashboard-level parameter
if (
widgetMapping.type === ParameterMappingType.DashboardLevel &&
widgetMapping.mapTo === sourceMapping.mapTo
) {
// dirty check - update only when needed
if (widgetMapping.title !== sourceMapping.title) {
widgetMapping.title = sourceMapping.title;
affectedWidgets.push(widget);
}
}
});
});
}
});
return affectedWidgets;
}
export class ParameterMappingInput extends React.Component {
static propTypes = {
mapping: PropTypes.object, // eslint-disable-line react/forbid-prop-types
existingParamNames: PropTypes.arrayOf(PropTypes.string),
onChange: PropTypes.func,
inputError: PropTypes.string,
};
static defaultProps = {
mapping: {},
existingParamNames: [],
onChange: () => {},
inputError: null,
};
formItemProps = {
labelCol: { span: 5 },
wrapperCol: { span: 16 },
className: "form-item",
};
updateSourceType = (type) => {
let {
mapping: { mapTo },
} = this.props;
const { existingParamNames } = this.props;
// if mapped name doesn't already exists
// default to first select option
if (type === MappingType.DashboardMapToExisting && !includes(existingParamNames, mapTo)) {
mapTo = existingParamNames[0];
}
this.updateParamMapping({ type, mapTo });
};
updateParamMapping = (update) => {
const { onChange, mapping } = this.props;
const newMapping = extend({}, mapping, update);
if (newMapping.value !== mapping.value) {
newMapping.param = cloneParameter(newMapping.param);
newMapping.param.setValue(newMapping.value);
}
if (has(update, "type")) {
if (update.type === MappingType.StaticValue) {
newMapping.value = newMapping.param.value;
} else {
newMapping.value = null;
}
}
onChange(newMapping);
};
renderMappingTypeSelector() {
const noExisting = isEmpty(this.props.existingParamNames);
return (
this.updateSourceType(e.target.value)}>
New dashboard parameter
Existing dashboard parameter{" "}
{noExisting ? (
) : null}
Widget parameter
Static value
);
}
renderDashboardAddNew() {
const {
mapping: { mapTo },
} = this.props;
return (
this.updateParamMapping({ mapTo: e.target.value })}
/>
);
}
renderDashboardMapToExisting() {
const { mapping, existingParamNames } = this.props;
const options = map(existingParamNames, (paramName) => ({ label: paramName, value: paramName }));
return this.updateParamMapping({ mapTo })} options={options} />;
}
renderStaticValue() {
const { mapping } = this.props;
return (
this.updateParamMapping({ value })}
regex={mapping.param.regex}
/>
);
}
renderInputBlock() {
const { mapping } = this.props;
switch (mapping.type) {
case MappingType.DashboardAddNew:
return ["Key", "Enter a new parameter keyword", this.renderDashboardAddNew()];
case MappingType.DashboardMapToExisting:
return ["Key", "Select from a list of existing parameters", this.renderDashboardMapToExisting()];
case MappingType.StaticValue:
return ["Value", null, this.renderStaticValue()];
default:
return [];
}
}
render() {
const { inputError } = this.props;
const [label, help, input] = this.renderInputBlock();
return (
{this.renderMappingTypeSelector()}
{input}
);
}
}
class MappingEditor extends React.Component {
static propTypes = {
mapping: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
existingParamNames: PropTypes.arrayOf(PropTypes.string).isRequired,
onChange: PropTypes.func.isRequired,
};
constructor(props) {
super(props);
this.state = {
visible: false,
mapping: clone(this.props.mapping),
inputError: null,
};
}
onVisibleChange = (visible) => {
if (visible) this.show();
else this.hide();
};
onChange = (mapping) => {
let inputError = null;
if (mapping.type === MappingType.DashboardAddNew) {
if (isEmpty(mapping.mapTo)) {
inputError = "Keyword must have a value";
} else if (includes(this.props.existingParamNames, mapping.mapTo)) {
inputError = "A parameter with this name already exists";
}
}
this.setState({ mapping, inputError });
};
save = () => {
this.props.onChange(this.props.mapping, this.state.mapping);
this.hide();
};
show = () => {
this.setState({
visible: true,
mapping: clone(this.props.mapping), // restore original state
});
};
hide = () => {
this.setState({ visible: false });
};
renderContent() {
const { mapping, inputError } = this.state;
return (
);
}
render() {
const { visible, mapping } = this.state;
return (
);
}
}
class TitleEditor extends React.Component {
static propTypes = {
existingParams: PropTypes.arrayOf(PropTypes.object),
mapping: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
onChange: PropTypes.func.isRequired,
};
static defaultProps = {
existingParams: [],
};
state = {
showPopup: false,
title: "", // will be set on editing
};
onPopupVisibleChange = (showPopup) => {
this.setState({
showPopup,
title: showPopup ? this.getMappingTitle() : "",
});
};
onEditingTitleChange = (event) => {
this.setState({ title: event.target.value });
};
getMappingTitle() {
let { mapping } = this.props;
if (isString(mapping.title) && mapping.title !== "") {
return mapping.title;
}
// if mapped to dashboard, find source param and return it's title
if (mapping.type === MappingType.DashboardMapToExisting) {
const source = find(this.props.existingParams, { name: mapping.mapTo });
if (source) {
mapping = source;
}
}
return mapping.title || mapping.param.title;
}
save = () => {
const newMapping = extend({}, this.props.mapping, { title: this.state.title });
this.props.onChange(newMapping);
this.hide();
};
hide = () => {
this.setState({ showPopup: false });
};
renderPopover() {
const {
param: { title: paramTitle },
} = this.props.mapping;
return (
);
}
renderEditButton() {
const { mapping } = this.props;
if (mapping.type === MappingType.StaticValue) {
return (
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */}
);
}
return (
);
}
render() {
const { mapping } = this.props;
// static value are non-editable hence disabled
const disabled = mapping.type === MappingType.StaticValue;
return (
{this.getMappingTitle()}
{this.renderEditButton()}
);
}
}
export class ParameterMappingListInput extends React.Component {
static propTypes = {
mappings: PropTypes.arrayOf(PropTypes.object),
existingParams: PropTypes.arrayOf(PropTypes.object),
onChange: PropTypes.func,
};
static defaultProps = {
mappings: [],
existingParams: [],
onChange: () => {},
};
static getStringValue(value) {
// null
if (!value) {
return "";
}
// range
if (value instanceof Object && "start" in value && "end" in value) {
return `${value.start} ~ ${value.end}`;
}
// just to be safe, array or object
if (typeof value === "object") {
return map(value, (v) => this.getStringValue(v)).join(", ");
}
// rest
return value.toString();
}
static getDefaultValue(mapping, existingParams) {
const { type, mapTo, name } = mapping;
let { param } = mapping;
// if mapped to another param, swap 'em
if (type === MappingType.DashboardMapToExisting && mapTo !== name) {
const mappedTo = find(existingParams, { name: mapTo });
if (mappedTo) {
// just being safe
param = mappedTo;
}
// static type is different since it's fed param.normalizedValue
} else if (type === MappingType.StaticValue) {
param = cloneParameter(param).setValue(mapping.value);
}
let value = Parameter.getExecutionValue(param);
// in case of dynamic value display the name instead of value
if (param.hasDynamicValue) {
value = param.normalizedValue.name;
}
return this.getStringValue(value);
}
static getSourceTypeLabel({ type, mapTo }) {
switch (type) {
case MappingType.DashboardAddNew:
case MappingType.DashboardMapToExisting:
return (
Dashboard {mapTo}
);
case MappingType.WidgetLevel:
return "Widget parameter";
case MappingType.StaticValue:
return "Static value";
default:
return ""; // won't happen (typescript-ftw)
}
}
updateParamMapping(oldMapping, newMapping) {
const mappings = [...this.props.mappings];
const index = findIndex(mappings, oldMapping);
if (index >= 0) {
// This should be the only possible case, but need to handle `else` too
mappings[index] = newMapping;
} else {
mappings.push(newMapping);
}
this.props.onChange(mappings);
}
render() {
const { existingParams } = this.props; // eslint-disable-line react/prop-types
const dataSource = this.props.mappings.map((mapping) => ({ mapping }));
return (
`row${idx}`}>
(
this.updateParamMapping(mapping, newMapping)}
/>
)}
/>
{`{{ ${mapping.name} }}`}}
/>
this.constructor.getDefaultValue(mapping, this.props.existingParams)}
/>
{
const existingParamsNames = existingParams
.filter(({ type }) => type === mapping.param.type) // exclude mismatching param types
.map(({ name }) => name); // keep names only
return (
{this.constructor.getSourceTypeLabel(mapping)}{" "}
this.updateParamMapping(oldMapping, newMapping)}
/>
);
}}
/>
);
}
}
================================================
FILE: client/app/components/ParameterMappingInput.less
================================================
@import (reference, less) "~@/assets/less/ant"; // for ant @vars
.parameters-mapping-list {
.keyword {
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
code {
white-space: nowrap; // so the curly braces don't line break
}
}
// Ant overrides
.tag {
margin: 0;
pointer-events: none; // unclickable
&:empty {
display: none;
}
}
}
.parameter-mapping-editor {
width: 390px;
.radio {
display: block;
height: 30px;
line-height: 30px;
}
.form-item {
margin-bottom: 10px;
}
header {
padding: 0 16px 10px;
margin: 0 -16px 20px;
border-bottom: @border-width-base @border-style-base @border-color-split;
font-size: @font-size-lg;
font-weight: 500;
color: @heading-color;
display: flex;
justify-content: space-between;
}
footer {
border-top: @border-width-base @border-style-base @border-color-split;
padding: 10px 16px 0;
margin: 0 -16px;
text-align: right;
button {
margin-left: 8px;
}
}
}
.parameter-mapping-title {
.text {
margin-right: 3px;
}
&.disabled,
.fa {
color: #a4a4a4;
}
.fa-eye-slash {
margin-left: 1px;
}
}
.parameter-mapping-title-editor {
input {
width: 100px;
}
button {
margin-left: 2px;
}
}
================================================
FILE: client/app/components/ParameterValueInput.jsx
================================================
import { isEqual, isEmpty, map } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import SelectWithVirtualScroll from "@/components/SelectWithVirtualScroll";
import Input from "antd/lib/input";
import InputNumber from "antd/lib/input-number";
import DateParameter from "@/components/dynamic-parameters/DateParameter";
import DateRangeParameter from "@/components/dynamic-parameters/DateRangeParameter";
import QueryBasedParameterInput from "./QueryBasedParameterInput";
import "./ParameterValueInput.less";
import Tooltip from "./Tooltip";
const multipleValuesProps = {
maxTagCount: 3,
maxTagTextLength: 10,
maxTagPlaceholder: (num) => `+${num.length} more`,
};
class ParameterValueInput extends React.Component {
static propTypes = {
type: PropTypes.string,
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
enumOptions: PropTypes.string,
queryId: PropTypes.number,
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
onSelect: PropTypes.func,
className: PropTypes.string,
regex: PropTypes.string,
};
static defaultProps = {
type: "text",
value: null,
enumOptions: "",
queryId: null,
parameter: null,
onSelect: () => {},
className: "",
regex: "",
};
constructor(props) {
super(props);
this.state = {
value: props.parameter.hasPendingValue ? props.parameter.pendingValue : props.value,
isDirty: props.parameter.hasPendingValue,
};
}
componentDidUpdate = (prevProps) => {
const { value, parameter } = this.props;
// if value prop updated, reset dirty state
if (prevProps.value !== value || prevProps.parameter !== parameter) {
this.setState({
value: parameter.hasPendingValue ? parameter.pendingValue : value,
isDirty: parameter.hasPendingValue,
});
}
};
onSelect = (value) => {
const isDirty = !isEqual(value, this.props.value);
this.setState({ value, isDirty });
this.props.onSelect(value, isDirty);
};
renderDateParameter() {
const { type, parameter } = this.props;
const { value } = this.state;
return (
);
}
renderDateRangeParameter() {
const { type, parameter } = this.props;
const { value } = this.state;
return (
);
}
renderEnumInput() {
const { enumOptions, parameter } = this.props;
const { value } = this.state;
const enumOptionsArray = enumOptions.split("\n").filter((v) => v !== "");
// Antd Select doesn't handle null in multiple mode
const normalize = (val) => (parameter.multiValuesOptions && val === null ? [] : val);
return (
({ label: String(opt), value: opt }))}
showSearch
showArrow
notFoundContent={isEmpty(enumOptionsArray) ? "No options available" : null}
{...multipleValuesProps}
/>
);
}
renderQueryBasedInput() {
const { queryId, parameter } = this.props;
const { value } = this.state;
return (
);
}
renderNumberInput() {
const { className } = this.props;
const { value } = this.state;
const normalize = (val) => (isNaN(val) ? undefined : val);
return (
this.onSelect(normalize(val))}
/>
);
}
renderTextPatternInput() {
const { className } = this.props;
const { value } = this.state;
return (
this.onSelect(e.target.value)}
/>
);
}
renderTextInput() {
const { className } = this.props;
const { value } = this.state;
return (
this.onSelect(e.target.value)}
/>
);
}
renderInput() {
const { type } = this.props;
switch (type) {
case "datetime-with-seconds":
case "datetime-local":
case "date":
return this.renderDateParameter();
case "datetime-range-with-seconds":
case "datetime-range":
case "date-range":
return this.renderDateRangeParameter();
case "enum":
return this.renderEnumInput();
case "query":
return this.renderQueryBasedInput();
case "number":
return this.renderNumberInput();
case "text-pattern":
return this.renderTextPatternInput();
default:
return this.renderTextInput();
}
}
render() {
const { isDirty } = this.state;
return (
{this.renderInput()}
);
}
}
export default ParameterValueInput;
================================================
FILE: client/app/components/ParameterValueInput.less
================================================
@import (reference, less) "~@/assets/less/ant"; // for ant @vars
@input-dirty: #fffce1;
.parameter-input {
display: inline-block;
position: relative;
width: 100%;
.@{ant-prefix}-input,
.@{ant-prefix}-input-number {
min-width: 100% !important;
}
.@{ant-prefix}-select {
width: 100%;
}
&[data-dirty] {
.@{ant-prefix}-input,
.@{ant-prefix}-input-number,
.@{ant-prefix}-select-selector,
.@{ant-prefix}-picker {
background-color: @input-dirty;
}
}
}
================================================
FILE: client/app/components/Parameters.jsx
================================================
import { size, filter, forEach, extend, isEmpty } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import { SortableContainer, SortableElement, DragHandle } from "@redash/viz/lib/components/sortable";
import location from "@/services/location";
import { Parameter, createParameter } from "@/services/parameters";
import ParameterApplyButton from "@/components/ParameterApplyButton";
import ParameterValueInput from "@/components/ParameterValueInput";
import PlainButton from "@/components/PlainButton";
import EditParameterSettingsDialog from "./EditParameterSettingsDialog";
import { toHuman } from "@/lib/utils";
import "./Parameters.less";
function updateUrl(parameters) {
const params = extend({}, location.search);
parameters.forEach((param) => {
extend(params, param.toUrlParams());
});
location.setSearch(params, true);
}
export default class Parameters extends React.Component {
static propTypes = {
parameters: PropTypes.arrayOf(PropTypes.instanceOf(Parameter)),
editable: PropTypes.bool,
sortable: PropTypes.bool,
disableUrlUpdate: PropTypes.bool,
onValuesChange: PropTypes.func,
onPendingValuesChange: PropTypes.func,
onParametersEdit: PropTypes.func,
appendSortableToParent: PropTypes.bool,
};
static defaultProps = {
parameters: [],
editable: false,
sortable: false,
disableUrlUpdate: false,
onValuesChange: () => {},
onPendingValuesChange: () => {},
onParametersEdit: () => {},
appendSortableToParent: true,
};
toCamelCase = (str) => {
if (isEmpty(str)) {
return "";
}
return str.replace(/\s+/g, "").toLowerCase();
};
constructor(props) {
super(props);
const { parameters, disableUrlUpdate } = props;
this.state = { parameters };
if (!disableUrlUpdate) {
updateUrl(parameters);
}
const hideRegex = /hide_filter=([^&]+)/g;
const matches = window.location.search.matchAll(hideRegex);
this.hideValues = Array.from(matches, (match) => match[1]);
}
componentDidUpdate = (prevProps) => {
const { parameters, disableUrlUpdate } = this.props;
const parametersChanged = prevProps.parameters !== parameters;
const disableUrlUpdateChanged = prevProps.disableUrlUpdate !== disableUrlUpdate;
if (parametersChanged) {
this.setState({ parameters });
}
if ((parametersChanged || disableUrlUpdateChanged) && !disableUrlUpdate) {
updateUrl(parameters);
}
};
handleKeyDown = (e) => {
// Cmd/Ctrl/Alt + Enter
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey || e.altKey)) {
e.stopPropagation();
this.applyChanges();
}
};
setPendingValue = (param, value, isDirty) => {
const { onPendingValuesChange } = this.props;
this.setState(({ parameters }) => {
if (isDirty) {
param.setPendingValue(value);
} else {
param.clearPendingValue();
}
onPendingValuesChange();
return { parameters };
});
};
moveParameter = ({ oldIndex, newIndex }) => {
const { onParametersEdit } = this.props;
if (oldIndex !== newIndex) {
this.setState(({ parameters }) => {
parameters.splice(newIndex, 0, parameters.splice(oldIndex, 1)[0]);
onParametersEdit(parameters);
return { parameters };
});
}
};
applyChanges = () => {
const { onValuesChange, disableUrlUpdate } = this.props;
this.setState(({ parameters }) => {
const parametersWithPendingValues = parameters.filter((p) => p.hasPendingValue);
forEach(parameters, (p) => p.applyPendingValue());
if (!disableUrlUpdate) {
updateUrl(parameters);
}
onValuesChange(parametersWithPendingValues);
return { parameters };
});
};
showParameterSettings = (parameter, index) => {
const { onParametersEdit } = this.props;
EditParameterSettingsDialog.showModal({ parameter }).onClose((updated) => {
this.setState(({ parameters }) => {
const updatedParameter = extend(parameter, updated);
parameters[index] = createParameter(updatedParameter, updatedParameter.parentQueryId);
onParametersEdit(parameters);
return { parameters };
});
});
};
renderParameter(param, index) {
if (this.hideValues.some((value) => this.toCamelCase(value) === this.toCamelCase(param.name))) {
return null;
}
const { editable } = this.props;
if (param.hidden) {
return null;
}
return (
{param.title || toHuman(param.name)}
{editable && (
this.showParameterSettings(param, index)}
data-test={`ParameterSettings-${param.name}`}
type="button"
>
)}
this.setPendingValue(param, value, isDirty)}
regex={param.regex}
/>
);
}
render() {
const { parameters } = this.state;
const { sortable, appendSortableToParent } = this.props;
const dirtyParamCount = size(filter(parameters, "hasPendingValue"));
return (
(appendSortableToParent ? containerEl : document.body)}
updateBeforeSortStart={this.onBeforeSortStart}
onSortEnd={this.moveParameter}
containerProps={{
className: "parameter-container",
onKeyDown: dirtyParamCount ? this.handleKeyDown : null,
}}
>
{parameters &&
parameters.map((param, index) => (
{sortable && }
{this.renderParameter(param, index)}
))}
);
}
}
================================================
FILE: client/app/components/Parameters.less
================================================
@import (reference, less) "~@/assets/less/ant";
.parameter-block {
display: inline-block;
background: white;
padding: 0 12px 6px 0;
vertical-align: top;
z-index: 1;
white-space: nowrap;
.drag-handle {
padding: 0 5px;
margin-left: -5px;
height: 36px;
}
.parameter-container.sortable-container & {
margin: 4px 0 0 4px;
padding: 3px 6px 6px;
}
&.parameter-dragged {
z-index: 2;
margin: 4px 0 0 4px;
padding: 3px 6px 6px;
box-shadow: 0 4px 9px -3px rgba(102, 136, 153, 0.15);
}
}
.parameter-heading {
display: flex;
align-items: center;
padding-bottom: 4px;
label {
margin-bottom: 1px;
overflow: hidden;
text-overflow: ellipsis;
min-width: 100%;
max-width: 195px;
white-space: nowrap;
.parameter-block[data-editable] & {
min-width: calc(100% - 27px); // make room for settings button
max-width: 195px - 27px;
}
}
}
.parameter-container {
position: relative;
&.sortable-container {
padding: 0 4px 4px 0;
}
.parameter-apply-button {
display: none; // default for mobile
// "floating" on desktop
@media (min-width: 768px) {
position: absolute;
bottom: -36px;
left: -15px;
border-radius: 2px;
z-index: 2;
transition: opacity 150ms ease-out;
box-shadow: 0 4px 9px -3px rgba(102, 136, 153, 0.15);
background-color: #ffffff;
padding: 4px;
padding-left: 16px;
opacity: 0;
display: block;
pointer-events: none; // so tooltip doesn't remain after button hides
}
&[data-show="true"] {
opacity: 1;
display: block;
pointer-events: auto;
}
button {
padding: 0 8px 0 6px;
color: #2096f3;
border-color: #50acf6;
// smaller on desktop
@media (min-width: 768px) {
font-size: 12px;
height: 27px;
}
&:hover,
&:focus,
&:active {
background-color: #eef7fe;
}
i {
margin-right: 3px;
}
}
.ant-badge-count {
min-width: 15px;
height: 15px;
padding: 0 5px;
font-size: 10px;
line-height: 15px;
background: #f77b74;
border-radius: 7px;
box-shadow: 0 0 0 1px white, -1px 1px 0 1px #5d6f7d85;
}
}
}
================================================
FILE: client/app/components/PermissionsEditorDialog/index.jsx
================================================
import React, { useState, useEffect, useCallback } from "react";
import { axios } from "@/services/axios";
import PropTypes from "prop-types";
import { each, debounce, get, find } from "lodash";
import Button from "antd/lib/button";
import List from "antd/lib/list";
import Modal from "antd/lib/modal";
import Select from "antd/lib/select";
import Tag from "antd/lib/tag";
import Tooltip from "@/components/Tooltip";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import { toHuman } from "@/lib/utils";
import HelpTrigger from "@/components/HelpTrigger";
import { UserPreviewCard } from "@/components/PreviewCard";
import PlainButton from "@/components/PlainButton";
import notification from "@/services/notification";
import User from "@/services/user";
import "./index.less";
const { Option } = Select;
const DEBOUNCE_SEARCH_DURATION = 200;
function useGrantees(url) {
const loadGrantees = useCallback(
() =>
axios.get(url).then((data) => {
const resultGrantees = [];
each(data, (grantees, accessType) => {
grantees.forEach((grantee) => {
grantee.accessType = toHuman(accessType);
resultGrantees.push(grantee);
});
});
return resultGrantees;
}),
[url]
);
const addPermission = useCallback(
(userId, accessType = "modify") =>
axios
.post(url, { access_type: accessType, user_id: userId })
.catch(() => notification.error("Could not grant permission to the user")),
[url]
);
const removePermission = useCallback(
(userId, accessType = "modify") =>
axios
.delete(url, { data: { access_type: accessType, user_id: userId } })
.catch(() => notification.error("Could not remove permission from the user")),
[url]
);
return { loadGrantees, addPermission, removePermission };
}
const searchUsers = (searchTerm) =>
User.query({ q: searchTerm })
.then(({ results }) => results)
.catch(() => []);
function PermissionsEditorDialogHeader({ context }) {
return (
<>
Manage Permissions
{`Editing this ${context} is enabled for the users in this list and for admins. `}
>
);
}
PermissionsEditorDialogHeader.propTypes = { context: PropTypes.oneOf(["query", "dashboard"]) };
PermissionsEditorDialogHeader.defaultProps = { context: "query" };
function UserSelect({ onSelect, shouldShowUser }) {
const [loadingUsers, setLoadingUsers] = useState(true);
const [users, setUsers] = useState([]);
const [searchTerm, setSearchTerm] = useState("");
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedSearchUsers = useCallback(
debounce(
(search) =>
searchUsers(search)
.then(setUsers)
.finally(() => setLoadingUsers(false)),
DEBOUNCE_SEARCH_DURATION
),
[]
);
useEffect(() => {
setLoadingUsers(true);
debouncedSearchUsers(searchTerm);
}, [debouncedSearchUsers, searchTerm]);
return (
Loading...
) : (
)
}
filterOption={false}
notFoundContent={null}
value={undefined}
getPopupContainer={(trigger) => trigger.parentNode}
onSelect={onSelect}
>
{users.filter(shouldShowUser).map((user) => (
))}
);
}
UserSelect.propTypes = {
onSelect: PropTypes.func,
shouldShowUser: PropTypes.func,
};
UserSelect.defaultProps = { onSelect: () => {}, shouldShowUser: () => true };
function PermissionsEditorDialog({ dialog, author, context, aclUrl }) {
const [loadingGrantees, setLoadingGrantees] = useState(true);
const [grantees, setGrantees] = useState([]);
const { loadGrantees, addPermission, removePermission } = useGrantees(aclUrl);
const loadUsersWithPermissions = useCallback(() => {
setLoadingGrantees(true);
loadGrantees()
.then(setGrantees)
.catch(() => notification.error("Failed to load grantees list"))
.finally(() => setLoadingGrantees(false));
}, [loadGrantees]);
const userHasPermission = useCallback(
(user) => user.id === author.id || !!get(find(grantees, { id: user.id }), "accessType"),
[author.id, grantees]
);
useEffect(() => {
loadUsersWithPermissions();
}, [aclUrl, loadUsersWithPermissions]);
return (
}
footer={Close }
>
addPermission(userId).then(loadUsersWithPermissions)}
shouldShowUser={(user) => !userHasPermission(user)}
/>
Users with permissions
{loadingGrantees && (
Loading...
)}
(
{user.id === author.id ? (
Author
) : (
removePermission(user.id).then(loadUsersWithPermissions)}
>
)}
)}
/>
);
}
PermissionsEditorDialog.propTypes = {
dialog: DialogPropType.isRequired,
author: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
context: PropTypes.oneOf(["query", "dashboard"]),
aclUrl: PropTypes.string.isRequired,
};
PermissionsEditorDialog.defaultProps = { context: "query" };
export default wrapDialog(PermissionsEditorDialog);
================================================
FILE: client/app/components/PermissionsEditorDialog/index.less
================================================
.permissions-editor-dialog {
.ant-select-dropdown-menu-item-disabled {
// make sure .text-muted has the disabled color
&, .text-muted {
color: rgba(0, 0, 0, 0.25);
}
}
}
================================================
FILE: client/app/components/PlainButton.less
================================================
@import (reference, less) "~@/assets/less/ant";
.plain-button {
all: unset;
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
.@{dropdown-prefix-cls}-menu-item > & {
width: 100%;
margin: -5px -12px;
padding: 5px 12px;
}
.@{menu-prefix-cls}-item > & {
width: 100%;
margin: 0 -16px;
padding: 0 16px;
}
}
.plain-button-link {
.btn-link();
}
================================================
FILE: client/app/components/PlainButton.tsx
================================================
import classNames from "classnames";
import React from "react";
import "./PlainButton.less";
export interface PlainButtonProps extends Omit, "type"> {
type?: "link" | "button";
}
function PlainButton({ className, type, ...rest }: PlainButtonProps) {
return (
);
}
export default PlainButton;
================================================
FILE: client/app/components/PreviewCard.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
import classNames from "classnames";
import Link from "@/components/Link";
// PreviewCard
export function PreviewCard({ imageUrl, roundedImage, title, body, children, className, ...props }) {
return (
{children}
);
}
PreviewCard.propTypes = {
imageUrl: PropTypes.string.isRequired,
title: PropTypes.node.isRequired,
body: PropTypes.node,
roundedImage: PropTypes.bool,
className: PropTypes.string,
children: PropTypes.node,
};
PreviewCard.defaultProps = {
body: null,
roundedImage: true,
className: "",
children: null,
};
// UserPreviewCard
export function UserPreviewCard({ user, withLink, children, ...props }) {
const title = withLink ? {user.name} : user.name;
return (
{children}
);
}
UserPreviewCard.propTypes = {
user: PropTypes.shape({
profile_image_url: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
email: PropTypes.string.isRequired,
}).isRequired,
withLink: PropTypes.bool,
children: PropTypes.node,
};
UserPreviewCard.defaultProps = {
withLink: false,
children: null,
};
// DataSourcePreviewCard
export function DataSourcePreviewCard({ dataSource, withLink, children, ...props }) {
const imageUrl = `/static/images/db-logos/${dataSource.type}.png`;
const title = withLink ? {dataSource.name} : dataSource.name;
return (
{children}
);
}
DataSourcePreviewCard.propTypes = {
dataSource: PropTypes.shape({
name: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
}).isRequired,
withLink: PropTypes.bool,
children: PropTypes.node,
};
DataSourcePreviewCard.defaultProps = {
withLink: false,
children: null,
};
================================================
FILE: client/app/components/QueryBasedParameterInput.jsx
================================================
import { find, isArray, get, first, map, intersection, isEqual, isEmpty } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import SelectWithVirtualScroll from "@/components/SelectWithVirtualScroll";
export default class QueryBasedParameterInput extends React.Component {
static propTypes = {
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
mode: PropTypes.oneOf(["default", "multiple"]),
queryId: PropTypes.number,
onSelect: PropTypes.func,
className: PropTypes.string,
};
static defaultProps = {
value: null,
mode: "default",
parameter: null,
queryId: null,
onSelect: () => {},
className: "",
};
constructor(props) {
super(props);
this.state = {
options: [],
value: null,
loading: false,
};
}
componentDidMount() {
this._loadOptions(this.props.queryId);
}
componentDidUpdate(prevProps) {
if (this.props.queryId !== prevProps.queryId) {
this._loadOptions(this.props.queryId);
}
if (this.props.value !== prevProps.value) {
this.setValue(this.props.value);
}
}
setValue(value) {
const { options } = this.state;
if (this.props.mode === "multiple") {
value = isArray(value) ? value : [value];
const optionValues = map(options, option => option.value);
const validValues = intersection(value, optionValues);
this.setState({ value: validValues });
return validValues;
}
const found = find(options, option => option.value === this.props.value) !== undefined;
value = found ? value : get(first(options), "value");
this.setState({ value });
return value;
}
async _loadOptions(queryId) {
if (queryId && queryId !== this.state.queryId) {
this.setState({ loading: true });
const options = await this.props.parameter.loadDropdownValues();
// stale queryId check
if (this.props.queryId === queryId) {
this.setState({ options, loading: false }, () => {
const updatedValue = this.setValue(this.props.value);
if (!isEqual(updatedValue, this.props.value)) {
this.props.onSelect(updatedValue);
}
});
}
}
}
render() {
const { className, mode, onSelect, queryId, value, ...otherProps } = this.props;
const { loading, options } = this.state;
return (
({ label: String(name), value }))}
showSearch
showArrow
notFoundContent={isEmpty(options) ? "No options available" : null}
{...otherProps}
/>
);
}
}
================================================
FILE: client/app/components/QueryLink.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
import { VisualizationType } from "@redash/viz/lib";
import Link from "@/components/Link";
import VisualizationName from "@/components/visualizations/VisualizationName";
import "./QueryLink.less";
function QueryLink({ query, visualization, readOnly }) {
const getUrl = () => {
let hash = null;
if (visualization) {
if (visualization.type === "TABLE") {
// link to hard-coded table tab instead of the (hidden) visualization tab
hash = "table";
} else {
hash = visualization.id;
}
}
return query.getUrl(false, hash);
};
const QueryLinkWrapper = props => (readOnly ? : );
return (
{query.name}
);
}
QueryLink.propTypes = {
query: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
visualization: VisualizationType,
readOnly: PropTypes.bool,
};
QueryLink.defaultProps = {
visualization: null,
readOnly: false,
};
export default QueryLink;
================================================
FILE: client/app/components/QueryLink.less
================================================
.query-link {
.visualization-name {
font-size: 15px;
font-weight: 500;
color: rgba(0, 0, 0, 0.8);
}
}
================================================
FILE: client/app/components/QuerySelector.jsx
================================================
import { find } from "lodash";
import React, { useState, useEffect } from "react";
import PropTypes from "prop-types";
import cx from "classnames";
import Input from "antd/lib/input";
import Select from "antd/lib/select";
import { Query } from "@/services/query";
import PlainButton from "@/components/PlainButton";
import notification from "@/services/notification";
import { QueryTagsControl } from "@/components/tags-control/TagsControl";
import useSearchResults from "@/lib/hooks/useSearchResults";
const { Option } = Select;
function search(term) {
if (term === null) {
return Promise.resolve(null);
}
// get recent
if (!term) {
return Query.recent().then(results => results.filter(item => !item.is_draft)); // filter out draft
}
// search by query
return Query.query({ q: term }).then(({ results }) => results);
}
export default function QuerySelector(props) {
const [searchTerm, setSearchTerm] = useState("");
const [selectedQuery, setSelectedQuery] = useState();
const [doSearch, searchResults, searching] = useSearchResults(search, { initialResults: [] });
const placeholder = "Search a query by name";
const clearIcon = (
selectQuery(null)}
/>
);
const spinIcon = (
Searching...
);
useEffect(() => {
doSearch(searchTerm);
}, [doSearch, searchTerm]);
// set selected from prop
useEffect(() => {
if (props.selectedQuery) {
setSelectedQuery(props.selectedQuery);
}
}, [props.selectedQuery]);
function selectQuery(queryId) {
let query = null;
if (queryId) {
query = find(searchResults, { id: queryId });
if (!query) {
// shouldn't happen
notification.error("Something went wrong...", "Couldn't select query");
}
}
setSearchTerm(query ? null : ""); // empty string triggers recent fetch
setSelectedQuery(query);
props.onChange(query);
}
function renderResults() {
if (!searchResults.length) {
return No results matching search term.
;
}
return (
{searchResults.map(q => (
selectQuery(q.id)}
data-test={`QueryId${q.id}`}>
{q.name}
))}
);
}
if (props.disabled) {
return (
);
}
if (props.type === "select") {
const suffixIcon = selectedQuery ? clearIcon : null;
const value = selectedQuery ? selectedQuery.name : searchTerm;
return (
{searchResults &&
searchResults.map(q => {
const disabled = q.is_draft;
return (
{q.name}{" "}
);
})}
);
}
return (
{selectedQuery ? (
) : (
setSearchTerm(e.target.value)}
suffix={spinIcon}
/>
)}
{searchResults && renderResults()}
);
}
QuerySelector.propTypes = {
onChange: PropTypes.func.isRequired,
selectedQuery: PropTypes.object, // eslint-disable-line react/forbid-prop-types
type: PropTypes.oneOf(["select", "default"]),
className: PropTypes.string,
disabled: PropTypes.bool,
};
QuerySelector.defaultProps = {
selectedQuery: null,
type: "default",
className: null,
disabled: false,
};
================================================
FILE: client/app/components/Resizable/index.jsx
================================================
import d3 from "d3";
import React, { useRef, useMemo, useCallback, useState, useEffect } from "react";
import PropTypes from "prop-types";
import { Resizable as ReactResizable } from "react-resizable";
import KeyboardShortcuts from "@/services/KeyboardShortcuts";
import "./index.less";
export default function Resizable({ toggleShortcut, direction, sizeAttribute, children }) {
const [size, setSize] = useState(0);
const elementRef = useRef();
const wasUsingTouchEventsRef = useRef(false);
const wasResizedRef = useRef(false);
const sizeProp = direction === "horizontal" ? "width" : "height";
sizeAttribute = sizeAttribute || sizeProp;
const getElementSize = useCallback(() => {
if (!elementRef.current) {
return 0;
}
return Math.floor(elementRef.current.getBoundingClientRect()[sizeProp]);
}, [sizeProp]);
const savedSize = useRef(null);
const toggle = useCallback(() => {
if (!elementRef.current) {
return;
}
const element = d3.select(elementRef.current);
let targetSize;
if (savedSize.current === null) {
targetSize = "0px";
savedSize.current = `${getElementSize()}px`;
} else {
targetSize = savedSize.current;
savedSize.current = null;
}
element
.style(sizeAttribute, savedSize.current || "0px")
.transition()
.duration(200)
.ease("swing")
.style(sizeAttribute, targetSize);
// update state to new element's size
setSize(parseInt(targetSize) || 0);
}, [getElementSize, sizeAttribute]);
const resizeHandle = useMemo(
() => (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
{
// TODO: add key controls
// On desktops resize uses `mousedown`/`mousemove`/`mouseup` events, and there is a conflict
// with this `click` handler: after user releases mouse - this handler will be executed.
// So we use `wasResized` flag to check if there was actual resize or user just pressed and released
// left mouse button (see also resize event handlers where ths flag is set).
// On mobile devices `touchstart`/`touchend` events wll be used, so it's safe to just execute this handler.
// To detect which set of events was actually used during particular resize operation, we pass
// `onMouseDown` handler to draggable core and check event type there (see also that handler's code).
if (wasUsingTouchEventsRef.current || !wasResizedRef.current) {
toggle();
}
wasUsingTouchEventsRef.current = false;
wasResizedRef.current = false;
}}
/>
),
[direction, toggle]
);
useEffect(() => {
if (toggleShortcut) {
const shortcuts = {
[toggleShortcut]: toggle,
};
KeyboardShortcuts.bind(shortcuts);
return () => {
KeyboardShortcuts.unbind(shortcuts);
};
}
}, [toggleShortcut, toggle]);
const resizeEventHandlers = useMemo(
() => ({
onResizeStart: () => {
// use element's size as initial value (it will also check constraints set in CSS)
// updated here and in `draggableCore::onMouseDown` handler to ensure that right value will be used
setSize(getElementSize());
},
onResize: (unused, data) => {
// update element directly for better UI responsiveness
d3.select(elementRef.current).style(sizeAttribute, `${data.size[sizeProp]}px`);
setSize(data.size[sizeProp]);
wasResizedRef.current = true;
},
onResizeStop: () => {
if (wasResizedRef.current) {
savedSize.current = null;
}
},
}),
[sizeProp, getElementSize, sizeAttribute]
);
const draggableCoreOptions = useMemo(
() => ({
onMouseDown: e => {
// In some cases this handler is executed twice during the same resize operation - first time
// with `touchstart` event and second time with `mousedown` (probably emulated by browser).
// Therefore we set the flag only when we receive `touchstart` because in ths case it's definitely
// mobile browser (desktop browsers will also send `mousedown` but never `touchstart`).
if (e.type === "touchstart") {
wasUsingTouchEventsRef.current = true;
}
// use element's size as initial value (it will also check constraints set in CSS)
// updated here and in `onResizeStart` handler to ensure that right value will be used
setSize(getElementSize());
},
}),
[getElementSize]
);
if (!children) {
return null;
}
children = React.createElement(children.type, { ...children.props, ref: elementRef });
return (
{children}
);
}
Resizable.propTypes = {
direction: PropTypes.oneOf(["horizontal", "vertical"]),
sizeAttribute: PropTypes.string,
toggleShortcut: PropTypes.string,
children: PropTypes.element,
};
Resizable.defaultProps = {
direction: "horizontal",
sizeAttribute: null, // "width"/"height" - depending on `direction`
toggleShortcut: null,
children: null,
};
================================================
FILE: client/app/components/Resizable/index.less
================================================
@import (reference, less) "~@/assets/less/inc/variables.less";
.resizable-component.react-resizable {
position: relative;
.react-resizable-handle {
position: absolute;
background: #fff;
margin: 0;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
&:hover,
&:active {
background: mix(@redash-gray, #fff, 6%);
}
&.react-resizable-handle-horizontal {
cursor: col-resize;
width: 10px;
height: auto;
right: 0;
top: 0;
bottom: 0;
&:before {
content: "";
display: inline-block;
width: 3px;
height: 25px;
border-left: 1px solid #ccc;
border-right: 1px solid #ccc;
}
}
&.react-resizable-handle-vertical {
cursor: row-resize;
width: auto;
height: 10px;
left: 0;
right: 0;
bottom: 0;
&:before {
content: "";
display: inline-block;
width: 25px;
height: 3px;
border-top: 1px solid #ccc;
border-bottom: 1px solid #ccc;
}
}
}
}
================================================
FILE: client/app/components/SelectItemsDialog.jsx
================================================
import { filter, find, isEmpty, size } from "lodash";
import React, { useState, useCallback, useEffect } from "react";
import PropTypes from "prop-types";
import classNames from "classnames";
import Modal from "antd/lib/modal";
import Input from "antd/lib/input";
import List from "antd/lib/list";
import Button from "antd/lib/button";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import BigMessage from "@/components/BigMessage";
import LoadingState from "@/components/items-list/components/LoadingState";
import notification from "@/services/notification";
import useSearchResults from "@/lib/hooks/useSearchResults";
import "./SelectItemsDialog.less";
function ItemsList({ items, renderItem, onItemClick }) {
const renderListItem = useCallback(
item => {
const { content, className, isDisabled } = renderItem(item);
return (
onItemClick(item)}>
{content}
);
},
[renderItem, onItemClick]
);
return
;
}
ItemsList.propTypes = {
items: PropTypes.array,
renderItem: PropTypes.func,
onItemClick: PropTypes.func,
};
ItemsList.defaultProps = {
items: [],
renderItem: () => {},
onItemClick: () => {},
};
function SelectItemsDialog({
dialog,
dialogTitle,
inputPlaceholder,
itemKey,
renderItem,
renderStagedItem,
searchItems,
selectedItemsTitle,
width,
showCount,
extraFooterContent,
}) {
const [selectedItems, setSelectedItems] = useState([]);
const [search, items, isLoading] = useSearchResults(searchItems, { initialResults: [] });
const hasResults = items.length > 0;
useEffect(() => {
search();
}, [search]);
const isItemSelected = useCallback(
item => {
const key = itemKey(item);
return !!find(selectedItems, i => itemKey(i) === key);
},
[selectedItems, itemKey]
);
const toggleItem = useCallback(
item => {
if (isItemSelected(item)) {
const key = itemKey(item);
setSelectedItems(filter(selectedItems, i => itemKey(i) !== key));
} else {
setSelectedItems([...selectedItems, item]);
}
},
[selectedItems, itemKey, isItemSelected]
);
const save = useCallback(() => {
dialog.close(selectedItems).catch(error => {
if (error) {
notification.error("Failed to save some of selected items.");
}
});
}, [dialog, selectedItems]);
return (
{extraFooterContent}
Cancel
Save
{showCount && !isEmpty(selectedItems) ? ` (${size(selectedItems)})` : null}
}>
search(event.target.value)}
placeholder={inputPlaceholder}
aria-label={inputPlaceholder}
autoFocus
/>
{renderStagedItem && (
{selectedItemsTitle}
)}
{isLoading && }
{!isLoading && !hasResults && (
)}
{!isLoading && hasResults && (
renderItem(item, { isSelected: isItemSelected(item) })}
onItemClick={toggleItem}
/>
)}
{renderStagedItem && (
{selectedItems.length > 0 && (
renderStagedItem(item, { isSelected: true })}
onItemClick={toggleItem}
/>
)}
)}
);
}
SelectItemsDialog.propTypes = {
dialog: DialogPropType.isRequired,
dialogTitle: PropTypes.string,
inputPlaceholder: PropTypes.string,
selectedItemsTitle: PropTypes.string,
searchItems: PropTypes.func.isRequired, // (searchTerm: string): Promise if `searchTerm === ''` load all
itemKey: PropTypes.func, // (item) => string|number - return key of item (by default `id`)
// left list
// (item, { isSelected }) => {
// content: node, // item contents
// className: string = '', // additional class for item wrapper
// isDisabled: bool = false, // is item clickable or disabled
// }
renderItem: PropTypes.func,
// right list; args/results save as for `renderItem`. if not specified - `renderItem` will be used
renderStagedItem: PropTypes.func,
width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
extraFooterContent: PropTypes.node,
showCount: PropTypes.bool,
};
SelectItemsDialog.defaultProps = {
dialogTitle: "Add Items",
inputPlaceholder: "Search...",
selectedItemsTitle: "Selected items",
itemKey: item => item.id,
renderItem: () => "",
renderStagedItem: null, // hidden by default
width: "80%",
extraFooterContent: null,
showCount: false,
};
export default wrapDialog(SelectItemsDialog);
================================================
FILE: client/app/components/SelectItemsDialog.less
================================================
.select-items-list {
&:hover,
&:focus,
&:focus-within {
color: #555;
background-color: #f5f5f5;
transition: all 150ms ease-in-out;
}
}
================================================
FILE: client/app/components/SelectWithVirtualScroll.tsx
================================================
import React, { useMemo } from "react";
import { maxBy } from "lodash";
import AntdSelect, { SelectProps, LabeledValue } from "antd/lib/select";
import { calculateTextWidth } from "@/lib/calculateTextWidth";
const MIN_LEN_FOR_VIRTUAL_SCROLL = 400;
interface VirtualScrollLabeledValue extends LabeledValue {
label: string;
}
interface VirtualScrollSelectProps extends Omit, "optionFilterProp" | "children"> {
options: Array;
}
function SelectWithVirtualScroll({ options, ...props }: VirtualScrollSelectProps): JSX.Element {
const dropdownMatchSelectWidth = useMemo(() => {
if (options && options.length > MIN_LEN_FOR_VIRTUAL_SCROLL) {
const largestOpt = maxBy(options, "label.length");
if (largestOpt) {
const offset = 40;
const optionText = largestOpt.label;
const width = calculateTextWidth(optionText);
if (width) {
return width + offset;
}
}
return true;
}
return false;
}, [options]);
return (
dropdownMatchSelectWidth={dropdownMatchSelectWidth}
options={options}
allowClear={true}
optionFilterProp="label" // as this component expects "options" prop
{...props}
/>
);
}
export default SelectWithVirtualScroll;
================================================
FILE: client/app/components/SettingsWrapper.jsx
================================================
import React from "react";
import Menu from "antd/lib/menu";
import PageHeader from "@/components/PageHeader";
import Link from "@/components/Link";
import location from "@/services/location";
import settingsMenu from "@/services/settingsMenu";
function wrapSettingsTab(id, options, WrappedComponent) {
settingsMenu.add(id, options);
return function SettingsTab(props) {
const activeItem = settingsMenu.getActiveItem(location.path);
return (
{settingsMenu.getAvailableItems().map(item => (
{item.title}
))}
);
};
}
export default wrapSettingsTab;
================================================
FILE: client/app/components/TagsList.less
================================================
@import (reference, less) "~@/assets/less/ant";
.tags-list {
.tags-list-title {
margin: 15px 5px 5px 5px;
display: flex;
justify-content: space-between;
align-items: center;
.tags-list-label {
display: block;
white-space: nowrap;
margin: 0;
}
a,
.plain-button {
display: block;
white-space: nowrap;
cursor: pointer;
.anticon {
font-size: 75%;
margin-right: 2px;
}
}
}
.ant-badge-count {
background-color: fade(@redash-gray, 10%);
color: fade(@redash-gray, 75%);
}
.ant-menu.ant-menu-inline {
border: none;
.ant-menu-item {
width: 100%;
}
.ant-menu-item-selected {
.ant-badge-count {
background-color: @primary-color;
color: white;
}
}
.ant-menu-item {
&:hover,
&:active,
&:focus,
&:focus-within {
color: @primary-color;
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
}
}
}
}
================================================
FILE: client/app/components/TagsList.tsx
================================================
import { map, includes, difference } from "lodash";
import React, { useState, useCallback, useEffect } from "react";
import Badge from "antd/lib/badge";
import Menu from "antd/lib/menu";
import CloseOutlinedIcon from "@ant-design/icons/CloseOutlined";
import getTags from "@/services/getTags";
import PlainButton from "@/components/PlainButton";
import "./TagsList.less";
type Tag = {
name: string;
count?: number;
};
type TagsListProps = {
tagsUrl: string;
showUnselectAll: boolean;
onUpdate?: (selectedTags: string[]) => void;
};
function TagsList({ tagsUrl, showUnselectAll = false, onUpdate }: TagsListProps): JSX.Element | null {
const [allTags, setAllTags] = useState([]);
const [selectedTags, setSelectedTags] = useState([]);
useEffect(() => {
let isCancelled = false;
getTags(tagsUrl).then(tags => {
if (!isCancelled) {
setAllTags(tags);
}
});
return () => {
isCancelled = true;
};
}, [tagsUrl]);
const toggleTag = useCallback(
(event, tag) => {
let newSelectedTags;
if (event.shiftKey) {
// toggle tag
if (includes(selectedTags, tag)) {
newSelectedTags = difference(selectedTags, [tag]);
} else {
newSelectedTags = [...selectedTags, tag];
}
} else {
// if the tag is the only selected, deselect it, otherwise select only it
if (includes(selectedTags, tag) && selectedTags.length === 1) {
newSelectedTags = [];
} else {
newSelectedTags = [tag];
}
}
setSelectedTags(newSelectedTags);
if (onUpdate) {
onUpdate([...newSelectedTags]);
}
},
[selectedTags, onUpdate]
);
const unselectAll = useCallback(() => {
setSelectedTags([]);
if (onUpdate) {
onUpdate([]);
}
}, [onUpdate]);
if (allTags.length === 0) {
return null;
}
return (
Tags
{showUnselectAll && selectedTags.length > 0 && (
clear selection
)}
{map(allTags, tag => (
toggleTag(event, tag.name)}>
{tag.name}
))}
);
}
export default TagsList;
================================================
FILE: client/app/components/TimeAgo.jsx
================================================
import moment from "moment";
import { isNil } from "lodash";
import React, { useEffect, useMemo, useState } from "react";
import PropTypes from "prop-types";
import { Moment } from "@/components/proptypes";
import { clientConfig } from "@/services/auth";
import Tooltip from "@/components/Tooltip";
function toMoment(value) {
value = !isNil(value) ? moment(value) : null;
return value && value.isValid() ? value : null;
}
export default function TimeAgo({ date, placeholder, autoUpdate, variation }) {
const startDate = toMoment(date);
const [value, setValue] = useState(null);
const title = useMemo(() => (startDate ? startDate.format(clientConfig.dateTimeFormat) : null), [startDate]);
useEffect(() => {
function update() {
setValue(startDate ? startDate.fromNow() : placeholder);
}
update();
if (autoUpdate) {
const timer = setInterval(update, 30 * 1000);
return () => clearInterval(timer);
}
}, [autoUpdate, startDate, placeholder]);
if (variation === "timeAgoInTooltip") {
return (
{title}
);
}
return (
{value}
);
}
TimeAgo.propTypes = {
date: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.instanceOf(Date), Moment]),
placeholder: PropTypes.string,
autoUpdate: PropTypes.bool,
variation: PropTypes.oneOf(["timeAgoInTooltip"]),
};
TimeAgo.defaultProps = {
date: null,
placeholder: "",
autoUpdate: true,
};
================================================
FILE: client/app/components/Timer.jsx
================================================
import React, { useMemo, useState, useEffect } from "react";
import moment from "moment";
import PropTypes from "prop-types";
import { Moment } from "@/components/proptypes";
export default function Timer({ from }) {
const startTime = useMemo(() => moment(from).valueOf(), [from]);
const [value, setValue] = useState(null);
useEffect(() => {
function update() {
const diff = moment.now() - startTime;
const format = diff > 1000 * 60 * 60 ? "HH:mm:ss" : "mm:ss"; // no HH under an hour
setValue(moment.utc(diff).format(format));
}
update();
const timer = setInterval(update, 1000);
return () => clearInterval(timer);
}, [startTime]);
return {value} ;
}
Timer.propTypes = {
from: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.instanceOf(Date), Moment]),
};
Timer.defaultProps = {
from: null,
};
================================================
FILE: client/app/components/Tooltip.tsx
================================================
import React from "react";
import AntTooltip, { TooltipProps } from "antd/lib/tooltip";
import { isNil } from "lodash";
export default function Tooltip({ title, ...restProps }: TooltipProps) {
const liveTitle = !isNil(title) ? (
{title}
) : null;
return ;
}
================================================
FILE: client/app/components/UserGroups.jsx
================================================
import { map } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import Tag from "antd/lib/tag";
import Link from "@/components/Link";
import "./UserGroups.less";
export default function UserGroups({ groups, linkGroups, ...props }) {
return (
{map(groups, group => (
{linkGroups ? {group.name} : group.name}
))}
);
}
UserGroups.propTypes = {
groups: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
name: PropTypes.string,
})
),
linkGroups: PropTypes.bool,
};
UserGroups.defaultProps = {
groups: [],
linkGroups: true,
};
================================================
FILE: client/app/components/UserGroups.less
================================================
.user-groups {
margin: -5px 0 0 -5px;
.ant-tag {
margin: 5px 0 0 5px;
}
}
================================================
FILE: client/app/components/admin/Layout.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
import Menu from "antd/lib/menu";
import PageHeader from "@/components/PageHeader";
import Link from "@/components/Link";
import "./layout.less";
export default function Layout({ activeTab, children }) {
return (
System Status
RQ Status
Outdated Queries
{children}
);
}
Layout.propTypes = {
activeTab: PropTypes.string,
children: PropTypes.node,
};
Layout.defaultProps = {
activeTab: "system_status",
children: null,
};
================================================
FILE: client/app/components/admin/RQStatus.jsx
================================================
import { map } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import Badge from "antd/lib/badge";
import Card from "antd/lib/card";
import Spin from "antd/lib/spin";
import Table from "antd/lib/table";
import { Columns } from "@/components/items-list/components/ItemsTable";
// CounterCard
export function CounterCard({ title, value, loading }) {
return (
{title}
{value}
);
}
CounterCard.propTypes = {
title: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
loading: PropTypes.bool.isRequired,
};
CounterCard.defaultProps = {
value: "",
};
// Tables
const queryJobsColumns = [
{ title: "Queue", dataIndex: "origin" },
{ title: "Query ID", dataIndex: ["meta", "query_id"] },
{ title: "Org ID", dataIndex: ["meta", "org_id"] },
{ title: "Data Source ID", dataIndex: ["meta", "data_source_id"] },
{ title: "User ID", dataIndex: ["meta", "user_id"] },
Columns.custom(scheduled => scheduled.toString(), { title: "Scheduled", dataIndex: ["meta", "scheduled"] }),
Columns.timeAgo({ title: "Start Time", dataIndex: "started_at" }),
Columns.timeAgo({ title: "Enqueue Time", dataIndex: "enqueued_at" }),
];
const otherJobsColumns = [
{ title: "Queue", dataIndex: "origin" },
{ title: "Job Name", dataIndex: "name" },
Columns.timeAgo({ title: "Start Time", dataIndex: "started_at" }),
Columns.timeAgo({ title: "Enqueue Time", dataIndex: "enqueued_at" }),
];
const workersColumns = [
Columns.custom(
value => (
{" "}
{value}
),
{ title: "State", dataIndex: "state" }
),
]
.concat(
map(["Hostname", "PID", "Name", "Queues", "Current Job", "Successful Jobs", "Failed Jobs"], c => ({
title: c,
dataIndex: c.toLowerCase().replace(/\s/g, "_"),
}))
)
.concat([
Columns.dateTime({ title: "Birth Date", dataIndex: "birth_date" }),
Columns.duration({ title: "Total Working Time", dataIndex: "total_working_time" }),
]);
const queuesColumns = map(["Name", "Started", "Queued"], c => ({ title: c, dataIndex: c.toLowerCase() }));
const TablePropTypes = {
loading: PropTypes.bool.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
};
export function WorkersTable({ loading, items }) {
return (
);
}
WorkersTable.propTypes = TablePropTypes;
export function QueuesTable({ loading, items }) {
return (
);
}
QueuesTable.propTypes = TablePropTypes;
export function QueryJobsTable({ loading, items }) {
return (
);
}
QueryJobsTable.propTypes = TablePropTypes;
export function OtherJobsTable({ loading, items }) {
return (
);
}
OtherJobsTable.propTypes = TablePropTypes;
================================================
FILE: client/app/components/admin/StatusBlock.jsx
================================================
/* eslint-disable react/prop-types */
import { toPairs } from "lodash";
import React from "react";
import List from "antd/lib/list";
import Card from "antd/lib/card";
import TimeAgo from "@/components/TimeAgo";
import { toHuman, prettySize } from "@/lib/utils";
export function General({ info }) {
info = toPairs(info);
return (
{info.length === 0 && No data
}
{info.length > 0 && (
(
{value}
}>{toHuman(name)}
)}
/>
)}
);
}
export function DatabaseMetrics({ info }) {
return (
{info.length === 0 && No data
}
{info.length > 0 && (
(
{prettySize(size)}}>{name}
)}
/>
)}
);
}
export function Queues({ info }) {
info = toPairs(info);
return (
{info.length === 0 && No data
}
{info.length > 0 && (
(
{queue.size}}>{name}
)}
/>
)}
);
}
export function Manager({ info }) {
const items = info
? [
}>
Last Refresh
,
}>
Started
,
{info.outdatedQueriesCount}}>
Outdated Queries Count
,
]
: [];
return (
{!info && No data
}
{info && item} />}
);
}
================================================
FILE: client/app/components/admin/layout.less
================================================
.admin-page-layout {
.ant-table {
overflow-x: auto;
}
}
================================================
FILE: client/app/components/cards-list/CardsList.less
================================================
@import (reference, less) "~@/assets/less/inc/variables";
.visual-card-list {
width: 100%;
margin: -5px 0 0 -5px; // compensate for .visual-card spacing
}
.visual-card {
background: #ffffff;
border: 1px solid fade(@redash-gray, 15%);
border-radius: 3px;
margin: 5px;
width: 212px;
padding: 15px 5px;
cursor: pointer;
box-shadow: none;
transition: transform 0.12s ease-out;
transition-duration: 0.3s;
transition-property: box-shadow;
display: flex;
align-items: center;
&:hover,
&:focus,
&:focus-within {
box-shadow: rgba(102, 136, 153, 0.15) 0px 4px 9px -3px;
}
img {
width: 64px !important;
height: 64px !important;
margin-right: 5px;
}
h3 {
font-size: 13px;
color: #323232;
margin: 0 !important;
text-overflow: ellipsis;
overflow: hidden;
}
}
@media (max-width: 1200px) {
.visual-card {
width: 217px;
}
}
@media (max-width: 755px) {
.visual-card {
width: 47%;
}
}
@media (max-width: 515px) {
.visual-card {
width: 47%;
img {
width: 48px;
height: 48px;
}
}
}
@media (max-width: 408px) {
.visual-card {
width: 100%;
padding: 5px;
img {
width: 48px;
height: 48px;
}
}
}
================================================
FILE: client/app/components/cards-list/CardsList.tsx
================================================
import { includes, isEmpty } from "lodash";
import PropTypes from "prop-types";
import React, { useState } from "react";
import Input from "antd/lib/input";
import Link from "@/components/Link";
import PlainButton from "@/components/PlainButton";
import EmptyState from "@/components/items-list/components/EmptyState";
import "./CardsList.less";
export interface CardsListItem {
title: string;
imgSrc: string;
href?: string;
onClick?: React.MouseEventHandler;
}
export interface CardsListProps {
items?: CardsListItem[];
showSearch?: boolean;
}
interface ListItemProps {
item: CardsListItem;
keySuffix: string;
}
function ListItem({ item, keySuffix }: ListItemProps) {
const commonProps = {
key: `card${keySuffix}`,
className: "visual-card",
onClick: item.onClick,
children: (
<>
{item.title}
>
),
};
return item.href ? : ;
}
export default function CardsList({ items = [], showSearch = false }: CardsListProps) {
const [searchText, setSearchText] = useState("");
const filteredItems = items.filter(
item => isEmpty(searchText) || includes(item.title.toLowerCase(), searchText.toLowerCase())
);
return (
{showSearch && (
) => setSearchText(e.target.value)}
autoFocus
/>
)}
{isEmpty(filteredItems) ? (
) : (
{filteredItems.map((item: CardsListItem, index: number) => (
))}
)}
);
}
CardsList.propTypes = {
items: PropTypes.arrayOf(
PropTypes.shape({
title: PropTypes.string.isRequired,
imgSrc: PropTypes.string.isRequired,
onClick: PropTypes.func,
href: PropTypes.string,
})
),
showSearch: PropTypes.bool,
};
================================================
FILE: client/app/components/dashboards/AddWidgetDialog.jsx
================================================
import { map, includes, groupBy, first, find } from "lodash";
import React, { useState, useMemo, useCallback } from "react";
import PropTypes from "prop-types";
import Select from "antd/lib/select";
import Modal from "antd/lib/modal";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import { MappingType, ParameterMappingListInput } from "@/components/ParameterMappingInput";
import QuerySelector from "@/components/QuerySelector";
import notification from "@/services/notification";
import { Query } from "@/services/query";
import { useUniqueId } from "@/lib/hooks/useUniqueId";
function VisualizationSelect({ query, visualization, onChange }) {
const visualizationGroups = useMemo(() => {
return query ? groupBy(query.visualizations, "type") : {};
}, [query]);
const vizSelectId = useUniqueId("visualization-select");
const handleChange = useCallback(
visualizationId => {
const selectedVisualization = query ? find(query.visualizations, { id: visualizationId }) : null;
onChange(selectedVisualization || null);
},
[query, onChange]
);
if (!query) {
return null;
}
return (
Choose Visualization
{map(visualizationGroups, (visualizations, groupKey) => (
{map(visualizations, visualization => (
{visualization.name}
))}
))}
);
}
VisualizationSelect.propTypes = {
query: PropTypes.object,
visualization: PropTypes.object,
onChange: PropTypes.func,
};
VisualizationSelect.defaultProps = {
query: null,
visualization: null,
onChange: () => {},
};
function AddWidgetDialog({ dialog, dashboard }) {
const [selectedQuery, setSelectedQuery] = useState(null);
const [selectedVisualization, setSelectedVisualization] = useState(null);
const [parameterMappings, setParameterMappings] = useState([]);
const selectQuery = useCallback(
queryId => {
// Clear previously selected query (if any)
setSelectedQuery(null);
setSelectedVisualization(null);
setParameterMappings([]);
if (queryId) {
Query.get({ id: queryId }).then(query => {
if (query) {
const existingParamNames = map(dashboard.getParametersDefs(), param => param.name);
setSelectedQuery(query);
setParameterMappings(
map(query.getParametersDefs(), param => ({
name: param.name,
type: includes(existingParamNames, param.name)
? MappingType.DashboardMapToExisting
: MappingType.DashboardAddNew,
mapTo: param.name,
value: param.normalizedValue,
title: "",
param,
}))
);
if (query.visualizations.length > 0) {
setSelectedVisualization(first(query.visualizations));
}
}
});
}
},
[dashboard]
);
const saveWidget = useCallback(() => {
dialog.close({ visualization: selectedVisualization, parameterMappings }).catch(() => {
notification.error("Widget could not be added");
});
}, [dialog, selectedVisualization, parameterMappings]);
const existingParams = dashboard.getParametersDefs();
const parameterMappingsId = useUniqueId("parameter-mappings");
return (
selectQuery(query ? query.id : null)} />
{selectedQuery && (
)}
{parameterMappings.length > 0 && [
Parameters
,
,
]}
);
}
AddWidgetDialog.propTypes = {
dialog: DialogPropType.isRequired,
dashboard: PropTypes.object.isRequired,
};
export default wrapDialog(AddWidgetDialog);
================================================
FILE: client/app/components/dashboards/AutoHeightController.js
================================================
import { includes, reduce, some } from "lodash";
// TODO: Revisit this implementation when migrating widget component to React
const WIDGET_SELECTOR = '[data-widgetid="{0}"]';
const WIDGET_CONTENT_SELECTOR = [
".widget-header", // header
".visualization-renderer", // visualization
".scrollbox .alert", // error state
".spinner-container", // loading state
".tile__bottom-control", // footer
].join(",");
const INTERVAL = 200;
export default class AutoHeightController {
widgets = {};
interval = null;
onHeightChange = null;
constructor(handler) {
this.onHeightChange = handler;
}
update(widgets) {
const newWidgetIds = widgets
.filter(widget => widget.options.position.autoHeight)
.map(widget => widget.id.toString());
// added
newWidgetIds.filter(id => !includes(Object.keys(this.widgets), id)).forEach(this.add);
// removed
Object.keys(this.widgets)
.filter(id => !includes(newWidgetIds, id))
.forEach(this.remove);
}
add = id => {
if (this.isEmpty()) {
this.start();
}
const selector = WIDGET_SELECTOR.replace("{0}", id);
this.widgets[id] = [
function getHeight() {
const widgetEl = document.querySelector(selector);
if (!widgetEl) {
return undefined; // safety
}
// get all content elements
const els = widgetEl.querySelectorAll(WIDGET_CONTENT_SELECTOR);
// calculate accumulated height
return reduce(
els,
(acc, el) => {
const height = el ? el.getBoundingClientRect().height : 0;
return acc + height;
},
0
);
},
];
};
remove = id => {
// ignore if not an active autoHeight widget
if (!this.exists(id)) {
return;
}
// not actually deleting from this.widgets to prevent case of unwanted re-adding
this.widgets[id.toString()] = false;
if (this.isEmpty()) {
this.stop();
}
};
exists = id => !!this.widgets[id.toString()];
isEmpty = () => !some(this.widgets);
checkHeightChanges = () => {
Object.keys(this.widgets)
.filter(this.exists) // reject already removed items
.forEach(id => {
const [getHeight, prevHeight] = this.widgets[id];
const height = getHeight();
if (height && height !== prevHeight) {
this.widgets[id][1] = height; // save
this.onHeightChange(id, height); // dispatch
}
});
};
start = () => {
this.stop();
this.interval = setInterval(this.checkHeightChanges, INTERVAL);
};
stop = () => {
clearInterval(this.interval);
};
resume = () => {
if (!this.isEmpty()) {
this.start();
}
};
destroy = () => {
this.stop();
this.widgets = null;
};
}
================================================
FILE: client/app/components/dashboards/CreateDashboardDialog.jsx
================================================
import { trim } from "lodash";
import React, { useState } from "react";
import Modal from "antd/lib/modal";
import Input from "antd/lib/input";
import DynamicComponent from "@/components/DynamicComponent";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import navigateTo from "@/components/ApplicationArea/navigateTo";
import recordEvent from "@/services/recordEvent";
import { policy } from "@/services/policy";
import { Dashboard } from "@/services/dashboard";
function CreateDashboardDialog({ dialog }) {
const [name, setName] = useState("");
const [isValid, setIsValid] = useState(false);
const [saveInProgress, setSaveInProgress] = useState(false);
const isCreateDashboardEnabled = policy.isCreateDashboardEnabled();
function handleNameChange(event) {
const value = trim(event.target.value);
setName(value);
setIsValid(value !== "");
}
function save() {
if (name !== "") {
setSaveInProgress(true);
Dashboard.save({ name }).then(data => {
dialog.close();
navigateTo(`${data.url}?edit`);
});
recordEvent("create", "dashboard");
}
}
return (
);
}
CreateDashboardDialog.propTypes = {
dialog: DialogPropType.isRequired,
};
export default wrapDialog(CreateDashboardDialog);
================================================
FILE: client/app/components/dashboards/DashboardGrid.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
import { chain, cloneDeep, find } from "lodash";
import cx from "classnames";
import { Responsive, WidthProvider } from "react-grid-layout";
import { VisualizationWidget, TextboxWidget, RestrictedWidget } from "@/components/dashboards/dashboard-widget";
import { FiltersType } from "@/components/Filters";
import cfg from "@/config/dashboard-grid-options";
import AutoHeightController from "./AutoHeightController";
import { WidgetTypeEnum } from "@/services/widget";
import "react-grid-layout/css/styles.css";
import "./dashboard-grid.less";
const ResponsiveGridLayout = WidthProvider(Responsive);
const WidgetType = PropTypes.shape({
id: PropTypes.number.isRequired,
options: PropTypes.shape({
position: PropTypes.shape({
col: PropTypes.number.isRequired,
row: PropTypes.number.isRequired,
sizeY: PropTypes.number.isRequired,
minSizeY: PropTypes.number.isRequired,
maxSizeY: PropTypes.number.isRequired,
sizeX: PropTypes.number.isRequired,
minSizeX: PropTypes.number.isRequired,
maxSizeX: PropTypes.number.isRequired,
}).isRequired,
}).isRequired,
});
const SINGLE = "single-column";
const MULTI = "multi-column";
const DashboardWidget = React.memo(
function DashboardWidget({
widget,
dashboard,
onLoadWidget,
onRefreshWidget,
onRemoveWidget,
onParameterMappingsChange,
isEditing,
canEdit,
isPublic,
isLoading,
filters,
}) {
const { type } = widget;
const onLoad = () => onLoadWidget(widget);
const onRefresh = () => onRefreshWidget(widget);
const onDelete = () => onRemoveWidget(widget.id);
if (type === WidgetTypeEnum.VISUALIZATION) {
return (
);
}
if (type === WidgetTypeEnum.TEXTBOX) {
return ;
}
return ;
},
(prevProps, nextProps) =>
prevProps.widget === nextProps.widget &&
prevProps.canEdit === nextProps.canEdit &&
prevProps.isPublic === nextProps.isPublic &&
prevProps.isLoading === nextProps.isLoading &&
prevProps.filters === nextProps.filters &&
prevProps.isEditing === nextProps.isEditing
);
class DashboardGrid extends React.Component {
static propTypes = {
isEditing: PropTypes.bool.isRequired,
isPublic: PropTypes.bool,
dashboard: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
widgets: PropTypes.arrayOf(WidgetType).isRequired,
filters: FiltersType,
onBreakpointChange: PropTypes.func,
onLoadWidget: PropTypes.func,
onRefreshWidget: PropTypes.func,
onRemoveWidget: PropTypes.func,
onLayoutChange: PropTypes.func,
onParameterMappingsChange: PropTypes.func,
};
static defaultProps = {
isPublic: false,
filters: [],
onLoadWidget: () => {},
onRefreshWidget: () => {},
onRemoveWidget: () => {},
onLayoutChange: () => {},
onBreakpointChange: () => {},
onParameterMappingsChange: () => {},
};
static normalizeFrom(widget) {
const {
id,
options: { position: pos },
} = widget;
return {
i: id.toString(),
x: pos.col,
y: pos.row,
w: pos.sizeX,
h: pos.sizeY,
minW: pos.minSizeX,
maxW: pos.maxSizeX,
minH: pos.minSizeY,
maxH: pos.maxSizeY,
};
}
mode = null;
autoHeightCtrl = null;
constructor(props) {
super(props);
this.state = {
layouts: {},
disableAnimations: true,
};
// init AutoHeightController
this.autoHeightCtrl = new AutoHeightController(this.onWidgetHeightUpdated);
this.autoHeightCtrl.update(this.props.widgets);
}
componentDidMount() {
this.onBreakpointChange(document.body.offsetWidth <= cfg.mobileBreakPoint ? SINGLE : MULTI);
// Work-around to disable initial animation on widgets; `measureBeforeMount` doesn't work properly:
// it disables animation, but it cannot detect scrollbars.
setTimeout(() => {
this.setState({ disableAnimations: false });
}, 50);
}
componentDidUpdate() {
// update, in case widgets added or removed
this.autoHeightCtrl.update(this.props.widgets);
}
componentWillUnmount() {
this.autoHeightCtrl.destroy();
}
onLayoutChange = (_, layouts) => {
// workaround for when dashboard starts at single mode and then multi is empty or carries single col data
// fixes test dashboard_spec['shows widgets with full width']
// TODO: open react-grid-layout issue
if (layouts[MULTI]) {
this.setState({ layouts });
}
// workaround for https://github.com/STRML/react-grid-layout/issues/889
// remove next line when fix lands
this.mode = document.body.offsetWidth <= cfg.mobileBreakPoint ? SINGLE : MULTI;
// end workaround
// don't save single column mode layout
if (this.mode === SINGLE) {
return;
}
const normalized = chain(layouts[MULTI])
.keyBy("i")
.mapValues(this.normalizeTo)
.value();
this.props.onLayoutChange(normalized);
};
onBreakpointChange = mode => {
this.mode = mode;
this.props.onBreakpointChange(mode === SINGLE);
};
// height updated by auto-height
onWidgetHeightUpdated = (widgetId, newHeight) => {
this.setState(({ layouts }) => {
const layout = cloneDeep(layouts[MULTI]); // must clone to allow react-grid-layout to compare prev/next state
const item = find(layout, { i: widgetId.toString() });
if (item) {
// update widget height
item.h = Math.ceil((newHeight + cfg.margins) / cfg.rowHeight);
}
return { layouts: { [MULTI]: layout } };
});
};
// height updated by manual resize
onWidgetResize = (layout, oldItem, newItem) => {
if (oldItem.h !== newItem.h) {
this.autoHeightCtrl.remove(Number(newItem.i));
}
this.autoHeightCtrl.resume();
};
normalizeTo = layout => ({
col: layout.x,
row: layout.y,
sizeX: layout.w,
sizeY: layout.h,
autoHeight: this.autoHeightCtrl.exists(layout.i),
});
render() {
const {
onLoadWidget,
onRefreshWidget,
onRemoveWidget,
onParameterMappingsChange,
filters,
dashboard,
isPublic,
isEditing,
widgets,
} = this.props;
const className = cx("dashboard-wrapper", isEditing ? "editing-mode" : "preview-mode");
return (
{widgets.map(widget => (
))}
);
}
}
export default DashboardGrid;
================================================
FILE: client/app/components/dashboards/EditParameterMappingsDialog.jsx
================================================
import { isMatch, map, find, sortBy } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import Modal from "antd/lib/modal";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import {
MappingType,
ParameterMappingListInput,
parameterMappingsToEditableMappings,
editableMappingsToParameterMappings,
synchronizeWidgetTitles,
} from "@/components/ParameterMappingInput";
import notification from "@/services/notification";
export function getParamValuesSnapshot(mappings, dashboardParameters) {
return map(
sortBy(mappings, m => m.name),
m => {
let param;
switch (m.type) {
case MappingType.StaticValue:
return [m.name, m.value];
case MappingType.WidgetLevel:
return [m.name, m.param.value];
case MappingType.DashboardAddNew:
case MappingType.DashboardMapToExisting:
param = find(dashboardParameters, p => p.name === m.mapTo);
return [m.name, param ? param.value : null];
// no default
}
}
);
}
class EditParameterMappingsDialog extends React.Component {
static propTypes = {
dashboard: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
widget: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
dialog: DialogPropType.isRequired,
};
originalParamValuesSnapshot = null;
constructor(props) {
super(props);
const parameterMappings = parameterMappingsToEditableMappings(
props.widget.options.parameterMappings,
props.widget.query.getParametersDefs(),
map(this.props.dashboard.getParametersDefs(), p => p.name)
);
this.originalParamValuesSnapshot = getParamValuesSnapshot(
parameterMappings,
this.props.dashboard.getParametersDefs()
);
this.state = {
saveInProgress: false,
parameterMappings,
};
}
saveWidget() {
const widget = this.props.widget;
this.setState({ saveInProgress: true });
const newMappings = editableMappingsToParameterMappings(this.state.parameterMappings);
widget.options.parameterMappings = newMappings;
const valuesChanged = !isMatch(
this.originalParamValuesSnapshot,
getParamValuesSnapshot(this.state.parameterMappings, this.props.dashboard.getParametersDefs())
);
const widgetsToSave = [
widget,
...synchronizeWidgetTitles(widget.options.parameterMappings, this.props.dashboard.widgets),
];
Promise.all(map(widgetsToSave, w => w.save()))
.then(() => {
this.props.dialog.close(valuesChanged);
})
.catch(() => {
notification.error("Widget cannot be updated");
})
.finally(() => {
this.setState({ saveInProgress: false });
});
}
updateParamMappings(parameterMappings) {
this.setState({ parameterMappings });
}
render() {
const { dialog } = this.props;
return (
this.saveWidget()}
okButtonProps={{ loading: this.state.saveInProgress }}
width={700}>
{this.state.parameterMappings.length > 0 && (
this.updateParamMappings(mappings)}
/>
)}
);
}
}
export default wrapDialog(EditParameterMappingsDialog);
================================================
FILE: client/app/components/dashboards/ExpandedWidgetDialog.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
import Button from "antd/lib/button";
import Modal from "antd/lib/modal";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import { FiltersType } from "@/components/Filters";
import VisualizationRenderer from "@/components/visualizations/VisualizationRenderer";
import VisualizationName from "@/components/visualizations/VisualizationName";
function ExpandedWidgetDialog({ dialog, widget, filters }) {
return (
{widget.getQuery().name}
>
}
width="95%"
footer={Close }>
);
}
ExpandedWidgetDialog.propTypes = {
dialog: DialogPropType.isRequired,
widget: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
filters: FiltersType,
};
ExpandedWidgetDialog.defaultProps = {
filters: [],
};
export default wrapDialog(ExpandedWidgetDialog);
================================================
FILE: client/app/components/dashboards/TextboxDialog.jsx
================================================
import { toString } from "lodash";
import { markdown } from "markdown";
import React, { useState, useEffect, useCallback } from "react";
import PropTypes from "prop-types";
import { useDebouncedCallback } from "use-debounce";
import Modal from "antd/lib/modal";
import Input from "antd/lib/input";
import Tooltip from "@/components/Tooltip";
import Divider from "antd/lib/divider";
import Link from "@/components/Link";
import HtmlContent from "@redash/viz/lib/components/HtmlContent";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import notification from "@/services/notification";
import "./TextboxDialog.less";
function TextboxDialog({ dialog, isNew, ...props }) {
const [text, setText] = useState(toString(props.text));
const [preview, setPreview] = useState(null);
useEffect(() => {
setText(props.text);
setPreview(markdown.toHTML(props.text));
}, [props.text]);
const [updatePreview] = useDebouncedCallback(() => {
setPreview(markdown.toHTML(text));
}, 200);
const handleInputChange = useCallback(
event => {
setText(event.target.value);
updatePreview();
},
[updatePreview]
);
const saveWidget = useCallback(() => {
dialog.close(text).catch(() => {
notification.error(isNew ? "Widget could not be added" : "Widget could not be saved");
});
}, [dialog, isNew, text]);
const confirmDialogDismiss = useCallback(() => {
const originalText = props.text;
if (text !== originalText) {
Modal.confirm({
title: "Quit editing?",
content: "Changes you made so far will not be saved. Are you sure?",
okText: "Yes, quit",
okType: "danger",
onOk: () => dialog.dismiss(),
maskClosable: true,
autoFocusButton: null,
style: { top: 170 },
});
} else {
dialog.dismiss();
}
}, [dialog, text, props.text]);
return (
Supports basic{" "}
Markdown
.
{text && (
Preview:
{preview}
)}
);
}
TextboxDialog.propTypes = {
dialog: DialogPropType.isRequired,
isNew: PropTypes.bool,
text: PropTypes.string,
};
TextboxDialog.defaultProps = {
isNew: false,
text: "",
};
export default wrapDialog(TextboxDialog);
================================================
FILE: client/app/components/dashboards/TextboxDialog.less
================================================
.textbox-dialog {
small {
display: block;
margin-top: 4px;
}
.preview {
padding: 9px 9px 1px;
background-color: #f7f7f7;
margin-top: 8px;
word-wrap: break-word
}
.preview-title {
display: block;
margin-top: -5px;
}
}
================================================
FILE: client/app/components/dashboards/dashboard-grid.less
================================================
.dashboard-wrapper {
flex-grow: 1;
margin-bottom: 70px;
.layout {
margin: -15px -15px 0;
}
.tile {
display: flex;
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
width: auto;
height: auto;
overflow: hidden;
margin: 0;
padding: 0;
}
.pivot-table-visualization-container > table,
.visualization-renderer > .visualization-renderer-wrapper {
overflow: visible;
}
&.preview-mode {
.widget-menu-regular {
display: block;
}
.widget-menu-remove {
display: none;
}
}
&.editing-mode {
/* Y axis lines */
background: linear-gradient(to right, transparent, transparent 1px, #f6f8f9 1px, #f6f8f9),
linear-gradient(to bottom, #b3babf, #b3babf 1px, transparent 1px, transparent);
background-size: 5px 50px;
background-position-y: -8px;
/* X axis lines */
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
bottom: 85px;
right: 0;
background: linear-gradient(to bottom, transparent, transparent 2px, #f6f8f9 2px, #f6f8f9 5px),
linear-gradient(to left, #b3babf, #b3babf 1px, transparent 1px, transparent);
background-size: calc((100% + 15px) / 12) 5px;
background-position: -7px 1px;
}
}
.widget-auto-height-enabled {
.spinner {
position: static;
}
.scrollbox {
overflow-y: hidden;
}
}
}
.react-grid-layout {
&.disable-animations {
& > .react-grid-item {
transition: none !important;
}
}
}
.dashboard-wrapper .dashboard-widget-wrapper:not(.widget-auto-height-enabled),
.query-fixed-layout {
.visualization-renderer {
display: flex;
flex-direction: column;
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
> .visualization-renderer-wrapper {
flex-grow: 1;
position: relative;
}
> .filters-wrapper {
flex-grow: 0;
flex-shrink: 0;
}
}
.sunburst-visualization-container,
.sankey-visualization-container,
.map-visualization-container,
.word-cloud-visualization-container,
.box-plot-deprecated-visualization-container,
.chart-visualization-container {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
width: auto;
height: auto;
overflow: hidden;
}
.counter-visualization-container {
height: 100%;
.counter-visualization-content {
position: absolute;
left: 10px;
top: 15px;
right: 10px;
bottom: 15px;
height: auto;
overflow: hidden;
padding: 0;
}
}
}
.query-fixed-layout {
.visualization-renderer > .visualization-renderer-wrapper {
.counter-visualization-container {
// counter is too large on Query pages, so let's add some constraints
max-width: 600px;
max-height: 400px;
// center it
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
margin: auto;
}
}
}
// react-grid-layout overrides
.react-grid-item {
touch-action: initial !important; // react-draggable disables touch by default
&.react-draggable {
touch-action: none !important;
}
// placeholder color
&.react-grid-placeholder {
border-radius: 3px;
background-color: #e0e6eb;
opacity: 0.5;
}
// resize placeholder behind widget, the lib's default is above 🤷♂️
&.resizing {
z-index: 3;
}
// auto-height animation
&.cssTransforms:not(.resizing) {
transition-property: transform, height; // added ", height"
}
// resize handle size
& > .react-resizable-handle {
background: none;
&:after {
width: 11px;
height: 11px;
right: 5px;
bottom: 5px;
}
}
}
================================================
FILE: client/app/components/dashboards/dashboard-widget/RestrictedWidget.jsx
================================================
import React from "react";
import Widget from "./Widget";
function RestrictedWidget(props) {
return (
This widget requires access to a data source you don't have access to.
);
}
export default RestrictedWidget;
================================================
FILE: client/app/components/dashboards/dashboard-widget/TextboxWidget.jsx
================================================
import React, { useState } from "react";
import PropTypes from "prop-types";
import { markdown } from "markdown";
import Menu from "antd/lib/menu";
import HtmlContent from "@redash/viz/lib/components/HtmlContent";
import TextboxDialog from "@/components/dashboards/TextboxDialog";
import Widget from "./Widget";
function TextboxWidget(props) {
const { widget, canEdit } = props;
const [text, setText] = useState(widget.text);
const editTextBox = () => {
TextboxDialog.showModal({
text: widget.text,
}).onClose(newText => {
widget.text = newText;
setText(newText);
return widget.save();
});
};
const TextboxMenuOptions = [
Edit
,
];
if (!widget.width) {
return null;
}
return (
{markdown.toHTML(text || "")}
);
}
TextboxWidget.propTypes = {
widget: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
canEdit: PropTypes.bool,
};
TextboxWidget.defaultProps = {
canEdit: false,
};
export default TextboxWidget;
================================================
FILE: client/app/components/dashboards/dashboard-widget/VisualizationWidget.jsx
================================================
import React, { useState } from "react";
import PropTypes from "prop-types";
import { compact, isEmpty, invoke, map } from "lodash";
import { markdown } from "markdown";
import cx from "classnames";
import Menu from "antd/lib/menu";
import HtmlContent from "@redash/viz/lib/components/HtmlContent";
import { currentUser } from "@/services/auth";
import recordEvent from "@/services/recordEvent";
import { formatDateTime } from "@/lib/utils";
import Link from "@/components/Link";
import Parameters from "@/components/Parameters";
import TimeAgo from "@/components/TimeAgo";
import Timer from "@/components/Timer";
import { Moment } from "@/components/proptypes";
import QueryLink from "@/components/QueryLink";
import { FiltersType } from "@/components/Filters";
import PlainButton from "@/components/PlainButton";
import ExpandedWidgetDialog from "@/components/dashboards/ExpandedWidgetDialog";
import EditParameterMappingsDialog from "@/components/dashboards/EditParameterMappingsDialog";
import VisualizationRenderer from "@/components/visualizations/VisualizationRenderer";
import Widget from "./Widget";
function visualizationWidgetMenuOptions({ widget, canEditDashboard, onParametersEdit }) {
const canViewQuery = currentUser.hasPermission("view_query");
const canEditParameters = canEditDashboard && !isEmpty(invoke(widget, "query.getParametersDefs"));
const widgetQueryResult = widget.getQueryResult();
const isQueryResultEmpty = !widgetQueryResult || !widgetQueryResult.isEmpty || widgetQueryResult.isEmpty();
const downloadLink = fileType => widgetQueryResult.getLink(widget.getQuery().id, fileType);
const downloadName = fileType => widgetQueryResult.getName(widget.getQuery().name, fileType);
return compact([
{!isQueryResultEmpty ? (
Download as CSV File
) : (
"Download as CSV File"
)}
,
{!isQueryResultEmpty ? (
Download as TSV File
) : (
"Download as TSV File"
)}
,
{!isQueryResultEmpty ? (
Download as Excel File
) : (
"Download as Excel File"
)}
,
(canViewQuery || canEditParameters) && ,
canViewQuery && (
View Query
),
canEditParameters && (
Edit Parameters
),
]);
}
function RefreshIndicator({ refreshStartedAt }) {
return (
);
}
RefreshIndicator.propTypes = { refreshStartedAt: Moment };
RefreshIndicator.defaultProps = { refreshStartedAt: null };
function VisualizationWidgetHeader({
widget,
refreshStartedAt,
parameters,
isEditing,
onParametersUpdate,
onParametersEdit,
}) {
const canViewQuery = currentUser.hasPermission("view_query");
return (
<>
{!isEmpty(widget.getQuery().description) && (
{markdown.toHTML(widget.getQuery().description || "")}
)}
{!isEmpty(parameters) && (
)}
>
);
}
VisualizationWidgetHeader.propTypes = {
widget: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
refreshStartedAt: Moment,
parameters: PropTypes.arrayOf(PropTypes.object),
isEditing: PropTypes.bool,
onParametersUpdate: PropTypes.func,
onParametersEdit: PropTypes.func,
};
VisualizationWidgetHeader.defaultProps = {
refreshStartedAt: null,
onParametersUpdate: () => {},
onParametersEdit: () => {},
isEditing: false,
parameters: [],
};
function VisualizationWidgetFooter({ widget, isPublic, onRefresh, onExpand }) {
const widgetQueryResult = widget.getQueryResult();
const updatedAt = invoke(widgetQueryResult, "getUpdatedAt");
const [refreshClickButtonId, setRefreshClickButtonId] = useState();
const refreshWidget = buttonId => {
if (!refreshClickButtonId) {
setRefreshClickButtonId(buttonId);
onRefresh().finally(() => setRefreshClickButtonId(null));
}
};
return widgetQueryResult ? (
<>
{!isPublic && !!widgetQueryResult && (
refreshWidget(1)}
data-test="RefreshButton">
{refreshClickButtonId === 1 ? "Refreshing, please wait. " : "Press to refresh. "}
{" "}
)}
{formatDateTime(updatedAt)}
{isPublic && (
)}
{!isPublic && (
refreshWidget(2)}>
{refreshClickButtonId === 2 ? "Refreshing, please wait." : "Press to refresh."}
)}
>
) : null;
}
VisualizationWidgetFooter.propTypes = {
widget: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
isPublic: PropTypes.bool,
onRefresh: PropTypes.func.isRequired,
onExpand: PropTypes.func.isRequired,
};
VisualizationWidgetFooter.defaultProps = { isPublic: false };
class VisualizationWidget extends React.Component {
static propTypes = {
widget: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
dashboard: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
filters: FiltersType,
isPublic: PropTypes.bool,
isLoading: PropTypes.bool,
canEdit: PropTypes.bool,
isEditing: PropTypes.bool,
onLoad: PropTypes.func,
onRefresh: PropTypes.func,
onDelete: PropTypes.func,
onParameterMappingsChange: PropTypes.func,
};
static defaultProps = {
filters: [],
isPublic: false,
isLoading: false,
canEdit: false,
isEditing: false,
onLoad: () => {},
onRefresh: () => {},
onDelete: () => {},
onParameterMappingsChange: () => {},
};
constructor(props) {
super(props);
this.state = {
localParameters: props.widget.getLocalParameters(),
localFilters: props.filters,
};
}
componentDidMount() {
const { widget, onLoad } = this.props;
recordEvent("view", "query", widget.visualization.query.id, { dashboard: true });
recordEvent("view", "visualization", widget.visualization.id, { dashboard: true });
onLoad();
}
onLocalFiltersChange = localFilters => {
this.setState({ localFilters });
};
expandWidget = () => {
ExpandedWidgetDialog.showModal({ widget: this.props.widget, filters: this.state.localFilters });
};
editParameterMappings = () => {
const { widget, dashboard, onRefresh, onParameterMappingsChange } = this.props;
EditParameterMappingsDialog.showModal({
dashboard,
widget,
}).onClose(valuesChanged => {
// refresh widget if any parameter value has been updated
if (valuesChanged) {
onRefresh();
}
onParameterMappingsChange();
this.setState({ localParameters: widget.getLocalParameters() });
});
};
renderVisualization() {
const { widget, filters } = this.props;
const widgetQueryResult = widget.getQueryResult();
const widgetStatus = widgetQueryResult && widgetQueryResult.getStatus();
switch (widgetStatus) {
case "failed":
return (
{widgetQueryResult.getError() && (
Error running query: {widgetQueryResult.getError()}
)}
);
case "done":
return (
);
default:
return (
);
}
}
render() {
const { widget, isLoading, isPublic, canEdit, isEditing, onRefresh } = this.props;
const { localParameters } = this.state;
const widgetQueryResult = widget.getQueryResult();
const isRefreshing = isLoading && !!(widgetQueryResult && widgetQueryResult.getStatus());
const onParametersEdit = parameters => {
const paramOrder = map(parameters, "name");
widget.options.paramOrder = paramOrder;
widget.save("options", { paramOrder });
};
return (
}
footer={
}
tileProps={{ "data-refreshing": isRefreshing }}>
{this.renderVisualization()}
);
}
}
export default VisualizationWidget;
================================================
FILE: client/app/components/dashboards/dashboard-widget/Widget.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
import cx from "classnames";
import { isEmpty } from "lodash";
import Dropdown from "antd/lib/dropdown";
import Modal from "antd/lib/modal";
import Menu from "antd/lib/menu";
import recordEvent from "@/services/recordEvent";
import { Moment } from "@/components/proptypes";
import PlainButton from "@/components/PlainButton";
import "./Widget.less";
function WidgetDropdownButton({ extraOptions, showDeleteOption, onDelete }) {
const WidgetMenu = (
{extraOptions}
{showDeleteOption && extraOptions && }
{showDeleteOption && Remove from Dashboard }
);
return (
);
}
WidgetDropdownButton.propTypes = {
extraOptions: PropTypes.node,
showDeleteOption: PropTypes.bool,
onDelete: PropTypes.func,
};
WidgetDropdownButton.defaultProps = {
extraOptions: null,
showDeleteOption: false,
onDelete: () => {},
};
function WidgetDeleteButton({ onClick }) {
return (
);
}
WidgetDeleteButton.propTypes = { onClick: PropTypes.func };
WidgetDeleteButton.defaultProps = { onClick: () => {} };
class Widget extends React.Component {
static propTypes = {
widget: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
className: PropTypes.string,
children: PropTypes.node,
header: PropTypes.node,
footer: PropTypes.node,
canEdit: PropTypes.bool,
isPublic: PropTypes.bool,
refreshStartedAt: Moment,
menuOptions: PropTypes.node,
tileProps: PropTypes.object, // eslint-disable-line react/forbid-prop-types
onDelete: PropTypes.func,
};
static defaultProps = {
className: "",
children: null,
header: null,
footer: null,
canEdit: false,
isPublic: false,
refreshStartedAt: null,
menuOptions: null,
tileProps: {},
onDelete: () => {},
};
componentDidMount() {
const { widget } = this.props;
recordEvent("view", "widget", widget.id);
}
deleteWidget = () => {
const { widget, onDelete } = this.props;
Modal.confirm({
title: "Delete Widget",
content: "Are you sure you want to remove this widget from the dashboard?",
okText: "Delete",
okType: "danger",
onOk: () => widget.delete().then(onDelete),
maskClosable: true,
autoFocusButton: null,
});
};
render() {
const { className, children, header, footer, canEdit, isPublic, menuOptions, tileProps } = this.props;
const showDropdownButton = !isPublic && (canEdit || !isEmpty(menuOptions));
return (
{showDropdownButton && (
)}
{canEdit && }
{header}
{children}
{footer &&
{footer}
}
);
}
}
export default Widget;
================================================
FILE: client/app/components/dashboards/dashboard-widget/Widget.less
================================================
@import (reference, less) "~@/assets/less/inc/variables";
.widget-wrapper {
.widget-actions {
display: flex;
position: absolute;
top: 0;
right: 0;
z-index: 1;
.action {
font-size: 24px;
cursor: pointer;
line-height: 100%;
display: block;
padding: 4px 10px 3px;
&:focus {
background-color: rgba(0, 0, 0, 0.1);
}
&:hover {
background-color: transparent;
color: @blue;
}
&:active {
filter: brightness(75%);
}
}
}
.parameter-container {
margin: 0 15px;
}
.body-container {
display: flex;
flex-direction: column;
align-items: stretch;
.body-row {
flex: 0 1 auto;
}
.body-row-auto {
flex: 1 1 auto;
}
}
.spinner-container {
position: relative;
.spinner {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
}
.scrollbox:empty {
padding: 0 !important;
font-size: 1px !important;
}
.widget-text {
:first-child {
margin-top: 0;
}
:last-child {
margin-bottom: 0;
}
}
}
.editing-mode {
.widget-menu-remove {
display: block;
}
.query-link {
pointer-events: none;
cursor: move;
}
.th-title {
cursor: move;
}
.refresh-indicator {
transition-duration: 0s;
.rd-timer {
display: none;
}
.refresh-indicator-mini();
}
}
.refresh-indicator {
font-size: 18px;
color: #86a1af;
transition: all 100ms linear;
transition-delay: 150ms; // waits for widget-menu to fade out before moving back over it
transform: translateX(22px);
position: absolute;
right: 29px;
top: 8px;
display: flex;
flex-direction: row-reverse;
.refresh-icon {
position: relative;
&:before {
content: "";
position: absolute;
top: 0px;
right: 0;
width: 24px;
height: 24px;
background-color: #e8ecf0;
border-radius: 50%;
transition: opacity 100ms linear;
transition-delay: 150ms;
}
i {
height: 24px;
width: 24px;
display: flex;
justify-content: center;
align-items: center;
}
}
.rd-timer {
font-size: 13px;
display: inline-block;
font-variant-numeric: tabular-nums;
opacity: 0;
transform: translateX(-6px);
transition: all 100ms linear;
transition-delay: 150ms;
color: #bbbbbb;
background-color: rgba(255, 255, 255, 0.9);
padding-left: 2px;
padding-right: 1px;
margin-right: -4px;
margin-top: 2px;
}
.widget-visualization[data-refreshing="false"] & {
display: none;
}
}
.refresh-indicator-mini() {
font-size: 13px;
transition-delay: 0s;
color: #bbbbbb;
transform: translateY(-4px);
.refresh-icon:before {
transition-delay: 0s;
opacity: 0;
}
.rd-timer {
transition-delay: 0s;
opacity: 1;
transform: translateX(0);
}
}
.tile {
.widget-menu-regular,
.btn__refresh {
opacity: 0 !important;
transition: opacity 0.35s ease-in-out;
}
.t-header {
.th-title {
padding-right: 23px; // no overlap on RefreshIndicator
.hidden-print {
margin-bottom: 0;
}
.query-link {
color: fade(@redash-black, 80%);
font-size: 15px;
font-weight: 500;
&:not(.visualization-name) {
color: fade(@redash-black, 50%);
}
}
}
.query--description {
font-size: 14px;
line-height: 1.5;
font-style: italic;
p {
margin-bottom: 0;
}
}
}
.t-header.widget {
padding: 15px;
}
&:hover,
&:focus,
&:active,
&:focus-within {
.widget-menu-regular,
.btn__refresh {
opacity: 1 !important;
transition: opacity 0.35s ease-in-out;
}
.refresh-indicator {
.refresh-indicator-mini();
}
}
.tile__bottom-control {
padding: 10px 15px;
display: flex;
justify-content: space-between;
align-items: center;
.btn-transparent {
&:first-child {
margin-left: -10px;
}
&:last-child {
margin-right: -10px;
}
}
a,
.plain-button {
color: fade(@redash-black, 65%);
&:hover,
&:focus {
color: fade(@redash-black, 95%);
}
}
}
}
================================================
FILE: client/app/components/dashboards/dashboard-widget/index.js
================================================
export { default as VisualizationWidget } from "./VisualizationWidget";
export { default as TextboxWidget } from "./TextboxWidget";
export { default as RestrictedWidget } from "./RestrictedWidget";
================================================
FILE: client/app/components/dynamic-form/DynamicForm.jsx
================================================
import React, { useState, useReducer, useCallback } from "react";
import PropTypes from "prop-types";
import cx from "classnames";
import Form from "antd/lib/form";
import Button from "antd/lib/button";
import { includes, isFunction, filter, find, difference, isEmpty, mapValues } from "lodash";
import notification from "@/services/notification";
import Collapse from "@/components/Collapse";
import DynamicFormField, { FieldType } from "./DynamicFormField";
import getFieldLabel from "./getFieldLabel";
import helper from "./dynamicFormHelper";
import "./DynamicForm.less";
const ActionType = PropTypes.shape({
name: PropTypes.string.isRequired,
callback: PropTypes.func.isRequired,
type: PropTypes.string,
pullRight: PropTypes.bool,
disabledWhenDirty: PropTypes.bool,
});
const AntdFormType = PropTypes.shape({
validateFieldsAndScroll: PropTypes.func,
});
const fieldRules = ({ type, required, minLength }) => {
const requiredRule = required;
const minLengthRule = minLength && includes(["text", "email", "password"], type);
const emailTypeRule = type === "email";
return [
requiredRule && { required, message: "This field is required." },
minLengthRule && { min: minLength, message: "This field is too short." },
emailTypeRule && { type: "email", message: "This field must be a valid email." },
].filter(rule => rule);
};
function normalizeEmptyValuesToNull(fields, values) {
return mapValues(values, (value, key) => {
const { initialValue } = find(fields, { name: key }) || {};
if ((initialValue === null || initialValue === undefined || initialValue === "") && value === "") {
return null;
}
return value;
});
}
function DynamicFormFields({ fields, feedbackIcons, form }) {
return fields.map(field => {
const { name, type, initialValue, contentAfter } = field;
const fieldLabel = getFieldLabel(field);
const formItemProps = {
name,
className: "m-b-10",
hasFeedback: type !== "checkbox" && type !== "file" && feedbackIcons,
label: type === "checkbox" ? "" : fieldLabel,
rules: fieldRules(field),
valuePropName: type === "checkbox" ? "checked" : "value",
initialValue,
};
if (type === "file") {
formItemProps.valuePropName = "data-value";
formItemProps.getValueFromEvent = e => {
if (e && e.fileList[0]) {
helper.getBase64(e.file).then(value => {
form.setFieldsValue({ [name]: value });
});
}
return undefined;
};
}
return (
{isFunction(contentAfter) ? contentAfter(form.getFieldValue(name)) : contentAfter}
);
});
}
DynamicFormFields.propTypes = {
fields: PropTypes.arrayOf(FieldType),
feedbackIcons: PropTypes.bool,
form: AntdFormType.isRequired,
};
DynamicFormFields.defaultProps = {
fields: [],
feedbackIcons: false,
};
const reducerForActionSet = (state, action) => {
if (action.inProgress) {
state.add(action.actionName);
} else {
state.delete(action.actionName);
}
return new Set(state);
};
function DynamicFormActions({ actions, isFormDirty }) {
const [inProgressActions, setActionInProgress] = useReducer(reducerForActionSet, new Set());
const handleAction = useCallback(action => {
const actionName = action.name;
if (isFunction(action.callback)) {
setActionInProgress({ actionName, inProgress: true });
action.callback(() => {
setActionInProgress({ actionName, inProgress: false });
});
}
}, []);
return actions.map(action => (
handleAction(action)}>
{action.name}
));
}
DynamicFormActions.propTypes = {
actions: PropTypes.arrayOf(ActionType),
isFormDirty: PropTypes.bool,
};
DynamicFormActions.defaultProps = {
actions: [],
isFormDirty: false,
};
export default function DynamicForm({
id,
fields,
actions,
feedbackIcons,
hideSubmitButton,
defaultShowExtraFields,
saveText,
onSubmit,
}) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [isTouched, setIsTouched] = useState(false);
const [showExtraFields, setShowExtraFields] = useState(defaultShowExtraFields);
const [form] = Form.useForm();
const extraFields = filter(fields, { extra: true });
const regularFields = difference(fields, extraFields);
const handleFinish = useCallback(
values => {
setIsSubmitting(true);
values = normalizeEmptyValuesToNull(fields, values);
onSubmit(
values,
msg => {
setIsSubmitting(false);
setIsTouched(false); // reset form touched state
notification.success(msg);
},
msg => {
setIsSubmitting(false);
notification.error(msg);
}
);
},
[fields, onSubmit]
);
const handleFinishFailed = useCallback(
({ errorFields }) => {
form.scrollToField(errorFields[0].name);
},
[form]
);
return (
);
}
DynamicForm.propTypes = {
id: PropTypes.string,
fields: PropTypes.arrayOf(FieldType),
actions: PropTypes.arrayOf(ActionType),
feedbackIcons: PropTypes.bool,
hideSubmitButton: PropTypes.bool,
defaultShowExtraFields: PropTypes.bool,
saveText: PropTypes.string,
onSubmit: PropTypes.func,
};
DynamicForm.defaultProps = {
id: null,
fields: [],
actions: [],
feedbackIcons: false,
hideSubmitButton: false,
defaultShowExtraFields: false,
saveText: "Save",
onSubmit: () => {},
};
================================================
FILE: client/app/components/dynamic-form/DynamicForm.less
================================================
@import (reference, less) "~@/assets/less/ant";
@btn-extra-options-bg: fade(@redash-gray, 10%);
@btn-extra-options-border: fade(@redash-gray, 15%);
.dynamic-form {
.extra-options {
margin: 25px 0 10px;
}
.extra-options-button {
&,
&:focus,
&:hover {
height: 40px;
font-weight: 500;
background-color: @btn-extra-options-bg;
border-color: @btn-extra-options-border;
color: @btn-default-color;
}
&:focus,
&:hover {
background-color: fade(@btn-extra-options-bg, 15%);
}
}
.extra-options-content {
margin-top: 15px;
.ant-form-item:last-of-type {
margin-bottom: 0 !important;
}
}
}
================================================
FILE: client/app/components/dynamic-form/DynamicFormField.jsx
================================================
import React from "react";
import { get } from "lodash";
import PropTypes from "prop-types";
import getFieldLabel from "./getFieldLabel";
import {
AceEditorField,
CheckboxField,
ContentField,
FileField,
InputField,
NumberField,
SelectField,
TextAreaField,
} from "./fields";
export const FieldType = PropTypes.shape({
name: PropTypes.string.isRequired,
title: PropTypes.string,
type: PropTypes.oneOf([
"ace",
"text",
"textarea",
"email",
"password",
"number",
"checkbox",
"file",
"select",
"content",
]).isRequired,
initialValue: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
PropTypes.bool,
PropTypes.arrayOf(PropTypes.string),
PropTypes.arrayOf(PropTypes.number),
]),
content: PropTypes.node,
mode: PropTypes.string,
required: PropTypes.bool,
extra: PropTypes.bool,
readOnly: PropTypes.bool,
autoFocus: PropTypes.bool,
minLength: PropTypes.number,
placeholder: PropTypes.string,
contentAfter: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
loading: PropTypes.bool,
props: PropTypes.object, // eslint-disable-line react/forbid-prop-types
});
const FieldTypeComponent = {
checkbox: CheckboxField,
file: FileField,
select: SelectField,
number: NumberField,
textarea: TextAreaField,
ace: AceEditorField,
content: ContentField,
};
export default function DynamicFormField({ form, field, ...otherProps }) {
const { name, type, readOnly, autoFocus } = field;
const fieldLabel = getFieldLabel(field);
const fieldProps = {
...field.props,
className: "w-100",
name,
type,
readOnly,
autoFocus,
placeholder: field.placeholder,
"data-test": fieldLabel,
...otherProps,
};
const FieldComponent = get(FieldTypeComponent, type, InputField);
return ;
}
DynamicFormField.propTypes = { field: FieldType.isRequired };
================================================
FILE: client/app/components/dynamic-form/dynamicFormHelper.js
================================================
import React from "react";
import { each, includes, isUndefined, isEmpty, isNil, map, get, some } from "lodash";
function orderedInputs(properties, order, targetOptions) {
const inputs = new Array(order.length);
Object.keys(properties).forEach(key => {
const position = order.indexOf(key);
const input = {
name: key,
title: properties[key].title,
type: properties[key].type,
placeholder: isNil(properties[key].default) ? null : properties[key].default.toString(),
required: properties[key].required,
extra: properties[key].extra,
initialValue: targetOptions[key],
};
if (input.type === "select") {
input.placeholder = "Select an option";
input.options = properties[key].options;
}
if (position > -1) {
inputs[position] = input;
} else {
inputs.push(input);
}
});
return inputs;
}
function normalizeSchema(configurationSchema) {
each(configurationSchema.properties, (prop, name) => {
if (name === "password" || name === "passwd") {
prop.type = "password";
}
if (name.endsWith("File")) {
prop.type = "file";
}
if (prop.type === "boolean") {
prop.type = "checkbox";
}
if (prop.type === "string") {
prop.type = "text";
}
if (!isEmpty(prop.enum)) {
prop.type = "select";
prop.options = map(prop.enum, value => ({ value, name: value }));
}
if (!isEmpty(prop.extendedEnum)) {
prop.type = "select";
prop.options = prop.extendedEnum;
}
prop.required = includes(configurationSchema.required, name);
prop.extra = includes(configurationSchema.extra_options, name);
});
configurationSchema.order = configurationSchema.order || [];
}
function setDefaultValueToFields(configurationSchema, options = {}) {
const properties = configurationSchema.properties;
Object.keys(properties).forEach(key => {
const property = properties[key];
// set default value for checkboxes
if (!isUndefined(property.default) && property.type === "checkbox") {
options[key] = property.default;
}
// set default or first value when value has predefined options
if (property.type === "select") {
const optionValues = map(property.options, option => option.value);
options[key] = includes(optionValues, property.default) ? property.default : optionValues[0];
}
});
}
function getFields(type = {}, target = { options: {} }) {
const configurationSchema = type.configuration_schema;
normalizeSchema(configurationSchema);
const hasTargetObject = Object.keys(target.options).length > 0;
if (!hasTargetObject) {
setDefaultValueToFields(configurationSchema, target.options);
}
const isNewTarget = !target.id;
const inputs = [
{
name: "name",
title: "Name",
type: "text",
required: true,
initialValue: target.name,
contentAfter: React.createElement("hr"),
placeholder: `My ${type.name}`,
autoFocus: isNewTarget,
},
...orderedInputs(configurationSchema.properties, configurationSchema.order, target.options),
];
return inputs;
}
function updateTargetWithValues(target, values) {
target.name = values.name;
Object.keys(values).forEach(key => {
if (key !== "name") {
target.options[key] = values[key];
}
});
}
function getBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result.substr(reader.result.indexOf(",") + 1));
reader.onerror = error => reject(error);
});
}
function hasFilledExtraField(type, target) {
const extraOptions = get(type, "configuration_schema.extra_options", []);
return some(extraOptions, optionName => {
const defaultOptionValue = get(type, ["configuration_schema", "properties", optionName, "default"]);
const targetOptionValue = get(target, ["options", optionName]);
return !isNil(targetOptionValue) && targetOptionValue !== defaultOptionValue;
});
}
export default {
getFields,
updateTargetWithValues,
getBase64,
hasFilledExtraField,
};
================================================
FILE: client/app/components/dynamic-form/fields/AceEditorField.jsx
================================================
import React from "react";
import AceEditorInput from "@/components/AceEditorInput";
export default function AceEditorField({ form, field, ...otherProps }) {
return ;
}
================================================
FILE: client/app/components/dynamic-form/fields/CheckboxField.jsx
================================================
import React from "react";
import Checkbox from "antd/lib/checkbox";
import getFieldLabel from "../getFieldLabel";
export default function CheckboxField({ form, field, ...otherProps }) {
const fieldLabel = getFieldLabel(field);
return {fieldLabel} ;
}
================================================
FILE: client/app/components/dynamic-form/fields/ContentField.jsx
================================================
export default function ContentField({ field }) {
return field.content;
}
================================================
FILE: client/app/components/dynamic-form/fields/FileField.jsx
================================================
import React from "react";
import Button from "antd/lib/button";
import Upload from "antd/lib/upload";
import UploadOutlinedIcon from "@ant-design/icons/UploadOutlined";
export default function FileField({ form, field, ...otherProps }) {
const { name, initialValue } = field;
const { getFieldValue } = form;
const disabled = getFieldValue(name) !== undefined && getFieldValue(name) !== initialValue;
return (
false}>
Click to upload
);
}
================================================
FILE: client/app/components/dynamic-form/fields/InputField.jsx
================================================
import React from "react";
import Input from "antd/lib/input";
export default function InputField({ form, field, ...otherProps }) {
return ;
}
================================================
FILE: client/app/components/dynamic-form/fields/NumberField.jsx
================================================
import React from "react";
import InputNumber from "antd/lib/input-number";
export default function NumberField({ form, field, ...otherProps }) {
return ;
}
================================================
FILE: client/app/components/dynamic-form/fields/SelectField.jsx
================================================
import React from "react";
import Select from "antd/lib/select";
export default function SelectField({ form, field, ...otherProps }) {
const { readOnly } = field;
return (
trigger.parentNode}>
{field.options &&
field.options.map(option => (
{option.name || option.value}
))}
);
}
================================================
FILE: client/app/components/dynamic-form/fields/TextAreaField.jsx
================================================
import React from "react";
import Input from "antd/lib/input";
export default function TextAreaField({ form, field, ...otherProps }) {
return ;
}
================================================
FILE: client/app/components/dynamic-form/fields/index.js
================================================
export { default as AceEditorField } from "./AceEditorField";
export { default as CheckboxField } from "./CheckboxField";
export { default as ContentField } from "./ContentField";
export { default as FileField } from "./FileField";
export { default as InputField } from "./InputField";
export { default as NumberField } from "./NumberField";
export { default as SelectField } from "./SelectField";
export { default as TextAreaField } from "./TextAreaField";
================================================
FILE: client/app/components/dynamic-form/getFieldLabel.js
================================================
import { toHuman } from "@/lib/utils";
export default function getFieldLabel(field) {
const { title, name } = field;
return title || toHuman(name);
}
================================================
FILE: client/app/components/dynamic-parameters/DateParameter.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
import { getDynamicDateFromString } from "@/services/parameters/DateParameter";
import DynamicDatePicker from "@/components/dynamic-parameters/DynamicDatePicker";
const DYNAMIC_DATE_OPTIONS = [
{
name: "Today/Now",
value: getDynamicDateFromString("d_now"),
label: () =>
getDynamicDateFromString("d_now")
.value()
.format("MMM D"),
},
{
name: "Yesterday",
value: getDynamicDateFromString("d_yesterday"),
label: () =>
getDynamicDateFromString("d_yesterday")
.value()
.format("MMM D"),
},
];
function DateParameter(props) {
return (
);
}
DateParameter.propTypes = {
type: PropTypes.string,
className: PropTypes.string,
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
onSelect: PropTypes.func,
};
DateParameter.defaultProps = {
type: "",
className: "",
value: null,
parameter: null,
onSelect: () => {},
};
export default DateParameter;
================================================
FILE: client/app/components/dynamic-parameters/DateRangeParameter.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
import { includes } from "lodash";
import { getDynamicDateRangeFromString } from "@/services/parameters/DateRangeParameter";
import DynamicDateRangePicker from "@/components/dynamic-parameters/DynamicDateRangePicker";
const DYNAMIC_DATE_OPTIONS = [
{
name: "This week",
value: getDynamicDateRangeFromString("d_this_week"),
label: () =>
getDynamicDateRangeFromString("d_this_week").value()[0].format("MMM D") +
" - " +
getDynamicDateRangeFromString("d_this_week").value()[1].format("MMM D"),
},
{
name: "This month",
value: getDynamicDateRangeFromString("d_this_month"),
label: () => getDynamicDateRangeFromString("d_this_month").value()[0].format("MMMM"),
},
{
name: "This year",
value: getDynamicDateRangeFromString("d_this_year"),
label: () => getDynamicDateRangeFromString("d_this_year").value()[0].format("YYYY"),
},
{
name: "Last week",
value: getDynamicDateRangeFromString("d_last_week"),
label: () =>
getDynamicDateRangeFromString("d_last_week").value()[0].format("MMM D") +
" - " +
getDynamicDateRangeFromString("d_last_week").value()[1].format("MMM D"),
},
{
name: "Last month",
value: getDynamicDateRangeFromString("d_last_month"),
label: () => getDynamicDateRangeFromString("d_last_month").value()[0].format("MMMM"),
},
{
name: "Last year",
value: getDynamicDateRangeFromString("d_last_year"),
label: () => getDynamicDateRangeFromString("d_last_year").value()[0].format("YYYY"),
},
{
name: "Last 7 days",
value: getDynamicDateRangeFromString("d_last_7_days"),
label: () => getDynamicDateRangeFromString("d_last_7_days").value()[0].format("MMM D") + " - Today",
},
{
name: "Last 14 days",
value: getDynamicDateRangeFromString("d_last_14_days"),
label: () => getDynamicDateRangeFromString("d_last_14_days").value()[0].format("MMM D") + " - Today",
},
{
name: "Last 30 days",
value: getDynamicDateRangeFromString("d_last_30_days"),
label: () => getDynamicDateRangeFromString("d_last_30_days").value()[0].format("MMM D") + " - Today",
},
{
name: "Last 60 days",
value: getDynamicDateRangeFromString("d_last_60_days"),
label: () => getDynamicDateRangeFromString("d_last_60_days").value()[0].format("MMM D") + " - Today",
},
{
name: "Last 90 days",
value: getDynamicDateRangeFromString("d_last_90_days"),
label: () => getDynamicDateRangeFromString("d_last_90_days").value()[0].format("MMM D") + " - Today",
},
{
name: "Last 12 months",
value: getDynamicDateRangeFromString("d_last_12_months"),
label: null,
},
{
name: "Last 2 years",
value: getDynamicDateRangeFromString("d_last_2_years"),
label: null,
},
{
name: "Last 3 years",
value: getDynamicDateRangeFromString("d_last_3_years"),
label: null,
},
{
name: "Last 10 years",
value: getDynamicDateRangeFromString("d_last_10_years"),
label: null,
},
];
const DYNAMIC_DATETIME_OPTIONS = [
{
name: "Today",
value: getDynamicDateRangeFromString("d_today"),
label: () => getDynamicDateRangeFromString("d_today").value()[0].format("MMM D"),
},
{
name: "Yesterday",
value: getDynamicDateRangeFromString("d_yesterday"),
label: () => getDynamicDateRangeFromString("d_yesterday").value()[0].format("MMM D"),
},
...DYNAMIC_DATE_OPTIONS,
];
function DateRangeParameter(props) {
const options = includes(props.type, "datetime-range") ? DYNAMIC_DATETIME_OPTIONS : DYNAMIC_DATE_OPTIONS;
return ;
}
DateRangeParameter.propTypes = {
type: PropTypes.string,
className: PropTypes.string,
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
onSelect: PropTypes.func,
};
DateRangeParameter.defaultProps = {
type: "",
className: "",
value: null,
parameter: null,
onSelect: () => {},
};
export default DateRangeParameter;
================================================
FILE: client/app/components/dynamic-parameters/DynamicButton.jsx
================================================
import React, { useRef } from "react";
import PropTypes from "prop-types";
import { isFunction, get, findIndex } from "lodash";
import Dropdown from "antd/lib/dropdown";
import Menu from "antd/lib/menu";
import Typography from "antd/lib/typography";
import { DynamicDateType } from "@/services/parameters/DateParameter";
import { DynamicDateRangeType } from "@/services/parameters/DateRangeParameter";
import ArrowLeftOutlinedIcon from "@ant-design/icons/ArrowLeftOutlined";
import ThunderboltTwoToneIcon from "@ant-design/icons/ThunderboltTwoTone";
import ThunderboltOutlinedIcon from "@ant-design/icons/ThunderboltOutlined";
import "./DynamicButton.less";
const { Text } = Typography;
function DynamicButton({ options, selectedDynamicValue, onSelect, enabled, staticValueLabel }) {
const menu = (
onSelect(get(options, key, "static"))}
selectedKeys={[`${findIndex(options, { value: selectedDynamicValue })}`]}
data-test="DynamicButtonMenu">
{options.map((option, index) => (
// eslint-disable-next-line react/no-array-index-key
{option.name} {option.label && {isFunction(option.label) ? option.label() : option.label} }
))}
{enabled && }
{enabled && (
{staticValueLabel}
)}
);
const containerRef = useRef(null);
return (
e.stopPropagation()}>
) : (
)
}
getPopupContainer={() => containerRef.current}
data-test="DynamicButton"
/>
);
}
DynamicButton.propTypes = {
options: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/forbid-prop-types
selectedDynamicValue: PropTypes.oneOfType([DynamicDateType, DynamicDateRangeType]),
onSelect: PropTypes.func,
enabled: PropTypes.bool,
staticValueLabel: PropTypes.string,
};
DynamicButton.defaultProps = {
options: [],
selectedDynamicValue: null,
onSelect: () => {},
enabled: false,
staticValueLabel: "Back to Static Value",
};
export default DynamicButton;
================================================
FILE: client/app/components/dynamic-parameters/DynamicButton.less
================================================
.dynamic-button {
height: 100%;
position: absolute !important;
right: 1px;
top: 0;
.ant-dropdown-trigger {
height: 100%;
}
button {
border: none;
padding: 0;
box-shadow: none;
background-color: transparent !important;
}
&:after {
content: "";
position: absolute;
width: 1px;
height: 19px;
left: 0;
top: 8px;
border-left: 1px dotted rgba(0, 0, 0, 0.12);
}
}
.dynamic-menu {
width: 187px;
em {
color: #ccc;
font-size: 11px;
}
}
.dynamic-icon {
display: flex !important;
align-items: center;
justify-content: center;
}
================================================
FILE: client/app/components/dynamic-parameters/DynamicDatePicker.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
import classNames from "classnames";
import moment from "moment";
import { includes } from "lodash";
import { isDynamicDate } from "@/services/parameters/DateParameter";
import DateInput from "@/components/DateInput";
import DateTimeInput from "@/components/DateTimeInput";
import DynamicButton from "@/components/dynamic-parameters/DynamicButton";
import "./DynamicParameters.less";
class DynamicDatePicker extends React.Component {
static propTypes = {
type: PropTypes.string,
className: PropTypes.string,
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
onSelect: PropTypes.func,
dynamicButtonOptions: PropTypes.shape({
staticValueLabel: PropTypes.string,
options: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string,
value: PropTypes.object,
label: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
})
),
}),
dateOptions: PropTypes.any, // eslint-disable-line react/forbid-prop-types
};
static defaultProps = {
type: "",
className: "",
value: null,
parameter: null,
dynamicButtonOptions: {
options: [],
},
onSelect: () => {},
};
constructor(props) {
super(props);
this.dateComponentRef = React.createRef();
}
onDynamicValueSelect = dynamicValue => {
const { onSelect, parameter } = this.props;
if (dynamicValue === "static") {
const parameterValue = parameter.getExecutionValue();
if (parameterValue) {
onSelect(moment(parameterValue));
} else {
onSelect(null);
}
} else {
onSelect(dynamicValue.value);
}
// give focus to the DatePicker to get keyboard shortcuts to work
this.dateComponentRef.current.focus();
};
render() {
const { type, value, className, dateOptions, dynamicButtonOptions, onSelect } = this.props;
const hasDynamicValue = isDynamicDate(value);
const isDateTime = includes(type, "datetime");
const additionalAttributes = {};
let DateComponent = DateInput;
if (isDateTime) {
DateComponent = DateTimeInput;
if (includes(type, "with-seconds")) {
additionalAttributes.withSeconds = true;
}
}
if (moment.isMoment(value) || value === null) {
additionalAttributes.value = value;
}
if (hasDynamicValue) {
const dynamicDate = value;
additionalAttributes.placeholder = dynamicDate && dynamicDate.name;
additionalAttributes.value = null;
}
return (
);
}
}
export default DynamicDatePicker;
================================================
FILE: client/app/components/dynamic-parameters/DynamicDateRangePicker.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
import classNames from "classnames";
import moment from "moment";
import { includes, isArray, isObject } from "lodash";
import { isDynamicDateRange } from "@/services/parameters/DateRangeParameter";
import DateRangeInput from "@/components/DateRangeInput";
import DateTimeRangeInput from "@/components/DateTimeRangeInput";
import DynamicButton from "@/components/dynamic-parameters/DynamicButton";
import "./DynamicParameters.less";
function isValidDateRangeValue(value) {
return isArray(value) && value.length === 2 && moment.isMoment(value[0]) && moment.isMoment(value[1]);
}
class DynamicDateRangePicker extends React.Component {
static propTypes = {
type: PropTypes.oneOf(["date-range", "datetime-range", "datetime-range-with-seconds"]).isRequired,
className: PropTypes.string,
value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
parameter: PropTypes.any, // eslint-disable-line react/forbid-prop-types
onSelect: PropTypes.func,
dynamicButtonOptions: PropTypes.shape({
staticValueLabel: PropTypes.string,
options: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string,
value: PropTypes.object,
label: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
})
),
}),
dateRangeOptions: PropTypes.any, // eslint-disable-line react/forbid-prop-types
};
static defaultProps = {
type: "date-range",
className: "",
value: null,
parameter: null,
dynamicButtonOptions: {
options: [],
},
onSelect: () => {},
};
constructor(props) {
super(props);
this.dateRangeComponentRef = React.createRef();
}
onDynamicValueSelect = dynamicValue => {
const { onSelect, parameter } = this.props;
if (dynamicValue === "static") {
const parameterValue = parameter.getExecutionValue();
if (isObject(parameterValue) && parameterValue.start && parameterValue.end) {
onSelect([moment(parameterValue.start), moment(parameterValue.end)]);
} else {
onSelect(null);
}
} else {
onSelect(dynamicValue.value);
}
// give focus to the DatePicker to get keyboard shortcuts to work
this.dateRangeComponentRef.current.focus();
};
render() {
const { type, value, onSelect, className, dynamicButtonOptions, dateRangeOptions, parameter, ...rest } = this.props;
const isDateTimeRange = includes(type, "datetime-range");
const hasDynamicValue = isDynamicDateRange(value);
const additionalAttributes = {};
let DateRangeComponent = DateRangeInput;
if (isDateTimeRange) {
DateRangeComponent = DateTimeRangeInput;
if (includes(type, "with-seconds")) {
additionalAttributes.withSeconds = true;
}
}
if (isValidDateRangeValue(value) || value === null) {
additionalAttributes.value = value;
}
if (hasDynamicValue) {
additionalAttributes.placeholder = [value && value.name];
additionalAttributes.value = null;
}
return (
);
}
}
export default DynamicDateRangePicker;
================================================
FILE: client/app/components/dynamic-parameters/DynamicParameters.less
================================================
@import (reference, less) "~@/assets/less/inc/variables";
.date-range-parameter,
.date-parameter {
position: relative;
}
.redash-datepicker {
padding-right: 35px !important;
&.date-range {
width: 294px;
}
&.datetime-range {
width: 352px;
}
&.datetime-range-with-seconds {
width: 382px;
}
&.dynamic-value {
width: 195px;
}
&.ant-picker-range .ant-picker-clear {
right: 35px !important;
background: transparent;
}
&.date-range-input {
transition: width 100ms ease-in-out;
}
&.dynamic-value {
& ::placeholder {
color: @input-color !important;
}
&.date-range-input {
.ant-picker-active-bar {
opacity: 0;
}
.ant-picker-separator,
.ant-picker-range-separator {
display: none;
}
.ant-picker-input:not(:first-child) {
width: 0;
}
}
}
}
================================================
FILE: client/app/components/empty-state/EmptyState.d.ts
================================================
import React from "react";
type DefaultStepKey = "dataSources" | "queries" | "alerts" | "dashboards" | "users";
export type StepKey = DefaultStepKey | K;
export interface StepItem {
key: StepKey;
node: React.ReactNode;
}
export interface EmptyStateHelpMessageProps {
helpTriggerType: string;
}
export declare const EmptyStateHelpMessage: React.FunctionComponent;
export interface EmptyStateProps {
header?: string;
icon?: string;
description: string;
illustration: string;
illustrationPath?: string;
helpMessage?: React.ReactNode;
closable?: boolean;
onClose?: () => void;
onboardingMode?: boolean;
showAlertStep?: boolean;
showDashboardStep?: boolean;
showDataSourceStep?: boolean;
showInviteStep?: boolean;
getStepsItems?: (items: Array>) => Array>;
}
declare class EmptyState extends React.Component> {}
export default EmptyState;
export interface StepProps {
show: boolean;
completed: boolean;
url?: string;
urlTarget?: string;
urlText?: React.ReactNode;
text?: React.ReactNode;
onClick?: () => void;
}
export declare const Step: React.FunctionComponent;
================================================
FILE: client/app/components/empty-state/EmptyState.jsx
================================================
import { keys, some } from "lodash";
import React, { useCallback } from "react";
import PropTypes from "prop-types";
import classNames from "classnames";
import CloseOutlinedIcon from "@ant-design/icons/CloseOutlined";
import Link from "@/components/Link";
import PlainButton from "@/components/PlainButton";
import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog";
import HelpTrigger from "@/components/HelpTrigger";
import { currentUser } from "@/services/auth";
import organizationStatus from "@/services/organizationStatus";
import "./empty-state.less";
export function Step({ show, completed, text, url, urlText, onClick }) {
if (!show) {
return null;
}
const commonProps = { children: urlText, onClick };
return (
{url ? : } {text}
);
}
Step.propTypes = {
show: PropTypes.bool.isRequired,
completed: PropTypes.bool.isRequired,
text: PropTypes.node,
url: PropTypes.string,
urlTarget: PropTypes.string,
urlText: PropTypes.node,
onClick: PropTypes.func,
};
Step.defaultProps = {
url: null,
urlTarget: null,
urlText: null,
text: null,
onClick: null,
};
export function EmptyStateHelpMessage({ helpTriggerType }) {
return (
Need more support?{" "}
See our Help
);
}
EmptyStateHelpMessage.propTypes = {
helpTriggerType: PropTypes.string.isRequired,
};
function EmptyState({
icon,
header,
description,
illustration,
helpMessage,
closable,
onClose,
onboardingMode,
showAlertStep,
showDashboardStep,
showDataSourceStep,
showInviteStep,
getStepsItems,
illustrationPath,
}) {
const isAvailable = {
dataSource: showDataSourceStep,
query: true,
alert: showAlertStep,
dashboard: showDashboardStep,
inviteUsers: showInviteStep,
};
const isCompleted = {
dataSource: organizationStatus.objectCounters.data_sources > 0,
query: organizationStatus.objectCounters.queries > 0,
alert: organizationStatus.objectCounters.alerts > 0,
dashboard: organizationStatus.objectCounters.dashboards > 0,
inviteUsers: organizationStatus.objectCounters.users > 1,
};
const showCreateDashboardDialog = useCallback(() => {
CreateDashboardDialog.showModal();
}, []);
// Show if `onboardingMode=false` or any requested step not completed
const shouldShow = !onboardingMode || some(keys(isAvailable), (step) => isAvailable[step] && !isCompleted[step]);
if (!shouldShow) {
return null;
}
const renderDataSourcesStep = () => {
if (currentUser.isAdmin) {
return (
);
}
return (
);
};
const defaultStepsItems = [
{
key: "dataSources",
node: renderDataSourcesStep(),
},
{
key: "queries",
node: (
),
},
{
key: "alerts",
node: (
),
},
{
key: "dashboards",
node: (
),
},
{
key: "users",
node: (
),
},
];
const stepsItems = getStepsItems ? getStepsItems(defaultStepsItems) : defaultStepsItems;
const imageSource = illustrationPath ? illustrationPath : "/static/images/illustrations/" + illustration + ".svg";
return (
{header &&
{header} }
{description}
Let's get started
{stepsItems.map((item) => item.node)}
{helpMessage}
{closable && (
)}
);
}
EmptyState.propTypes = {
icon: PropTypes.string,
header: PropTypes.string,
description: PropTypes.string.isRequired,
illustration: PropTypes.string.isRequired,
illustrationPath: PropTypes.string,
helpMessage: PropTypes.node,
closable: PropTypes.bool,
onClose: PropTypes.func,
onboardingMode: PropTypes.bool,
showAlertStep: PropTypes.bool,
showDashboardStep: PropTypes.bool,
showDataSourceStep: PropTypes.bool,
showInviteStep: PropTypes.bool,
getStepItems: PropTypes.func,
};
EmptyState.defaultProps = {
icon: null,
header: null,
helpMessage: null,
closable: false,
onClose: () => {},
onboardingMode: false,
showAlertStep: false,
showDashboardStep: false,
showDataSourceStep: true,
showInviteStep: false,
};
export default EmptyState;
================================================
FILE: client/app/components/empty-state/empty-state.less
================================================
@import (reference, less) "~@/assets/less/ant";
// Empty states
.empty-state {
width: 100%;
margin: 0 auto 10px;
display: flex;
flex-direction: row;
justify-content: space-between;
font-size: 14px;
line-height: 21px;
.empty-state__summary,
.empty-state__steps {
width: 48%;
padding: 35px;
padding-bottom: 25px;
}
.empty-state__steps {
padding-left: 0;
}
.empty-state__summary {
align-self: flex-start;
text-align: center;
background: rgba(102, 136, 153, 0.025);
p {
margin-bottom: 0;
}
}
ol {
margin-bottom: 15px;
padding: 17px;
}
li.done {
text-decoration: line-through;
}
h2 {
margin: 0 0 15px;
}
h4 {
margin-top: 0;
margin-bottom: 15px;
}
a:hover {
cursor: pointer;
}
@media (max-width: 767px) {
flex-direction: column;
.empty-state__summary {
margin-bottom: 25px;
padding-bottom: 15px;
}
.empty-state__summary,
.empty-state__steps {
width: 100%;
}
.empty-state__steps {
padding-left: 35px;
padding-top: 15px;
}
}
}
// close button
.empty-state-wrapper {
position: relative;
.close-button {
position: absolute;
top: 15px;
right: 25px;
font-size: 15px;
color: @text-color-secondary;
cursor: pointer;
transition: color @animation-duration-slow;
&:hover,
&:focus {
color: @text-color;
}
&:active {
filter: contrast(200%);
}
}
}
================================================
FILE: client/app/components/groups/CreateGroupDialog.jsx
================================================
import React from "react";
import Modal from "antd/lib/modal";
import Input from "antd/lib/input";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
class CreateGroupDialog extends React.Component {
static propTypes = {
dialog: DialogPropType.isRequired,
};
state = {
name: "",
};
save = () => {
this.props.dialog.close({
name: this.state.name,
});
};
render() {
const { dialog } = this.props;
return (
this.save()}>
this.setState({ name: event.target.value })}
onPressEnter={() => this.save()}
placeholder="Group Name"
aria-label="Group name"
autoFocus
/>
);
}
}
export default wrapDialog(CreateGroupDialog);
================================================
FILE: client/app/components/groups/DeleteGroupButton.jsx
================================================
import { isString } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import Button from "antd/lib/button";
import Modal from "antd/lib/modal";
import Tooltip from "@/components/Tooltip";
import notification from "@/services/notification";
import Group from "@/services/group";
function deleteGroup(event, group, onGroupDeleted) {
Modal.confirm({
title: "Delete Group",
content: "Are you sure you want to delete this group?",
okText: "Yes",
okType: "danger",
cancelText: "No",
onOk: () => {
Group.delete(group).then(() => {
notification.success("Group deleted successfully.");
onGroupDeleted();
});
},
});
}
export default function DeleteGroupButton({ group, title, onClick, children, ...props }) {
if (!group) {
return null;
}
const button = (
deleteGroup(event, group, onClick)}>
{children}
);
if (isString(title) && title !== "") {
return (
{button}
);
}
return button;
}
DeleteGroupButton.propTypes = {
group: PropTypes.object, // eslint-disable-line react/forbid-prop-types
title: PropTypes.string,
onClick: PropTypes.func,
children: PropTypes.node,
};
DeleteGroupButton.defaultProps = {
group: null,
title: null,
onClick: () => {},
children: null,
};
================================================
FILE: client/app/components/groups/DetailsPageSidebar.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
import Button from "antd/lib/button";
import Divider from "antd/lib/divider";
import * as Sidebar from "@/components/items-list/components/Sidebar";
import { ControllerType } from "@/components/items-list/ItemsList";
import DeleteGroupButton from "./DeleteGroupButton";
import { currentUser } from "@/services/auth";
export default function DetailsPageSidebar({
controller,
group,
items,
canAddMembers,
onAddMembersClick,
canAddDataSources,
onAddDataSourcesClick,
onGroupDeleted,
}) {
const canRemove = group && currentUser.isAdmin && group.type !== "builtin";
return (
{canAddMembers && (
Add Members
)}
{canAddDataSources && (
Add Data Sources
)}
{canRemove && (
Delete Group
)}
);
}
DetailsPageSidebar.propTypes = {
controller: ControllerType.isRequired,
group: PropTypes.object, // eslint-disable-line react/forbid-prop-types
items: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
canAddMembers: PropTypes.bool,
onAddMembersClick: PropTypes.func,
canAddDataSources: PropTypes.bool,
onAddDataSourcesClick: PropTypes.func,
onGroupDeleted: PropTypes.func,
};
DetailsPageSidebar.defaultProps = {
group: null,
canAddMembers: false,
onAddMembersClick: null,
canAddDataSources: false,
onAddDataSourcesClick: null,
onGroupDeleted: null,
};
================================================
FILE: client/app/components/groups/GroupName.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
import EditInPlace from "@/components/EditInPlace";
import { currentUser } from "@/services/auth";
import Group from "@/services/group";
function updateGroupName(group, name, onChange) {
group.name = name;
Group.save(group);
onChange();
}
export default function GroupName({ group, onChange, ...props }) {
if (!group) {
return null;
}
const canEdit = currentUser.isAdmin && group.type !== "builtin";
return (
updateGroupName(group, name, onChange)}
value={group.name}
/>
);
}
GroupName.propTypes = {
group: PropTypes.shape({
name: PropTypes.string.isRequired,
}),
onChange: PropTypes.func,
};
GroupName.defaultProps = {
group: null,
onChange: () => {},
};
================================================
FILE: client/app/components/groups/ListItemAddon.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
import Tooltip from "@/components/Tooltip";
export default function ListItemAddon({ isSelected, isStaged, alreadyInGroup, deselectedIcon }) {
if (isStaged) {
return (
<>
Remove
>
);
}
if (alreadyInGroup) {
return (
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */}
Already selected
);
}
return isSelected ? (
<>
Selected
>
) : (
<>
Select
>
);
}
ListItemAddon.propTypes = {
isSelected: PropTypes.bool,
isStaged: PropTypes.bool,
alreadyInGroup: PropTypes.bool,
deselectedIcon: PropTypes.string,
};
ListItemAddon.defaultProps = {
isSelected: false,
isStaged: false,
alreadyInGroup: false,
deselectedIcon: "fa-angle-double-right",
};
================================================
FILE: client/app/components/items-list/ItemsList.tsx
================================================
import { omit, debounce } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import hoistNonReactStatics from "hoist-non-react-statics";
import { clientConfig } from "@/services/auth";
import { AxiosError } from "axios";
export interface PaginationOptions {
page?: number;
itemsPerPage?: number;
}
export interface SearchOptions {
isServerSideFTS?: boolean;
}
export interface Controller {
params: P; // TODO: Find out what params is (except merging with props)
isLoaded: boolean;
isEmpty: boolean;
// search
searchTerm?: string;
updateSearch: (searchTerm: string, searchOptions?: SearchOptions) => void;
// tags
selectedTags: string[];
updateSelectedTags: (selectedTags: string[]) => void;
// sorting
orderByField?: string;
orderByReverse: boolean;
toggleSorting: (orderByField: string) => void;
setSorting: (orderByField: string, orderByReverse: boolean) => void;
// pagination
page: number;
itemsPerPage: number;
totalItemsCount: number;
pageSizeOptions: number[];
pageItems: I[];
updatePagination: (options: PaginationOptions) => void; // ({ page: number, itemsPerPage: number }) => void
handleError: (error: any) => void; // TODO: Find out if error is string or object or Exception.
}
export const ControllerType = PropTypes.shape({
// values of props declared by wrapped component and some additional props from items list
params: PropTypes.object.isRequired,
isLoaded: PropTypes.bool.isRequired,
isEmpty: PropTypes.bool.isRequired,
// search
searchTerm: PropTypes.string,
updateSearch: PropTypes.func.isRequired, // (searchTerm: string) => void
// tags
selectedTags: PropTypes.array.isRequired,
updateSelectedTags: PropTypes.func.isRequired, // (selectedTags: array of tags) => void
// sorting
orderByField: PropTypes.string,
orderByReverse: PropTypes.bool.isRequired,
toggleSorting: PropTypes.func.isRequired, // (orderByField: string) => void
// pagination
page: PropTypes.number.isRequired,
itemsPerPage: PropTypes.number.isRequired,
totalItemsCount: PropTypes.number.isRequired,
pageSizeOptions: PropTypes.arrayOf(PropTypes.number).isRequired,
pageItems: PropTypes.array.isRequired,
updatePagination: PropTypes.func.isRequired, // ({ page: number, itemsPerPage: number }) => void
handleError: PropTypes.func.isRequired, // (error) => void
});
export type GenericItemSourceError = AxiosError | Error;
export interface ItemsListWrapperProps {
onError?: (error: AxiosError | Error) => void;
children: React.ReactNode;
}
interface ItemsListWrapperState extends Controller {
totalCount?: number;
update: () => void;
}
type ItemsSource = any; // TODO: Type ItemsSource
type StateStorage = any; // TODO: Type StateStore
export interface ItemsListWrappedComponentProps {
controller: Controller;
}
export function wrap(
WrappedComponent: React.ComponentType>,
createItemsSource: () => ItemsSource,
createStateStorage: ( { ...props }) => StateStorage
) {
class ItemsListWrapper extends React.Component> {
private _itemsSource: ItemsSource;
static propTypes = {
onError: PropTypes.func,
children: PropTypes.node,
};
static defaultProps = {
onError: (error: GenericItemSourceError) => {
// Allow calling chain to roll up, and then throw the error in global context
setTimeout(() => {
throw error;
});
},
children: null,
};
constructor(props: ItemsListWrapperProps) {
super(props);
const stateStorage = createStateStorage({ ...props });
const itemsSource = createItemsSource();
this._itemsSource = itemsSource;
itemsSource.setState({ ...stateStorage.getState(), validate: false });
itemsSource.getCallbackContext = () => this.state;
itemsSource.onBeforeUpdate = () => {
const state = itemsSource.getState();
stateStorage.setState(state);
this.setState(this.getState({ ...state, isLoaded: false }));
};
itemsSource.onAfterUpdate = () => {
const state = itemsSource.getState();
this.setState(this.getState({ ...state, isLoaded: true }));
};
itemsSource.onError = (error: GenericItemSourceError) =>
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.props.onError!(error);
const initialState = this.getState({ ...itemsSource.getState(), isLoaded: false });
const { updatePagination, toggleSorting, setSorting, updateSearch, updateSelectedTags, update, handleError } = itemsSource;
let isRunningUpdateSearch = false;
let pendingUpdateSearchParams: any[] | null = null;
const debouncedUpdateSearch = debounce(async (...params) => {
// Avoid running multiple updateSerch concurrently.
// If an updateSearch is already running, we save the params for the latest call.
// When the current updateSearch is finished, we call debouncedUpdateSearch again with the saved params.
if (isRunningUpdateSearch) {
pendingUpdateSearchParams = params;
return;
}
isRunningUpdateSearch = true;
await updateSearch(...params);
isRunningUpdateSearch = false;
if (pendingUpdateSearchParams) {
const pendingParams = pendingUpdateSearchParams;
pendingUpdateSearchParams = null;
debouncedUpdateSearch(...pendingParams);
}
}, 200);
this.state = {
...initialState,
toggleSorting, // eslint-disable-line react/no-unused-state
setSorting, // eslint-disable-line react/no-unused-state
updateSearch: debouncedUpdateSearch, // eslint-disable-line react/no-unused-state
updateSelectedTags, // eslint-disable-line react/no-unused-state
updatePagination, // eslint-disable-line react/no-unused-state
update, // eslint-disable-line react/no-unused-state
handleError, // eslint-disable-line react/no-unused-state
};
}
componentDidMount() {
this.state.update();
}
componentWillUnmount() {
// eslint-disable-next-line @typescript-eslint/no-empty-function
this._itemsSource.onBeforeUpdate = () => {};
// eslint-disable-next-line @typescript-eslint/no-empty-function
this._itemsSource.onAfterUpdate = () => {};
// eslint-disable-next-line @typescript-eslint/no-empty-function
this._itemsSource.onError = () => {};
}
// eslint-disable-next-line class-methods-use-this
getState({
isLoaded,
totalCount,
pageItems,
params,
...rest
}: ItemsListWrapperState): ItemsListWrapperState {
return {
...rest,
params: {
...params, // custom params from items source
...omit(this.props, ["onError", "children"]), // add all props except of own ones
},
isLoaded,
isEmpty: !isLoaded || totalCount === 0,
totalItemsCount: totalCount || 0,
pageSizeOptions: (clientConfig as any).pageSizeOptions, // TODO: Type auth.js
pageItems: pageItems || [],
};
}
render() {
// don't pass own props to wrapped component
const { children, onError, ...props } = this.props;
return (
{children}
);
}
}
// Copy static methods from `WrappedComponent`
hoistNonReactStatics(ItemsListWrapper, WrappedComponent);
return ItemsListWrapper;
}
================================================
FILE: client/app/components/items-list/classes/ItemsFetcher.js
================================================
import { identity, isFunction, isNil, isString } from "lodash";
class ItemsFetcher {
_getRequest(state, context) {
return this._originalGetRequest({}, context);
}
_processResults({ results, count }, state, context) {
return {
results: this._originalProcessResults(results, context),
count,
};
}
constructor({ getRequest, doRequest, processResults }) {
this._originalGetRequest = isFunction(getRequest) ? getRequest : identity;
this._originalDoRequest = doRequest;
this._originalProcessResults = isFunction(processResults) ? processResults : identity;
}
fetch(changes, state, context) {
const request = this._getRequest(state, context);
return this._originalDoRequest(request, context).then(data => this._processResults(data, state, context));
}
}
// For endpoints that return just an array with items; sorting and pagination
// is performed on client
export class PlainListFetcher extends ItemsFetcher {
_allItems = [];
_getRequest({ searchTerm, selectedTags }, context) {
return this._originalGetRequest(
{
q: isString(searchTerm) && searchTerm !== "" ? searchTerm : undefined,
tags: selectedTags,
},
context
);
}
_processResults(data, { paginator, sorter }, context) {
this._allItems = this._originalProcessResults(data, context);
this._allItems = sorter.sort(this._allItems);
return {
results: paginator.getItemsForPage(this._allItems),
count: this._allItems.length,
allResults: this._allItems,
};
}
fetch(changes, state, context) {
// For plain lists we need to reload items from server only if tags or search changes.
if (isNil(changes) || changes.tags || changes.sorting) {
return super.fetch(changes, state, context);
}
// Sorting and pagination could be updated using previously fetched items.
const { paginator, sorter } = state;
if (changes.sorting) {
this._allItems = sorter.sort(this._allItems);
}
return Promise.resolve({
results: paginator.getItemsForPage(this._allItems),
count: this._allItems.length,
allResults: this._allItems,
});
}
}
// For endpoints that support server-side pagination (return object with
// items for current page and total items count)
export class PaginatedListFetcher extends ItemsFetcher {
_getRequest({ paginator, sorter, searchTerm, selectedTags }, context) {
return this._originalGetRequest(
{
page: paginator.page,
page_size: paginator.itemsPerPage,
order: sorter.compiled,
q: isString(searchTerm) && searchTerm !== "" ? searchTerm : undefined,
tags: selectedTags,
},
context
);
}
}
================================================
FILE: client/app/components/items-list/classes/ItemsSource.d.ts
================================================
export interface ItemsSourceOptions extends Partial {
getRequest?: (params: any, context: any) => any; // TODO: Add stricter types
doRequest?: () => any; // TODO: Add stricter type
processResults?: () => any; // TODO: Add stricter type
isPlainList?: boolean;
sortByIteratees?: { [fieldName: string]: (a: I) => number };
}
export interface GetResourceContext extends ItemsSourceState {
params: {
currentPage: number;
// TODO: Add more context parameters
};
}
export type GetResourceRequest = any; // TODO: Add stricter type
export interface ItemsPage {
count: number;
page: number;
page_size: number;
results: INPUT[];
}
export interface ResourceItemsSourceOptions extends ItemsSourceOptions {
getResource: (context: GetResourceContext) => (request: GetResourceRequest) => Promise ;
getItemProcessor?: () => (input: INPUT) => ITEM;
}
export type ItemsSourceState- = {
page: number;
itemsPerPage: number;
orderByField: string;
orderByReverse: boolean;
searchTerm: string;
selectedTags: string[];
totalCount: number;
pageItems: ITEM[];
allItems: ITEM[] | undefined;
params: {
pageTitle?: string;
} & { [key: string]: string | number };
};
declare class ItemsSource {
constructor(options: ItemsSourceOptions);
}
declare class ResourceItemsSource
{
constructor(options: ResourceItemsSourceOptions);
}
================================================
FILE: client/app/components/items-list/classes/ItemsSource.js
================================================
import { isFunction, identity, map, extend } from "lodash";
import Paginator from "./Paginator";
import Sorter from "./Sorter";
import { PlainListFetcher, PaginatedListFetcher } from "./ItemsFetcher";
export class ItemsSource {
onBeforeUpdate = null;
onAfterUpdate = null;
onError = null;
sortByIteratees = undefined;
getCallbackContext = () => null;
_beforeUpdate() {
if (isFunction(this.onBeforeUpdate)) {
return Promise.resolve(this.onBeforeUpdate(this.getState(), this.getCallbackContext()));
}
return Promise.resolve();
}
_afterUpdate() {
if (isFunction(this.onAfterUpdate)) {
return Promise.resolve(this.onAfterUpdate(this.getState(), this.getCallbackContext()));
}
return Promise.resolve();
}
// changes: object with flags or null (full refresh requested)
_changed(changes) {
const state = {
paginator: this._paginator,
sorter: this._sorter,
searchTerm: this._searchTerm,
selectedTags: this._selectedTags,
};
const customParams = {};
const context = {
...this.getCallbackContext(),
setCustomParams: (params) => {
extend(customParams, params);
},
};
return this._beforeUpdate().then(() => {
const fetchToken = Math.random().toString(36).substr(2);
this._currentFetchToken = fetchToken;
return this._fetcher
.fetch(changes, state, context)
.then(({ results, count, allResults }) => {
if (this._currentFetchToken === fetchToken) {
this._pageItems = results;
this._allItems = allResults || null;
this._paginator.setTotalCount(count);
this._params = { ...this._params, ...customParams };
return this._afterUpdate();
}
})
.catch((error) => this.handleError(error));
});
}
constructor({
getRequest,
doRequest,
processResults,
isPlainList = false,
sortByIteratees = undefined,
...defaultState
}) {
if (!isFunction(getRequest)) {
getRequest = identity;
}
this._fetcher = isPlainList
? new PlainListFetcher({ getRequest, doRequest, processResults })
: new PaginatedListFetcher({ getRequest, doRequest, processResults });
this.sortByIteratees = sortByIteratees;
this.setState(defaultState);
this._pageItems = [];
this._params = {};
}
getState() {
return {
page: this._paginator.page,
itemsPerPage: this._paginator.itemsPerPage,
orderByField: this._sorter.field,
orderByReverse: this._sorter.reverse,
searchTerm: this._searchTerm,
selectedTags: this._selectedTags,
totalCount: this._paginator.totalCount,
pageItems: this._pageItems,
allItems: this._allItems,
params: this._params,
};
}
setState(state) {
this._paginator = new Paginator(state);
this._sorter = new Sorter(state, this.sortByIteratees);
this._searchTerm = state.searchTerm || "";
this._selectedTags = state.selectedTags || [];
this._savedOrderByField = this._sorter.field;
}
updatePagination = ({ page, itemsPerPage }) => {
const { page: prevPage, itemsPerPage: prevItemsPerPage } = this._paginator;
this._paginator.setItemsPerPage(itemsPerPage);
this._paginator.setPage(page);
this._changed({
pagination: {
page: prevPage !== this._paginator.page, // page changed flag
itemsPerPage: prevItemsPerPage !== this._paginator.itemsPerPage, // items per page changed flags
},
});
};
toggleSorting = (orderByField) => {
this._sorter.toggleField(orderByField);
this._savedOrderByField = this._sorter.field;
this._changed({ sorting: true });
};
setSorting = (orderByField, orderByReverse) => {
this._sorter.setField(orderByField);
this._sorter.setReverse(orderByReverse);
this._savedOrderByField = this._sorter.field;
this._changed({ sorting: true });
};
updateSearch = (searchTerm, options) => {
// here we update state directly, but later `fetchData` will update it properly
this._searchTerm = searchTerm;
// in search mode ignore the ordering and use the ranking order
// provided by the server-side FTS backend instead, unless it was
// requested by the user by actively ordering in search mode
if (searchTerm === "" || !options?.isServerSideFTS) {
this._sorter.setField(this._savedOrderByField); // restore ordering
} else {
this._sorter.setField(null);
}
this._paginator.setPage(1);
return this._changed({ search: true, pagination: { page: true } });
};
updateSelectedTags = (selectedTags) => {
this._selectedTags = selectedTags;
this._paginator.setPage(1);
this._changed({ tags: true, pagination: { page: true } });
};
update = () => this._changed();
handleError = (error) => {
if (isFunction(this.onError)) {
this.onError(error);
}
};
}
export class ResourceItemsSource extends ItemsSource {
constructor({ getResource, getItemProcessor, ...rest }) {
getItemProcessor = isFunction(getItemProcessor) ? getItemProcessor : () => null;
super({
...rest,
doRequest: (request, context) => {
const resource = getResource(context)(request);
return resource;
},
processResults: (results, context) => {
let processItem = getItemProcessor(context);
processItem = isFunction(processItem) ? processItem : identity;
return map(results, (item) => processItem(item, context));
},
});
}
}
================================================
FILE: client/app/components/items-list/classes/Paginator.js
================================================
import { isUndefined } from "lodash";
export default class Paginator {
page = 1;
itemsPerPage = 20;
totalCount = 0;
get totalPages() {
return Math.ceil(this.totalCount / this.itemsPerPage);
}
setPage(value, validate = true) {
if (isUndefined(value)) {
return;
}
value = parseInt(value, 10) || 1;
if (validate) {
this.page = value >= 1 && value <= this.totalPages ? value : 1;
} else {
this.page = value >= 1 ? value : 1;
}
}
setItemsPerPage(value, validate = true) {
if (isUndefined(value)) {
return;
}
value = parseInt(value, 10) || 20;
this.itemsPerPage = value >= 1 ? value : 1;
if (validate) {
this.setPage(this.page, validate);
}
}
setTotalCount(value, validate = true) {
if (isUndefined(value)) {
return;
}
value = parseInt(value, 10) || 0;
this.totalCount = value;
if (validate) {
this.setPage(this.page, validate);
}
}
constructor({ page, itemsPerPage, totalCount, validate = true } = {}) {
this.setItemsPerPage(itemsPerPage, validate);
this.setTotalCount(totalCount, validate);
this.setPage(page, validate);
}
getItemsForPage(items) {
const first = this.itemsPerPage * (this.page - 1);
const last = first + this.itemsPerPage;
return items.slice(first, last);
}
}
================================================
FILE: client/app/components/items-list/classes/Sorter.js
================================================
import { isString, sortBy } from "lodash";
const ORDER_BY_REVERSE = "-";
export function compile(field, reverse) {
if (!field) {
return null;
}
return reverse ? ORDER_BY_REVERSE + field : field;
}
export function parse(compiled) {
compiled = isString(compiled) ? compiled : "";
const reverse = compiled.startsWith(ORDER_BY_REVERSE);
if (reverse) {
compiled = compiled.substring(1);
}
const field = compiled !== "" ? compiled : null;
return { field, reverse };
}
export default class Sorter {
field = null;
reverse = false;
sortByIteratees = null;
get compiled() {
return compile(this.field, this.reverse);
}
set compiled(value) {
const { field, reverse } = parse(value);
this.field = field;
this.reverse = reverse;
}
setField(value) {
this.field = isString(value) && value !== "" ? value : null;
}
setReverse(value) {
this.reverse = !!value; // cast to boolean
}
constructor({ orderByField, orderByReverse } = {}, sortByIteratees = undefined) {
this.setField(orderByField);
this.setReverse(orderByReverse);
this.sortByIteratees = sortByIteratees;
}
toggleField(field) {
if (!isString(field) || field === "") {
return;
}
if (field === this.field) {
this.reverse = !this.reverse;
} else {
this.field = field;
this.reverse = false;
}
}
sort(items) {
if (this.field) {
const customIteratee = this.sortByIteratees && this.sortByIteratees[this.field];
items = sortBy(items, customIteratee ? [customIteratee] : this.field);
if (this.reverse) {
items.reverse();
}
return items;
}
}
}
================================================
FILE: client/app/components/items-list/classes/StateStorage.js
================================================
import { defaults } from "lodash";
import { clientConfig } from "@/services/auth";
import location from "@/services/location";
import { parse as parseOrderBy, compile as compileOrderBy } from "./Sorter";
export class StateStorage {
constructor(state = {}) {
this._state = { ...state };
}
getState() {
return defaults(this._state, {
page: 1,
itemsPerPage: clientConfig.pageSize,
orderByField: "created_at",
orderByReverse: false,
searchTerm: "",
tags: [],
});
}
// eslint-disable-next-line class-methods-use-this
setState() {}
}
export class UrlStateStorage extends StateStorage {
getState() {
const defaultState = super.getState();
const params = location.search;
const searchTerm = params.q || "";
// in search mode order by should be explicitly specified in url, otherwise use default
const defaultOrderBy =
searchTerm !== "" ? "" : compileOrderBy(defaultState.orderByField, defaultState.orderByReverse);
const { field: orderByField, reverse: orderByReverse } = parseOrderBy(params.order || defaultOrderBy);
return {
page: parseInt(params.page, 10) || defaultState.page,
itemsPerPage: parseInt(params.page_size, 10) || defaultState.itemsPerPage,
orderByField,
orderByReverse,
searchTerm,
};
}
// eslint-disable-next-line class-methods-use-this
setState({ page, itemsPerPage, orderByField, orderByReverse, searchTerm }) {
location.setSearch(
{
page,
page_size: itemsPerPage,
order: compileOrderBy(orderByField, orderByReverse),
q: searchTerm !== "" ? searchTerm : null,
},
true
);
}
}
================================================
FILE: client/app/components/items-list/components/EmptyState.jsx
================================================
import React from "react";
import BigMessage from "@/components/BigMessage";
// Default "list empty" message for list pages
export default function EmptyState(props) {
return (
);
}
================================================
FILE: client/app/components/items-list/components/ItemsTable.jsx
================================================
import { isFunction, map, filter, extend, omit, identity, range, isEmpty } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import classNames from "classnames";
import Table from "antd/lib/table";
import Skeleton from "antd/lib/skeleton";
import FavoritesControl from "@/components/FavoritesControl";
import TimeAgo from "@/components/TimeAgo";
import { durationHumanize, formatDate, formatDateTime } from "@/lib/utils";
// `this` refers to previous function in the chain (`Columns.***`).
// Adds `sorter: true` field to column definition
function sortable(...args) {
return extend(this(...args), { sorter: true });
}
export const Columns = {
favorites(overrides) {
return extend(
{
width: "1%",
render: (text, item) => ,
},
overrides
);
},
avatar(overrides, formatTitle) {
formatTitle = isFunction(formatTitle) ? formatTitle : identity;
return extend(
{
width: "1%",
render: (user, item) => (
),
},
overrides
);
},
date(overrides) {
return extend(
{
render: (text) => formatDate(text),
},
overrides
);
},
dateTime(overrides) {
return extend(
{
render: (text) => formatDateTime(text),
},
overrides
);
},
duration(overrides) {
return extend(
{
width: "1%",
className: "text-nowrap",
render: (text) => durationHumanize(text),
},
overrides
);
},
timeAgo(overrides, timeAgoCustomProps = undefined) {
return extend(
{
render: (value) => ,
},
overrides
);
},
custom(render, overrides) {
return extend(
{
render,
},
overrides
);
},
};
Columns.date.sortable = sortable;
Columns.dateTime.sortable = sortable;
Columns.duration.sortable = sortable;
Columns.timeAgo.sortable = sortable;
Columns.custom.sortable = sortable;
export default class ItemsTable extends React.Component {
static propTypes = {
loading: PropTypes.bool,
// eslint-disable-next-line react/forbid-prop-types
items: PropTypes.arrayOf(PropTypes.object),
columns: PropTypes.arrayOf(
PropTypes.shape({
field: PropTypes.string, // data field
orderByField: PropTypes.string, // field to order by (defaults to `field`)
render: PropTypes.func, // (prop, item) => text | node; `prop` is `item[field]`
isAvailable: PropTypes.func, // return `true` to show column and `false` to hide; if omitted: show column
})
),
showHeader: PropTypes.bool,
onRowClick: PropTypes.func, // (event, item) => void
orderByField: PropTypes.string,
orderByReverse: PropTypes.bool,
toggleSorting: PropTypes.func,
setSorting: PropTypes.func,
"data-test": PropTypes.string,
rowKey: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
};
static defaultProps = {
loading: false,
items: [],
columns: [],
showHeader: true,
onRowClick: null,
orderByField: null,
orderByReverse: false,
toggleSorting: () => {},
};
prepareColumns() {
const { orderByField, orderByReverse } = this.props;
const orderByDirection = orderByReverse ? "descend" : "ascend";
return map(
map(
filter(this.props.columns, (column) => (isFunction(column.isAvailable) ? column.isAvailable() : true)),
(column) => extend(column, { orderByField: column.orderByField || column.field })
),
(column, index) => {
// Wrap render function to pass correct arguments
const render = isFunction(column.render) ? (text, row) => column.render(text, row.item) : identity;
return extend(omit(column, ["field", "orderByField", "render"]), {
key: "column" + index,
dataIndex: ["item", column.field],
defaultSortOrder: column.orderByField === orderByField ? orderByDirection : null,
render,
});
}
);
}
getRowKey = (record) => {
const { rowKey } = this.props;
if (rowKey) {
if (isFunction(rowKey)) {
return rowKey(record.item);
}
return record.item[rowKey];
}
return record.key;
};
render() {
const tableDataProps = {
columns: this.prepareColumns(),
dataSource: map(this.props.items, (item, index) => ({ key: "row" + index, item })),
};
// Bind events only if `onRowClick` specified
const onTableRow = isFunction(this.props.onRowClick)
? (row) => ({
onClick: (event) => {
this.props.onRowClick(event, row.item);
},
})
: null;
const onChange = (pagination, filters, sorter, extra) => {
const action = extra?.action;
if (action === "sort") {
const propsColumn = this.props.columns.find((column) => column.field === sorter.field[1]);
if (!propsColumn.sorter) {
return;
}
let orderByField = propsColumn.orderByField;
const orderByReverse = sorter.order === "descend";
if (orderByReverse === undefined) {
orderByField = null;
}
if (this.props.setSorting) {
this.props.setSorting(orderByField, orderByReverse);
} else {
this.props.toggleSorting(orderByField);
}
}
};
const { showHeader } = this.props;
if (this.props.loading) {
if (isEmpty(tableDataProps.dataSource)) {
tableDataProps.columns = tableDataProps.columns.map((column) => ({
...column,
sorter: false,
render: () => ,
}));
tableDataProps.dataSource = range(10).map((key) => ({ key: `${key}` }));
} else {
tableDataProps.loading = { indicator: null };
}
}
return (
);
}
}
================================================
FILE: client/app/components/items-list/components/LoadingState.jsx
================================================
import React from "react";
import BigMessage from "@/components/BigMessage";
// Default "loading" message for list pages
export default function LoadingState(props) {
return (
);
}
================================================
FILE: client/app/components/items-list/components/Sidebar.jsx
================================================
import { isFunction, isString, filter, map } from "lodash";
import React, { useState, useCallback, useEffect } from "react";
import PropTypes from "prop-types";
import Input from "antd/lib/input";
import AntdMenu from "antd/lib/menu";
import Link from "@/components/Link";
import TagsList from "@/components/TagsList";
/*
SearchInput
*/
export function SearchInput({ placeholder, value, showIcon, onChange, label }) {
const [currentValue, setCurrentValue] = useState(value);
useEffect(() => {
setCurrentValue(value);
}, [value]);
const onInputChange = useCallback(
event => {
const newValue = event.target.value;
setCurrentValue(newValue);
onChange(newValue);
},
[onChange]
);
const InputControl = showIcon ? Input.Search : Input;
return (
);
}
SearchInput.propTypes = {
value: PropTypes.string.isRequired,
placeholder: PropTypes.string,
showIcon: PropTypes.bool,
onChange: PropTypes.func.isRequired,
label: PropTypes.string,
};
SearchInput.defaultProps = {
placeholder: "Search...",
showIcon: false,
label: "Search",
};
/*
Menu
*/
export function Menu({ items, selected }) {
items = filter(items, item => (isFunction(item.isAvailable) ? item.isAvailable() : true));
if (items.length === 0) {
return null;
}
return (
{map(items, item => (
{isString(item.icon) && item.icon !== "" && (
)}
{isFunction(item.icon) && (item.icon(item) || null)}
{item.title}
))}
);
}
Menu.propTypes = {
items: PropTypes.arrayOf(
PropTypes.shape({
key: PropTypes.string.isRequired,
href: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
icon: PropTypes.func, // function to render icon
isAvailable: PropTypes.func, // return `true` to show item and `false` to hide; if omitted: show item
})
),
selected: PropTypes.string,
};
Menu.defaultProps = {
items: [],
selected: null,
};
/*
MenuIcon
*/
export function MenuIcon({ icon }) {
return (
);
}
MenuIcon.propTypes = {
icon: PropTypes.string.isRequired,
};
/*
ProfileImage
*/
export function ProfileImage({ user }) {
if (!isString(user.profile_image_url) || user.profile_image_url === "") {
return null;
}
return ;
}
ProfileImage.propTypes = {
user: PropTypes.shape({
profile_image_url: PropTypes.string,
name: PropTypes.string,
}).isRequired,
};
/*
Tags
*/
export function Tags({ url, onChange, showUnselectAll }) {
if (url === "") {
return null;
}
return (
);
}
Tags.propTypes = {
url: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
showUnselectAll: PropTypes.bool,
unselectAllButtonTitle: PropTypes.string,
};
================================================
FILE: client/app/components/items-list/hooks/useItemsListExtraActions.js
================================================
import { filter, includes, intersection } from "lodash";
import React, { useState, useMemo, useEffect, useCallback } from "react";
import Checkbox from "antd/lib/checkbox";
import { Columns } from "../components/ItemsTable";
export default function useItemsListExtraActions(controller, listColumns, ExtraActionsComponent) {
const [actionsState, setActionsState] = useState({ isAvailable: false });
const [selectedItems, setSelectedItems] = useState([]);
// clear selection when page changes
useEffect(() => {
setSelectedItems([]);
}, [controller.pageItems, actionsState.isAvailable]);
const areAllItemsSelected = useMemo(() => {
const allItems = controller.pageItems;
if (allItems.length === 0 || selectedItems.length === 0) {
return false;
}
return intersection(selectedItems, allItems).length === allItems.length;
}, [selectedItems, controller.pageItems]);
const toggleAllItems = useCallback(() => {
if (areAllItemsSelected) {
setSelectedItems([]);
} else {
setSelectedItems(controller.pageItems);
}
}, [areAllItemsSelected, controller.pageItems]);
const toggleItem = useCallback(
item => {
if (includes(selectedItems, item)) {
setSelectedItems(filter(selectedItems, s => s !== item));
} else {
setSelectedItems([...selectedItems, item]);
}
},
[selectedItems]
);
const checkboxColumn = useMemo(
() =>
Columns.custom(
(text, item) => toggleItem(item)} />,
{
title: () => ,
field: "id",
width: "1%",
}
),
[selectedItems, areAllItemsSelected, toggleAllItems, toggleItem]
);
const Component = useCallback(
function ItemsListExtraActionsComponentWrapper(props) {
// this check mostly needed to avoid eslint exhaustive deps warning
if (!ExtraActionsComponent) {
return null;
}
return ;
},
[ExtraActionsComponent]
);
return useMemo(
() => ({
areExtraActionsAvailable: actionsState.isAvailable,
listColumns: actionsState.isAvailable ? [checkboxColumn, ...listColumns] : listColumns,
Component,
selectedItems,
setSelectedItems,
}),
[actionsState, listColumns, checkboxColumn, selectedItems, Component]
);
}
================================================
FILE: client/app/components/layouts/ContentWithSidebar.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
import classNames from "classnames";
import "./content-with-sidebar.less";
const propTypes = {
className: PropTypes.string,
children: PropTypes.node,
};
const defaultProps = {
className: null,
children: null,
};
// Sidebar
function Sidebar({ className, children, ...props }) {
return (
);
}
Sidebar.propTypes = propTypes;
Sidebar.defaultProps = defaultProps;
// Content
function Content({ className, children, ...props }) {
return (
);
}
Content.propTypes = propTypes;
Content.defaultProps = defaultProps;
// Layout
export default function Layout({ children, className = undefined, ...props }) {
return (
{children}
);
}
Layout.propTypes = propTypes;
Layout.defaultProps = defaultProps;
Layout.Sidebar = Sidebar;
Layout.Content = Content;
================================================
FILE: client/app/components/layouts/content-with-sidebar.less
================================================
.layout-with-sidebar {
@spacing: 15px;
position: relative;
display: flex;
align-items: stretch;
justify-content: stretch;
flex-direction: row;
margin: 0;
> .layout-content {
flex: 1 0 auto;
width: 75%;
order: 1;
margin: 0;
padding: 0 0 0 @spacing
}
> .layout-sidebar {
flex: 0 0 auto;
width: 25%;
max-width: 350px;
order: 0;
margin: 0;
}
@media (max-width: 990px) {
flex-direction: column;
> .layout-content {
width: 100%;
order: 1;
margin: 0;
padding: 0;
}
> .layout-sidebar {
width: 100%;
max-width: none;
order: 0;
margin: 0 0 @spacing 0;
}
}
}
================================================
FILE: client/app/components/proptypes.js
================================================
import PropTypes from "prop-types";
import { wrap } from "lodash";
import moment from "moment";
export const DataSource = PropTypes.shape({
syntax: PropTypes.string,
options: PropTypes.shape({
doc: PropTypes.string,
doc_url: PropTypes.string,
}),
type_name: PropTypes.string,
});
export const Table = PropTypes.shape({
columns: PropTypes.arrayOf(PropTypes.string).isRequired,
});
export const Schema = PropTypes.arrayOf(Table);
export const RefreshScheduleType = PropTypes.shape({
interval: PropTypes.number,
time: PropTypes.string,
day_of_week: PropTypes.string,
until: PropTypes.string,
});
export const RefreshScheduleDefault = {
interval: null,
time: null,
day_of_week: null,
until: null,
};
export const UserProfile = PropTypes.shape({
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
email: PropTypes.string.isRequired,
profileImageUrl: PropTypes.string,
apiKey: PropTypes.string,
isDisabled: PropTypes.bool,
});
export const Destination = PropTypes.shape({
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
icon: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
});
export const Query = PropTypes.shape({
id: PropTypes.any.isRequired,
name: PropTypes.string.isRequired,
description: PropTypes.string,
data_source_id: PropTypes.any.isRequired,
created_at: PropTypes.string.isRequired,
updated_at: PropTypes.string,
user: UserProfile,
query: PropTypes.string,
queryHash: PropTypes.string,
is_safe: PropTypes.bool.isRequired,
is_draft: PropTypes.bool.isRequired,
is_archived: PropTypes.bool.isRequired,
api_key: PropTypes.string.isRequired,
});
export const AlertOptions = PropTypes.shape({
column: PropTypes.string,
selector: PropTypes.oneOf(["first", "min", "max"]),
op: PropTypes.oneOf([">", ">=", "<", "<=", "==", "!="]),
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
custom_subject: PropTypes.string,
custom_body: PropTypes.string,
});
export const Alert = PropTypes.shape({
id: PropTypes.any,
name: PropTypes.string,
created_at: PropTypes.string,
last_triggered_at: PropTypes.string,
updated_at: PropTypes.string,
rearm: PropTypes.number,
state: PropTypes.oneOf(["ok", "triggered", "unknown"]),
user: UserProfile,
query: Query,
options: PropTypes.shape({
column: PropTypes.string,
selector: PropTypes.string,
op: PropTypes.string,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
}).isRequired,
});
function checkMoment(isRequired, props, propName, componentName) {
const value = props[propName];
const isRequiredValid = isRequired && value !== null && value !== undefined && moment.isMoment(value);
const isOptionalValid = !isRequired && (value === null || value === undefined || moment.isMoment(value));
if (!isRequiredValid && !isOptionalValid) {
return new Error("Prop `" + propName + "` supplied to `" + componentName + "` should be a Moment.js instance.");
}
}
export const Moment = wrap(false, checkMoment);
Moment.isRequired = wrap(true, checkMoment);
================================================
FILE: client/app/components/queries/AddToDashboardDialog.jsx
================================================
import { isString } from "lodash";
import React, { useState, useEffect } from "react";
import PropTypes from "prop-types";
import Modal from "antd/lib/modal";
import Input from "antd/lib/input";
import List from "antd/lib/list";
import Link from "@/components/Link";
import PlainButton from "@/components/PlainButton";
import CloseOutlinedIcon from "@ant-design/icons/CloseOutlined";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import { QueryTagsControl } from "@/components/tags-control/TagsControl";
import { Dashboard } from "@/services/dashboard";
import notification from "@/services/notification";
import useSearchResults from "@/lib/hooks/useSearchResults";
import "./add-to-dashboard-dialog.less";
function AddToDashboardDialog({ dialog, visualization }) {
const [searchTerm, setSearchTerm] = useState("");
const [doSearch, dashboards, isLoading] = useSearchResults(
term => {
if (isString(term) && term !== "") {
return Dashboard.query({ q: term })
.then(results => results.results)
.catch(() => []);
}
return Promise.resolve([]);
},
{ initialResults: [] }
);
const [selectedDashboard, setSelectedDashboard] = useState(null);
const [saveInProgress, setSaveInProgress] = useState(false);
useEffect(() => {
doSearch(searchTerm);
}, [doSearch, searchTerm]);
function addWidgetToDashboard() {
// Load dashboard with all widgets
Dashboard.get(selectedDashboard)
.then(dashboard => {
dashboard.addWidget(visualization);
return dashboard;
})
.then(dashboard => {
dialog.close();
const key = `notification-${Math.random()
.toString(36)
.substr(2, 10)}`;
notification.success(
"Widget added to dashboard",
notification.close(key)}>
{dashboard.name}
,
{ key }
);
})
.catch(() => {
notification.error("Widget not added.");
})
.finally(() => {
setSaveInProgress(false);
});
}
const items = selectedDashboard ? [selectedDashboard] : dashboards;
return (
Choose the dashboard to add this query to:
{!selectedDashboard && (
setSearchTerm(event.target.value)}
suffix={
setSearchTerm("")}>
}
/>
)}
{(items.length > 0 || isLoading) && (
(
setSelectedDashboard(null)}>
,
]
: []
}
onClick={selectedDashboard ? null : () => setSelectedDashboard(d)}>
{d.name}
)}
/>
)}
);
}
AddToDashboardDialog.propTypes = {
dialog: DialogPropType.isRequired,
visualization: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
};
export default wrapDialog(AddToDashboardDialog);
================================================
FILE: client/app/components/queries/ApiKeyDialog/index.jsx
================================================
import { extend } from "lodash";
import React, { useMemo, useState, useCallback } from "react";
import PropTypes from "prop-types";
import Modal from "antd/lib/modal";
import Input from "antd/lib/input";
import Button from "antd/lib/button";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import CodeBlock from "@/components/CodeBlock";
import { axios } from "@/services/axios";
import { clientConfig } from "@/services/auth";
import notification from "@/services/notification";
import { useUniqueId } from "@/lib/hooks/useUniqueId";
import "./index.less";
import { policy } from "@/services/policy";
function ApiKeyDialog({ dialog, ...props }) {
const [query, setQuery] = useState(props.query);
const [updatingApiKey, setUpdatingApiKey] = useState(false);
const regenerateQueryApiKey = useCallback(() => {
setUpdatingApiKey(true);
axios
.post(`api/queries/${query.id}/regenerate_api_key`)
.then(data => {
setUpdatingApiKey(false);
setQuery(extend(query.clone(), { api_key: data.api_key }));
})
.catch(() => {
setUpdatingApiKey(false);
notification.error("Failed to update API key");
});
}, [query]);
const { csvUrl, jsonUrl } = useMemo(
() => ({
csvUrl: `${clientConfig.basePath}api/queries/${query.id}/results.csv?api_key=${query.api_key}`,
jsonUrl: `${clientConfig.basePath}api/queries/${query.id}/results.json?api_key=${query.api_key}`,
}),
[query.id, query.api_key]
);
const csvResultsLabelId = useUniqueId("csv-results-label");
const jsonResultsLabelId = useUniqueId("json-results-label");
return (
dialog.close(query)}>Close}>
API Key
{policy.canEdit(query) && (
Regenerate
)}
Example API Calls:
Results in CSV format:
{csvUrl}
Results in JSON format:
{jsonUrl}
);
}
ApiKeyDialog.propTypes = {
dialog: DialogPropType.isRequired,
query: PropTypes.shape({
id: PropTypes.number.isRequired,
api_key: PropTypes.string,
can_edit: PropTypes.bool,
}).isRequired,
};
export default wrapDialog(ApiKeyDialog);
================================================
FILE: client/app/components/queries/ApiKeyDialog/index.less
================================================
.query-api-key-dialog-wrapper {
.ant-input-group.ant-input-group-compact {
display: flex;
flex-wrap: nowrap;
.ant-input {
flex-grow: 1;
flex-shrink: 1;
}
.ant-btn {
flex-grow: 0;
flex-shrink: 0;
height: auto;
}
}
}
================================================
FILE: client/app/components/queries/EmbedQueryDialog.jsx
================================================
import { uniqueId } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import Alert from "antd/lib/alert";
import Button from "antd/lib/button";
import Checkbox from "antd/lib/checkbox";
import Form from "antd/lib/form";
import InputNumber from "antd/lib/input-number";
import Modal from "antd/lib/modal";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import { clientConfig } from "@/services/auth";
import CodeBlock from "@/components/CodeBlock";
import "./EmbedQueryDialog.less";
class EmbedQueryDialog extends React.Component {
static propTypes = {
dialog: DialogPropType.isRequired,
query: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
visualization: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
};
state = {
enableChangeIframeSize: false,
iframeWidth: 720,
iframeHeight: 391,
};
constructor(props) {
super(props);
const { query, visualization } = props;
this.embedUrl = `${clientConfig.basePath}embed/query/${query.id}/visualization/${visualization.id}?api_key=${
query.api_key
}&${query.getParameters().toUrlParams()}`;
if (window.snapshotUrlBuilder) {
this.snapshotUrl = window.snapshotUrlBuilder(query, visualization);
}
}
urlEmbedLabelId = uniqueId("url-embed-label");
iframeEmbedLabelId = uniqueId("iframe-embed-label");
render() {
const { query, dialog } = this.props;
const { enableChangeIframeSize, iframeWidth, iframeHeight } = this.state;
return (
Close}>
{query.is_safe ? (
Public URL
{this.embedUrl}
IFrame Embed
{``}
this.setState({ enableChangeIframeSize: e.target.checked })}
/>
this.setState({ iframeWidth: value })}
size="small"
disabled={!enableChangeIframeSize}
/>
this.setState({ iframeHeight: value })}
size="small"
disabled={!enableChangeIframeSize}
/>
{this.snapshotUrl && (
Image Embed
{this.snapshotUrl}
)}
) : (
)}
);
}
}
export default wrapDialog(EmbedQueryDialog);
================================================
FILE: client/app/components/queries/EmbedQueryDialog.less
================================================
@import (reference, less) "~@/assets/less/ant";
.embed-query-dialog {
label {
font-weight: normal;
}
.size-input {
width: 72px;
}
}
================================================
FILE: client/app/components/queries/QueryEditor/AutoLimitCheckbox.jsx
================================================
import React, { useCallback } from "react";
import PropTypes from "prop-types";
import recordEvent from "@/services/recordEvent";
import Checkbox from "antd/lib/checkbox";
import Tooltip from "@/components/Tooltip";
export default function AutoLimitCheckbox({ available, checked, onChange }) {
const handleClick = useCallback(() => {
recordEvent("checkbox_auto_limit", "screen", "query_editor", { state: !checked });
onChange(!checked);
}, [checked, onChange]);
let tooltipMessage = null;
if (!available) {
tooltipMessage = "Auto limiting is not available for this Data Source type.";
} else {
tooltipMessage = "Auto limit results to first 1000 rows.";
}
return (
LIMIT 1000
);
}
AutoLimitCheckbox.propTypes = {
available: PropTypes.bool,
checked: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
};
================================================
FILE: client/app/components/queries/QueryEditor/AutocompleteToggle.jsx
================================================
import React, { useCallback } from "react";
import Tooltip from "@/components/Tooltip";
import Button from "antd/lib/button";
import PropTypes from "prop-types";
import "@/redash-font/style.less";
import recordEvent from "@/services/recordEvent";
export default function AutocompleteToggle({ available, enabled, onToggle }) {
let tooltipMessage = "Live Autocomplete Enabled";
let icon = "icon-flash";
if (!enabled) {
tooltipMessage = "Live Autocomplete Disabled";
icon = "icon-flash-off";
}
if (!available) {
tooltipMessage = "Live Autocomplete Not Available (Use Ctrl+Space to Trigger)";
icon = "icon-flash-off";
}
const handleClick = useCallback(() => {
recordEvent("toggle_autocomplete", "screen", "query_editor", { state: !enabled });
onToggle(!enabled);
}, [enabled, onToggle]);
return (
);
}
AutocompleteToggle.propTypes = {
available: PropTypes.bool.isRequired,
enabled: PropTypes.bool.isRequired,
onToggle: PropTypes.func.isRequired,
};
================================================
FILE: client/app/components/queries/QueryEditor/QueryEditorControls.jsx
================================================
import { isFunction, map, filter, fromPairs, noop } from "lodash";
import React, { useEffect } from "react";
import PropTypes from "prop-types";
import Tooltip from "@/components/Tooltip";
import Button from "antd/lib/button";
import Select from "antd/lib/select";
import KeyboardShortcuts, { humanReadableShortcut } from "@/services/KeyboardShortcuts";
import AutocompleteToggle from "./AutocompleteToggle";
import "./QueryEditorControls.less";
import AutoLimitCheckbox from "@/components/queries/QueryEditor/AutoLimitCheckbox";
export function ButtonTooltip({ title, shortcut, ...props }) {
shortcut = humanReadableShortcut(shortcut, 1); // show only primary shortcut
title =
title && shortcut ? (
{title} ({shortcut} )
) : (
title || shortcut
);
return ;
}
ButtonTooltip.propTypes = {
title: PropTypes.node,
shortcut: PropTypes.string,
};
ButtonTooltip.defaultProps = {
title: null,
shortcut: null,
};
export default function EditorControl({
addParameterButtonProps,
formatButtonProps,
saveButtonProps,
executeButtonProps,
autocompleteToggleProps,
autoLimitCheckboxProps,
dataSourceSelectorProps,
}) {
useEffect(() => {
const buttons = filter(
[addParameterButtonProps, formatButtonProps, saveButtonProps, executeButtonProps],
b => b.shortcut && isFunction(b.onClick)
);
if (buttons.length > 0) {
const shortcuts = fromPairs(map(buttons, b => [b.shortcut, b.disabled ? noop : b.onClick]));
KeyboardShortcuts.bind(shortcuts);
return () => {
KeyboardShortcuts.unbind(shortcuts);
};
}
}, [addParameterButtonProps, formatButtonProps, saveButtonProps, executeButtonProps]);
return (
{addParameterButtonProps !== false && (
{"{{"} {"}}"}
)}
{formatButtonProps !== false && (
{formatButtonProps.text}
)}
{autocompleteToggleProps !== false && (
)}
{autoLimitCheckboxProps !== false &&
}
{dataSourceSelectorProps === false &&
}
{dataSourceSelectorProps !== false && (
{map(dataSourceSelectorProps.options, option => (
{option.label}
))}
)}
{saveButtonProps !== false && (
{!saveButtonProps.loading && }
{saveButtonProps.text}
)}
{executeButtonProps !== false && (
{executeButtonProps.text}
)}
);
}
const ButtonPropsPropType = PropTypes.oneOfType([
PropTypes.bool, // `false` to hide button
PropTypes.shape({
title: PropTypes.node,
disabled: PropTypes.bool,
loading: PropTypes.bool,
onClick: PropTypes.func,
text: PropTypes.node,
shortcut: PropTypes.string,
}),
]);
EditorControl.propTypes = {
addParameterButtonProps: ButtonPropsPropType,
formatButtonProps: ButtonPropsPropType,
saveButtonProps: ButtonPropsPropType,
executeButtonProps: ButtonPropsPropType,
autocompleteToggleProps: PropTypes.oneOfType([
PropTypes.bool, // `false` to hide
PropTypes.shape({
available: PropTypes.bool,
enabled: PropTypes.bool,
onToggle: PropTypes.func,
}),
]),
autoLimitCheckboxProps: PropTypes.oneOfType([
PropTypes.bool, // `false` to hide
PropTypes.shape(AutoLimitCheckbox.propTypes),
]),
dataSourceSelectorProps: PropTypes.oneOfType([
PropTypes.bool, // `false` to hide
PropTypes.shape({
disabled: PropTypes.bool,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
options: PropTypes.arrayOf(
PropTypes.shape({
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
label: PropTypes.node,
})
),
onChange: PropTypes.func,
}),
]),
};
EditorControl.defaultProps = {
addParameterButtonProps: false,
formatButtonProps: false,
saveButtonProps: false,
executeButtonProps: false,
autocompleteToggleProps: false,
autoLimitCheckboxProps: false,
dataSourceSelectorProps: false,
};
================================================
FILE: client/app/components/queries/QueryEditor/QueryEditorControls.less
================================================
.query-editor-controls {
display: flex;
flex-wrap: nowrap;
align-items: stretch;
justify-content: stretch;
// Styles for a wrapper that `Tooltip` adds for disabled `Button`s
span.query-editor-controls-button {
display: flex !important;
align-items: stretch;
justify-content: stretch;
}
.ant-btn {
height: auto;
.fa + span,
.zmdi + span {
// if button has icon and label - add some space between them
margin-left: 5px;
}
}
.query-editor-controls-checkbox {
display: inline-block;
white-space: nowrap;
margin: auto 5px;
}
.query-editor-controls-spacer {
flex: 1 1 auto;
height: 35px; // same as Antd
}
}
================================================
FILE: client/app/components/queries/QueryEditor/ace.js
================================================
import { capitalize, isNil, map, get } from "lodash";
import AceEditor from "react-ace";
import ace from "ace-builds";
import "ace-builds/src-noconflict/ext-language_tools";
import "ace-builds/src-noconflict/mode-json";
import "ace-builds/src-noconflict/mode-python";
import "ace-builds/src-noconflict/mode-sql";
import "ace-builds/src-noconflict/mode-yaml";
import "ace-builds/src-noconflict/theme-textmate";
import "ace-builds/src-noconflict/ext-searchbox";
const langTools = ace.acequire("ace/ext/language_tools");
const snippetsModule = ace.acequire("ace/snippets");
// 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) {
ace.define(`ace/snippets/${mode}`, ["require", "exports", "module"], (require, exports) => {
exports.snippetText = "";
exports.scope = mode;
});
}
defineDummySnippets("python");
defineDummySnippets("sql");
defineDummySnippets("json");
defineDummySnippets("yaml");
// without this line, ace will try to load a non-existent mode-custom.js file
// for data sources with syntax = "custom"
ace.define("ace/mode/custom", [], () => {});
function buildTableColumnKeywords(table) {
const keywords = [];
table.columns.forEach(column => {
const columnName = get(column, "name");
keywords.push({
name: `${table.name}.${columnName}`,
value: `${table.name}.${columnName}`,
score: 100,
meta: capitalize(get(column, "type", "Column")),
});
});
return keywords;
}
function buildKeywordsFromSchema(schema) {
const tableKeywords = [];
const columnKeywords = {};
const tableColumnKeywords = {};
schema.forEach(table => {
tableKeywords.push({
name: table.name,
value: table.name,
score: 100,
meta: "Table",
});
tableColumnKeywords[table.name] = buildTableColumnKeywords(table);
table.columns.forEach(c => {
const columnName = get(c, "name", c);
columnKeywords[columnName] = capitalize(get(c, "type", "Column"));
});
});
return {
table: tableKeywords,
column: map(columnKeywords, (v, k) => ({
name: k,
value: k,
score: 50,
meta: v,
})),
tableColumn: tableColumnKeywords,
};
}
const schemaCompleterKeywords = {};
export function updateSchemaCompleter(editorKey, schema = null) {
schemaCompleterKeywords[editorKey] = isNil(schema) ? null : buildKeywordsFromSchema(schema);
}
langTools.setCompleters([
langTools.snippetCompleter,
langTools.keyWordCompleter,
langTools.textCompleter,
{
identifierRegexps: [/[a-zA-Z_0-9.\-\u00A2-\uFFFF]/],
getCompletions: (editor, session, pos, prefix, callback) => {
const { table, column, tableColumn } = schemaCompleterKeywords[editor.id] || {
table: [],
column: [],
tableColumn: [],
};
if (prefix.length === 0 || table.length === 0) {
callback(null, []);
return;
}
if (prefix[prefix.length - 1] === ".") {
const tableName = prefix.substring(0, prefix.length - 1);
callback(null, table.concat(tableColumn[tableName]));
return;
}
callback(null, table.concat(column));
},
},
]);
export { AceEditor, langTools, snippetsModule };
================================================
FILE: client/app/components/queries/QueryEditor/index.jsx
================================================
import React, { useEffect, useMemo, useState, useCallback, useImperativeHandle } from "react";
import PropTypes from "prop-types";
import cx from "classnames";
import { AceEditor, snippetsModule, updateSchemaCompleter } from "./ace";
import { srNotify } from "@/lib/accessibility";
import { SchemaItemType } from "@/components/queries/SchemaBrowser";
import resizeObserver from "@/services/resizeObserver";
import QuerySnippet from "@/services/query-snippet";
import QueryEditorControls from "./QueryEditorControls";
import "./index.less";
const editorProps = { $blockScrolling: Infinity };
const QueryEditor = React.forwardRef(function(
{ className, syntax, value, autocompleteEnabled, schema, onChange, onSelectionChange, ...props },
ref
) {
const [container, setContainer] = useState(null);
const [editorRef, setEditorRef] = useState(null);
// For some reason, value for AceEditor should be managed in this way - otherwise it goes berserk when selecting text
const [currentValue, setCurrentValue] = useState(value);
useEffect(() => {
setCurrentValue(value);
}, [value]);
const handleChange = useCallback(
str => {
setCurrentValue(str);
onChange(str);
},
[onChange]
);
const editorOptions = useMemo(
() => ({
behavioursEnabled: true,
enableSnippets: true,
enableBasicAutocompletion: true,
enableLiveAutocompletion: autocompleteEnabled,
autoScrollEditorIntoView: true,
}),
[autocompleteEnabled]
);
useEffect(() => {
if (editorRef) {
const editorId = editorRef.editor.id;
updateSchemaCompleter(editorId, schema);
return () => {
updateSchemaCompleter(editorId, null);
};
}
}, [schema, editorRef]);
useEffect(() => {
function resize() {
if (editorRef) {
editorRef.editor.resize();
}
}
if (container) {
resize();
const unwatch = resizeObserver(container, resize);
return unwatch;
}
}, [container, editorRef]);
const handleSelectionChange = useCallback(
selection => {
const rawSelectedQueryText = editorRef.editor.session.doc.getTextRange(selection.getRange());
const selectedQueryText = rawSelectedQueryText.length > 1 ? rawSelectedQueryText : null;
onSelectionChange(selectedQueryText);
},
[editorRef, onSelectionChange]
);
const initEditor = useCallback(editor => {
// Release Cmd/Ctrl+L to the browser
editor.commands.bindKey({ win: "Ctrl+L", mac: "Cmd+L" }, null);
// Release Cmd/Ctrl+Shift+F for format query action
editor.commands.bindKey({ win: "Ctrl+Shift+F", mac: "Cmd+Shift+F" }, null);
// Release Ctrl+P for open new parameter dialog
editor.commands.bindKey({ win: "Ctrl+P", mac: null }, null);
// Lineup only mac
editor.commands.bindKey({ win: null, mac: "Ctrl+P" }, "golineup");
// Esc for exiting
editor.commands.bindKey({ win: "Esc", mac: "Esc" }, () => {
editor.blur();
});
let notificationCleanup = null;
editor.on("focus", () => {
notificationCleanup = srNotify({
text: "You've entered the SQL editor. To exit press the ESC key.",
politeness: "assertive",
});
});
editor.on("blur", () => {
if (notificationCleanup) {
notificationCleanup();
}
});
// Reset Completer in case dot is pressed
editor.commands.on("afterExec", e => {
if (e.command.name === "insertstring" && e.args === "." && editor.completer) {
editor.completer.showPopup(editor);
}
});
QuerySnippet.query().then(snippets => {
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.focus();
}, []);
useImperativeHandle(
ref,
() => ({
paste: text => {
if (editorRef) {
const { editor } = editorRef;
editor.session.doc.replace(editor.selection.getRange(), text);
const range = editor.selection.getRange();
onChange(editor.session.getValue());
editor.selection.setRange(range);
}
},
focus: () => {
if (editorRef) {
editorRef.editor.focus();
}
},
}),
[editorRef, onChange]
);
return (
);
});
QueryEditor.propTypes = {
className: PropTypes.string,
syntax: PropTypes.string,
value: PropTypes.string,
autocompleteEnabled: PropTypes.bool,
schema: PropTypes.arrayOf(SchemaItemType),
onChange: PropTypes.func,
onSelectionChange: PropTypes.func,
};
QueryEditor.defaultProps = {
className: null,
syntax: null,
value: null,
autocompleteEnabled: true,
schema: [],
onChange: () => {},
onSelectionChange: () => {},
};
QueryEditor.Controls = QueryEditorControls;
export default QueryEditor;
================================================
FILE: client/app/components/queries/QueryEditor/index.less
================================================
.query-editor-container {
margin-bottom: 0;
position: relative;
.ace_editor {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
margin: 0;
}
}
================================================
FILE: client/app/components/queries/ScheduleDialog.css
================================================
.schedule {
width: 449px !important;
margin: 0 auto;
}
.schedule-component {
padding: 5px 0px;
}
.schedule-component > * {
display: inline-block;
}
.schedule-component h5 {
margin-right: 10px;
width: 87px;
text-align: right;
}
.schedule-component > div > *:not(:last-child) {
margin-right: 3px;
}
.schedule-component datepicker {
display: block;
}
.schedule-phrase {
display: inline-block;
}
a.schedule-phrase {
cursor: pointer;
}
.utc {
opacity: 0.4;
margin-left: 10px;
}
================================================
FILE: client/app/components/queries/ScheduleDialog.jsx
================================================
import React, { useState } from "react";
import PropTypes from "prop-types";
import Modal from "antd/lib/modal";
import DatePicker from "antd/lib/date-picker";
import TimePicker from "antd/lib/time-picker";
import Select from "antd/lib/select";
import Radio from "antd/lib/radio";
import { capitalize, clone, isEqual, omitBy, isNil, isEmpty } from "lodash";
import moment from "moment";
import { secondsToInterval, durationHumanize, pluralize, IntervalEnum, localizeTime } from "@/lib/utils";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import { RefreshScheduleType, RefreshScheduleDefault, Moment } from "../proptypes";
import "./ScheduleDialog.css";
const WEEKDAYS_SHORT = moment.weekdaysShort();
const WEEKDAYS_FULL = moment.weekdays();
const DATE_FORMAT = "YYYY-MM-DD";
const HOUR_FORMAT = "HH:mm";
const { Option, OptGroup } = Select;
export function TimeEditor(props) {
const [time, setTime] = useState(props.defaultValue);
const showUtc = time && !time.isUTC();
function onChange(newTime) {
setTime(newTime);
props.onChange(newTime);
}
return (
{showUtc && (
({moment.utc(time).format(HOUR_FORMAT)} UTC)
)}
);
}
TimeEditor.propTypes = {
defaultValue: Moment,
onChange: PropTypes.func.isRequired,
};
TimeEditor.defaultProps = {
defaultValue: null,
};
class ScheduleDialog extends React.Component {
static propTypes = {
schedule: RefreshScheduleType,
refreshOptions: PropTypes.arrayOf(PropTypes.number).isRequired,
dialog: DialogPropType.isRequired,
};
static defaultProps = {
schedule: RefreshScheduleDefault,
};
state = this.getState();
getState() {
const newSchedule = clone(this.props.schedule || ScheduleDialog.defaultProps.schedule);
const { time, interval: seconds, day_of_week: day } = newSchedule;
const { interval } = secondsToInterval(seconds);
const [hour, minute] = time ? localizeTime(time).split(":") : [null, null];
return {
hour,
minute,
seconds,
interval,
dayOfWeek: day ? WEEKDAYS_SHORT[WEEKDAYS_FULL.indexOf(day)] : null,
newSchedule,
};
}
get intervals() {
const ret = {
[IntervalEnum.NEVER]: [],
};
this.props.refreshOptions.forEach(seconds => {
const { count, interval } = secondsToInterval(seconds);
if (!(interval in ret)) {
ret[interval] = [];
}
ret[interval].push([count, seconds]);
});
Object.defineProperty(this, "intervals", { value: ret }); // memoize
return ret;
}
set newSchedule(newProps) {
this.setState(prevState => ({
newSchedule: Object.assign(prevState.newSchedule, newProps),
}));
}
setTime = time => {
this.newSchedule = {
time: moment(time)
.utc()
.format(HOUR_FORMAT),
};
};
setInterval = newSeconds => {
const { newSchedule } = this.state;
const { interval: newInterval } = secondsToInterval(newSeconds);
// resets to defaults
if (newInterval === IntervalEnum.NEVER) {
newSchedule.until = null;
}
if ([IntervalEnum.NEVER, IntervalEnum.MINUTES, IntervalEnum.HOURS].indexOf(newInterval) !== -1) {
newSchedule.time = null;
}
if (newInterval !== IntervalEnum.WEEKS) {
newSchedule.day_of_week = null;
}
if (
(newInterval === IntervalEnum.DAYS || newInterval === IntervalEnum.WEEKS) &&
(!this.state.minute || !this.state.hour)
) {
newSchedule.time = moment()
.hour("00")
.minute("15")
.utc()
.format(HOUR_FORMAT);
}
if (newInterval === IntervalEnum.WEEKS && !this.state.dayOfWeek) {
newSchedule.day_of_week = WEEKDAYS_FULL[0];
}
newSchedule.interval = newSeconds;
const [hour, minute] = newSchedule.time ? localizeTime(newSchedule.time).split(":") : [null, null];
this.setState({
interval: newInterval,
seconds: newSeconds,
hour,
minute,
dayOfWeek: newSchedule.day_of_week ? WEEKDAYS_SHORT[WEEKDAYS_FULL.indexOf(newSchedule.day_of_week)] : null,
});
this.newSchedule = newSchedule;
};
setScheduleUntil = (_, date) => {
this.newSchedule = { until: date };
};
setWeekday = e => {
const dayOfWeek = e.target.value;
this.setState({ dayOfWeek });
this.newSchedule = {
day_of_week: dayOfWeek ? WEEKDAYS_FULL[WEEKDAYS_SHORT.indexOf(dayOfWeek)] : null,
};
};
setUntilToggle = e => {
const date = e.target.value ? moment().format(DATE_FORMAT) : null;
this.setScheduleUntil(null, date);
};
save() {
const { newSchedule } = this.state;
const hasChanged = () => {
const newCompact = omitBy(newSchedule, isNil);
const oldCompact = omitBy(this.props.schedule, isNil);
return !isEqual(newCompact, oldCompact);
};
// save if changed
if (hasChanged()) {
if (newSchedule.interval) {
this.props.dialog.close(clone(newSchedule));
} else {
this.props.dialog.close(null);
}
}
this.props.dialog.dismiss();
}
render() {
const { dialog } = this.props;
const {
interval,
minute,
hour,
seconds,
newSchedule: { until },
} = this.state;
return (
this.save()}>
Refresh every
Never
{Object.keys(this.intervals)
.filter(int => !isEmpty(this.intervals[int]))
.map(int => (
{this.intervals[int].map(([cnt, secs]) => (
{durationHumanize(secs)}
))}
))}
{[IntervalEnum.DAYS, IntervalEnum.WEEKS].indexOf(interval) !== -1 ? (
) : null}
{IntervalEnum.WEEKS === interval ? (
On day
{WEEKDAYS_SHORT.map(day => (
{day[0]}
))}
) : null}
{interval !== IntervalEnum.NEVER ? (
Ends
Never
On
{until ? (
) : null}
) : null}
);
}
}
export default wrapDialog(ScheduleDialog);
================================================
FILE: client/app/components/queries/ScheduleDialog.test.js
================================================
import React from "react";
import { mount } from "enzyme";
import moment from "moment";
import { durationHumanize } from "@/lib/utils";
import ScheduleDialog, { TimeEditor } from "./ScheduleDialog";
import RefreshScheduleDefault from "../proptypes";
const defaultProps = {
schedule: RefreshScheduleDefault,
refreshOptions: [
60,
300,
600, // 1, 5 ,10 mins
3600,
36000,
82800, // 1, 10, 23 hours
86400,
172800,
518400, // 1, 2, 6 days
604800,
1209600, // 1, 2, 4 weeks
],
dialog: {
props: {
visible: true,
onOk: () => {},
onCancel: () => {},
afterClose: () => {},
},
close: () => {},
dismiss: () => {},
},
};
function getWrapper(schedule = {}, { onConfirm, onCancel, ...props } = {}) {
onConfirm = onConfirm || (() => {});
onCancel = onCancel || (() => {});
props = {
...defaultProps,
...props,
schedule: {
...RefreshScheduleDefault,
...schedule,
},
dialog: {
props: {
visible: true,
onOk: onConfirm,
onCancel,
afterClose: () => {},
},
close: onConfirm,
dismiss: onCancel,
},
};
return [mount( ), props];
}
function findByTestID(wrapper, id) {
return wrapper.find(`[data-testid="${id}"]`);
}
describe("ScheduleDialog", () => {
describe("Sets correct schedule settings", () => {
test('Sets to "Never"', () => {
const [wrapper] = getWrapper();
const el = findByTestID(wrapper, "interval");
expect(el).toMatchSnapshot();
});
test('Sets to "5 Minutes"', () => {
const [wrapper] = getWrapper({ interval: 300 });
const el = findByTestID(wrapper, "interval");
expect(el).toMatchSnapshot();
});
test('Sets to "2 Hours"', () => {
const [wrapper] = getWrapper({ interval: 7200 });
const el = findByTestID(wrapper, "interval");
expect(el).toMatchSnapshot();
});
describe('Sets to "1 Day 22:15"', () => {
const [wrapper] = getWrapper({
interval: 86400,
time: "22:15",
});
test("Sets to correct interval", () => {
const el = findByTestID(wrapper, "interval");
expect(el).toMatchSnapshot();
});
test("Sets to correct time", () => {
const el = findByTestID(wrapper, "time");
expect(el).toMatchSnapshot();
});
});
describe("TimeEditor", () => {
const defaultValue = moment().hour(5).minute(25); // 05:25
test("UTC set correctly on init", () => {
const editor = mount( {}} />);
const utc = findByTestID(editor, "utc");
// expect utc to be 2h below initial time
expect(utc.text()).toBe("(03:25 UTC)");
});
test("UTC time should not render", () => {
const utcValue = moment.utc(defaultValue);
const editor = mount( {}} />);
const utc = findByTestID(editor, "utc");
// expect utc to not render
expect(utc.exists()).toBeFalsy();
});
// Disabling this test as the TimePicker wasn't setting values from here after Antd v4
// eslint-disable-next-line jest/no-disabled-tests
test.skip("onChange correct result", () => {
const onChangeCb = jest.fn((time) => time.format("HH:mm"));
const editor = mount( );
// click TimePicker
editor.find(".ant-picker-input input").simulate("mouseDown");
const timePickerPanel = editor.find(".ant-picker-panel");
// select hour "07"
const hourSelector = timePickerPanel.find(".ant-picker-time-panel-column").at(0);
hourSelector.find("li").at(7).simulate("click");
// select minute "30"
const minuteSelector = timePickerPanel.find(".ant-picker-time-panel-column").at(1);
minuteSelector.find("li").at(6).simulate("click");
timePickerPanel.find(".ant-picker-ok").find("button").simulate("mouseDown");
// expect utc to be 2h below initial time
const utc = findByTestID(editor, "utc");
expect(utc.text()).toBe("(05:30 UTC)");
// expect 07:30 from onChange
const onChangeResult = onChangeCb.mock.results[1].value;
expect(onChangeResult).toBe("07:30");
});
});
describe('Sets to "2 Weeks 22:15 Tuesday"', () => {
const [wrapper] = getWrapper({
interval: 1209600,
time: "22:15",
day_of_week: "Monday",
});
test("Sets to correct interval", () => {
const el = findByTestID(wrapper, "interval");
expect(el).toMatchSnapshot();
});
test("Sets to correct time", () => {
const el = findByTestID(wrapper, "time");
expect(el).toMatchSnapshot();
});
test("Sets to correct weekday", () => {
const el = findByTestID(wrapper, "weekday");
expect(el).toMatchSnapshot();
});
});
describe("Until feature", () => {
test("Until not set", () => {
const [wrapper] = getWrapper({ interval: 300 });
const el = findByTestID(wrapper, "ends");
expect(el).toMatchSnapshot();
});
test("Until is set", () => {
const [wrapper] = getWrapper({ interval: 300, until: "2030-01-01" });
const el = findByTestID(wrapper, "ends");
expect(el).toMatchSnapshot();
});
});
describe("Supports 30 days interval with no time value", () => {
test("Time is none", () => {
const [wrapper] = getWrapper({ interval: 30 * 24 * 3600 });
const el = findByTestID(wrapper, "time");
expect(el).toMatchSnapshot();
});
});
});
describe("Adheres to user permissions", () => {
test("Shows correct interval options", () => {
const refreshOptions = [60, 300, 3600, 7200]; // 1 min, 5 min, 1 hour, 2 hours
const [wrapper] = getWrapper(null, { refreshOptions });
// Get the ScheduleDialog component instance and verify its computed intervals
const component = wrapper.find("ScheduleDialog").instance();
const intervals = component.intervals;
// Flatten all interval options to [label, seconds] pairs, prepend "Never"
const allOptions = ["Never"];
Object.keys(intervals)
.filter((key) => intervals[key].length > 0)
.forEach((key) => {
intervals[key].forEach(([, secs]) => {
allOptions.push(durationHumanize(secs));
});
});
const expected = ["Never", "1 minute", "5 minutes", "1 hour", "2 hours"];
expect(allOptions).toEqual(expected);
});
});
describe("Modal Confirm/Cancel feature", () => {
const confirmCb = jest.fn().mockName("confirmCb");
const closeCb = jest.fn().mockName("closeCb");
const initProps = { onConfirm: confirmCb, onCancel: closeCb };
beforeEach(() => {
jest.clearAllMocks();
});
test("Query saved on confirm if state changed", () => {
// init
const [wrapper, props] = getWrapper(null, initProps);
// change state
const change = { time: "22:15" };
const newSchedule = Object.assign({}, props.schedule, change);
wrapper.setState({ newSchedule });
// click confirm button
wrapper.find(".ant-modal-footer").find(".ant-btn-primary").simulate("click");
// expect calls
expect(confirmCb).toHaveBeenCalled();
expect(closeCb).toHaveBeenCalled();
});
test("Query not saved on confirm if state unchanged", () => {
// init
const [wrapper] = getWrapper(null, initProps);
// click confirm button
wrapper.find(".ant-modal-footer").find(".ant-btn-primary").simulate("click");
// expect calls
expect(confirmCb).not.toHaveBeenCalled();
expect(closeCb).toHaveBeenCalled();
});
test("Cancel closes modal and query unsaved", () => {
// init
const [wrapper, props] = getWrapper(null, initProps);
// change state
const change = { time: "22:15" };
const newSchedule = Object.assign({}, props.schedule, change);
wrapper.setState({ newSchedule });
// click cancel button
wrapper.find(".ant-modal-footer").find("button:not(.ant-btn-primary)").simulate("click");
// expect calls
expect(confirmCb).not.toHaveBeenCalled();
expect(closeCb).toHaveBeenCalled();
});
});
});
================================================
FILE: client/app/components/queries/SchedulePhrase.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
import Tooltip from "@/components/Tooltip";
import PlainButton from "@/components/PlainButton";
import { localizeTime, durationHumanize } from "@/lib/utils";
import { RefreshScheduleType, RefreshScheduleDefault } from "../proptypes";
import "./ScheduleDialog.css";
export default class SchedulePhrase extends React.Component {
static propTypes = {
schedule: RefreshScheduleType,
isNew: PropTypes.bool.isRequired,
isLink: PropTypes.bool,
onClick: PropTypes.func,
};
static defaultProps = {
schedule: RefreshScheduleDefault,
isLink: false,
onClick: () => {},
};
get content() {
const { interval: seconds } = this.props.schedule || SchedulePhrase.defaultProps.schedule;
if (!seconds) {
return ["Never"];
}
const humanized = durationHumanize(seconds, {
omitSingleValueNumber: true,
});
const short = `Every ${humanized}`;
let full = `Refreshes every ${humanized}`;
const { time, day_of_week: dayOfWeek } = this.props.schedule;
if (time) {
full += ` at ${localizeTime(time)}`;
}
if (dayOfWeek) {
full += ` on ${dayOfWeek}`;
}
return [short, full];
}
render() {
if (this.props.isNew) {
return "Never";
}
const [short, full] = this.content;
const content = full ? {short} : short;
return this.props.isLink ? (
{content}
) : (
content
);
}
}
================================================
FILE: client/app/components/queries/SchemaBrowser.jsx
================================================
import { isNil, map, filter, some, includes, get } from "lodash";
import cx from "classnames";
import React, { useState, useCallback, useMemo, useEffect } from "react";
import PropTypes from "prop-types";
import { useDebouncedCallback } from "use-debounce";
import Input from "antd/lib/input";
import Button from "antd/lib/button";
import AutoSizer from "react-virtualized/dist/commonjs/AutoSizer";
import List from "react-virtualized/dist/commonjs/List";
import PlainButton from "@/components/PlainButton";
import Tooltip from "@/components/Tooltip";
import useDataSourceSchema from "@/pages/queries/hooks/useDataSourceSchema";
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
import LoadingState from "../items-list/components/LoadingState";
const SchemaItemColumnType = PropTypes.shape({
name: PropTypes.string.isRequired,
type: PropTypes.string,
});
export const SchemaItemType = PropTypes.shape({
name: PropTypes.string.isRequired,
size: PropTypes.number,
loading: PropTypes.bool,
columns: PropTypes.arrayOf(SchemaItemColumnType).isRequired,
});
const schemaTableHeight = 22;
const schemaColumnHeight = 18;
function SchemaItem({ item, expanded, onToggle, onSelect, ...props }) {
const handleSelect = useCallback(
(event, ...args) => {
event.preventDefault();
event.stopPropagation();
onSelect(...args);
},
[onSelect]
);
if (!item) {
return null;
}
const tableDisplayName = item.displayName || item.name;
return (
{tableDisplayName}
{!isNil(item.size) && ({item.size}) }
handleSelect(e, item.name)}>
{expanded && (
{item.loading ? (
Loading...
) : (
map(item.columns, (column) => {
const columnName = get(column, "name");
const columnType = get(column, "type");
const columnDescription = get(column, "description");
return (
handleSelect(e, columnName)}
>
{columnName} {columnType && {columnType} }
);
})
)}
)}
);
}
SchemaItem.propTypes = {
item: SchemaItemType,
expanded: PropTypes.bool,
onToggle: PropTypes.func,
onSelect: PropTypes.func,
};
SchemaItem.defaultProps = {
item: null,
expanded: false,
onToggle: () => {},
onSelect: () => {},
};
function SchemaLoadingState() {
return (
);
}
export function SchemaList({ loading, schema, expandedFlags, onTableExpand, onItemSelect }) {
const [listRef, setListRef] = useState(null);
useEffect(() => {
if (listRef) {
listRef.recomputeRowHeights();
}
}, [listRef, schema, expandedFlags]);
return (
{loading &&
}
{!loading && (
{({ width, height }) => (
{
const item = schema[index];
const columnsLength = !item.loading ? item.columns.length : 1;
let columnCount = expandedFlags[item.name] ? columnsLength : 0;
return schemaTableHeight + schemaColumnHeight * columnCount;
}}
rowRenderer={({ key, index, style }) => {
const item = schema[index];
return (
onTableExpand(item.name)}
onSelect={onItemSelect}
/>
);
}}
/>
)}
)}
);
}
export function applyFilterOnSchema(schema, filterString) {
const filters = filter(filterString.toLowerCase().split(/\s+/), (s) => s.length > 0);
// Empty string: return original schema
if (filters.length === 0) {
return schema;
}
// Single word: matches table or column
if (filters.length === 1) {
const nameFilter = filters[0];
const columnFilter = filters[0];
return filter(
schema,
(item) =>
includes(item.name.toLowerCase(), nameFilter) ||
some(item.columns, (column) => includes(get(column, "name").toLowerCase(), columnFilter))
);
}
// Two (or more) words: first matches table, seconds matches column
const nameFilter = filters[0];
const columnFilter = filters[1];
return filter(
map(schema, (item) => {
if (includes(item.name.toLowerCase(), nameFilter)) {
item = {
...item,
columns: filter(item.columns, (column) => includes(get(column, "name").toLowerCase(), columnFilter)),
};
return item.columns.length > 0 ? item : null;
}
})
);
}
export default function SchemaBrowser({
dataSource,
onSchemaUpdate,
onItemSelect,
options,
onOptionsUpdate,
...props
}) {
const [schema, isLoading, refreshSchema] = useDataSourceSchema(dataSource);
const [filterString, setFilterString] = useState("");
const filteredSchema = useMemo(() => applyFilterOnSchema(schema, filterString), [schema, filterString]);
const [handleFilterChange] = useDebouncedCallback(setFilterString, 500);
const [expandedFlags, setExpandedFlags] = useState({});
const handleSchemaUpdate = useImmutableCallback(onSchemaUpdate);
useEffect(() => {
setExpandedFlags({});
handleSchemaUpdate(schema);
}, [schema, handleSchemaUpdate]);
if (schema.length === 0 && !isLoading) {
return null;
}
function toggleTable(tableName) {
setExpandedFlags({
...expandedFlags,
[tableName]: !expandedFlags[tableName],
});
}
return (
);
}
SchemaBrowser.propTypes = {
dataSource: PropTypes.object, // eslint-disable-line react/forbid-prop-types
onSchemaUpdate: PropTypes.func,
onItemSelect: PropTypes.func,
};
SchemaBrowser.defaultProps = {
dataSource: null,
onSchemaUpdate: () => {},
onItemSelect: () => {},
};
================================================
FILE: client/app/components/queries/__snapshots__/ScheduleDialog.test.js.snap
================================================
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`ScheduleDialog Sets correct schedule settings Sets to "1 Day 22:15" Sets to correct interval 1`] = `
}
dropdownClassName=""
dropdownMatchSelectWidth={false}
inputIcon={[Function]}
listHeight={256}
listItemHeight={24}
menuItemSelectedIcon={null}
notFoundContent={
[Function]
}
onChange={[Function]}
prefixCls="ant-select"
removeIcon={ }
transitionName="slide-up"
value={86400}
>
}
dropdownClassName=""
dropdownMatchSelectWidth={false}
inputIcon={[Function]}
listHeight={256}
listItemHeight={24}
menuItemSelectedIcon={null}
notFoundContent={
[Function]
}
onChange={[Function]}
prefixCls="ant-select"
removeIcon={ }
transitionName="slide-up"
value={86400}
>
[Function]
}
onActiveValue={[Function]}
onMouseEnter={[Function]}
onSelect={[Function]}
onToggleOpen={[Function]}
options={
[
{
"children": "Never",
"key": "never",
"value": null,
},
{
"key": "__RC_SELECT_GRP__minute__",
"label": "Minutes",
"options": [
{
"children": "1 minute",
"key": "minute-1",
"value": 60,
},
{
"children": "5 minutes",
"key": "minute-5",
"value": 300,
},
{
"children": "10 minutes",
"key": "minute-10",
"value": 600,
},
],
},
{
"key": "__RC_SELECT_GRP__hour__",
"label": "Hours",
"options": [
{
"children": "1 hour",
"key": "hour-1",
"value": 3600,
},
{
"children": "10 hours",
"key": "hour-10",
"value": 36000,
},
{
"children": "23 hours",
"key": "hour-23",
"value": 82800,
},
],
},
{
"key": "__RC_SELECT_GRP__day__",
"label": "Days",
"options": [
{
"children": "1 day",
"key": "day-1",
"value": 86400,
},
{
"children": "2 days",
"key": "day-2",
"value": 172800,
},
{
"children": "6 days",
"key": "day-6",
"value": 518400,
},
],
},
{
"key": "__RC_SELECT_GRP__week__",
"label": "Weeks",
"options": [
{
"children": "1 week",
"key": "week-1",
"value": 604800,
},
{
"children": "2 weeks",
"key": "week-2",
"value": 1209600,
},
],
},
]
}
prefixCls="ant-select"
searchValue=""
values={
Set {
86400,
}
}
virtual={false}
/>
}
prefixCls="ant-select"
transitionName="slide-up"
>
[Function]
}
onActiveValue={[Function]}
onMouseEnter={[Function]}
onSelect={[Function]}
onToggleOpen={[Function]}
options={
[
{
"children": "Never",
"key": "never",
"value": null,
},
{
"key": "__RC_SELECT_GRP__minute__",
"label": "Minutes",
"options": [
{
"children": "1 minute",
"key": "minute-1",
"value": 60,
},
{
"children": "5 minutes",
"key": "minute-5",
"value": 300,
},
{
"children": "10 minutes",
"key": "minute-10",
"value": 600,
},
],
},
{
"key": "__RC_SELECT_GRP__hour__",
"label": "Hours",
"options": [
{
"children": "1 hour",
"key": "hour-1",
"value": 3600,
},
{
"children": "10 hours",
"key": "hour-10",
"value": 36000,
},
{
"children": "23 hours",
"key": "hour-23",
"value": 82800,
},
],
},
{
"key": "__RC_SELECT_GRP__day__",
"label": "Days",
"options": [
{
"children": "1 day",
"key": "day-1",
"value": 86400,
},
{
"children": "2 days",
"key": "day-2",
"value": 172800,
},
{
"children": "6 days",
"key": "day-6",
"value": 518400,
},
],
},
{
"key": "__RC_SELECT_GRP__week__",
"label": "Weeks",
"options": [
{
"children": "1 week",
"key": "week-1",
"value": 604800,
},
{
"children": "2 weeks",
"key": "week-2",
"value": 1209600,
},
],
},
]
}
prefixCls="ant-select"
searchValue=""
values={
Set {
86400,
}
}
virtual={false}
/>
}
popupAlign={{}}
popupClassName=""
popupPlacement="bottomLeft"
popupStyle={
{
"minWidth": null,
}
}
popupTransitionName="slide-up"
prefixCls="ant-select-dropdown"
showAction={[]}
>
}
domRef={
{
"current":
1 day
,
}
}
dropdownClassName=""
dropdownMatchSelectWidth={false}
id="rc_select_TEST_OR_SSR"
inputElement={null}
inputIcon={[Function]}
key="trigger"
listHeight={256}
listItemHeight={24}
menuItemSelectedIcon={null}
multiple={false}
notFoundContent={
[Function]
}
onChange={[Function]}
onSearch={[Function]}
onSearchSubmit={[Function]}
onSelect={[Function]}
onToggleOpen={[Function]}
prefixCls="ant-select"
removeIcon={ }
searchValue=""
showSearch={false}
tokenWithEnter={false}
transitionName="slide-up"
value={86400}
values={
[
{
"disabled": undefined,
"key": 86400,
"label": "1 day",
"value": 86400,
},
]
}
>
`;
exports[`ScheduleDialog Sets correct schedule settings Sets to "1 Day 22:15" Sets to correct time 1`] = `
}
components={
{
"button": [Function],
"rangeItem": [Function],
}
}
format="HH:mm"
generateConfig={
{
"addDate": [Function],
"addMonth": [Function],
"addYear": [Function],
"getDate": [Function],
"getHour": [Function],
"getMinute": [Function],
"getMonth": [Function],
"getNow": [Function],
"getSecond": [Function],
"getWeekDay": [Function],
"getYear": [Function],
"isAfter": [Function],
"isValidate": [Function],
"locale": {
"format": [Function],
"getShortMonths": [Function],
"getShortWeekDays": [Function],
"getWeek": [Function],
"getWeekFirstDay": [Function],
"parse": [Function],
},
"setDate": [Function],
"setHour": [Function],
"setMinute": [Function],
"setMonth": [Function],
"setSecond": [Function],
"setYear": [Function],
}
}
locale={
{
"backToToday": "Back to today",
"clear": "Clear",
"dateFormat": "M/D/YYYY",
"dateSelect": "select date",
"dateTimeFormat": "M/D/YYYY HH:mm:ss",
"dayFormat": "D",
"decadeSelect": "Choose a decade",
"locale": "en_US",
"month": "Month",
"monthBeforeYear": true,
"monthPlaceholder": "Select month",
"monthSelect": "Choose a month",
"nextCentury": "Next century",
"nextDecade": "Next decade",
"nextMonth": "Next month (PageDown)",
"nextYear": "Next year (Control + right)",
"now": "Now",
"ok": "Ok",
"placeholder": "Select date",
"previousCentury": "Last century",
"previousDecade": "Last decade",
"previousMonth": "Previous month (PageUp)",
"previousYear": "Last year (Control + left)",
"quarterPlaceholder": "Select quarter",
"rangeMonthPlaceholder": [
"Start month",
"End month",
],
"rangePlaceholder": [
"Start date",
"End date",
],
"rangeWeekPlaceholder": [
"Start week",
"End week",
],
"rangeYearPlaceholder": [
"Start year",
"End year",
],
"timeSelect": "select time",
"today": "Today",
"weekPlaceholder": "Select week",
"weekSelect": "Choose a week",
"year": "Year",
"yearFormat": "YYYY",
"yearPlaceholder": "Select year",
"yearSelect": "Choose a year",
}
}
minuteStep={5}
nextIcon={
}
onChange={[Function]}
picker="time"
placeholder="Select time"
prefixCls="ant-picker"
prevIcon={
}
showSecond={false}
showToday={true}
suffixIcon={ }
superNextIcon={
}
superPrevIcon={
}
transitionName="slide-up"
value={"1999-12-31T22:15:00.000Z"}
>
}
components={
{
"button": [Function],
"rangeItem": [Function],
}
}
format="HH:mm"
generateConfig={
{
"addDate": [Function],
"addMonth": [Function],
"addYear": [Function],
"getDate": [Function],
"getHour": [Function],
"getMinute": [Function],
"getMonth": [Function],
"getNow": [Function],
"getSecond": [Function],
"getWeekDay": [Function],
"getYear": [Function],
"isAfter": [Function],
"isValidate": [Function],
"locale": {
"format": [Function],
"getShortMonths": [Function],
"getShortWeekDays": [Function],
"getWeek": [Function],
"getWeekFirstDay": [Function],
"parse": [Function],
},
"setDate": [Function],
"setHour": [Function],
"setMinute": [Function],
"setMonth": [Function],
"setSecond": [Function],
"setYear": [Function],
}
}
locale={
{
"backToToday": "Back to today",
"clear": "Clear",
"dateFormat": "M/D/YYYY",
"dateSelect": "select date",
"dateTimeFormat": "M/D/YYYY HH:mm:ss",
"dayFormat": "D",
"decadeSelect": "Choose a decade",
"locale": "en_US",
"month": "Month",
"monthBeforeYear": true,
"monthPlaceholder": "Select month",
"monthSelect": "Choose a month",
"nextCentury": "Next century",
"nextDecade": "Next decade",
"nextMonth": "Next month (PageDown)",
"nextYear": "Next year (Control + right)",
"now": "Now",
"ok": "Ok",
"placeholder": "Select date",
"previousCentury": "Last century",
"previousDecade": "Last decade",
"previousMonth": "Previous month (PageUp)",
"previousYear": "Last year (Control + left)",
"quarterPlaceholder": "Select quarter",
"rangeMonthPlaceholder": [
"Start month",
"End month",
],
"rangePlaceholder": [
"Start date",
"End date",
],
"rangeWeekPlaceholder": [
"Start week",
"End week",
],
"rangeYearPlaceholder": [
"Start year",
"End year",
],
"timeSelect": "select time",
"today": "Today",
"weekPlaceholder": "Select week",
"weekSelect": "Choose a week",
"year": "Year",
"yearFormat": "YYYY",
"yearPlaceholder": "Select year",
"yearSelect": "Choose a year",
}
}
minuteStep={5}
nextIcon={
}
onChange={[Function]}
picker="time"
pickerRef={
{
"current": {
"blur": [Function],
"focus": [Function],
},
}
}
placeholder="Select time"
prefixCls="ant-picker"
prevIcon={
}
showSecond={false}
showToday={true}
suffixIcon={ }
superNextIcon={
}
superPrevIcon={
}
transitionName="slide-up"
value={"1999-12-31T22:15:00.000Z"}
>
}
components={
{
"button": [Function],
"rangeItem": [Function],
}
}
format="HH:mm"
generateConfig={
{
"addDate": [Function],
"addMonth": [Function],
"addYear": [Function],
"getDate": [Function],
"getHour": [Function],
"getMinute": [Function],
"getMonth": [Function],
"getNow": [Function],
"getSecond": [Function],
"getWeekDay": [Function],
"getYear": [Function],
"isAfter": [Function],
"isValidate": [Function],
"locale": {
"format": [Function],
"getShortMonths": [Function],
"getShortWeekDays": [Function],
"getWeek": [Function],
"getWeekFirstDay": [Function],
"parse": [Function],
},
"setDate": [Function],
"setHour": [Function],
"setMinute": [Function],
"setMonth": [Function],
"setSecond": [Function],
"setYear": [Function],
}
}
locale={
{
"backToToday": "Back to today",
"clear": "Clear",
"dateFormat": "M/D/YYYY",
"dateSelect": "select date",
"dateTimeFormat": "M/D/YYYY HH:mm:ss",
"dayFormat": "D",
"decadeSelect": "Choose a decade",
"locale": "en_US",
"month": "Month",
"monthBeforeYear": true,
"monthPlaceholder": "Select month",
"monthSelect": "Choose a month",
"nextCentury": "Next century",
"nextDecade": "Next decade",
"nextMonth": "Next month (PageDown)",
"nextYear": "Next year (Control + right)",
"now": "Now",
"ok": "Ok",
"placeholder": "Select date",
"previousCentury": "Last century",
"previousDecade": "Last decade",
"previousMonth": "Previous month (PageUp)",
"previousYear": "Last year (Control + left)",
"quarterPlaceholder": "Select quarter",
"rangeMonthPlaceholder": [
"Start month",
"End month",
],
"rangePlaceholder": [
"Start date",
"End date",
],
"rangeWeekPlaceholder": [
"Start week",
"End week",
],
"rangeYearPlaceholder": [
"Start year",
"End year",
],
"timeSelect": "select time",
"today": "Today",
"weekPlaceholder": "Select week",
"weekSelect": "Choose a week",
"year": "Year",
"yearFormat": "YYYY",
"yearPlaceholder": "Select year",
"yearSelect": "Choose a year",
}
}
minuteStep={5}
nextIcon={
}
onChange={[Function]}
picker="time"
pickerRef={
{
"current": {
"blur": [Function],
"focus": [Function],
},
}
}
placeholder="Select time"
prefixCls="ant-picker"
prevIcon={
}
showSecond={false}
showToday={true}
suffixIcon={ }
superNextIcon={
}
superPrevIcon={
}
tabIndex={-1}
transitionName="slide-up"
value={"1999-12-31T22:15:00.000Z"}
/>
}
popupPlacement="bottomLeft"
prefixCls="ant-picker"
transitionName="slide-up"
visible={false}
>
}
components={
{
"button": [Function],
"rangeItem": [Function],
}
}
format="HH:mm"
generateConfig={
{
"addDate": [Function],
"addMonth": [Function],
"addYear": [Function],
"getDate": [Function],
"getHour": [Function],
"getMinute": [Function],
"getMonth": [Function],
"getNow": [Function],
"getSecond": [Function],
"getWeekDay": [Function],
"getYear": [Function],
"isAfter": [Function],
"isValidate": [Function],
"locale": {
"format": [Function],
"getShortMonths": [Function],
"getShortWeekDays": [Function],
"getWeek": [Function],
"getWeekFirstDay": [Function],
"parse": [Function],
},
"setDate": [Function],
"setHour": [Function],
"setMinute": [Function],
"setMonth": [Function],
"setSecond": [Function],
"setYear": [Function],
}
}
locale={
{
"backToToday": "Back to today",
"clear": "Clear",
"dateFormat": "M/D/YYYY",
"dateSelect": "select date",
"dateTimeFormat": "M/D/YYYY HH:mm:ss",
"dayFormat": "D",
"decadeSelect": "Choose a decade",
"locale": "en_US",
"month": "Month",
"monthBeforeYear": true,
"monthPlaceholder": "Select month",
"monthSelect": "Choose a month",
"nextCentury": "Next century",
"nextDecade": "Next decade",
"nextMonth": "Next month (PageDown)",
"nextYear": "Next year (Control + right)",
"now": "Now",
"ok": "Ok",
"placeholder": "Select date",
"previousCentury": "Last century",
"previousDecade": "Last decade",
"previousMonth": "Previous month (PageUp)",
"previousYear": "Last year (Control + left)",
"quarterPlaceholder": "Select quarter",
"rangeMonthPlaceholder": [
"Start month",
"End month",
],
"rangePlaceholder": [
"Start date",
"End date",
],
"rangeWeekPlaceholder": [
"Start week",
"End week",
],
"rangeYearPlaceholder": [
"Start year",
"End year",
],
"timeSelect": "select time",
"today": "Today",
"weekPlaceholder": "Select week",
"weekSelect": "Choose a week",
"year": "Year",
"yearFormat": "YYYY",
"yearPlaceholder": "Select year",
"yearSelect": "Choose a year",
}
}
minuteStep={5}
nextIcon={
}
onChange={[Function]}
picker="time"
pickerRef={
{
"current": {
"blur": [Function],
"focus": [Function],
},
}
}
placeholder="Select time"
prefixCls="ant-picker"
prevIcon={
}
showSecond={false}
showToday={true}
suffixIcon={ }
superNextIcon={
}
superPrevIcon={
}
tabIndex={-1}
transitionName="slide-up"
value={"1999-12-31T22:15:00.000Z"}
/>
}
popupAlign={{}}
popupClassName=""
popupPlacement="bottomLeft"
popupStyle={{}}
popupTransitionName="slide-up"
popupVisible={false}
prefixCls="ant-picker-dropdown"
showAction={[]}
>
(
22:15
UTC)
`;
exports[`ScheduleDialog Sets correct schedule settings Sets to "2 Hours" 1`] = `
}
dropdownClassName=""
dropdownMatchSelectWidth={false}
inputIcon={[Function]}
listHeight={256}
listItemHeight={24}
menuItemSelectedIcon={null}
notFoundContent={
[Function]
}
onChange={[Function]}
prefixCls="ant-select"
removeIcon={ }
transitionName="slide-up"
value={7200}
>
}
dropdownClassName=""
dropdownMatchSelectWidth={false}
inputIcon={[Function]}
listHeight={256}
listItemHeight={24}
menuItemSelectedIcon={null}
notFoundContent={
[Function]
}
onChange={[Function]}
prefixCls="ant-select"
removeIcon={ }
transitionName="slide-up"
value={7200}
>
[Function]
}
onActiveValue={[Function]}
onMouseEnter={[Function]}
onSelect={[Function]}
onToggleOpen={[Function]}
options={
[
{
"children": "Never",
"key": "never",
"value": null,
},
{
"key": "__RC_SELECT_GRP__minute__",
"label": "Minutes",
"options": [
{
"children": "1 minute",
"key": "minute-1",
"value": 60,
},
{
"children": "5 minutes",
"key": "minute-5",
"value": 300,
},
{
"children": "10 minutes",
"key": "minute-10",
"value": 600,
},
],
},
{
"key": "__RC_SELECT_GRP__hour__",
"label": "Hours",
"options": [
{
"children": "1 hour",
"key": "hour-1",
"value": 3600,
},
{
"children": "10 hours",
"key": "hour-10",
"value": 36000,
},
{
"children": "23 hours",
"key": "hour-23",
"value": 82800,
},
],
},
{
"key": "__RC_SELECT_GRP__day__",
"label": "Days",
"options": [
{
"children": "1 day",
"key": "day-1",
"value": 86400,
},
{
"children": "2 days",
"key": "day-2",
"value": 172800,
},
{
"children": "6 days",
"key": "day-6",
"value": 518400,
},
],
},
{
"key": "__RC_SELECT_GRP__week__",
"label": "Weeks",
"options": [
{
"children": "1 week",
"key": "week-1",
"value": 604800,
},
{
"children": "2 weeks",
"key": "week-2",
"value": 1209600,
},
],
},
]
}
prefixCls="ant-select"
searchValue=""
values={
Set {
7200,
}
}
virtual={false}
/>
}
prefixCls="ant-select"
transitionName="slide-up"
>
[Function]
}
onActiveValue={[Function]}
onMouseEnter={[Function]}
onSelect={[Function]}
onToggleOpen={[Function]}
options={
[
{
"children": "Never",
"key": "never",
"value": null,
},
{
"key": "__RC_SELECT_GRP__minute__",
"label": "Minutes",
"options": [
{
"children": "1 minute",
"key": "minute-1",
"value": 60,
},
{
"children": "5 minutes",
"key": "minute-5",
"value": 300,
},
{
"children": "10 minutes",
"key": "minute-10",
"value": 600,
},
],
},
{
"key": "__RC_SELECT_GRP__hour__",
"label": "Hours",
"options": [
{
"children": "1 hour",
"key": "hour-1",
"value": 3600,
},
{
"children": "10 hours",
"key": "hour-10",
"value": 36000,
},
{
"children": "23 hours",
"key": "hour-23",
"value": 82800,
},
],
},
{
"key": "__RC_SELECT_GRP__day__",
"label": "Days",
"options": [
{
"children": "1 day",
"key": "day-1",
"value": 86400,
},
{
"children": "2 days",
"key": "day-2",
"value": 172800,
},
{
"children": "6 days",
"key": "day-6",
"value": 518400,
},
],
},
{
"key": "__RC_SELECT_GRP__week__",
"label": "Weeks",
"options": [
{
"children": "1 week",
"key": "week-1",
"value": 604800,
},
{
"children": "2 weeks",
"key": "week-2",
"value": 1209600,
},
],
},
]
}
prefixCls="ant-select"
searchValue=""
values={
Set {
7200,
}
}
virtual={false}
/>
}
popupAlign={{}}
popupClassName=""
popupPlacement="bottomLeft"
popupStyle={
{
"minWidth": null,
}
}
popupTransitionName="slide-up"
prefixCls="ant-select-dropdown"
showAction={[]}
>
}
domRef={
{
"current":
7200
,
}
}
dropdownClassName=""
dropdownMatchSelectWidth={false}
id="rc_select_TEST_OR_SSR"
inputElement={null}
inputIcon={[Function]}
key="trigger"
listHeight={256}
listItemHeight={24}
menuItemSelectedIcon={null}
multiple={false}
notFoundContent={
[Function]
}
onChange={[Function]}
onSearch={[Function]}
onSearchSubmit={[Function]}
onSelect={[Function]}
onToggleOpen={[Function]}
prefixCls="ant-select"
removeIcon={ }
searchValue=""
showSearch={false}
tokenWithEnter={false}
transitionName="slide-up"
value={7200}
values={
[
{
"disabled": undefined,
"key": 7200,
"label": 7200,
"value": 7200,
},
]
}
>
`;
exports[`ScheduleDialog Sets correct schedule settings Sets to "2 Weeks 22:15 Tuesday" Sets to correct interval 1`] = `
}
dropdownClassName=""
dropdownMatchSelectWidth={false}
inputIcon={[Function]}
listHeight={256}
listItemHeight={24}
menuItemSelectedIcon={null}
notFoundContent={
[Function]
}
onChange={[Function]}
prefixCls="ant-select"
removeIcon={ }
transitionName="slide-up"
value={1209600}
>
}
dropdownClassName=""
dropdownMatchSelectWidth={false}
inputIcon={[Function]}
listHeight={256}
listItemHeight={24}
menuItemSelectedIcon={null}
notFoundContent={
[Function]
}
onChange={[Function]}
prefixCls="ant-select"
removeIcon={ }
transitionName="slide-up"
value={1209600}
>
[Function]
}
onActiveValue={[Function]}
onMouseEnter={[Function]}
onSelect={[Function]}
onToggleOpen={[Function]}
options={
[
{
"children": "Never",
"key": "never",
"value": null,
},
{
"key": "__RC_SELECT_GRP__minute__",
"label": "Minutes",
"options": [
{
"children": "1 minute",
"key": "minute-1",
"value": 60,
},
{
"children": "5 minutes",
"key": "minute-5",
"value": 300,
},
{
"children": "10 minutes",
"key": "minute-10",
"value": 600,
},
],
},
{
"key": "__RC_SELECT_GRP__hour__",
"label": "Hours",
"options": [
{
"children": "1 hour",
"key": "hour-1",
"value": 3600,
},
{
"children": "10 hours",
"key": "hour-10",
"value": 36000,
},
{
"children": "23 hours",
"key": "hour-23",
"value": 82800,
},
],
},
{
"key": "__RC_SELECT_GRP__day__",
"label": "Days",
"options": [
{
"children": "1 day",
"key": "day-1",
"value": 86400,
},
{
"children": "2 days",
"key": "day-2",
"value": 172800,
},
{
"children": "6 days",
"key": "day-6",
"value": 518400,
},
],
},
{
"key": "__RC_SELECT_GRP__week__",
"label": "Weeks",
"options": [
{
"children": "1 week",
"key": "week-1",
"value": 604800,
},
{
"children": "2 weeks",
"key": "week-2",
"value": 1209600,
},
],
},
]
}
prefixCls="ant-select"
searchValue=""
values={
Set {
1209600,
}
}
virtual={false}
/>
}
prefixCls="ant-select"
transitionName="slide-up"
>
[Function]
}
onActiveValue={[Function]}
onMouseEnter={[Function]}
onSelect={[Function]}
onToggleOpen={[Function]}
options={
[
{
"children": "Never",
"key": "never",
"value": null,
},
{
"key": "__RC_SELECT_GRP__minute__",
"label": "Minutes",
"options": [
{
"children": "1 minute",
"key": "minute-1",
"value": 60,
},
{
"children": "5 minutes",
"key": "minute-5",
"value": 300,
},
{
"children": "10 minutes",
"key": "minute-10",
"value": 600,
},
],
},
{
"key": "__RC_SELECT_GRP__hour__",
"label": "Hours",
"options": [
{
"children": "1 hour",
"key": "hour-1",
"value": 3600,
},
{
"children": "10 hours",
"key": "hour-10",
"value": 36000,
},
{
"children": "23 hours",
"key": "hour-23",
"value": 82800,
},
],
},
{
"key": "__RC_SELECT_GRP__day__",
"label": "Days",
"options": [
{
"children": "1 day",
"key": "day-1",
"value": 86400,
},
{
"children": "2 days",
"key": "day-2",
"value": 172800,
},
{
"children": "6 days",
"key": "day-6",
"value": 518400,
},
],
},
{
"key": "__RC_SELECT_GRP__week__",
"label": "Weeks",
"options": [
{
"children": "1 week",
"key": "week-1",
"value": 604800,
},
{
"children": "2 weeks",
"key": "week-2",
"value": 1209600,
},
],
},
]
}
prefixCls="ant-select"
searchValue=""
values={
Set {
1209600,
}
}
virtual={false}
/>
}
popupAlign={{}}
popupClassName=""
popupPlacement="bottomLeft"
popupStyle={
{
"minWidth": null,
}
}
popupTransitionName="slide-up"
prefixCls="ant-select-dropdown"
showAction={[]}
>
}
domRef={
{
"current":
2 weeks
,
}
}
dropdownClassName=""
dropdownMatchSelectWidth={false}
id="rc_select_TEST_OR_SSR"
inputElement={null}
inputIcon={[Function]}
key="trigger"
listHeight={256}
listItemHeight={24}
menuItemSelectedIcon={null}
multiple={false}
notFoundContent={
[Function]
}
onChange={[Function]}
onSearch={[Function]}
onSearchSubmit={[Function]}
onSelect={[Function]}
onToggleOpen={[Function]}
prefixCls="ant-select"
removeIcon={ }
searchValue=""
showSearch={false}
tokenWithEnter={false}
transitionName="slide-up"
value={1209600}
values={
[
{
"disabled": undefined,
"key": 1209600,
"label": "2 weeks",
"value": 1209600,
},
]
}
>
`;
exports[`ScheduleDialog Sets correct schedule settings Sets to "2 Weeks 22:15 Tuesday" Sets to correct time 1`] = `
}
components={
{
"button": [Function],
"rangeItem": [Function],
}
}
format="HH:mm"
generateConfig={
{
"addDate": [Function],
"addMonth": [Function],
"addYear": [Function],
"getDate": [Function],
"getHour": [Function],
"getMinute": [Function],
"getMonth": [Function],
"getNow": [Function],
"getSecond": [Function],
"getWeekDay": [Function],
"getYear": [Function],
"isAfter": [Function],
"isValidate": [Function],
"locale": {
"format": [Function],
"getShortMonths": [Function],
"getShortWeekDays": [Function],
"getWeek": [Function],
"getWeekFirstDay": [Function],
"parse": [Function],
},
"setDate": [Function],
"setHour": [Function],
"setMinute": [Function],
"setMonth": [Function],
"setSecond": [Function],
"setYear": [Function],
}
}
locale={
{
"backToToday": "Back to today",
"clear": "Clear",
"dateFormat": "M/D/YYYY",
"dateSelect": "select date",
"dateTimeFormat": "M/D/YYYY HH:mm:ss",
"dayFormat": "D",
"decadeSelect": "Choose a decade",
"locale": "en_US",
"month": "Month",
"monthBeforeYear": true,
"monthPlaceholder": "Select month",
"monthSelect": "Choose a month",
"nextCentury": "Next century",
"nextDecade": "Next decade",
"nextMonth": "Next month (PageDown)",
"nextYear": "Next year (Control + right)",
"now": "Now",
"ok": "Ok",
"placeholder": "Select date",
"previousCentury": "Last century",
"previousDecade": "Last decade",
"previousMonth": "Previous month (PageUp)",
"previousYear": "Last year (Control + left)",
"quarterPlaceholder": "Select quarter",
"rangeMonthPlaceholder": [
"Start month",
"End month",
],
"rangePlaceholder": [
"Start date",
"End date",
],
"rangeWeekPlaceholder": [
"Start week",
"End week",
],
"rangeYearPlaceholder": [
"Start year",
"End year",
],
"timeSelect": "select time",
"today": "Today",
"weekPlaceholder": "Select week",
"weekSelect": "Choose a week",
"year": "Year",
"yearFormat": "YYYY",
"yearPlaceholder": "Select year",
"yearSelect": "Choose a year",
}
}
minuteStep={5}
nextIcon={
}
onChange={[Function]}
picker="time"
placeholder="Select time"
prefixCls="ant-picker"
prevIcon={
}
showSecond={false}
showToday={true}
suffixIcon={ }
superNextIcon={
}
superPrevIcon={
}
transitionName="slide-up"
value={"1999-12-31T22:15:00.000Z"}
>
}
components={
{
"button": [Function],
"rangeItem": [Function],
}
}
format="HH:mm"
generateConfig={
{
"addDate": [Function],
"addMonth": [Function],
"addYear": [Function],
"getDate": [Function],
"getHour": [Function],
"getMinute": [Function],
"getMonth": [Function],
"getNow": [Function],
"getSecond": [Function],
"getWeekDay": [Function],
"getYear": [Function],
"isAfter": [Function],
"isValidate": [Function],
"locale": {
"format": [Function],
"getShortMonths": [Function],
"getShortWeekDays": [Function],
"getWeek": [Function],
"getWeekFirstDay": [Function],
"parse": [Function],
},
"setDate": [Function],
"setHour": [Function],
"setMinute": [Function],
"setMonth": [Function],
"setSecond": [Function],
"setYear": [Function],
}
}
locale={
{
"backToToday": "Back to today",
"clear": "Clear",
"dateFormat": "M/D/YYYY",
"dateSelect": "select date",
"dateTimeFormat": "M/D/YYYY HH:mm:ss",
"dayFormat": "D",
"decadeSelect": "Choose a decade",
"locale": "en_US",
"month": "Month",
"monthBeforeYear": true,
"monthPlaceholder": "Select month",
"monthSelect": "Choose a month",
"nextCentury": "Next century",
"nextDecade": "Next decade",
"nextMonth": "Next month (PageDown)",
"nextYear": "Next year (Control + right)",
"now": "Now",
"ok": "Ok",
"placeholder": "Select date",
"previousCentury": "Last century",
"previousDecade": "Last decade",
"previousMonth": "Previous month (PageUp)",
"previousYear": "Last year (Control + left)",
"quarterPlaceholder": "Select quarter",
"rangeMonthPlaceholder": [
"Start month",
"End month",
],
"rangePlaceholder": [
"Start date",
"End date",
],
"rangeWeekPlaceholder": [
"Start week",
"End week",
],
"rangeYearPlaceholder": [
"Start year",
"End year",
],
"timeSelect": "select time",
"today": "Today",
"weekPlaceholder": "Select week",
"weekSelect": "Choose a week",
"year": "Year",
"yearFormat": "YYYY",
"yearPlaceholder": "Select year",
"yearSelect": "Choose a year",
}
}
minuteStep={5}
nextIcon={
}
onChange={[Function]}
picker="time"
pickerRef={
{
"current": {
"blur": [Function],
"focus": [Function],
},
}
}
placeholder="Select time"
prefixCls="ant-picker"
prevIcon={
}
showSecond={false}
showToday={true}
suffixIcon={ }
superNextIcon={
}
superPrevIcon={
}
transitionName="slide-up"
value={"1999-12-31T22:15:00.000Z"}
>
}
components={
{
"button": [Function],
"rangeItem": [Function],
}
}
format="HH:mm"
generateConfig={
{
"addDate": [Function],
"addMonth": [Function],
"addYear": [Function],
"getDate": [Function],
"getHour": [Function],
"getMinute": [Function],
"getMonth": [Function],
"getNow": [Function],
"getSecond": [Function],
"getWeekDay": [Function],
"getYear": [Function],
"isAfter": [Function],
"isValidate": [Function],
"locale": {
"format": [Function],
"getShortMonths": [Function],
"getShortWeekDays": [Function],
"getWeek": [Function],
"getWeekFirstDay": [Function],
"parse": [Function],
},
"setDate": [Function],
"setHour": [Function],
"setMinute": [Function],
"setMonth": [Function],
"setSecond": [Function],
"setYear": [Function],
}
}
locale={
{
"backToToday": "Back to today",
"clear": "Clear",
"dateFormat": "M/D/YYYY",
"dateSelect": "select date",
"dateTimeFormat": "M/D/YYYY HH:mm:ss",
"dayFormat": "D",
"decadeSelect": "Choose a decade",
"locale": "en_US",
"month": "Month",
"monthBeforeYear": true,
"monthPlaceholder": "Select month",
"monthSelect": "Choose a month",
"nextCentury": "Next century",
"nextDecade": "Next decade",
"nextMonth": "Next month (PageDown)",
"nextYear": "Next year (Control + right)",
"now": "Now",
"ok": "Ok",
"placeholder": "Select date",
"previousCentury": "Last century",
"previousDecade": "Last decade",
"previousMonth": "Previous month (PageUp)",
"previousYear": "Last year (Control + left)",
"quarterPlaceholder": "Select quarter",
"rangeMonthPlaceholder": [
"Start month",
"End month",
],
"rangePlaceholder": [
"Start date",
"End date",
],
"rangeWeekPlaceholder": [
"Start week",
"End week",
],
"rangeYearPlaceholder": [
"Start year",
"End year",
],
"timeSelect": "select time",
"today": "Today",
"weekPlaceholder": "Select week",
"weekSelect": "Choose a week",
"year": "Year",
"yearFormat": "YYYY",
"yearPlaceholder": "Select year",
"yearSelect": "Choose a year",
}
}
minuteStep={5}
nextIcon={
}
onChange={[Function]}
picker="time"
pickerRef={
{
"current": {
"blur": [Function],
"focus": [Function],
},
}
}
placeholder="Select time"
prefixCls="ant-picker"
prevIcon={
}
showSecond={false}
showToday={true}
suffixIcon={ }
superNextIcon={
}
superPrevIcon={
}
tabIndex={-1}
transitionName="slide-up"
value={"1999-12-31T22:15:00.000Z"}
/>
}
popupPlacement="bottomLeft"
prefixCls="ant-picker"
transitionName="slide-up"
visible={false}
>
}
components={
{
"button": [Function],
"rangeItem": [Function],
}
}
format="HH:mm"
generateConfig={
{
"addDate": [Function],
"addMonth": [Function],
"addYear": [Function],
"getDate": [Function],
"getHour": [Function],
"getMinute": [Function],
"getMonth": [Function],
"getNow": [Function],
"getSecond": [Function],
"getWeekDay": [Function],
"getYear": [Function],
"isAfter": [Function],
"isValidate": [Function],
"locale": {
"format": [Function],
"getShortMonths": [Function],
"getShortWeekDays": [Function],
"getWeek": [Function],
"getWeekFirstDay": [Function],
"parse": [Function],
},
"setDate": [Function],
"setHour": [Function],
"setMinute": [Function],
"setMonth": [Function],
"setSecond": [Function],
"setYear": [Function],
}
}
locale={
{
"backToToday": "Back to today",
"clear": "Clear",
"dateFormat": "M/D/YYYY",
"dateSelect": "select date",
"dateTimeFormat": "M/D/YYYY HH:mm:ss",
"dayFormat": "D",
"decadeSelect": "Choose a decade",
"locale": "en_US",
"month": "Month",
"monthBeforeYear": true,
"monthPlaceholder": "Select month",
"monthSelect": "Choose a month",
"nextCentury": "Next century",
"nextDecade": "Next decade",
"nextMonth": "Next month (PageDown)",
"nextYear": "Next year (Control + right)",
"now": "Now",
"ok": "Ok",
"placeholder": "Select date",
"previousCentury": "Last century",
"previousDecade": "Last decade",
"previousMonth": "Previous month (PageUp)",
"previousYear": "Last year (Control + left)",
"quarterPlaceholder": "Select quarter",
"rangeMonthPlaceholder": [
"Start month",
"End month",
],
"rangePlaceholder": [
"Start date",
"End date",
],
"rangeWeekPlaceholder": [
"Start week",
"End week",
],
"rangeYearPlaceholder": [
"Start year",
"End year",
],
"timeSelect": "select time",
"today": "Today",
"weekPlaceholder": "Select week",
"weekSelect": "Choose a week",
"year": "Year",
"yearFormat": "YYYY",
"yearPlaceholder": "Select year",
"yearSelect": "Choose a year",
}
}
minuteStep={5}
nextIcon={
}
onChange={[Function]}
picker="time"
pickerRef={
{
"current": {
"blur": [Function],
"focus": [Function],
},
}
}
placeholder="Select time"
prefixCls="ant-picker"
prevIcon={
}
showSecond={false}
showToday={true}
suffixIcon={ }
superNextIcon={
}
superPrevIcon={
}
tabIndex={-1}
transitionName="slide-up"
value={"1999-12-31T22:15:00.000Z"}
/>
}
popupAlign={{}}
popupClassName=""
popupPlacement="bottomLeft"
popupStyle={{}}
popupTransitionName="slide-up"
popupVisible={false}
prefixCls="ant-picker-dropdown"
showAction={[]}
>
(
22:15
UTC)
`;
exports[`ScheduleDialog Sets correct schedule settings Sets to "2 Weeks 22:15 Tuesday" Sets to correct weekday 1`] = `
`;
exports[`ScheduleDialog Sets correct schedule settings Sets to "5 Minutes" 1`] = `
}
dropdownClassName=""
dropdownMatchSelectWidth={false}
inputIcon={[Function]}
listHeight={256}
listItemHeight={24}
menuItemSelectedIcon={null}
notFoundContent={
[Function]
}
onChange={[Function]}
prefixCls="ant-select"
removeIcon={ }
transitionName="slide-up"
value={300}
>
}
dropdownClassName=""
dropdownMatchSelectWidth={false}
inputIcon={[Function]}
listHeight={256}
listItemHeight={24}
menuItemSelectedIcon={null}
notFoundContent={
[Function]
}
onChange={[Function]}
prefixCls="ant-select"
removeIcon={ }
transitionName="slide-up"
value={300}
>
[Function]
}
onActiveValue={[Function]}
onMouseEnter={[Function]}
onSelect={[Function]}
onToggleOpen={[Function]}
options={
[
{
"children": "Never",
"key": "never",
"value": null,
},
{
"key": "__RC_SELECT_GRP__minute__",
"label": "Minutes",
"options": [
{
"children": "1 minute",
"key": "minute-1",
"value": 60,
},
{
"children": "5 minutes",
"key": "minute-5",
"value": 300,
},
{
"children": "10 minutes",
"key": "minute-10",
"value": 600,
},
],
},
{
"key": "__RC_SELECT_GRP__hour__",
"label": "Hours",
"options": [
{
"children": "1 hour",
"key": "hour-1",
"value": 3600,
},
{
"children": "10 hours",
"key": "hour-10",
"value": 36000,
},
{
"children": "23 hours",
"key": "hour-23",
"value": 82800,
},
],
},
{
"key": "__RC_SELECT_GRP__day__",
"label": "Days",
"options": [
{
"children": "1 day",
"key": "day-1",
"value": 86400,
},
{
"children": "2 days",
"key": "day-2",
"value": 172800,
},
{
"children": "6 days",
"key": "day-6",
"value": 518400,
},
],
},
{
"key": "__RC_SELECT_GRP__week__",
"label": "Weeks",
"options": [
{
"children": "1 week",
"key": "week-1",
"value": 604800,
},
{
"children": "2 weeks",
"key": "week-2",
"value": 1209600,
},
],
},
]
}
prefixCls="ant-select"
searchValue=""
values={
Set {
300,
}
}
virtual={false}
/>
}
prefixCls="ant-select"
transitionName="slide-up"
>
[Function]
}
onActiveValue={[Function]}
onMouseEnter={[Function]}
onSelect={[Function]}
onToggleOpen={[Function]}
options={
[
{
"children": "Never",
"key": "never",
"value": null,
},
{
"key": "__RC_SELECT_GRP__minute__",
"label": "Minutes",
"options": [
{
"children": "1 minute",
"key": "minute-1",
"value": 60,
},
{
"children": "5 minutes",
"key": "minute-5",
"value": 300,
},
{
"children": "10 minutes",
"key": "minute-10",
"value": 600,
},
],
},
{
"key": "__RC_SELECT_GRP__hour__",
"label": "Hours",
"options": [
{
"children": "1 hour",
"key": "hour-1",
"value": 3600,
},
{
"children": "10 hours",
"key": "hour-10",
"value": 36000,
},
{
"children": "23 hours",
"key": "hour-23",
"value": 82800,
},
],
},
{
"key": "__RC_SELECT_GRP__day__",
"label": "Days",
"options": [
{
"children": "1 day",
"key": "day-1",
"value": 86400,
},
{
"children": "2 days",
"key": "day-2",
"value": 172800,
},
{
"children": "6 days",
"key": "day-6",
"value": 518400,
},
],
},
{
"key": "__RC_SELECT_GRP__week__",
"label": "Weeks",
"options": [
{
"children": "1 week",
"key": "week-1",
"value": 604800,
},
{
"children": "2 weeks",
"key": "week-2",
"value": 1209600,
},
],
},
]
}
prefixCls="ant-select"
searchValue=""
values={
Set {
300,
}
}
virtual={false}
/>
}
popupAlign={{}}
popupClassName=""
popupPlacement="bottomLeft"
popupStyle={
{
"minWidth": null,
}
}
popupTransitionName="slide-up"
prefixCls="ant-select-dropdown"
showAction={[]}
>
}
domRef={
{
"current":
5 minutes
,
}
}
dropdownClassName=""
dropdownMatchSelectWidth={false}
id="rc_select_TEST_OR_SSR"
inputElement={null}
inputIcon={[Function]}
key="trigger"
listHeight={256}
listItemHeight={24}
menuItemSelectedIcon={null}
multiple={false}
notFoundContent={
[Function]
}
onChange={[Function]}
onSearch={[Function]}
onSearchSubmit={[Function]}
onSelect={[Function]}
onToggleOpen={[Function]}
prefixCls="ant-select"
removeIcon={ }
searchValue=""
showSearch={false}
tokenWithEnter={false}
transitionName="slide-up"
value={300}
values={
[
{
"disabled": undefined,
"key": 300,
"label": "5 minutes",
"value": 300,
},
]
}
>
`;
exports[`ScheduleDialog Sets correct schedule settings Sets to "Never" 1`] = `
}
dropdownClassName=""
dropdownMatchSelectWidth={false}
inputIcon={[Function]}
listHeight={256}
listItemHeight={24}
menuItemSelectedIcon={null}
notFoundContent={
[Function]
}
onChange={[Function]}
prefixCls="ant-select"
removeIcon={ }
transitionName="slide-up"
>
}
dropdownClassName=""
dropdownMatchSelectWidth={false}
inputIcon={[Function]}
listHeight={256}
listItemHeight={24}
menuItemSelectedIcon={null}
notFoundContent={
[Function]
}
onChange={[Function]}
prefixCls="ant-select"
removeIcon={ }
transitionName="slide-up"
>
[Function]
}
onActiveValue={[Function]}
onMouseEnter={[Function]}
onSelect={[Function]}
onToggleOpen={[Function]}
options={
[
{
"children": "Never",
"key": "never",
"value": null,
},
{
"key": "__RC_SELECT_GRP__minute__",
"label": "Minutes",
"options": [
{
"children": "1 minute",
"key": "minute-1",
"value": 60,
},
{
"children": "5 minutes",
"key": "minute-5",
"value": 300,
},
{
"children": "10 minutes",
"key": "minute-10",
"value": 600,
},
],
},
{
"key": "__RC_SELECT_GRP__hour__",
"label": "Hours",
"options": [
{
"children": "1 hour",
"key": "hour-1",
"value": 3600,
},
{
"children": "10 hours",
"key": "hour-10",
"value": 36000,
},
{
"children": "23 hours",
"key": "hour-23",
"value": 82800,
},
],
},
{
"key": "__RC_SELECT_GRP__day__",
"label": "Days",
"options": [
{
"children": "1 day",
"key": "day-1",
"value": 86400,
},
{
"children": "2 days",
"key": "day-2",
"value": 172800,
},
{
"children": "6 days",
"key": "day-6",
"value": 518400,
},
],
},
{
"key": "__RC_SELECT_GRP__week__",
"label": "Weeks",
"options": [
{
"children": "1 week",
"key": "week-1",
"value": 604800,
},
{
"children": "2 weeks",
"key": "week-2",
"value": 1209600,
},
],
},
]
}
prefixCls="ant-select"
searchValue=""
values={Set {}}
virtual={false}
/>
}
prefixCls="ant-select"
transitionName="slide-up"
>
[Function]
}
onActiveValue={[Function]}
onMouseEnter={[Function]}
onSelect={[Function]}
onToggleOpen={[Function]}
options={
[
{
"children": "Never",
"key": "never",
"value": null,
},
{
"key": "__RC_SELECT_GRP__minute__",
"label": "Minutes",
"options": [
{
"children": "1 minute",
"key": "minute-1",
"value": 60,
},
{
"children": "5 minutes",
"key": "minute-5",
"value": 300,
},
{
"children": "10 minutes",
"key": "minute-10",
"value": 600,
},
],
},
{
"key": "__RC_SELECT_GRP__hour__",
"label": "Hours",
"options": [
{
"children": "1 hour",
"key": "hour-1",
"value": 3600,
},
{
"children": "10 hours",
"key": "hour-10",
"value": 36000,
},
{
"children": "23 hours",
"key": "hour-23",
"value": 82800,
},
],
},
{
"key": "__RC_SELECT_GRP__day__",
"label": "Days",
"options": [
{
"children": "1 day",
"key": "day-1",
"value": 86400,
},
{
"children": "2 days",
"key": "day-2",
"value": 172800,
},
{
"children": "6 days",
"key": "day-6",
"value": 518400,
},
],
},
{
"key": "__RC_SELECT_GRP__week__",
"label": "Weeks",
"options": [
{
"children": "1 week",
"key": "week-1",
"value": 604800,
},
{
"children": "2 weeks",
"key": "week-2",
"value": 1209600,
},
],
},
]
}
prefixCls="ant-select"
searchValue=""
values={Set {}}
virtual={false}
/>
}
popupAlign={{}}
popupClassName=""
popupPlacement="bottomLeft"
popupStyle={
{
"minWidth": null,
}
}
popupTransitionName="slide-up"
prefixCls="ant-select-dropdown"
showAction={[]}
>
}
domRef={
{
"current":
,
}
}
dropdownClassName=""
dropdownMatchSelectWidth={false}
id="rc_select_TEST_OR_SSR"
inputElement={null}
inputIcon={[Function]}
key="trigger"
listHeight={256}
listItemHeight={24}
menuItemSelectedIcon={null}
multiple={false}
notFoundContent={
[Function]
}
onChange={[Function]}
onSearch={[Function]}
onSearchSubmit={[Function]}
onSelect={[Function]}
onToggleOpen={[Function]}
prefixCls="ant-select"
removeIcon={ }
searchValue=""
showSearch={false}
tokenWithEnter={false}
transitionName="slide-up"
values={[]}
>
`;
exports[`ScheduleDialog Sets correct schedule settings Supports 30 days interval with no time value Time is none 1`] = `
}
components={
{
"button": [Function],
"rangeItem": [Function],
}
}
format="HH:mm"
generateConfig={
{
"addDate": [Function],
"addMonth": [Function],
"addYear": [Function],
"getDate": [Function],
"getHour": [Function],
"getMinute": [Function],
"getMonth": [Function],
"getNow": [Function],
"getSecond": [Function],
"getWeekDay": [Function],
"getYear": [Function],
"isAfter": [Function],
"isValidate": [Function],
"locale": {
"format": [Function],
"getShortMonths": [Function],
"getShortWeekDays": [Function],
"getWeek": [Function],
"getWeekFirstDay": [Function],
"parse": [Function],
},
"setDate": [Function],
"setHour": [Function],
"setMinute": [Function],
"setMonth": [Function],
"setSecond": [Function],
"setYear": [Function],
}
}
locale={
{
"backToToday": "Back to today",
"clear": "Clear",
"dateFormat": "M/D/YYYY",
"dateSelect": "select date",
"dateTimeFormat": "M/D/YYYY HH:mm:ss",
"dayFormat": "D",
"decadeSelect": "Choose a decade",
"locale": "en_US",
"month": "Month",
"monthBeforeYear": true,
"monthPlaceholder": "Select month",
"monthSelect": "Choose a month",
"nextCentury": "Next century",
"nextDecade": "Next decade",
"nextMonth": "Next month (PageDown)",
"nextYear": "Next year (Control + right)",
"now": "Now",
"ok": "Ok",
"placeholder": "Select date",
"previousCentury": "Last century",
"previousDecade": "Last decade",
"previousMonth": "Previous month (PageUp)",
"previousYear": "Last year (Control + left)",
"quarterPlaceholder": "Select quarter",
"rangeMonthPlaceholder": [
"Start month",
"End month",
],
"rangePlaceholder": [
"Start date",
"End date",
],
"rangeWeekPlaceholder": [
"Start week",
"End week",
],
"rangeYearPlaceholder": [
"Start year",
"End year",
],
"timeSelect": "select time",
"today": "Today",
"weekPlaceholder": "Select week",
"weekSelect": "Choose a week",
"year": "Year",
"yearFormat": "YYYY",
"yearPlaceholder": "Select year",
"yearSelect": "Choose a year",
}
}
minuteStep={5}
nextIcon={
}
onChange={[Function]}
picker="time"
placeholder="Select time"
prefixCls="ant-picker"
prevIcon={
}
showSecond={false}
showToday={true}
suffixIcon={ }
superNextIcon={
}
superPrevIcon={
}
transitionName="slide-up"
value={null}
>
}
components={
{
"button": [Function],
"rangeItem": [Function],
}
}
format="HH:mm"
generateConfig={
{
"addDate": [Function],
"addMonth": [Function],
"addYear": [Function],
"getDate": [Function],
"getHour": [Function],
"getMinute": [Function],
"getMonth": [Function],
"getNow": [Function],
"getSecond": [Function],
"getWeekDay": [Function],
"getYear": [Function],
"isAfter": [Function],
"isValidate": [Function],
"locale": {
"format": [Function],
"getShortMonths": [Function],
"getShortWeekDays": [Function],
"getWeek": [Function],
"getWeekFirstDay": [Function],
"parse": [Function],
},
"setDate": [Function],
"setHour": [Function],
"setMinute": [Function],
"setMonth": [Function],
"setSecond": [Function],
"setYear": [Function],
}
}
locale={
{
"backToToday": "Back to today",
"clear": "Clear",
"dateFormat": "M/D/YYYY",
"dateSelect": "select date",
"dateTimeFormat": "M/D/YYYY HH:mm:ss",
"dayFormat": "D",
"decadeSelect": "Choose a decade",
"locale": "en_US",
"month": "Month",
"monthBeforeYear": true,
"monthPlaceholder": "Select month",
"monthSelect": "Choose a month",
"nextCentury": "Next century",
"nextDecade": "Next decade",
"nextMonth": "Next month (PageDown)",
"nextYear": "Next year (Control + right)",
"now": "Now",
"ok": "Ok",
"placeholder": "Select date",
"previousCentury": "Last century",
"previousDecade": "Last decade",
"previousMonth": "Previous month (PageUp)",
"previousYear": "Last year (Control + left)",
"quarterPlaceholder": "Select quarter",
"rangeMonthPlaceholder": [
"Start month",
"End month",
],
"rangePlaceholder": [
"Start date",
"End date",
],
"rangeWeekPlaceholder": [
"Start week",
"End week",
],
"rangeYearPlaceholder": [
"Start year",
"End year",
],
"timeSelect": "select time",
"today": "Today",
"weekPlaceholder": "Select week",
"weekSelect": "Choose a week",
"year": "Year",
"yearFormat": "YYYY",
"yearPlaceholder": "Select year",
"yearSelect": "Choose a year",
}
}
minuteStep={5}
nextIcon={
}
onChange={[Function]}
picker="time"
pickerRef={
{
"current": {
"blur": [Function],
"focus": [Function],
},
}
}
placeholder="Select time"
prefixCls="ant-picker"
prevIcon={
}
showSecond={false}
showToday={true}
suffixIcon={ }
superNextIcon={
}
superPrevIcon={
}
transitionName="slide-up"
value={null}
>
}
components={
{
"button": [Function],
"rangeItem": [Function],
}
}
format="HH:mm"
generateConfig={
{
"addDate": [Function],
"addMonth": [Function],
"addYear": [Function],
"getDate": [Function],
"getHour": [Function],
"getMinute": [Function],
"getMonth": [Function],
"getNow": [Function],
"getSecond": [Function],
"getWeekDay": [Function],
"getYear": [Function],
"isAfter": [Function],
"isValidate": [Function],
"locale": {
"format": [Function],
"getShortMonths": [Function],
"getShortWeekDays": [Function],
"getWeek": [Function],
"getWeekFirstDay": [Function],
"parse": [Function],
},
"setDate": [Function],
"setHour": [Function],
"setMinute": [Function],
"setMonth": [Function],
"setSecond": [Function],
"setYear": [Function],
}
}
locale={
{
"backToToday": "Back to today",
"clear": "Clear",
"dateFormat": "M/D/YYYY",
"dateSelect": "select date",
"dateTimeFormat": "M/D/YYYY HH:mm:ss",
"dayFormat": "D",
"decadeSelect": "Choose a decade",
"locale": "en_US",
"month": "Month",
"monthBeforeYear": true,
"monthPlaceholder": "Select month",
"monthSelect": "Choose a month",
"nextCentury": "Next century",
"nextDecade": "Next decade",
"nextMonth": "Next month (PageDown)",
"nextYear": "Next year (Control + right)",
"now": "Now",
"ok": "Ok",
"placeholder": "Select date",
"previousCentury": "Last century",
"previousDecade": "Last decade",
"previousMonth": "Previous month (PageUp)",
"previousYear": "Last year (Control + left)",
"quarterPlaceholder": "Select quarter",
"rangeMonthPlaceholder": [
"Start month",
"End month",
],
"rangePlaceholder": [
"Start date",
"End date",
],
"rangeWeekPlaceholder": [
"Start week",
"End week",
],
"rangeYearPlaceholder": [
"Start year",
"End year",
],
"timeSelect": "select time",
"today": "Today",
"weekPlaceholder": "Select week",
"weekSelect": "Choose a week",
"year": "Year",
"yearFormat": "YYYY",
"yearPlaceholder": "Select year",
"yearSelect": "Choose a year",
}
}
minuteStep={5}
nextIcon={
}
onChange={[Function]}
picker="time"
pickerRef={
{
"current": {
"blur": [Function],
"focus": [Function],
},
}
}
placeholder="Select time"
prefixCls="ant-picker"
prevIcon={
}
showSecond={false}
showToday={true}
suffixIcon={ }
superNextIcon={
}
superPrevIcon={
}
tabIndex={-1}
transitionName="slide-up"
value={null}
/>
}
popupPlacement="bottomLeft"
prefixCls="ant-picker"
transitionName="slide-up"
visible={false}
>
}
components={
{
"button": [Function],
"rangeItem": [Function],
}
}
format="HH:mm"
generateConfig={
{
"addDate": [Function],
"addMonth": [Function],
"addYear": [Function],
"getDate": [Function],
"getHour": [Function],
"getMinute": [Function],
"getMonth": [Function],
"getNow": [Function],
"getSecond": [Function],
"getWeekDay": [Function],
"getYear": [Function],
"isAfter": [Function],
"isValidate": [Function],
"locale": {
"format": [Function],
"getShortMonths": [Function],
"getShortWeekDays": [Function],
"getWeek": [Function],
"getWeekFirstDay": [Function],
"parse": [Function],
},
"setDate": [Function],
"setHour": [Function],
"setMinute": [Function],
"setMonth": [Function],
"setSecond": [Function],
"setYear": [Function],
}
}
locale={
{
"backToToday": "Back to today",
"clear": "Clear",
"dateFormat": "M/D/YYYY",
"dateSelect": "select date",
"dateTimeFormat": "M/D/YYYY HH:mm:ss",
"dayFormat": "D",
"decadeSelect": "Choose a decade",
"locale": "en_US",
"month": "Month",
"monthBeforeYear": true,
"monthPlaceholder": "Select month",
"monthSelect": "Choose a month",
"nextCentury": "Next century",
"nextDecade": "Next decade",
"nextMonth": "Next month (PageDown)",
"nextYear": "Next year (Control + right)",
"now": "Now",
"ok": "Ok",
"placeholder": "Select date",
"previousCentury": "Last century",
"previousDecade": "Last decade",
"previousMonth": "Previous month (PageUp)",
"previousYear": "Last year (Control + left)",
"quarterPlaceholder": "Select quarter",
"rangeMonthPlaceholder": [
"Start month",
"End month",
],
"rangePlaceholder": [
"Start date",
"End date",
],
"rangeWeekPlaceholder": [
"Start week",
"End week",
],
"rangeYearPlaceholder": [
"Start year",
"End year",
],
"timeSelect": "select time",
"today": "Today",
"weekPlaceholder": "Select week",
"weekSelect": "Choose a week",
"year": "Year",
"yearFormat": "YYYY",
"yearPlaceholder": "Select year",
"yearSelect": "Choose a year",
}
}
minuteStep={5}
nextIcon={
}
onChange={[Function]}
picker="time"
pickerRef={
{
"current": {
"blur": [Function],
"focus": [Function],
},
}
}
placeholder="Select time"
prefixCls="ant-picker"
prevIcon={
}
showSecond={false}
showToday={true}
suffixIcon={ }
superNextIcon={
}
superPrevIcon={
}
tabIndex={-1}
transitionName="slide-up"
value={null}
/>
}
popupAlign={{}}
popupClassName=""
popupPlacement="bottomLeft"
popupStyle={{}}
popupTransitionName="slide-up"
popupVisible={false}
prefixCls="ant-picker-dropdown"
showAction={[]}
>
`;
exports[`ScheduleDialog Sets correct schedule settings Until feature Until is set 1`] = `
Never
On
}
components={
{
"button": [Function],
"rangeItem": [Function],
}
}
format="YYYY-MM-DD"
generateConfig={
{
"addDate": [Function],
"addMonth": [Function],
"addYear": [Function],
"getDate": [Function],
"getHour": [Function],
"getMinute": [Function],
"getMonth": [Function],
"getNow": [Function],
"getSecond": [Function],
"getWeekDay": [Function],
"getYear": [Function],
"isAfter": [Function],
"isValidate": [Function],
"locale": {
"format": [Function],
"getShortMonths": [Function],
"getShortWeekDays": [Function],
"getWeek": [Function],
"getWeekFirstDay": [Function],
"parse": [Function],
},
"setDate": [Function],
"setHour": [Function],
"setMinute": [Function],
"setMonth": [Function],
"setSecond": [Function],
"setYear": [Function],
}
}
locale={
{
"backToToday": "Back to today",
"clear": "Clear",
"dateFormat": "M/D/YYYY",
"dateSelect": "select date",
"dateTimeFormat": "M/D/YYYY HH:mm:ss",
"dayFormat": "D",
"decadeSelect": "Choose a decade",
"locale": "en_US",
"month": "Month",
"monthBeforeYear": true,
"monthPlaceholder": "Select month",
"monthSelect": "Choose a month",
"nextCentury": "Next century",
"nextDecade": "Next decade",
"nextMonth": "Next month (PageDown)",
"nextYear": "Next year (Control + right)",
"now": "Now",
"ok": "Ok",
"placeholder": "Select date",
"previousCentury": "Last century",
"previousDecade": "Last decade",
"previousMonth": "Previous month (PageUp)",
"previousYear": "Last year (Control + left)",
"quarterPlaceholder": "Select quarter",
"rangeMonthPlaceholder": [
"Start month",
"End month",
],
"rangePlaceholder": [
"Start date",
"End date",
],
"rangeWeekPlaceholder": [
"Start week",
"End week",
],
"rangeYearPlaceholder": [
"Start year",
"End year",
],
"timeSelect": "select time",
"today": "Today",
"weekPlaceholder": "Select week",
"weekSelect": "Choose a week",
"year": "Year",
"yearFormat": "YYYY",
"yearPlaceholder": "Select year",
"yearSelect": "Choose a year",
}
}
nextIcon={
}
onChange={[Function]}
placeholder="Select date"
prefixCls="ant-picker"
prevIcon={
}
showToday={true}
suffixIcon={ }
superNextIcon={
}
superPrevIcon={
}
transitionName="slide-up"
value={"2029-12-31T22:00:00.000Z"}
>
}
components={
{
"button": [Function],
"rangeItem": [Function],
}
}
format="YYYY-MM-DD"
generateConfig={
{
"addDate": [Function],
"addMonth": [Function],
"addYear": [Function],
"getDate": [Function],
"getHour": [Function],
"getMinute": [Function],
"getMonth": [Function],
"getNow": [Function],
"getSecond": [Function],
"getWeekDay": [Function],
"getYear": [Function],
"isAfter": [Function],
"isValidate": [Function],
"locale": {
"format": [Function],
"getShortMonths": [Function],
"getShortWeekDays": [Function],
"getWeek": [Function],
"getWeekFirstDay": [Function],
"parse": [Function],
},
"setDate": [Function],
"setHour": [Function],
"setMinute": [Function],
"setMonth": [Function],
"setSecond": [Function],
"setYear": [Function],
}
}
locale={
{
"backToToday": "Back to today",
"clear": "Clear",
"dateFormat": "M/D/YYYY",
"dateSelect": "select date",
"dateTimeFormat": "M/D/YYYY HH:mm:ss",
"dayFormat": "D",
"decadeSelect": "Choose a decade",
"locale": "en_US",
"month": "Month",
"monthBeforeYear": true,
"monthPlaceholder": "Select month",
"monthSelect": "Choose a month",
"nextCentury": "Next century",
"nextDecade": "Next decade",
"nextMonth": "Next month (PageDown)",
"nextYear": "Next year (Control + right)",
"now": "Now",
"ok": "Ok",
"placeholder": "Select date",
"previousCentury": "Last century",
"previousDecade": "Last decade",
"previousMonth": "Previous month (PageUp)",
"previousYear": "Last year (Control + left)",
"quarterPlaceholder": "Select quarter",
"rangeMonthPlaceholder": [
"Start month",
"End month",
],
"rangePlaceholder": [
"Start date",
"End date",
],
"rangeWeekPlaceholder": [
"Start week",
"End week",
],
"rangeYearPlaceholder": [
"Start year",
"End year",
],
"timeSelect": "select time",
"today": "Today",
"weekPlaceholder": "Select week",
"weekSelect": "Choose a week",
"year": "Year",
"yearFormat": "YYYY",
"yearPlaceholder": "Select year",
"yearSelect": "Choose a year",
}
}
nextIcon={
}
onChange={[Function]}
pickerRef={
{
"current": {
"blur": [Function],
"focus": [Function],
},
}
}
placeholder="Select date"
prefixCls="ant-picker"
prevIcon={
}
showToday={true}
suffixIcon={ }
superNextIcon={
}
superPrevIcon={
}
transitionName="slide-up"
value={"2029-12-31T22:00:00.000Z"}
>
}
components={
{
"button": [Function],
"rangeItem": [Function],
}
}
format="YYYY-MM-DD"
generateConfig={
{
"addDate": [Function],
"addMonth": [Function],
"addYear": [Function],
"getDate": [Function],
"getHour": [Function],
"getMinute": [Function],
"getMonth": [Function],
"getNow": [Function],
"getSecond": [Function],
"getWeekDay": [Function],
"getYear": [Function],
"isAfter": [Function],
"isValidate": [Function],
"locale": {
"format": [Function],
"getShortMonths": [Function],
"getShortWeekDays": [Function],
"getWeek": [Function],
"getWeekFirstDay": [Function],
"parse": [Function],
},
"setDate": [Function],
"setHour": [Function],
"setMinute": [Function],
"setMonth": [Function],
"setSecond": [Function],
"setYear": [Function],
}
}
locale={
{
"backToToday": "Back to today",
"clear": "Clear",
"dateFormat": "M/D/YYYY",
"dateSelect": "select date",
"dateTimeFormat": "M/D/YYYY HH:mm:ss",
"dayFormat": "D",
"decadeSelect": "Choose a decade",
"locale": "en_US",
"month": "Month",
"monthBeforeYear": true,
"monthPlaceholder": "Select month",
"monthSelect": "Choose a month",
"nextCentury": "Next century",
"nextDecade": "Next decade",
"nextMonth": "Next month (PageDown)",
"nextYear": "Next year (Control + right)",
"now": "Now",
"ok": "Ok",
"placeholder": "Select date",
"previousCentury": "Last century",
"previousDecade": "Last decade",
"previousMonth": "Previous month (PageUp)",
"previousYear": "Last year (Control + left)",
"quarterPlaceholder": "Select quarter",
"rangeMonthPlaceholder": [
"Start month",
"End month",
],
"rangePlaceholder": [
"Start date",
"End date",
],
"rangeWeekPlaceholder": [
"Start week",
"End week",
],
"rangeYearPlaceholder": [
"Start year",
"End year",
],
"timeSelect": "select time",
"today": "Today",
"weekPlaceholder": "Select week",
"weekSelect": "Choose a week",
"year": "Year",
"yearFormat": "YYYY",
"yearPlaceholder": "Select year",
"yearSelect": "Choose a year",
}
}
nextIcon={
}
onChange={[Function]}
pickerRef={
{
"current": {
"blur": [Function],
"focus": [Function],
},
}
}
placeholder="Select date"
prefixCls="ant-picker"
prevIcon={
}
showToday={true}
suffixIcon={ }
superNextIcon={
}
superPrevIcon={
}
tabIndex={-1}
transitionName="slide-up"
value={"2029-12-31T22:00:00.000Z"}
/>
}
popupPlacement="bottomLeft"
prefixCls="ant-picker"
transitionName="slide-up"
visible={false}
>
}
components={
{
"button": [Function],
"rangeItem": [Function],
}
}
format="YYYY-MM-DD"
generateConfig={
{
"addDate": [Function],
"addMonth": [Function],
"addYear": [Function],
"getDate": [Function],
"getHour": [Function],
"getMinute": [Function],
"getMonth": [Function],
"getNow": [Function],
"getSecond": [Function],
"getWeekDay": [Function],
"getYear": [Function],
"isAfter": [Function],
"isValidate": [Function],
"locale": {
"format": [Function],
"getShortMonths": [Function],
"getShortWeekDays": [Function],
"getWeek": [Function],
"getWeekFirstDay": [Function],
"parse": [Function],
},
"setDate": [Function],
"setHour": [Function],
"setMinute": [Function],
"setMonth": [Function],
"setSecond": [Function],
"setYear": [Function],
}
}
locale={
{
"backToToday": "Back to today",
"clear": "Clear",
"dateFormat": "M/D/YYYY",
"dateSelect": "select date",
"dateTimeFormat": "M/D/YYYY HH:mm:ss",
"dayFormat": "D",
"decadeSelect": "Choose a decade",
"locale": "en_US",
"month": "Month",
"monthBeforeYear": true,
"monthPlaceholder": "Select month",
"monthSelect": "Choose a month",
"nextCentury": "Next century",
"nextDecade": "Next decade",
"nextMonth": "Next month (PageDown)",
"nextYear": "Next year (Control + right)",
"now": "Now",
"ok": "Ok",
"placeholder": "Select date",
"previousCentury": "Last century",
"previousDecade": "Last decade",
"previousMonth": "Previous month (PageUp)",
"previousYear": "Last year (Control + left)",
"quarterPlaceholder": "Select quarter",
"rangeMonthPlaceholder": [
"Start month",
"End month",
],
"rangePlaceholder": [
"Start date",
"End date",
],
"rangeWeekPlaceholder": [
"Start week",
"End week",
],
"rangeYearPlaceholder": [
"Start year",
"End year",
],
"timeSelect": "select time",
"today": "Today",
"weekPlaceholder": "Select week",
"weekSelect": "Choose a week",
"year": "Year",
"yearFormat": "YYYY",
"yearPlaceholder": "Select year",
"yearSelect": "Choose a year",
}
}
nextIcon={
}
onChange={[Function]}
pickerRef={
{
"current": {
"blur": [Function],
"focus": [Function],
},
}
}
placeholder="Select date"
prefixCls="ant-picker"
prevIcon={
}
showToday={true}
suffixIcon={ }
superNextIcon={
}
superPrevIcon={
}
tabIndex={-1}
transitionName="slide-up"
value={"2029-12-31T22:00:00.000Z"}
/>
}
popupAlign={{}}
popupClassName=""
popupPlacement="bottomLeft"
popupStyle={{}}
popupTransitionName="slide-up"
popupVisible={false}
prefixCls="ant-picker-dropdown"
showAction={[]}
>
`;
exports[`ScheduleDialog Sets correct schedule settings Until feature Until not set 1`] = `
`;
================================================
FILE: client/app/components/queries/add-to-dashboard-dialog.less
================================================
@import (reference, less) "~@/assets/less/main.less";
.ant-list {
&.add-to-dashboard-dialog-search-results {
margin-top: 15px;
.ant-list-items {
max-height: 300px;
overflow: auto;
}
.ant-list-item {
padding: 12px;
cursor: pointer;
&:hover,
&:active {
@table-row-hover-bg: fade(@redash-gray, 5%);
background-color: @table-row-hover-bg;
}
}
}
&.add-to-dashboard-dialog-selection {
.ant-list-item {
padding: 12px;
.add-to-dashboard-dialog-item-content {
flex: 1 1 auto;
}
.ant-list-item-action li {
margin: 0;
padding: 0;
}
}
}
}
================================================
FILE: client/app/components/queries/editor-components/databricks/DatabricksSchemaBrowser.jsx
================================================
import React, { useState, useMemo, useEffect, useCallback } from "react";
import { filter, includes, get, find } from "lodash";
import PropTypes from "prop-types";
import { useDebouncedCallback } from "use-debounce";
import Button from "antd/lib/button";
import SyncOutlinedIcon from "@ant-design/icons/SyncOutlined";
import Input from "antd/lib/input";
import Select from "antd/lib/select";
import Tooltip from "@/components/Tooltip";
import { SchemaList, applyFilterOnSchema } from "@/components/queries/SchemaBrowser";
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
import useDatabricksSchema from "./useDatabricksSchema";
import "./DatabricksSchemaBrowser.less";
export default function DatabricksSchemaBrowser({
dataSource,
options,
onOptionsUpdate,
onSchemaUpdate,
onItemSelect,
...props
}) {
const {
databases,
loadingDatabases,
schema,
loadingSchema,
loadTableColumns,
currentDatabaseName,
setCurrentDatabase,
refreshAll,
refreshing,
} = useDatabricksSchema(dataSource, options, onOptionsUpdate);
const [filterString, setFilterString] = useState("");
const [databaseFilterString, setDatabaseFilterString] = useState("");
const filteredSchema = useMemo(() => applyFilterOnSchema(schema, filterString), [schema, filterString]);
const [isDatabaseSelectOpen, setIsDatabaseSelectOpen] = useState(false);
const [expandedFlags, setExpandedFlags] = useState({});
const [handleFilterChange] = useDebouncedCallback(setFilterString, 500);
const [handleDatabaseFilterChange, cancelHandleDatabaseFilterChange] = useDebouncedCallback(
setDatabaseFilterString,
500
);
const handleDatabaseSelection = useCallback(
databaseName => {
setCurrentDatabase(databaseName);
cancelHandleDatabaseFilterChange();
setDatabaseFilterString("");
},
[cancelHandleDatabaseFilterChange, setCurrentDatabase]
);
const filteredDatabases = useMemo(
() => filter(databases, database => includes(database.toLowerCase(), databaseFilterString.toLowerCase())),
[databases, databaseFilterString]
);
const handleSchemaUpdate = useImmutableCallback(onSchemaUpdate);
useEffect(() => {
handleSchemaUpdate(schema);
}, [schema, handleSchemaUpdate]);
useEffect(() => {
setExpandedFlags({});
}, [currentDatabaseName]);
function toggleTable(tableName) {
const table = find(schema, { name: tableName });
if (!expandedFlags[tableName] && get(table, "loading", false)) {
loadTableColumns(tableName);
}
setExpandedFlags({
...expandedFlags,
[tableName]: !expandedFlags[tableName],
});
}
return (
handleFilterChange(event.target.value)}
addonBefore={
Database
>
}>
{filteredDatabases.map(database => (
{database}
))}
}
/>
{!(loadingSchema || loadingDatabases) && (
)}
);
}
DatabricksSchemaBrowser.propTypes = {
dataSource: PropTypes.object, // eslint-disable-line react/forbid-prop-types
options: PropTypes.object, // eslint-disable-line react/forbid-prop-types
onOptionsUpdate: PropTypes.func,
onSchemaUpdate: PropTypes.func,
onItemSelect: PropTypes.func,
};
DatabricksSchemaBrowser.defaultProps = {
dataSource: null,
options: null,
onOptionsUpdate: () => {},
onSchemaUpdate: () => {},
onItemSelect: () => {},
};
================================================
FILE: client/app/components/queries/editor-components/databricks/DatabricksSchemaBrowser.less
================================================
@import (reference, less) "~@/assets/less/ant";
.databricks-schema-browser {
.schema-control {
.database-select-open .ant-input-group-addon {
background-color: #fff;
.ant-select-selection-item {
visibility: hidden;
}
}
.ant-input-wrapper {
table-layout: fixed; // antd uses display: table, so this is needed for % units
.ant-input-group-addon {
width: 40%;
padding: 0;
border-bottom-left-radius: 0;
.ant-select {
width: 100%;
.ant-select-selection-item {
text-align: left;
}
&.ant-select-focused .ant-select-selector {
color: inherit;
}
}
}
.ant-input {
border-bottom-right-radius: 0;
}
}
}
.schema-list-wrapper {
position: relative;
height: 100%;
border: 1px solid #eaeaea;
border-top: 0;
border-radius: 0 0 4px 4px;
margin-bottom: 20px;
padding-bottom: 32px;
.load-button {
display: flex;
justify-content: center;
position: absolute;
width: 100%;
bottom: 0;
.ant-btn {
color: @text-color;
padding: 0 10px;
}
}
}
}
.databricks-schema-browser-db-dropdown {
width: 50vw !important;
}
================================================
FILE: client/app/components/queries/editor-components/databricks/useDatabricksSchema.js
================================================
import { includes, has, get, map, first, isFunction, isEmpty, startsWith } from "lodash";
import { useEffect, useState, useMemo, useCallback, useRef } from "react";
import notification from "@/services/notification";
import DatabricksDataSource from "@/services/databricks-data-source";
function getDatabases(dataSource, refresh = false) {
if (!dataSource) {
return Promise.resolve([]);
}
return DatabricksDataSource.getDatabases(dataSource, refresh).catch(() => {
notification.error("Failed to load Database list.", "Please try again later.");
return Promise.reject();
});
}
function getSchema(dataSource, databaseName, refresh = false) {
if (!dataSource || !databaseName) {
return Promise.resolve([]);
}
return DatabricksDataSource.getDatabaseTables(dataSource, databaseName, refresh).catch(() => {
notification.error(`Failed to load tables for ${databaseName}.`, "Please try again later.");
return Promise.reject();
});
}
function addDisplayNameWithoutDatabaseName(schema, databaseName) {
if (!databaseName) {
return schema;
}
// add display name without {databaseName} + "."
return map(schema, table => {
const databaseNamePrefix = databaseName + ".";
let displayName = table.name;
if (startsWith(table.name, databaseNamePrefix)) {
displayName = table.name.slice(databaseNamePrefix.length);
}
return { ...table, displayName };
});
}
export default function useDatabricksSchema(dataSource, options = null, onOptionsUpdate = null) {
const [databases, setDatabases] = useState([]);
const [loadingDatabases, setLoadingDatabases] = useState(true);
const [currentDatabaseName, setCurrentDatabaseName] = useState();
const [schemas, setSchemas] = useState({});
const [loadingSchema, setLoadingSchema] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const setCurrentSchema = useCallback(
schema =>
setSchemas(currentSchemas => ({
...currentSchemas,
[currentDatabaseName]: schema,
})),
[currentDatabaseName]
);
const currentDatabaseNameRef = useRef();
currentDatabaseNameRef.current = currentDatabaseName;
const loadTableColumns = useCallback(
tableName => {
// remove [databaseName.] from the tableName
DatabricksDataSource.getTableColumns(
dataSource,
currentDatabaseName,
tableName.substring(currentDatabaseName.length + 1)
).then(columns => {
if (currentDatabaseNameRef.current === currentDatabaseName) {
setSchemas(currentSchemas => {
const schema = get(currentSchemas, currentDatabaseName, []);
const updatedSchema = map(schema, table => {
if (table.name === tableName) {
return { ...table, columns, loading: false };
}
return table;
});
return {
...currentSchemas,
[currentDatabaseName]: updatedSchema,
};
});
}
});
},
[dataSource, currentDatabaseName]
);
const schema = useMemo(() => {
const currentSchema = get(schemas, currentDatabaseName, []);
return addDisplayNameWithoutDatabaseName(currentSchema, currentDatabaseName);
}, [schemas, currentDatabaseName]);
const refreshAll = useCallback(() => {
if (!refreshing) {
setRefreshing(true);
const getDatabasesPromise = getDatabases(dataSource, true).then(setDatabases);
const getSchemasPromise = getSchema(dataSource, currentDatabaseName, true).then(({ schema }) =>
setCurrentSchema(schema)
);
Promise.all([getSchemasPromise.catch(() => {}), getDatabasesPromise.catch(() => {})]).then(() =>
setRefreshing(false)
);
}
}, [dataSource, currentDatabaseName, setCurrentSchema, refreshing]);
const schemasRef = useRef();
schemasRef.current = schemas;
useEffect(() => {
let isCancelled = false;
if (currentDatabaseName && !has(schemasRef.current, currentDatabaseName)) {
setLoadingSchema(true);
getSchema(dataSource, currentDatabaseName)
.catch(() => Promise.resolve({ schema: [], has_columns: true }))
.then(({ schema, has_columns }) => {
if (!isCancelled) {
if (!has_columns && !isEmpty(schema)) {
schema = map(schema, table => ({ ...table, loading: true }));
getSchema(dataSource, currentDatabaseName, true).then(({ schema }) => {
if (!isCancelled) {
setCurrentSchema(schema);
}
});
}
setCurrentSchema(schema);
}
})
.finally(() => {
if (!isCancelled) {
setLoadingSchema(false);
}
});
}
return () => {
isCancelled = true;
};
}, [dataSource, currentDatabaseName, setCurrentSchema]);
const defaultDatabaseNameRef = useRef();
defaultDatabaseNameRef.current = get(options, "selectedDatabase", null);
useEffect(() => {
let isCancelled = false;
setLoadingDatabases(true);
setCurrentDatabaseName(undefined);
setSchemas({});
getDatabases(dataSource)
.catch(() => Promise.resolve([]))
.then(data => {
if (!isCancelled) {
setDatabases(data);
// We set the database using this order:
// 1. Currently selected value.
// 2. Last used stored in localStorage.
// 3. default database.
// 4. first database in the list.
let lastUsedDatabase =
defaultDatabaseNameRef.current || localStorage.getItem(`lastSelectedDatabricksDatabase_${dataSource.id}`);
if (!lastUsedDatabase) {
lastUsedDatabase = includes(data, "default") ? "default" : first(data) || null;
}
setCurrentDatabaseName(lastUsedDatabase);
}
})
.finally(() => {
if (!isCancelled) {
setLoadingDatabases(false);
}
});
return () => {
isCancelled = true;
};
}, [dataSource]);
const setCurrentDatabase = useCallback(
databaseName => {
if (databaseName) {
try {
localStorage.setItem(`lastSelectedDatabricksDatabase_${dataSource.id}`, databaseName);
} catch (e) {
// `localStorage.setItem` may throw exception if there are no enough space - in this case it could be ignored
}
}
setCurrentDatabaseName(databaseName);
if (isFunction(onOptionsUpdate) && databaseName !== defaultDatabaseNameRef.current) {
onOptionsUpdate({
...options,
selectedDatabase: databaseName,
});
}
},
[dataSource.id, options, onOptionsUpdate]
);
return {
databases,
loadingDatabases,
schema,
loadingSchema,
currentDatabaseName,
setCurrentDatabase,
loadTableColumns,
refreshAll,
refreshing,
};
}
================================================
FILE: client/app/components/queries/editor-components/editorComponents.js
================================================
import { isArray, isNil, each } from "lodash";
const componentsRegistry = new Map();
export const QueryEditorComponents = {
SCHEMA_BROWSER: "SchemaBrowser",
QUERY_EDITOR: "QueryEditor",
};
export function registerEditorComponent(componentName, component, dataSourceTypes) {
if (isNil(dataSourceTypes)) {
dataSourceTypes = [null]; // use `null` entry for the default set of components
}
if (!isArray(dataSourceTypes)) {
dataSourceTypes = [dataSourceTypes];
}
each(dataSourceTypes, dataSourceType => {
componentsRegistry.set(dataSourceType, { ...componentsRegistry.get(dataSourceType), [componentName]: component });
});
}
export function getEditorComponents(dataSourceType) {
return { ...componentsRegistry.get(null), ...componentsRegistry.get(dataSourceType) };
}
================================================
FILE: client/app/components/queries/editor-components/index.js
================================================
import SchemaBrowser from "@/components/queries/SchemaBrowser";
import QueryEditor from "@/components/queries/QueryEditor";
import DatabricksSchemaBrowser from "./databricks/DatabricksSchemaBrowser";
import { registerEditorComponent, getEditorComponents, QueryEditorComponents } from "./editorComponents";
// default
registerEditorComponent(QueryEditorComponents.SCHEMA_BROWSER, SchemaBrowser);
registerEditorComponent(QueryEditorComponents.QUERY_EDITOR, QueryEditor);
// databricks
registerEditorComponent(QueryEditorComponents.SCHEMA_BROWSER, DatabricksSchemaBrowser, [
"databricks",
"databricks_internal",
]);
export { getEditorComponents };
================================================
FILE: client/app/components/query-snippets/QuerySnippetDialog.jsx
================================================
import { isNil, get } from "lodash";
import React, { useCallback } from "react";
import PropTypes from "prop-types";
import Button from "antd/lib/button";
import Modal from "antd/lib/modal";
import DynamicForm from "@/components/dynamic-form/DynamicForm";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import { useUniqueId } from "@/lib/hooks/useUniqueId";
function QuerySnippetDialog({ querySnippet, dialog, readOnly }) {
const handleSubmit = useCallback(
(values, successCallback, errorCallback) => {
const querySnippetId = get(querySnippet, "id");
if (isNil(values.description)) {
values.description = "";
}
dialog
.close(querySnippetId ? { id: querySnippetId, ...values } : values)
.then(() => successCallback("Saved."))
.catch(() => errorCallback("Failed saving snippet."));
},
[dialog, querySnippet]
);
const isEditing = !!get(querySnippet, "id");
const formFields = [
{ name: "trigger", title: "Trigger", type: "text", required: true, autoFocus: !isEditing },
{ name: "description", title: "Description", type: "text" },
{ name: "snippet", title: "Snippet", type: "ace", required: true },
].map(field => ({ ...field, readOnly, initialValue: get(querySnippet, field.name, "") }));
const querySnippetsFormId = useUniqueId("querySnippetForm");
return (
{readOnly ? "Close" : "Cancel"}
,
!readOnly && (
{isEditing ? "Save" : "Create"}
),
]}
wrapProps={{
"data-test": "QuerySnippetDialog",
}}>
);
}
QuerySnippetDialog.propTypes = {
dialog: DialogPropType.isRequired,
querySnippet: PropTypes.object,
readOnly: PropTypes.bool,
};
QuerySnippetDialog.defaultProps = {
querySnippet: null,
readOnly: false,
};
export default wrapDialog(QuerySnippetDialog);
================================================
FILE: client/app/components/tags-control/EditTagsDialog.jsx
================================================
import { map, trim, uniq, compact } from "lodash";
import React, { useState, useEffect } from "react";
import PropTypes from "prop-types";
import Select from "antd/lib/select";
import Modal from "antd/lib/modal";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
function EditTagsDialog({ dialog, tags, getAvailableTags }) {
const [availableTags, setAvailableTags] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [values, setValues] = useState(() => uniq(map(tags, trim))); // lazy evaluate
const [selectRef, setSelectRef] = useState(null);
// Select is initially disabled, so autoFocus prop cannot make it focused.
// Solution is to pass focus to the select when available tags are loaded and
// select becomes enabled.
useEffect(() => {
if (selectRef && !isLoading) {
selectRef.focus();
}
}, [selectRef, isLoading]);
useEffect(() => {
let isCancelled = false;
getAvailableTags().then(availableTags => {
if (!isCancelled) {
setAvailableTags(uniq(compact(map(availableTags, trim))));
setIsLoading(false);
}
});
return () => {
isCancelled = true;
};
}, [getAvailableTags]);
return (
dialog.close(values)}
title="Add/Edit Tags"
className="shortModal"
wrapProps={{ "data-test": "EditTagsDialog" }}>
setValues(compact(map(v, trim)))}
disabled={isLoading}
loading={isLoading}>
{map(availableTags, tag => (
{tag}
))}
);
}
EditTagsDialog.propTypes = {
dialog: DialogPropType.isRequired,
tags: PropTypes.arrayOf(PropTypes.string),
getAvailableTags: PropTypes.func.isRequired,
};
EditTagsDialog.defaultProps = {
tags: [],
};
export default wrapDialog(EditTagsDialog);
================================================
FILE: client/app/components/tags-control/TagsControl.jsx
================================================
import { map, trim } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import Tooltip from "@/components/Tooltip";
import EditTagsDialog from "./EditTagsDialog";
import PlainButton from "@/components/PlainButton";
export class TagsControl extends React.Component {
static propTypes = {
tags: PropTypes.arrayOf(PropTypes.string),
canEdit: PropTypes.bool,
getAvailableTags: PropTypes.func,
onEdit: PropTypes.func,
className: PropTypes.string,
tagsExtra: PropTypes.node,
tagSeparator: PropTypes.node,
children: PropTypes.node,
};
static defaultProps = {
tags: [],
canEdit: false,
getAvailableTags: () => Promise.resolve([]),
onEdit: () => {},
className: "",
tagsExtra: null,
tagSeparator: null,
children: null,
};
editTags = (tags, getAvailableTags) => {
EditTagsDialog.showModal({ tags, getAvailableTags }).onClose(this.props.onEdit);
};
renderEditButton() {
const tags = map(this.props.tags, trim);
return (
this.editTags(tags, this.props.getAvailableTags)}
data-test="EditTagsButton">
{tags.length === 0 && (
Add tag
)}
{tags.length > 0 && (
<>
Edit
>
)}
);
}
render() {
const { tags, tagSeparator } = this.props;
return (
{this.props.children}
{map(tags, (tag, i) => (
{tagSeparator && i > 0 && {tagSeparator} }
{tag}
))}
{this.props.canEdit && this.renderEditButton()}
{this.props.tagsExtra}
);
}
}
function modelTagsControl({ archivedTooltip }) {
// See comment for `propTypes`/`defaultProps`
// eslint-disable-next-line react/prop-types
function ModelTagsControl({ isDraft, isArchived, ...props }) {
return (
{!isArchived && isDraft && Unpublished }
{isArchived && (
Archived
)}
);
}
ModelTagsControl.propTypes = {
isDraft: PropTypes.bool,
isArchived: PropTypes.bool,
};
ModelTagsControl.defaultProps = {
isDraft: false,
isArchived: false,
};
return ModelTagsControl;
}
export const QueryTagsControl = modelTagsControl({
archivedTooltip: "This query is archived and can't be used in dashboards, or appear in search results.",
});
export const DashboardTagsControl = modelTagsControl({
archivedTooltip: "This dashboard is archived and won't be listed in dashboards nor search results.",
});
================================================
FILE: client/app/components/visualizations/EditVisualizationDialog.jsx
================================================
import { isEqual, extend, map, sortBy, findIndex, filter, pick, omit } from "lodash";
import React, { useState, useMemo, useRef, useEffect } from "react";
import PropTypes from "prop-types";
import Modal from "antd/lib/modal";
import Select from "antd/lib/select";
import Input from "antd/lib/input";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import Filters, { filterData } from "@/components/Filters";
import notification from "@/services/notification";
import Visualization from "@/services/visualization";
import recordEvent from "@/services/recordEvent";
import useQueryResultData from "@/lib/useQueryResultData";
import { useUniqueId } from "@/lib/hooks/useUniqueId";
import {
registeredVisualizations,
getDefaultVisualization,
newVisualization,
VisualizationType,
} from "@redash/viz/lib";
import { Renderer, Editor } from "@/components/visualizations/visualizationComponents";
import "./EditVisualizationDialog.less";
function updateQueryVisualizations(query, visualization) {
const index = findIndex(query.visualizations, (v) => v.id === visualization.id);
if (index > -1) {
query.visualizations[index] = visualization;
} else {
// new visualization
query.visualizations.push(visualization);
}
query.visualizations = [...query.visualizations]; // clone array
}
function saveVisualization(visualization) {
if (visualization.id) {
recordEvent("update", "visualization", visualization.id, { type: visualization.type });
} else {
recordEvent("create", "visualization", null, { type: visualization.type });
}
return Visualization.save(visualization)
.then((result) => {
notification.success("Visualization saved");
return result;
})
.catch((error) => {
notification.error("Visualization could not be saved");
return Promise.reject(error);
});
}
function confirmDialogClose(isDirty) {
return new Promise((resolve, reject) => {
if (isDirty) {
Modal.confirm({
title: "Visualization Editor",
content: "Are you sure you want to close the editor without saving?",
okText: "Yes",
cancelText: "No",
onOk: () => resolve(),
onCancel: () => reject(),
});
} else {
resolve();
}
});
}
function EditVisualizationDialog({ dialog, visualization, query, queryResult }) {
const errorHandlerRef = useRef();
const isNew = !visualization;
const data = useQueryResultData(queryResult);
const [filters, setFilters] = useState(data.filters);
const filteredData = useMemo(
() => ({
columns: data.columns,
rows: filterData(data.rows, filters),
}),
[data, filters]
);
const defaultState = useMemo(() => {
const config = visualization ? registeredVisualizations[visualization.type] : getDefaultVisualization();
const options = config.getOptions(isNew ? {} : visualization.options, data);
return {
type: config.type,
name: isNew ? config.name : visualization.name,
options,
originalOptions: options,
};
}, [data, isNew, visualization]);
const [type, setType] = useState(defaultState.type);
const [name, setName] = useState(defaultState.name);
const [nameChanged, setNameChanged] = useState(false);
const [options, setOptions] = useState(defaultState.options);
const [saveInProgress, setSaveInProgress] = useState(false);
useEffect(() => {
if (errorHandlerRef.current) {
errorHandlerRef.current.reset();
}
}, [data, options]);
function onTypeChanged(newType) {
setType(newType);
const config = registeredVisualizations[newType];
if (!nameChanged) {
setName(config.name);
}
setOptions(config.getOptions(isNew ? {} : visualization.options, data));
}
function onNameChanged(newName) {
setName(newName);
setNameChanged(newName !== name);
}
function onOptionsChanged(newOptions) {
const config = registeredVisualizations[type];
setOptions(config.getOptions(newOptions, data));
}
function save() {
setSaveInProgress(true);
let visualizationOptions = options;
if (type === "TABLE") {
visualizationOptions = omit(visualizationOptions, ["paginationSize"]);
}
const visualizationData = extend(newVisualization(type), visualization, {
name,
options: visualizationOptions,
query_id: query.id,
});
saveVisualization(visualizationData).then((savedVisualization) => {
updateQueryVisualizations(query, savedVisualization);
dialog.close(savedVisualization);
});
}
function dismiss() {
const optionsChanged = !isEqual(options, defaultState.originalOptions);
confirmDialogClose(nameChanged || optionsChanged)
.then(dialog.dismiss)
.catch(() => {});
}
// When editing existing visualization chart type selector is disabled, so add only existing visualization's
// descriptor there (to properly render the component). For new visualizations show all types except of deprecated
const availableVisualizations = isNew
? filter(sortBy(registeredVisualizations, ["name"]), (vis) => !vis.isDeprecated)
: pick(registeredVisualizations, [type]);
const vizTypeId = useUniqueId("visualization-type");
const vizNameId = useUniqueId("visualization-name");
return (
Visualization Type
{map(availableVisualizations, (vis) => (
{vis.name}
))}
Visualization Name
onNameChanged(event.target.value)}
/>
);
}
EditVisualizationDialog.propTypes = {
dialog: DialogPropType.isRequired,
query: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
visualization: VisualizationType,
queryResult: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
};
EditVisualizationDialog.defaultProps = {
visualization: null,
};
export default wrapDialog(EditVisualizationDialog);
================================================
FILE: client/app/components/visualizations/EditVisualizationDialog.less
================================================
@media (min-width: 992px) {
.edit-visualization-dialog {
display: flex;
height: 100%;
.visualization-settings {
padding-right: 12px;
width: 40%;
overflow: auto;
}
.visualization-preview {
padding-left: 12px;
width: 60%;
overflow: auto;
}
}
}
================================================
FILE: client/app/components/visualizations/VisualizationName.jsx
================================================
import React from "react";
import { VisualizationType, registeredVisualizations } from "@redash/viz/lib";
import "./VisualizationName.less";
function VisualizationName({ visualization }) {
const config = registeredVisualizations[visualization.type];
return (
{config && visualization.name !== config.name ? visualization.name : null}
);
}
VisualizationName.propTypes = {
visualization: VisualizationType.isRequired,
};
export default VisualizationName;
================================================
FILE: client/app/components/visualizations/VisualizationName.less
================================================
.visualization-name:empty + span {
color: rgba(0, 0, 0, 0.8);
}
.visualization-name {
&:after {
content: "−";
margin-left: 5px;
}
&:empty:after {
content: none;
}
}
================================================
FILE: client/app/components/visualizations/VisualizationRenderer.jsx
================================================
import { isEqual, map, find, fromPairs } from "lodash";
import React, { useState, useMemo, useEffect, useRef } from "react";
import PropTypes from "prop-types";
import useQueryResultData from "@/lib/useQueryResultData";
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
import Filters, { FiltersType, filterData } from "@/components/Filters";
import { VisualizationType } from "@redash/viz/lib";
import { Renderer } from "@/components/visualizations/visualizationComponents";
function combineFilters(localFilters, globalFilters) {
// tiny optimization - to avoid unnecessary updates
if (localFilters.length === 0 || globalFilters.length === 0) {
return localFilters;
}
return map(localFilters, localFilter => {
const globalFilter = find(globalFilters, f => f.name === localFilter.name);
if (globalFilter) {
return {
...localFilter,
current: globalFilter.current,
};
}
return localFilter;
});
}
function areFiltersEqual(a, b) {
if (a.length !== b.length) {
return false;
}
a = fromPairs(map(a, item => [item.name, item]));
b = fromPairs(map(b, item => [item.name, item]));
return isEqual(a, b);
}
export default function VisualizationRenderer(props) {
const data = useQueryResultData(props.queryResult);
const [filters, setFilters] = useState(() => combineFilters(data.filters, props.filters)); // lazy initialization
const filtersRef = useRef();
filtersRef.current = filters;
const handleFiltersChange = useImmutableCallback(newFilters => {
if (!areFiltersEqual(newFilters, filters)) {
setFilters(newFilters);
props.onFiltersChange(newFilters);
}
});
// Reset local filters when query results updated
useEffect(() => {
handleFiltersChange(combineFilters(data.filters, props.filters));
}, [data.filters, props.filters, handleFiltersChange]);
// Update local filters when global filters changed.
// For correct behavior need to watch only `props.filters` here,
// therefore using ref to access current local filters
useEffect(() => {
handleFiltersChange(combineFilters(filtersRef.current, props.filters));
}, [props.filters, handleFiltersChange]);
const filteredData = useMemo(
() => ({
columns: data.columns,
rows: filterData(data.rows, filters),
}),
[data, filters]
);
const { showFilters, visualization } = props;
let options = { ...visualization.options };
// define pagination size based on context for Table visualization
if (visualization.type === "TABLE") {
options.paginationSize = props.context === "widget" ? "small" : "default";
}
return (
}
/>
);
}
VisualizationRenderer.propTypes = {
visualization: VisualizationType.isRequired,
queryResult: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
showFilters: PropTypes.bool,
filters: FiltersType,
onFiltersChange: PropTypes.func,
context: PropTypes.oneOf(["query", "widget"]).isRequired,
};
VisualizationRenderer.defaultProps = {
showFilters: true,
filters: [],
onFiltersChange: () => {},
};
================================================
FILE: client/app/components/visualizations/visualizationComponents.jsx
================================================
import React from "react";
import { pick } from "lodash";
import HelpTrigger from "@/components/HelpTrigger";
import Link from "@/components/Link";
import { Renderer as VisRenderer, Editor as VisEditor, updateVisualizationsSettings } from "@redash/viz/lib";
import { clientConfig } from "@/services/auth";
import countriesDataUrl from "@redash/viz/lib/visualizations/choropleth/maps/countries.geo.json";
import usaDataUrl from "@redash/viz/lib/visualizations/choropleth/maps/usa-albers.geo.json";
import subdivJapanDataUrl from "@redash/viz/lib/visualizations/choropleth/maps/japan.prefectures.geo.json";
function wrapComponentWithSettings(WrappedComponent) {
return function VisualizationComponent(props) {
updateVisualizationsSettings({
HelpTriggerComponent: HelpTrigger,
LinkComponent: Link,
choroplethAvailableMaps: {
countries: {
name: "Countries",
url: countriesDataUrl,
fieldNames: {
name: "Short name",
name_long: "Full name",
abbrev: "Abbreviated name",
iso_a2: "ISO code (2 letters)",
iso_a3: "ISO code (3 letters)",
iso_n3: "ISO code (3 digits)",
},
},
usa: {
name: "USA",
url: usaDataUrl,
fieldNames: {
name: "Name",
ns_code: "National Standard ANSI Code (8-character)",
geoid: "Geographic ID",
usps_abbrev: "USPS Abbreviation",
fips_code: "FIPS Code (2-character)",
},
},
subdiv_japan: {
name: "Japan/Prefectures",
url: subdivJapanDataUrl,
fieldNames: {
name: "Name",
name_alt: "Name (alternative)",
name_local: "Name (local)",
iso_3166_2: "ISO-3166-2",
postal: "Postal Code",
type: "Type",
type_en: "Type (EN)",
region: "Region",
region_code: "Region Code",
},
},
},
...pick(clientConfig, [
"dateFormat",
"dateTimeFormat",
"integerFormat",
"floatFormat",
"nullValue",
"booleanValues",
"tableCellMaxJSONSize",
"allowCustomJSVisualizations",
"hidePlotlyModeBar",
]),
});
return ;
};
}
export const Renderer = wrapComponentWithSettings(VisRenderer);
export const Editor = wrapComponentWithSettings(VisEditor);
================================================
FILE: client/app/config/antd-spinner.jsx
================================================
import React from "react";
import Spin from "antd/lib/spin";
Spin.setDefaultIndicator(
Loading...
);
================================================
FILE: client/app/config/dashboard-grid-options.js
================================================
export default {
columns: 12, // grid columns count
rowHeight: 50, // grid row height (incl. bottom padding)
margins: 15, // widget margins
mobileBreakPoint: 800,
// defaults for widgets
defaultSizeX: 6,
defaultSizeY: 3,
minSizeX: 2,
maxSizeX: 12,
minSizeY: 2,
maxSizeY: 1000,
};
================================================
FILE: client/app/config/index.js
================================================
import moment from "moment";
import { isFunction } from "lodash";
// Ensure that this image will be available in assets folder
import "@/assets/images/avatar.svg";
// Register visualizations
import "@redash/viz/lib";
// Register routes before registering extensions as they may want to override some
import "@/pages";
import "./antd-spinner";
moment.updateLocale("en", {
relativeTime: {
future: "%s",
past: "%s",
s: "just now",
m: "a minute ago",
mm: "%d minutes ago",
h: "an hour ago",
hh: "%d hours ago",
d: "a day ago",
dd: "%d days ago",
M: "a month ago",
MM: "%d months ago",
y: "a year ago",
yy: "%d years ago",
},
});
function requireImages() {
// client/app/assets/images/ => /images/
const ctx = require.context("@/assets/images/", true, /\.(png|jpe?g|gif|svg)$/);
ctx.keys().forEach(ctx);
}
function registerExtensions() {
const context = require.context("extensions", true, /^((?![\\/.]test[\\./]).)*\.jsx?$/);
const modules = context
.keys()
.map(context)
.map(module => module.default);
return modules
.filter(isFunction)
.filter(f => f.init)
.map(f => f());
}
requireImages();
registerExtensions();
================================================
FILE: client/app/extensions/.gitkeep
================================================
================================================
FILE: client/app/index.html
================================================
<%= htmlWebpackPlugin.options.title %>
================================================
FILE: client/app/index.js
================================================
import React from "react";
import ReactDOM from "react-dom";
import "@/config";
import ApplicationArea from "@/components/ApplicationArea";
import offlineListener from "@/services/offline-listener";
ReactDOM.render( , document.getElementById("application-root"), () => {
offlineListener.init();
});
================================================
FILE: client/app/lib/accessibility.ts
================================================
import { HTMLAttributes } from "react";
interface SrNotifyProps {
text: string;
expiry: number;
container: HTMLElement;
politeness: HTMLAttributes["aria-live"];
}
export function srNotify({ text, expiry = 1000, container = document.body, politeness = "polite" }: SrNotifyProps) {
const element = document.createElement("div");
const id = `speak-${Date.now()}`;
element.id = id;
element.className = "sr-only";
element.textContent = text;
element.setAttribute("role", "alert");
element.setAttribute("aria-live", politeness);
container.appendChild(element);
let timer: null | number = null;
let isDone = false;
const cleanupFn = () => {
if (isDone) {
return;
}
isDone = true;
try {
container.removeChild(element);
} catch (e) {
console.error(e);
}
if (timer) {
window.clearTimeout(timer);
}
};
timer = window.setTimeout(cleanupFn, expiry);
return cleanupFn;
}
================================================
FILE: client/app/lib/calculateTextWidth.ts
================================================
const canvas = document.createElement("canvas");
canvas.style.display = "none";
document.body.appendChild(canvas);
export function calculateTextWidth(text: string, container = document.body) {
const ctx = canvas.getContext("2d");
if (ctx) {
const containerStyle = window.getComputedStyle(container);
ctx.font = `${containerStyle.fontSize} ${containerStyle.fontFamily}`;
const textMetrics = ctx.measureText(text);
let actualWidth = textMetrics.width;
if ("actualBoundingBoxLeft" in textMetrics) {
// only available on evergreen browsers
actualWidth = Math.abs(textMetrics.actualBoundingBoxLeft) + Math.abs(textMetrics.actualBoundingBoxRight);
}
return actualWidth;
}
return null;
}
================================================
FILE: client/app/lib/hooks/useFullscreenHandler.js
================================================
import { has } from "lodash";
import { useEffect, useState } from "react";
import location from "@/services/location";
export default function useFullscreenHandler() {
const [fullscreen, setFullscreen] = useState(has(location.search, "fullscreen"));
useEffect(() => {
document.body.classList.toggle("headless", fullscreen);
location.setSearch({ fullscreen: fullscreen ? true : null }, true);
}, [fullscreen]);
const toggleFullscreen = () => setFullscreen(!fullscreen);
return [fullscreen, toggleFullscreen];
}
================================================
FILE: client/app/lib/hooks/useImmutableCallback.js
================================================
import { isFunction, noop } from "lodash";
import { useRef, useCallback } from "react";
// This hook wraps a potentially changeable function object and always returns the same
// function so it's safe to use it with other hooks: wrapper function stays the same,
// but will always call a latest wrapped function.
// A quick note regarding `react-hooks/exhaustive-deps`: since wrapper function doesn't
// change, it's safe to use it as a dependency, it will never trigger other hooks.
export default function useImmutableCallback(callback) {
const callbackRef = useRef();
callbackRef.current = isFunction(callback) ? callback : noop;
return useCallback((...args) => callbackRef.current(...args), []);
}
================================================
FILE: client/app/lib/hooks/useLazyRef.ts
================================================
import { useRef } from "react";
export function useLazyRef(getInitialValue: () => T) {
const lazyRef = useRef(null) as React.MutableRefObject;
if (lazyRef.current === null) {
lazyRef.current = getInitialValue();
}
return lazyRef;
}
================================================
FILE: client/app/lib/hooks/useSearchResults.js
================================================
import { useState, useEffect, useRef } from "react";
import { useDebouncedCallback } from "use-debounce";
export default function useSearchResults(fetch, { initialResults = null, debounceTimeout = 200 } = {}) {
const [result, setResult] = useState(initialResults);
const [isLoading, setIsLoading] = useState(false);
const currentSearchTerm = useRef(null);
const isDestroyed = useRef(false);
const [doSearch] = useDebouncedCallback(searchTerm => {
setIsLoading(true);
currentSearchTerm.current = searchTerm;
fetch(searchTerm)
.catch(() => initialResults)
.then(data => {
if (searchTerm === currentSearchTerm.current && !isDestroyed.current) {
setResult(data);
setIsLoading(false);
}
});
}, debounceTimeout);
useEffect(
() =>
// ignore all requests after component destruction
() => {
isDestroyed.current = true;
},
[]
);
return [doSearch, result, isLoading];
}
================================================
FILE: client/app/lib/hooks/useUniqueId.ts
================================================
import { uniqueId } from "lodash";
import { useLazyRef } from "./useLazyRef";
export function useUniqueId(prefix: string) {
const { current: id } = useLazyRef(() => uniqueId(prefix));
return id;
}
================================================
FILE: client/app/lib/localOptions.js
================================================
const PREFIX = "localOptions:";
function get(key, defaultValue = undefined) {
const fullKey = PREFIX + key;
if (fullKey in window.localStorage) {
return JSON.parse(window.localStorage.getItem(fullKey));
}
return defaultValue;
}
function set(key, value) {
const fullKey = PREFIX + key;
window.localStorage.setItem(fullKey, JSON.stringify(value));
}
export default {
get,
set,
};
================================================
FILE: client/app/lib/pagination/index.js
================================================
/* eslint-disable import/prefer-default-export */
export { default as Paginator } from "./paginator";
================================================
FILE: client/app/lib/pagination/paginator.js
================================================
import { sortBy } from "lodash";
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();
}
}
}
}
================================================
FILE: client/app/lib/queryFormat.test.js
================================================
import { Query } from "@/services/query";
import * as queryFormat from "./queryFormat";
describe("QueryFormat.formatQuery", () => {
test("returns same query text when syntax is not supported", () => {
const unsupportedSyntax = "unsupported-syntax";
const queryText = "select * from example";
const isFormatQueryAvailable = queryFormat.isFormatQueryAvailable(unsupportedSyntax);
const formattedQuery = queryFormat.formatQuery(queryText, unsupportedSyntax);
expect(isFormatQueryAvailable).toBeFalsy();
expect(formattedQuery).toBe(queryText);
});
describe("sql", () => {
const syntax = "sql";
test("returns the formatted query text", () => {
const queryText = "select column1, column2 from example where column1 = 2";
const expectedFormattedQueryText = [
"select",
" column1,",
" column2",
"from",
" example",
"where",
" column1 = 2",
].join("\n");
const isFormatQueryAvailable = queryFormat.isFormatQueryAvailable(syntax);
const formattedQueryText = queryFormat.formatQuery(queryText, syntax);
expect(isFormatQueryAvailable).toBeTruthy();
expect(formattedQueryText).toBe(expectedFormattedQueryText);
});
test("still recognizes parameters after formatting", () => {
const queryText = "select {{param1}}, {{ param2 }}, {{ date-range.start }} from example";
const formattedQueryText = queryFormat.formatQuery(queryText, syntax);
const queryParameters = new Query({ query: queryText }).getParameters().parseQuery();
const formattedQueryParameters = new Query({ query: formattedQueryText }).getParameters().parseQuery();
expect(formattedQueryParameters.sort()).toEqual(queryParameters.sort());
});
});
describe("json", () => {
const syntax = "json";
test("returns the formatted query text", () => {
const queryText = '{"collection": "example","limit": 10}';
const expectedFormattedQueryText = '{\n "collection": "example",\n "limit": 10\n}';
const isFormatQueryAvailable = queryFormat.isFormatQueryAvailable(syntax);
const formattedQueryText = queryFormat.formatQuery(queryText, syntax);
expect(isFormatQueryAvailable).toBeTruthy();
expect(formattedQueryText).toBe(expectedFormattedQueryText);
});
});
});
================================================
FILE: client/app/lib/queryFormat.ts
================================================
import { trim } from "lodash";
import sqlFormatter from "sql-formatter";
interface QueryFormatterMap {
[syntax: string]: (queryText: string) => string;
}
const QueryFormatters: QueryFormatterMap = {
sql: queryText => sqlFormatter.format(trim(queryText)),
json: queryText => JSON.stringify(JSON.parse(queryText), null, 4),
};
export function isFormatQueryAvailable(syntax: string) {
return syntax in QueryFormatters;
}
export function formatQuery(queryText: string, syntax: string) {
if (!isFormatQueryAvailable(syntax)) {
return queryText;
}
const formatter = QueryFormatters[syntax];
return formatter(queryText);
}
================================================
FILE: client/app/lib/useQueryResultData.js
================================================
import { useMemo } from "react";
import { get, invoke } from "lodash";
function getQueryResultData(queryResult, queryResultStatus = null) {
return {
status: queryResultStatus || invoke(queryResult, "getStatus") || null,
columns: invoke(queryResult, "getColumns") || [],
rows: invoke(queryResult, "getData") || [],
filters: invoke(queryResult, "getFilters") || [],
updatedAt: invoke(queryResult, "getUpdatedAt") || null,
retrievedAt: get(queryResult, "query_result.retrieved_at", null),
truncated: invoke(queryResult, "getTruncated") || null,
log: invoke(queryResult, "getLog") || [],
error: invoke(queryResult, "getError") || null,
runtime: invoke(queryResult, "getRuntime") || null,
metadata: get(queryResult, "query_result.data.metadata", {}),
};
}
export default function useQueryResultData(queryResult) {
// make sure it re-executes when queryResult status changes
const queryResultStatus = invoke(queryResult, "getStatus");
return useMemo(() => getQueryResultData(queryResult, queryResultStatus), [queryResult, queryResultStatus]);
}
================================================
FILE: client/app/lib/utils.js
================================================
import moment from "moment";
import { clientConfig } from "@/services/auth";
export const IntervalEnum = {
NEVER: "Never",
SECONDS: "second",
MINUTES: "minute",
HOURS: "hour",
DAYS: "day",
WEEKS: "week",
MILLISECONDS: "millisecond",
};
export const AbbreviatedTimeUnits = {
SECONDS: "s",
MINUTES: "m",
HOURS: "h",
DAYS: "d",
WEEKS: "w",
MILLISECONDS: "ms",
};
function formatDateTimeValue(value, format) {
if (!value) {
return "";
}
const parsed = moment(value);
if (!parsed.isValid()) {
return "-";
}
return parsed.format(format);
}
export function formatDateTime(value) {
return formatDateTimeValue(value, clientConfig.dateTimeFormat);
}
export function formatDateTimePrecise(value, withMilliseconds = false) {
return formatDateTimeValue(value, clientConfig.dateFormat + (withMilliseconds ? " HH:mm:ss.SSS" : " HH:mm:ss"));
}
export function formatDate(value) {
return formatDateTimeValue(value, clientConfig.dateFormat);
}
export function localizeTime(time) {
const [hrs, mins] = time.split(":");
return moment
.utc()
.hour(hrs)
.minute(mins)
.local()
.format("HH:mm");
}
export function secondsToInterval(count) {
if (!count) {
return { interval: IntervalEnum.NEVER };
}
let interval = IntervalEnum.SECONDS;
if (count >= 60) {
count /= 60;
interval = IntervalEnum.MINUTES;
}
if (count >= 60) {
count /= 60;
interval = IntervalEnum.HOURS;
}
if (count >= 24 && interval === IntervalEnum.HOURS) {
count /= 24;
interval = IntervalEnum.DAYS;
}
if (count >= 7 && !(count % 7) && interval === IntervalEnum.DAYS) {
count /= 7;
interval = IntervalEnum.WEEKS;
}
return { count, interval };
}
export function pluralize(text, count) {
const should = count !== 1;
return text + (should ? "s" : "");
}
export function durationHumanize(durationInSeconds, options = {}) {
if (!durationInSeconds) {
return "-";
}
let ret = "";
const { interval, count } = secondsToInterval(durationInSeconds);
const rounded = Math.round(count);
if (rounded !== 1 || !options.omitSingleValueNumber) {
ret = `${rounded} `;
}
ret += pluralize(interval, rounded);
return ret;
}
export function toHuman(text) {
return text.replace(/_/g, " ").replace(/(?:^|\s)\S/g, a => a.toUpperCase());
}
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;
}
/**
* Formats number to string
* @param value {number}
* @param [fractionDigits] {number}
* @return {string}
*/
export function formatNumber(value, fractionDigits = 3) {
return Math.round(value) !== value ? value.toFixed(fractionDigits) : value.toString();
}
/**
* Formats any number using predefined units
* @param value {string|number}
* @param divisor {number}
* @param [units] {Array}
* @param [fractionDigits] {number}
* @return {{unit: string, value: string, divisor: number}}
*/
export function prettyNumberWithUnit(value, divisor, units = [], fractionDigits) {
if (isNaN(parseFloat(value)) || !isFinite(value)) {
return {
value: "",
unit: "",
divisor: 1,
};
}
let unit = 0;
let greatestDivisor = 1;
while (value >= divisor && unit < units.length - 1) {
value /= divisor;
greatestDivisor *= divisor;
unit += 1;
}
return {
value: formatNumber(value, fractionDigits),
unit: units[unit],
divisor: greatestDivisor,
};
}
export function prettySizeWithUnit(bytes, fractionDigits) {
return prettyNumberWithUnit(bytes, 1024, ["bytes", "KB", "MB", "GB", "TB", "PB"], fractionDigits);
}
export function prettySize(bytes) {
const { value, unit } = prettySizeWithUnit(bytes);
if (!value) {
return "?";
}
return value + " " + unit;
}
export function join(arr) {
if (arr === undefined || arr === null) {
return "";
}
return arr.join(" / ");
}
export function formatColumnValue(value, columnType = null) {
if (moment.isMoment(value)) {
if (columnType === "date") {
return formatDate(value);
}
return formatDateTime(value);
}
if (typeof value === "boolean") {
return value.toString();
}
return value;
}
================================================
FILE: client/app/multi_org.html
================================================
<%= htmlWebpackPlugin.options.title %>
================================================
FILE: client/app/pages/admin/Jobs.jsx
================================================
import { partition, flatMap, values } from "lodash";
import React from "react";
import moment from "moment";
import Alert from "antd/lib/alert";
import Tabs from "antd/lib/tabs";
import * as Grid from "antd/lib/grid";
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
import Layout from "@/components/admin/Layout";
import { CounterCard, WorkersTable, QueuesTable, QueryJobsTable, OtherJobsTable } from "@/components/admin/RQStatus";
import { axios } from "@/services/axios";
import location from "@/services/location";
import recordEvent from "@/services/recordEvent";
import routes from "@/services/routes";
class Jobs extends React.Component {
state = {
activeTab: location.hash,
isLoading: true,
error: null,
queueCounters: [],
overallCounters: { started: 0, queued: 0 },
startedJobs: [],
workers: [],
};
_refreshTimer = null;
componentDidMount() {
recordEvent("view", "page", "admin/rq_status");
this.refresh();
}
componentWillUnmount() {
// Ignore data after component unmounted
clearTimeout(this._refreshTimer);
this.processQueues = () => {};
this.handleError = () => {};
}
refresh = () => {
axios
.get("/api/admin/queries/rq_status")
.then(data => this.processQueues(data))
.catch(error => this.handleError(error));
this._refreshTimer = setTimeout(this.refresh, 60 * 1000);
};
processQueues = ({ queues, workers }) => {
const queueCounters = values(queues).map(({ started, ...rest }) => ({
started: started.length,
...rest,
}));
const overallCounters = queueCounters.reduce(
(c, q) => ({
started: c.started + q.started,
queued: c.queued + q.queued,
}),
{ started: 0, queued: 0 }
);
const startedJobs = flatMap(values(queues), queue =>
queue.started.map(job => ({
...job,
enqueued_at: moment.utc(job.enqueued_at),
started_at: moment.utc(job.started_at),
}))
);
this.setState({ isLoading: false, queueCounters, startedJobs, overallCounters, workers });
};
handleError = error => {
this.setState({ isLoading: false, error });
};
render() {
const { isLoading, error, queueCounters, startedJobs, overallCounters, workers, activeTab } = this.state;
const [startedQueryJobs, otherStartedJobs] = partition(startedJobs, [
"name",
"redash.tasks.queries.execution.execute_query",
]);
const changeTab = newTab => {
location.setHash(newTab);
this.setState({ activeTab: newTab });
};
return (
{error &&
}
{!error && (
)}
);
}
}
routes.register(
"Admin.Jobs",
routeWithUserSession({
path: "/admin/queries/jobs",
title: "RQ Status",
render: pageProps => ,
})
);
================================================
FILE: client/app/pages/admin/OutdatedQueries.jsx
================================================
import { map, uniqueId } from "lodash";
import React from "react";
import Switch from "antd/lib/switch";
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
import Link from "@/components/Link";
import Paginator from "@/components/Paginator";
import { QueryTagsControl } from "@/components/tags-control/TagsControl";
import SchedulePhrase from "@/components/queries/SchedulePhrase";
import TimeAgo from "@/components/TimeAgo";
import Layout from "@/components/admin/Layout";
import { wrap as itemsList, ControllerType } from "@/components/items-list/ItemsList";
import { ItemsSource } from "@/components/items-list/classes/ItemsSource";
import { StateStorage } from "@/components/items-list/classes/StateStorage";
import LoadingState from "@/components/items-list/components/LoadingState";
import ItemsTable, { Columns } from "@/components/items-list/components/ItemsTable";
import { axios } from "@/services/axios";
import { Query } from "@/services/query";
import recordEvent from "@/services/recordEvent";
import routes from "@/services/routes";
class OutdatedQueries extends React.Component {
static propTypes = {
controller: ControllerType.isRequired,
};
listColumns = [
{
title: "ID",
field: "id",
width: "1%",
align: "right",
sorter: true,
},
Columns.custom.sortable(
(text, item) => (
{item.name}
),
{
title: "Name",
field: "name",
width: null,
}
),
Columns.avatar({ field: "user", className: "p-l-0 p-r-0" }, name => `Created by ${name}`),
Columns.dateTime.sortable({ title: "Created At", field: "created_at" }),
Columns.duration.sortable({ title: "Runtime", field: "runtime" }),
Columns.dateTime.sortable({ title: "Last Executed At", field: "retrieved_at", orderByField: "executed_at" }),
Columns.custom.sortable((text, item) => , {
title: "Update Schedule",
field: "schedule",
}),
];
state = {
autoUpdate: true,
};
_updateTimer = null;
autoUpdateSwitchId = uniqueId("auto-update-switch");
componentDidMount() {
recordEvent("view", "page", "admin/queries/outdated");
this.update(true);
}
componentWillUnmount() {
clearTimeout(this._updateTimer);
}
update = (isInitialCall = false) => {
if (!isInitialCall && this.state.autoUpdate) {
this.props.controller.update();
}
this._updateTimer = setTimeout(this.update, 60 * 1000);
};
render() {
const { controller } = this.props;
return (
Auto update
this.setState({ autoUpdate })}
/>
{controller.params.lastUpdatedAt && (
Last updated:
)}
{!controller.isLoaded && }
{controller.isLoaded && controller.isEmpty && (
There are no outdated queries.
)}
{controller.isLoaded && !controller.isEmpty && (
controller.updatePagination({ itemsPerPage })}
page={controller.page}
onChange={page => controller.updatePagination({ page })}
/>
)}
);
}
}
const OutdatedQueriesPage = itemsList(
OutdatedQueries,
() =>
new ItemsSource({
doRequest(request, context) {
return (
axios
.get("/api/admin/queries/outdated")
// eslint-disable-next-line camelcase
.then(({ queries, updated_at }) => {
context.setCustomParams({ lastUpdatedAt: parseFloat(updated_at) });
return queries;
})
);
},
processResults(items) {
return map(items, item => new Query(item));
},
isPlainList: true,
}),
() => new StateStorage({ orderByField: "created_at", orderByReverse: true })
);
routes.register(
"Admin.OutdatedQueries",
routeWithUserSession({
path: "/admin/queries/outdated",
title: "Outdated Queries",
render: pageProps => ,
})
);
================================================
FILE: client/app/pages/admin/SystemStatus.jsx
================================================
import { omit } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
import Layout from "@/components/admin/Layout";
import * as StatusBlock from "@/components/admin/StatusBlock";
import { axios } from "@/services/axios";
import recordEvent from "@/services/recordEvent";
import routes from "@/services/routes";
import "./system-status.less";
class SystemStatus extends React.Component {
static propTypes = {
onError: PropTypes.func,
};
static defaultProps = {
onError: () => {},
};
state = {
queues: [],
manager: null,
databaseMetrics: {},
status: {},
};
_refreshTimer = null;
componentDidMount() {
recordEvent("view", "page", "admin/status");
this.refresh();
}
componentWillUnmount() {
clearTimeout(this._refreshTimer);
}
refresh = () => {
axios
.get("/status.json")
.then(data => {
this.setState({
queues: data.manager.queues,
manager: {
startedAt: data.manager.started_at * 1000,
lastRefreshAt: data.manager.last_refresh_at * 1000,
outdatedQueriesCount: data.manager.outdated_queries_count,
},
databaseMetrics: data.database_metrics.metrics || [],
status: omit(data, ["workers", "manager", "database_metrics"]),
});
})
.catch(error => this.props.onError(error));
this._refreshTimer = setTimeout(this.refresh, 60 * 1000);
};
render() {
return (
);
}
}
routes.register(
"Admin.SystemStatus",
routeWithUserSession({
path: "/admin/status",
title: "System Status",
render: pageProps => ,
})
);
================================================
FILE: client/app/pages/admin/system-status.less
================================================
.system-status-page {
@gutter: 15px;
overflow: hidden;
padding: @gutter;
.system-status-page-blocks {
display: flex;
align-items: stretch;
flex-wrap: wrap;
margin: -@gutter / 2;
.system-status-page-block {
flex: 0 0 auto;
padding: @gutter / 2;
width: 100%;
display: flex;
align-items: stretch;
> div {
width: 100%;
}
@media (min-width: 768px) {
& {
width: 50%;
}
}
@media (min-width: 1600px) {
& {
width: 25%;
}
}
}
.ant-list-item {
&:first-child {
padding-top: 0;
}
&:last-child {
padding-bottom: 0;
}
&-content {
margin: 0;
}
}
}
}
================================================
FILE: client/app/pages/alert/Alert.jsx
================================================
import { head, includes, trim, template, values } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import LoadingState from "@/components/items-list/components/LoadingState";
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
import navigateTo from "@/components/ApplicationArea/navigateTo";
import { currentUser } from "@/services/auth";
import notification from "@/services/notification";
import AlertService from "@/services/alert";
import { Query as QueryService } from "@/services/query";
import routes from "@/services/routes";
import MenuButton from "./components/MenuButton";
import AlertView from "./AlertView";
import AlertEdit from "./AlertEdit";
import AlertNew from "./AlertNew";
import notifications from "@/services/notifications";
const MODES = {
NEW: 0,
VIEW: 1,
EDIT: 2,
};
const defaultNameBuilder = template("<%= query.name %>: <%= options.column %> <%= options.op %> <%= options.value %>");
export function getDefaultName(alert) {
if (!alert.query) {
return "New Alert";
}
return defaultNameBuilder(alert);
}
class Alert extends React.Component {
static propTypes = {
mode: PropTypes.oneOf(values(MODES)),
alertId: PropTypes.string,
onError: PropTypes.func,
};
static defaultProps = {
mode: null,
alertId: null,
onError: () => {},
};
_isMounted = false;
state = {
alert: null,
queryResult: null,
pendingRearm: null,
canEdit: false,
mode: null,
};
componentDidMount() {
this._isMounted = true;
const { mode } = this.props;
this.setState({ mode });
if (mode === MODES.NEW) {
this.setState({
alert: {
options: {
selector: "first",
op: ">",
value: 1,
muted: false,
},
},
pendingRearm: 0,
canEdit: true,
});
} else {
const { alertId } = this.props;
AlertService.get({ id: alertId })
.then((alert) => {
if (this._isMounted) {
const canEdit = currentUser.canEdit(alert);
// force view mode if can't edit
if (!canEdit) {
this.setState({ mode: MODES.VIEW });
notification.warn(
"You cannot edit this alert",
"You do not have sufficient permissions to edit this alert, and have been redirected to the view-only page.",
{ duration: 0 }
);
}
this.setState({ alert, canEdit, pendingRearm: alert.rearm });
this.onQuerySelected(alert.query);
}
})
.catch((error) => {
if (this._isMounted) {
this.props.onError(error);
}
});
}
}
componentWillUnmount() {
this._isMounted = false;
}
save = () => {
const { alert, pendingRearm } = this.state;
alert.name = trim(alert.name) || getDefaultName(alert);
alert.rearm = pendingRearm || null;
return AlertService.save(alert)
.then((alert) => {
notification.success("Saved.");
navigateTo(`alerts/${alert.id}`, true);
this.setState({ alert, mode: MODES.VIEW });
})
.catch(() => {
notification.error("Failed saving alert.");
});
};
onQuerySelected = (query) => {
this.setState(({ alert }) => ({
alert: Object.assign(alert, { query }),
queryResult: null,
}));
if (query) {
// get cached result for column names and values
new QueryService(query).getQueryResultPromise().then((queryResult) => {
if (this._isMounted) {
this.setState({ queryResult });
let { column } = this.state.alert.options;
const columns = queryResult.getColumnNames();
// default to first column name if none chosen, or irrelevant in current query
if (!column || !includes(columns, column)) {
column = head(queryResult.getColumnNames());
}
this.setAlertOptions({ column });
}
});
}
};
onNameChange = (name) => {
const { alert } = this.state;
this.setState({
alert: Object.assign(alert, { name }),
});
};
onRearmChange = (pendingRearm) => {
this.setState({ pendingRearm });
};
setAlertOptions = (obj) => {
const { alert } = this.state;
const options = { ...alert.options, ...obj };
this.setState({
alert: Object.assign(alert, { options }),
});
};
delete = () => {
const { alert } = this.state;
return AlertService.delete(alert)
.then(() => {
notification.success("Alert deleted successfully.");
navigateTo("alerts");
})
.catch(() => {
notification.error("Failed deleting alert.");
});
};
evaluate = () => {
const { alert } = this.state;
return AlertService.evaluate(alert)
.then(() => {
notification.success("Alert evaluated. Refresh page for updated status.");
})
.catch(() => {
notifications.error("Failed to evaluate alert.");
});
};
mute = () => {
const { alert } = this.state;
return AlertService.mute(alert)
.then(() => {
this.setAlertOptions({ muted: true });
notification.warn("Notifications have been muted.");
})
.catch(() => {
notification.error("Failed muting notifications.");
});
};
unmute = () => {
const { alert } = this.state;
return AlertService.unmute(alert)
.then(() => {
this.setAlertOptions({ muted: false });
notification.success("Notifications have been restored.");
})
.catch(() => {
notification.error("Failed restoring notifications.");
});
};
edit = () => {
const { id } = this.state.alert;
navigateTo(`alerts/${id}/edit`, true);
this.setState({ mode: MODES.EDIT });
};
cancel = () => {
const { id } = this.state.alert;
navigateTo(`alerts/${id}`, true);
this.setState({ mode: MODES.VIEW });
};
render() {
const { alert } = this.state;
if (!alert) {
return ;
}
const muted = !!alert.options.muted;
const { queryResult, mode, canEdit, pendingRearm } = this.state;
const menuButton = (
);
const commonProps = {
alert,
queryResult,
pendingRearm,
save: this.save,
menuButton,
onQuerySelected: this.onQuerySelected,
onRearmChange: this.onRearmChange,
onNameChange: this.onNameChange,
onCriteriaChange: this.setAlertOptions,
onNotificationTemplateChange: this.setAlertOptions,
};
return (
{mode === MODES.NEW &&
}
{mode === MODES.VIEW && (
)}
{mode === MODES.EDIT &&
}
);
}
}
routes.register(
"Alerts.New",
routeWithUserSession({
path: "/alerts/new",
title: "New Alert",
render: (pageProps) => ,
})
);
routes.register(
"Alerts.View",
routeWithUserSession({
path: "/alerts/:alertId",
title: "Alert",
render: (pageProps) => ,
})
);
routes.register(
"Alerts.Edit",
routeWithUserSession({
path: "/alerts/:alertId/edit",
title: "Alert",
render: (pageProps) => ,
})
);
================================================
FILE: client/app/pages/alert/AlertEdit.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
import HelpTrigger from "@/components/HelpTrigger";
import DynamicComponent from "@/components/DynamicComponent";
import { Alert as AlertType } from "@/components/proptypes";
import Form from "antd/lib/form";
import Button from "antd/lib/button";
import Title from "./components/Title";
import Criteria from "./components/Criteria";
import NotificationTemplate from "./components/NotificationTemplate";
import Rearm from "./components/Rearm";
import Query from "./components/Query";
import HorizontalFormItem from "./components/HorizontalFormItem";
export default class AlertEdit extends React.Component {
_isMounted = false;
state = {
saving: false,
};
componentDidMount() {
this._isMounted = true;
}
componentWillUnmount() {
this._isMounted = false;
}
save = () => {
this.setState({ saving: true });
this.props.save().catch(() => {
if (this._isMounted) {
this.setState({ saving: false });
}
});
};
cancel = () => {
this.props.cancel();
};
render() {
const { alert, queryResult, pendingRearm, onNotificationTemplateChange, menuButton } = this.props;
const { onQuerySelected, onNameChange, onRearmChange, onCriteriaChange } = this.props;
const { query, name, options } = alert;
const { saving } = this.state;
return (
<>
this.cancel()}>
Cancel
this.save()}>
{saving ? (
Saving...
) : (
<>
>
)}
Save Changes
{menuButton}
Setup Instructions
(help)
>
);
}
}
AlertEdit.propTypes = {
alert: AlertType.isRequired,
queryResult: PropTypes.object, // eslint-disable-line react/forbid-prop-types,
pendingRearm: PropTypes.number,
menuButton: PropTypes.node.isRequired,
save: PropTypes.func.isRequired,
cancel: PropTypes.func.isRequired,
onQuerySelected: PropTypes.func.isRequired,
onNameChange: PropTypes.func.isRequired,
onCriteriaChange: PropTypes.func.isRequired,
onRearmChange: PropTypes.func.isRequired,
onNotificationTemplateChange: PropTypes.func.isRequired,
};
AlertEdit.defaultProps = {
queryResult: null,
pendingRearm: null,
};
================================================
FILE: client/app/pages/alert/AlertNew.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
import HelpTrigger from "@/components/HelpTrigger";
import { Alert as AlertType } from "@/components/proptypes";
import Form from "antd/lib/form";
import Button from "antd/lib/button";
import Title from "./components/Title";
import Criteria from "./components/Criteria";
import NotificationTemplate from "./components/NotificationTemplate";
import Rearm from "./components/Rearm";
import Query from "./components/Query";
import HorizontalFormItem from "./components/HorizontalFormItem";
export default class AlertNew extends React.Component {
state = {
saving: false,
};
save = () => {
this.setState({ saving: true });
this.props.save().catch(() => {
this.setState({ saving: false });
});
};
render() {
const { alert, queryResult, pendingRearm, onNotificationTemplateChange } = this.props;
const { onQuerySelected, onNameChange, onRearmChange, onCriteriaChange } = this.props;
const { query, name, options } = alert;
const { saving } = this.state;
return (
<>
Setup Instructions
(help)
>
);
}
}
AlertNew.propTypes = {
alert: AlertType.isRequired,
queryResult: PropTypes.object, // eslint-disable-line react/forbid-prop-types,
pendingRearm: PropTypes.number,
onQuerySelected: PropTypes.func.isRequired,
save: PropTypes.func.isRequired,
onNameChange: PropTypes.func.isRequired,
onRearmChange: PropTypes.func.isRequired,
onCriteriaChange: PropTypes.func.isRequired,
onNotificationTemplateChange: PropTypes.func.isRequired,
};
AlertNew.defaultProps = {
queryResult: null,
pendingRearm: null,
};
================================================
FILE: client/app/pages/alert/AlertView.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
import cx from "classnames";
import Link from "@/components/Link";
import TimeAgo from "@/components/TimeAgo";
import { Alert as AlertType } from "@/components/proptypes";
import Form from "antd/lib/form";
import Button from "antd/lib/button";
import Tooltip from "@/components/Tooltip";
import AntAlert from "antd/lib/alert";
import * as Grid from "antd/lib/grid";
import Title from "./components/Title";
import Criteria from "./components/Criteria";
import Rearm from "./components/Rearm";
import Query from "./components/Query";
import AlertDestinations from "./components/AlertDestinations";
import HorizontalFormItem from "./components/HorizontalFormItem";
import { STATE_CLASS } from "../alerts/AlertsList";
import DynamicComponent from "@/components/DynamicComponent";
function AlertState({ state, lastTriggered }) {
return (
Status: {state}
{state === "unknown" &&
Alert condition has not been evaluated.
}
{lastTriggered && (
Last triggered{" "}
)}
);
}
AlertState.propTypes = {
state: PropTypes.string.isRequired,
lastTriggered: PropTypes.string,
};
AlertState.defaultProps = {
lastTriggered: null,
};
// eslint-disable-next-line react/prefer-stateless-function
export default class AlertView extends React.Component {
state = {
unmuting: false,
};
unmute = () => {
this.setState({ unmuting: true });
this.props.unmute().finally(() => {
this.setState({ unmuting: false });
});
};
render() {
const { alert, queryResult, canEdit, onEdit, menuButton } = this.props;
const { query, name, options, rearm } = alert;
return (
<>
{canEdit ? (
<>
Edit
{menuButton}
>
) : (
Edit
{menuButton}
)}
{options.muted && (
Notifications are muted
>
}
description={
<>
Notifications for this alert will not be sent.
{canEdit && (
<>
To restore notifications click
Unmute
>
)}
>
}
type="warning"
/>
)}
Destinations{" "}
(opens in a new tab)
>
);
}
}
AlertView.propTypes = {
alert: AlertType.isRequired,
queryResult: PropTypes.object, // eslint-disable-line react/forbid-prop-types,
canEdit: PropTypes.bool.isRequired,
onEdit: PropTypes.func.isRequired,
menuButton: PropTypes.node.isRequired,
unmute: PropTypes.func,
};
AlertView.defaultProps = {
queryResult: null,
unmute: null,
};
================================================
FILE: client/app/pages/alert/components/AlertDestinations.jsx
================================================
import { without, find, includes, map, toLower } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import Link from "@/components/Link";
import Button from "antd/lib/button";
import SelectItemsDialog from "@/components/SelectItemsDialog";
import { Destination as DestinationType, UserProfile as UserType } from "@/components/proptypes";
import DestinationService, { IMG_ROOT } from "@/services/destination";
import AlertSubscription from "@/services/alert-subscription";
import { clientConfig, currentUser } from "@/services/auth";
import notification from "@/services/notification";
import ListItemAddon from "@/components/groups/ListItemAddon";
import EmailSettingsWarning from "@/components/EmailSettingsWarning";
import PlainButton from "@/components/PlainButton";
import Tooltip from "@/components/Tooltip";
import CloseOutlinedIcon from "@ant-design/icons/CloseOutlined";
import Switch from "antd/lib/switch";
import "./AlertDestinations.less";
const USER_EMAIL_DEST_ID = -1;
function normalizeSub(sub) {
if (!sub.destination) {
sub.destination = {
id: USER_EMAIL_DEST_ID,
name: sub.user.email,
icon: "DEPRECATED",
type: "email",
};
}
return sub;
}
function ListItem({ destination: { name, type }, user, unsubscribe }) {
const canUnsubscribe = currentUser.isAdmin || currentUser.id === user.id;
return (
{name}
{type === "email" && (
)}
{canUnsubscribe && (
{/* TODO: lacks visual feedback */}
)}
);
}
ListItem.propTypes = {
destination: DestinationType.isRequired,
user: UserType.isRequired,
unsubscribe: PropTypes.func.isRequired,
};
export default class AlertDestinations extends React.Component {
static propTypes = {
alertId: PropTypes.any.isRequired,
};
state = {
dests: [],
subs: null,
};
componentDidMount() {
const { alertId } = this.props;
Promise.all([
DestinationService.query(), // get all destinations
AlertSubscription.query({ alertId }), // get subcriptions per alert
]).then(([dests, subs]) => {
subs = subs.map(normalizeSub);
this.setState({ dests, subs });
});
}
showAddAlertSubDialog = () => {
const { dests, subs } = this.state;
SelectItemsDialog.showModal({
width: 570,
showCount: true,
extraFooterContent: (
<>
Create new destinations in{" "}
Alert Destinations
>
),
dialogTitle: "Add Existing Alert Destinations",
inputPlaceholder: "Search destinations...",
searchItems: searchTerm => {
searchTerm = toLower(searchTerm);
return Promise.resolve(dests.filter(d => includes(toLower(d.name), searchTerm)));
},
renderItem: (item, { isSelected }) => {
const alreadyInGroup = !!find(subs, s => s.destination.id === item.id);
return {
content: (
{item.name}
),
isDisabled: alreadyInGroup,
className: isSelected || alreadyInGroup ? "selected" : "",
};
},
}).onClose(items => {
const promises = map(items, item => this.subscribe(item));
return Promise.all(promises)
.then(() => {
notification.success("Subscribed.");
})
.catch(() => {
notification.error("Failed saving subscription.");
return Promise.reject(null); // keep dialog visible but suppress its default error message
});
});
};
onUserEmailToggle = sub => {
if (sub) {
this.unsubscribe(sub);
} else {
this.subscribe();
}
};
subscribe = dest => {
const { alertId } = this.props;
const sub = { alert_id: alertId };
if (dest) {
sub.destination_id = dest.id;
}
return AlertSubscription.create(sub).then(sub => {
const { subs } = this.state;
this.setState({
subs: [...subs, normalizeSub(sub)],
});
});
};
unsubscribe = sub => {
AlertSubscription.delete(sub)
.then(() => {
// not showing subscribe notification cause it's redundant here
const { subs } = this.state;
this.setState({
subs: without(subs, sub),
});
})
.catch(() => {
notification.error("Failed unsubscribing.");
});
};
render() {
if (!this.props.alertId) {
return null;
}
const { subs } = this.state;
const currentUserEmailSub = find(subs, {
destination: { id: USER_EMAIL_DEST_ID },
user: { id: currentUser.id },
});
const filteredSubs = without(subs, currentUserEmailSub);
const { mailSettingsMissing } = clientConfig;
return (
Add
{currentUser.email}
{!mailSettingsMissing && (
this.onUserEmailToggle(currentUserEmailSub)}
data-test="UserEmailToggle"
/>
)}
{filteredSubs.map(s => (
this.unsubscribe(s)} {...s} />
))}
);
}
}
================================================
FILE: client/app/pages/alert/components/AlertDestinations.less
================================================
.alert-destinations {
position: relative;
ul {
list-style: none;
padding: 0;
margin-top: 15px;
li {
color: rgba(0, 0, 0, 0.85);
height: 46px;
border-bottom: 1px solid #e8e8e8;
.remove-button {
cursor: pointer;
height: 40px;
width: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.toggle-button {
margin: 0 7px;
}
.destination-warning {
color: #f5222d;
&:last-child {
margin-right: 14px;
}
}
}
}
.add-button {
position: absolute;
right: 0;
top:-33px;
}
}
.destination-wrapper {
padding-left: 8px;
display: flex;
align-items: center;
min-height: 38px;
width: 100%;
.select-items-dialog & {
padding: 0;
}
.destination-icon {
height: 25px;
width: 25px;
margin: 2px 5px 0 0;
filter: grayscale(1);
&.fa {
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
}
.select-items-dialog & {
width: 35px;
height: 35px;
}
}
}
================================================
FILE: client/app/pages/alert/components/Criteria.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
import { head, includes, toString, isEmpty } from "lodash";
import Input from "antd/lib/input";
import WarningFilledIcon from "@ant-design/icons/WarningFilled";
import Select from "antd/lib/select";
import Divider from "antd/lib/divider";
import { AlertOptions as AlertOptionsType } from "@/components/proptypes";
import "./Criteria.less";
const CONDITIONS = {
">": "\u003e",
">=": "\u2265",
"<": "\u003c",
"<=": "\u2264",
"==": "\u003d",
"!=": "\u2260",
};
const VALID_STRING_CONDITIONS = ["==", "!="];
function DisabledInput({ children, minWidth }) {
return (
{children}
);
}
DisabledInput.propTypes = {
children: PropTypes.node.isRequired,
minWidth: PropTypes.number.isRequired,
};
export default function Criteria({ columnNames, resultValues, alertOptions, onChange, editMode }) {
const columnValue = !isEmpty(resultValues) ? head(resultValues)[alertOptions.column] : null;
const invalidMessage = (() => {
// bail if condition is valid for strings
if (includes(VALID_STRING_CONDITIONS, alertOptions.op)) {
return null;
}
if (isNaN(alertOptions.value)) {
return "Value column type doesn't match threshold type.";
}
if (isNaN(columnValue)) {
return "Value column isn't supported by condition type.";
}
return null;
})();
let columnHint;
if (alertOptions.selector === "first") {
columnHint = (
Top row value is {toString(columnValue) || "unknown"}
);
} else if (alertOptions.selector === "max") {
columnHint = (
Max column value is{" "}
{toString(
Math.max(...resultValues.map((o) => Number(o[alertOptions.column])).filter((value) => !isNaN(value)))
) || "unknown"}
);
} else if (alertOptions.selector === "min") {
columnHint = (
Min column value is{" "}
{toString(
Math.min(...resultValues.map((o) => Number(o[alertOptions.column])).filter((value) => !isNaN(value)))
) || "unknown"}
);
}
return (
Selector
{editMode ? (
onChange({ selector })}
optionLabelProp="label"
dropdownMatchSelectWidth={false}
style={{ width: 80 }}
>
first
min
max
) : (
{alertOptions.selector}
)}
Value column
{editMode ? (
onChange({ column })}
dropdownMatchSelectWidth={false}
style={{ minWidth: 100 }}
>
{columnNames.map((name) => (
{name}
))}
) : (
{alertOptions.column}
)}
Condition
{editMode ? (
onChange({ op })}
optionLabelProp="label"
dropdownMatchSelectWidth={false}
style={{ width: 55 }}
>
"]}>
{CONDITIONS[">"]} greater than
="]}>
{CONDITIONS[">="]} greater than or equals
{CONDITIONS["<"]} less than
{CONDITIONS["<="]} less than or equals
{CONDITIONS["=="]} equals
{CONDITIONS["!="]} not equal to
) : (
{CONDITIONS[alertOptions.op]}
)}
Threshold
{editMode ? (
onChange({ value: e.target.value })}
/>
) : (
{alertOptions.value}
)}
{columnHint}
{invalidMessage && (
{invalidMessage}
)}
);
}
Criteria.propTypes = {
columnNames: PropTypes.arrayOf(PropTypes.string).isRequired,
resultValues: PropTypes.arrayOf(PropTypes.object).isRequired,
alertOptions: AlertOptionsType.isRequired,
onChange: PropTypes.func,
editMode: PropTypes.bool,
};
Criteria.defaultProps = {
onChange: () => {},
editMode: false,
};
================================================
FILE: client/app/pages/alert/components/Criteria.less
================================================
.alert-criteria {
margin-top: 40px !important;
.input-title {
display: inline-block;
width: auto;
margin-right: 8px;
margin-bottom: 17px; // assure no collision when not enough room for horizontal layout
position: relative;
vertical-align: middle;
& > .input-label {
position: absolute;
top: -16px;
left: 0;
line-height: normal;
font-size: 10px;
& + * {
vertical-align: top;
}
}
}
.ant-form-item-explain {
margin-top: -17px; // compensation for .input-title bottom margin
}
.alert-criteria-hint code {
overflow: hidden;
max-width: 100px;
display: inline-block;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}
}
.criteria-disabled-input {
text-align: center;
line-height: 35px;
height: 35px;
max-width: 200px;
background: #ececec;
border: 1px solid #d9d9d9;
border-radius: 2px;
margin-bottom: 5px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
padding: 0 8px;
}
================================================
FILE: client/app/pages/alert/components/HorizontalFormItem.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
import cx from "classnames";
import Form from "antd/lib/form";
export default function HorizontalFormItem({ children, label, className, ...props }) {
const labelCol = { span: 4 };
const wrapperCol = { span: 16 };
if (!label) {
wrapperCol.offset = 4;
}
className = cx("alert-form-item", className);
return (
{children}
);
}
HorizontalFormItem.propTypes = {
children: PropTypes.node,
label: PropTypes.string,
className: PropTypes.string,
};
HorizontalFormItem.defaultProps = {
children: null,
label: null,
className: null,
};
================================================
FILE: client/app/pages/alert/components/MenuButton.jsx
================================================
import React, { useState, useCallback } from "react";
import PropTypes from "prop-types";
import cx from "classnames";
import Modal from "antd/lib/modal";
import Dropdown from "antd/lib/dropdown";
import Menu from "antd/lib/menu";
import Button from "antd/lib/button";
import LoadingOutlinedIcon from "@ant-design/icons/LoadingOutlined";
import EllipsisOutlinedIcon from "@ant-design/icons/EllipsisOutlined";
import PlainButton from "@/components/PlainButton";
export default function MenuButton({ doDelete, canEdit, mute, unmute, evaluate, muted }) {
const [loading, setLoading] = useState(false);
const execute = useCallback(action => {
setLoading(true);
action().finally(() => {
setLoading(false);
});
}, []);
const confirmDelete = useCallback(() => {
Modal.confirm({
title: "Delete Alert",
content: "Are you sure you want to delete this alert?",
okText: "Delete",
okType: "danger",
onOk: () => {
setLoading(true);
doDelete().catch(() => {
setLoading(false);
});
},
maskClosable: true,
autoFocusButton: null,
});
}, [doDelete]);
return (
{muted ? (
execute(unmute)}>Unmute Notifications
) : (
execute(mute)}>Mute Notifications
)}
Delete
execute(evaluate)}>Evaluate
}>
{loading ? : }
);
}
MenuButton.propTypes = {
doDelete: PropTypes.func.isRequired,
canEdit: PropTypes.bool.isRequired,
mute: PropTypes.func.isRequired,
unmute: PropTypes.func.isRequired,
evaluate: PropTypes.func.isRequired,
muted: PropTypes.bool,
};
MenuButton.defaultProps = {
muted: false,
};
================================================
FILE: client/app/pages/alert/components/NotificationTemplate.jsx
================================================
import React, { useState } from "react";
import PropTypes from "prop-types";
import { head, isEmpty, isNull, isUndefined } from "lodash";
import Mustache from "mustache";
import HelpTrigger from "@/components/HelpTrigger";
import { Alert as AlertType, Query as QueryType } from "@/components/proptypes";
import Input from "antd/lib/input";
import Select from "antd/lib/select";
import Modal from "antd/lib/modal";
import Switch from "antd/lib/switch";
import "./NotificationTemplate.less";
function normalizeCustomTemplateData(alert, query, columnNames, resultValues) {
const topValue = !isEmpty(resultValues) ? head(resultValues)[alert.options.column] : null;
return {
ALERT_STATUS: "TRIGGERED",
ALERT_CONDITION: alert.options.op,
ALERT_THRESHOLD: alert.options.value,
ALERT_NAME: alert.name,
ALERT_URL: `${window.location.origin}/alerts/${alert.id}`,
QUERY_NAME: query.name,
QUERY_URL: `${window.location.origin}/queries/${query.id}`,
QUERY_RESULT_VALUE: isNull(topValue) || isUndefined(topValue) ? "UNKNOWN" : topValue,
QUERY_RESULT_ROWS: resultValues,
QUERY_RESULT_COLS: columnNames,
};
}
function NotificationTemplate({ alert, query, columnNames, resultValues, subject, setSubject, body, setBody }) {
const hasContent = !!(subject || body);
const [enabled, setEnabled] = useState(hasContent ? 1 : 0);
const [showPreview, setShowPreview] = useState(false);
const renderData = normalizeCustomTemplateData(alert, query, columnNames, resultValues);
const render = tmpl => Mustache.render(tmpl || "", renderData);
const onEnabledChange = value => {
if (value || !hasContent) {
setEnabled(value);
setShowPreview(false);
} else {
Modal.confirm({
title: "Are you sure?",
content: "Switching to default template will discard your custom template.",
onOk: () => {
setSubject(null);
setBody(null);
setEnabled(value);
setShowPreview(false);
},
maskClosable: true,
autoFocusButton: null,
});
}
};
return (
Default template
Custom template
{!!enabled && (
Subject / Body
Preview{" "}
{/* TODO: consider adding real labels (not clear for sighted users as well) */}
setSubject(e.target.value)}
disabled={showPreview}
data-test="CustomSubject"
/>
setBody(e.target.value)}
disabled={showPreview}
data-test="CustomBody"
/>
Formatting guide{" "}
(help)
)}
);
}
NotificationTemplate.propTypes = {
alert: AlertType.isRequired,
query: QueryType.isRequired,
columnNames: PropTypes.arrayOf(PropTypes.string).isRequired,
resultValues: PropTypes.arrayOf(PropTypes.any).isRequired,
subject: PropTypes.string,
setSubject: PropTypes.func.isRequired,
body: PropTypes.string,
setBody: PropTypes.func.isRequired,
};
NotificationTemplate.defaultProps = {
subject: "",
body: "",
};
export default NotificationTemplate;
================================================
FILE: client/app/pages/alert/components/NotificationTemplate.less
================================================
.alert-template {
display: flex;
flex-direction: column;
input {
margin-bottom: 10px;
}
textarea {
margin-bottom: 0 !important;
}
input, textarea {
font-family: "Roboto Mono", monospace;
font-size: 12px;
letter-spacing: -0.4px ;
&[disabled] {
color: inherit;
cursor: auto;
}
}
.alert-custom-template {
margin-top: 10px;
padding: 4px 10px 2px;
background: #fbfbfb;
border: 1px dashed #d9d9d9;
border-radius: 3px;
max-width: 500px;
}
.alert-template-preview {
margin: 0 0 0 5px !important;
}
}
================================================
FILE: client/app/pages/alert/components/Query.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
import Link from "@/components/Link";
import QuerySelector from "@/components/QuerySelector";
import SchedulePhrase from "@/components/queries/SchedulePhrase";
import { Query as QueryType } from "@/components/proptypes";
import Tooltip from "@/components/Tooltip";
import WarningFilledIcon from "@ant-design/icons/WarningFilled";
import QuestionCircleTwoToneIcon from "@ant-design/icons/QuestionCircleTwoTone";
import LoadingOutlinedIcon from "@ant-design/icons/LoadingOutlined";
import "./Query.less";
export default function QueryFormItem({ query, queryResult, onChange, editMode }) {
const queryHint =
query && query.schedule ? (
Scheduled to refresh{" "}
) : (
This query has no refresh schedule .{" "}
Why it's recommended
);
return (
<>
{editMode ? (
) : (
{query.name}
(opens in a new tab)
)}
{query && queryHint}
{query && !queryResult && (
Loading query data
)}
>
);
}
QueryFormItem.propTypes = {
query: QueryType,
queryResult: PropTypes.object, // eslint-disable-line react/forbid-prop-types
onChange: PropTypes.func,
editMode: PropTypes.bool,
};
QueryFormItem.defaultProps = {
query: null,
queryResult: null,
onChange: () => {},
editMode: false,
};
================================================
FILE: client/app/pages/alert/components/Query.less
================================================
.alert-query-link {
font-size: 14px;
.fa-external-link {
vertical-align: text-bottom;
}
}
.alert-query-schedule {
font-style: italic;
text-transform: lowercase;
}
@media only percy {
// hide query selector arrow in Percy to avoid a flaky snapshot
.alert-query-selector {
.ant-select-arrow-icon {
display: none !important;
}
}
}
================================================
FILE: client/app/pages/alert/components/Rearm.jsx
================================================
import React, { useState, useEffect } from "react";
import PropTypes from "prop-types";
import { toLower, isNumber } from "lodash";
import InputNumber from "antd/lib/input-number";
import Select from "antd/lib/select";
import "./Rearm.less";
const DURATIONS = [
["Second", 1],
["Minute", 60],
["Hour", 3600],
["Day", 86400],
["Week", 604800],
];
function RearmByDuration({ value, onChange, editMode }) {
const [durationIdx, setDurationIdx] = useState();
const [count, setCount] = useState();
useEffect(() => {
for (let i = DURATIONS.length - 1; i >= 0; i -= 1) {
const [, durValue] = DURATIONS[i];
if (value % durValue === 0) {
setDurationIdx(i);
setCount(value / durValue);
break;
}
}
}, [value]);
if (!isNumber(count) || !isNumber(durationIdx)) {
return null;
}
const onChangeCount = newCount => {
setCount(newCount);
onChange(newCount * DURATIONS[durationIdx][1]);
};
const onChangeIdx = newIdx => {
setDurationIdx(newIdx);
onChange(count * DURATIONS[newIdx][1]);
};
const plural = count !== 1 ? "s" : "";
if (editMode) {
return (
<>
{DURATIONS.map(([name], idx) => (
{name}
{plural}
))}
>
);
}
const [name] = DURATIONS[durationIdx];
return count + " " + toLower(name) + plural;
}
RearmByDuration.propTypes = {
onChange: PropTypes.func,
value: PropTypes.number.isRequired,
editMode: PropTypes.bool.isRequired,
};
RearmByDuration.defaultProps = {
onChange: () => {},
};
function RearmEditor({ value, onChange }) {
const [selected, setSelected] = useState(value < 2 ? value : 2);
const _onChange = newSelected => {
setSelected(newSelected);
onChange(newSelected < 2 ? newSelected : 3600);
};
return (
Just once until back to normal
Each time alert is evaluated until back to normal
At most every ... when alert is evaluated
{selected === 2 && value && }
);
}
RearmEditor.propTypes = {
onChange: PropTypes.func.isRequired,
value: PropTypes.number.isRequired,
};
function RearmViewer({ value }) {
let phrase = "";
switch (value) {
case 0:
phrase = "just once, until back to normal";
break;
case 1:
phrase = "each time alert is evaluated, until back to normal";
break;
default:
phrase = (
<>
at most every , when alert is evaluated
>
);
}
return Notifications are sent {phrase}. ;
}
RearmViewer.propTypes = {
value: PropTypes.number.isRequired,
};
export default function Rearm({ editMode, ...props }) {
return editMode ? : ;
}
Rearm.propTypes = {
onChange: PropTypes.func,
value: PropTypes.number.isRequired,
editMode: PropTypes.bool,
};
Rearm.defaultProps = {
onChange: null,
editMode: false,
};
================================================
FILE: client/app/pages/alert/components/Rearm.less
================================================
.alert-rearm > * {
vertical-align: top;
margin-right: 8px !important;
&.ant-select {
width: auto !important;
}
}
================================================
FILE: client/app/pages/alert/components/Title.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
import Input from "antd/lib/input";
import { getDefaultName } from "../Alert";
import { Alert as AlertType } from "@/components/proptypes";
import "./Title.less";
export default function Title({ alert, editMode, name, onChange, children }) {
const defaultName = getDefaultName(alert);
return (
);
}
Title.propTypes = {
alert: AlertType.isRequired,
name: PropTypes.string,
children: PropTypes.node,
onChange: PropTypes.func,
editMode: PropTypes.bool,
};
Title.defaultProps = {
name: null,
children: null,
onChange: null,
editMode: false,
};
================================================
FILE: client/app/pages/alert/components/Title.less
================================================
.alert-header {
display: flex;
align-items: center;
flex-wrap: wrap;
margin-top: 10px;
margin-bottom: 5px;
& > div {
padding: 5px 0;
}
.alert-title {
flex: 1 1;
h3 {
margin: 0 15px 0 0;
@media (max-width: 767px) {
font-size: 18px;
}
}
}
.alert-actions {
display: flex;
flex-wrap: nowrap;
@media (max-width: 515px) {
flex-basis: 100%;
}
}
}
================================================
FILE: client/app/pages/alerts/AlertsList.jsx
================================================
import { toUpper } from "lodash";
import React from "react";
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
import Link from "@/components/Link";
import PageHeader from "@/components/PageHeader";
import Paginator from "@/components/Paginator";
import EmptyState, { EmptyStateHelpMessage } from "@/components/empty-state/EmptyState";
import { wrap as itemsList, ControllerType } from "@/components/items-list/ItemsList";
import { ResourceItemsSource } from "@/components/items-list/classes/ItemsSource";
import { StateStorage } from "@/components/items-list/classes/StateStorage";
import DynamicComponent from "@/components/DynamicComponent";
import ItemsTable, { Columns } from "@/components/items-list/components/ItemsTable";
import Alert from "@/services/alert";
import { currentUser } from "@/services/auth";
import routes from "@/services/routes";
export const STATE_CLASS = {
unknown: "label-warning",
ok: "label-success",
triggered: "label-danger",
};
class AlertsList extends React.Component {
static propTypes = {
controller: ControllerType.isRequired,
};
listColumns = [
Columns.custom.sortable(
(text, alert) => (
{alert.options.muted ? "Muted" : "Active"}
),
{
title: (
<>
Sort by notification status.
>
),
field: "muted",
width: "1%",
}
),
Columns.custom.sortable(
(text, alert) => (
{alert.name}
),
{
title: "Name",
field: "name",
}
),
Columns.custom((text, item) => item.user.name, { title: "Created By", width: "1%" }),
Columns.custom.sortable(
(text, alert) => (
{toUpper(alert.state)}
),
{
title: "State",
field: "state",
width: "1%",
className: "text-nowrap",
}
),
Columns.timeAgo.sortable({ title: "Last Updated At", field: "updated_at", width: "1%" }),
Columns.dateTime.sortable({ title: "Created At", field: "created_at", width: "1%" }),
];
render() {
const { controller } = this.props;
return (
New Alert
) : null
}
/>
{controller.isLoaded && controller.isEmpty ? (
}
showAlertStep
/>
) : (
controller.updatePagination({ itemsPerPage })}
page={controller.page}
onChange={page => controller.updatePagination({ page })}
/>
)}
);
}
}
const AlertsListPage = itemsList(
AlertsList,
() =>
new ResourceItemsSource({
isPlainList: true,
getRequest() {
return {};
},
getResource() {
return Alert.query.bind(Alert);
},
}),
() => new StateStorage({ orderByField: "created_at", orderByReverse: true, itemsPerPage: 20 })
);
routes.register(
"Alerts.List",
routeWithUserSession({
path: "/alerts",
title: "Alerts",
render: pageProps => ,
})
);
================================================
FILE: client/app/pages/dashboards/DashboardList.jsx
================================================
import React from "react";
import cx from "classnames";
import Button from "antd/lib/button";
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
import Link from "@/components/Link";
import PageHeader from "@/components/PageHeader";
import Paginator from "@/components/Paginator";
import DynamicComponent from "@/components/DynamicComponent";
import { DashboardTagsControl } from "@/components/tags-control/TagsControl";
import { wrap as itemsList, ControllerType } from "@/components/items-list/ItemsList";
import { ResourceItemsSource } from "@/components/items-list/classes/ItemsSource";
import { UrlStateStorage } from "@/components/items-list/classes/StateStorage";
import * as Sidebar from "@/components/items-list/components/Sidebar";
import ItemsTable, { Columns } from "@/components/items-list/components/ItemsTable";
import useItemsListExtraActions from "@/components/items-list/hooks/useItemsListExtraActions";
import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog";
import Layout from "@/components/layouts/ContentWithSidebar";
import { Dashboard } from "@/services/dashboard";
import { currentUser } from "@/services/auth";
import routes from "@/services/routes";
import DashboardListEmptyState from "./components/DashboardListEmptyState";
import "./dashboard-list.css";
const sidebarMenu = [
{
key: "all",
href: "dashboards",
title: "All Dashboards",
icon: () => ,
},
{
key: "my",
href: "dashboards/my",
title: "My Dashboards",
icon: () => ,
},
{
key: "favorites",
href: "dashboards/favorites",
title: "Favorites",
icon: () => ,
},
];
const listColumns = [
Columns.favorites({ className: "p-r-0" }),
Columns.custom.sortable(
(text, item) => (
{item.name}
),
{
title: "Name",
field: "name",
width: null,
}
),
Columns.custom((text, item) => item.user.name, { title: "Created By", width: "1%" }),
Columns.dateTime.sortable({
title: "Created At",
field: "created_at",
width: "1%",
}),
];
function DashboardListExtraActions(props) {
return ;
}
function DashboardList({ controller }) {
let usedListColumns = listColumns;
if (controller.params.currentPage === "favorites") {
usedListColumns = [
...usedListColumns,
Columns.dateTime.sortable({ title: "Starred At", field: "starred_at", width: "1%" }),
];
}
const {
areExtraActionsAvailable,
listColumns: tableColumns,
Component: ExtraActionsComponent,
selectedItems,
} = useItemsListExtraActions(controller, usedListColumns, DashboardListExtraActions);
return (
CreateDashboardDialog.showModal()}>
New Dashboard
) : null
}
/>
{controller.isLoaded && controller.isEmpty ? (
) : (
controller.updatePagination({ itemsPerPage })}
page={controller.page}
onChange={(page) => controller.updatePagination({ page })}
/>
)}
);
}
DashboardList.propTypes = {
controller: ControllerType.isRequired,
};
const DashboardListPage = itemsList(
DashboardList,
() =>
new ResourceItemsSource({
getResource({ params: { currentPage } }) {
return {
all: Dashboard.query.bind(Dashboard),
my: Dashboard.myDashboards.bind(Dashboard),
favorites: Dashboard.favorites.bind(Dashboard),
}[currentPage];
},
getItemProcessor() {
return (item) => new Dashboard(item);
},
}),
({ ...props }) => new UrlStateStorage({ orderByField: props.orderByField ?? "created_at", orderByReverse: true })
);
routes.register(
"Dashboards.List",
routeWithUserSession({
path: "/dashboards",
title: "Dashboards",
render: (pageProps) => ,
})
);
routes.register(
"Dashboards.Favorites",
routeWithUserSession({
path: "/dashboards/favorites",
title: "Favorite Dashboards",
render: (pageProps) => ,
})
);
routes.register(
"Dashboards.My",
routeWithUserSession({
path: "/dashboards/my",
title: "My Dashboards",
render: (pageProps) => ,
})
);
================================================
FILE: client/app/pages/dashboards/DashboardPage.jsx
================================================
import { isEmpty, map } from "lodash";
import React, { useState, useEffect } from "react";
import PropTypes from "prop-types";
import cx from "classnames";
import Button from "antd/lib/button";
import Checkbox from "antd/lib/checkbox";
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
import DynamicComponent from "@/components/DynamicComponent";
import DashboardGrid from "@/components/dashboards/DashboardGrid";
import Parameters from "@/components/Parameters";
import Filters from "@/components/Filters";
import { Dashboard } from "@/services/dashboard";
import recordEvent from "@/services/recordEvent";
import resizeObserver from "@/services/resizeObserver";
import routes from "@/services/routes";
import location from "@/services/location";
import url from "@/services/url";
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
import useDashboard from "./hooks/useDashboard";
import DashboardHeader from "./components/DashboardHeader";
import "./DashboardPage.less";
function DashboardSettings({ dashboardConfiguration }) {
const { dashboard, updateDashboard } = dashboardConfiguration;
return (
updateDashboard({ dashboard_filters_enabled: target.checked })}
data-test="DashboardFiltersCheckbox"
>
Use Dashboard Level Filters
);
}
DashboardSettings.propTypes = {
dashboardConfiguration: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
};
function AddWidgetContainer({ dashboardConfiguration, className, ...props }) {
const { showAddTextboxDialog, showAddWidgetDialog } = dashboardConfiguration;
return (
Widgets are individual query visualizations or text boxes you can place on your dashboard in various
arrangements.
Add Textbox
Add Widget
);
}
AddWidgetContainer.propTypes = {
dashboardConfiguration: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
className: PropTypes.string,
};
function DashboardComponent(props) {
const dashboardConfiguration = useDashboard(props.dashboard);
const {
dashboard,
filters,
setFilters,
loadDashboard,
loadWidget,
removeWidget,
saveDashboardLayout,
globalParameters,
updateDashboard,
refreshDashboard,
refreshWidget,
editingLayout,
setGridDisabled,
} = dashboardConfiguration;
const [pageContainer, setPageContainer] = useState(null);
const [bottomPanelStyles, setBottomPanelStyles] = useState({});
const onParametersEdit = (parameters) => {
const paramOrder = map(parameters, "name");
updateDashboard({ options: { ...dashboard.options, globalParamOrder: paramOrder } });
};
useEffect(() => {
if (pageContainer) {
const unobserve = resizeObserver(pageContainer, () => {
if (editingLayout) {
const style = window.getComputedStyle(pageContainer, null);
const bounds = pageContainer.getBoundingClientRect();
const paddingLeft = parseFloat(style.paddingLeft) || 0;
const paddingRight = parseFloat(style.paddingRight) || 0;
setBottomPanelStyles({
left: Math.round(bounds.left) + paddingRight,
width: pageContainer.clientWidth - paddingLeft - paddingRight,
});
}
// reflow grid when container changes its size
window.dispatchEvent(new Event("resize"));
});
return unobserve;
}
}, [pageContainer, editingLayout]);
return (
}
/>
{!isEmpty(globalParameters) && (
)}
{!isEmpty(filters) && (
)}
{editingLayout &&
}
{}}
onBreakpointChange={setGridDisabled}
onLoadWidget={loadWidget}
onRefreshWidget={refreshWidget}
onRemoveWidget={removeWidget}
onParameterMappingsChange={loadDashboard}
/>
{editingLayout && (
)}
);
}
DashboardComponent.propTypes = {
dashboard: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
};
function DashboardPage({ dashboardSlug, dashboardId, onError }) {
const [dashboard, setDashboard] = useState(null);
const handleError = useImmutableCallback(onError);
useEffect(() => {
Dashboard.get({ id: dashboardId, slug: dashboardSlug })
.then((dashboardData) => {
recordEvent("view", "dashboard", dashboardData.id);
setDashboard(dashboardData);
// if loaded by slug, update location url to use the id
if (!dashboardId) {
location.setPath(url.parse(dashboardData.url).pathname, true);
}
})
.catch(handleError);
}, [dashboardId, dashboardSlug, handleError]);
return {dashboard && }
;
}
DashboardPage.propTypes = {
dashboardSlug: PropTypes.string,
dashboardId: PropTypes.string,
onError: PropTypes.func,
};
DashboardPage.defaultProps = {
dashboardSlug: null,
dashboardId: null,
onError: PropTypes.func,
};
// route kept for backward compatibility
routes.register(
"Dashboards.LegacyViewOrEdit",
routeWithUserSession({
path: "/dashboard/:dashboardSlug",
render: (pageProps) => ,
})
);
routes.register(
"Dashboards.ViewOrEdit",
routeWithUserSession({
path: "/dashboards/:dashboardId([^-]+)(-.*)?",
render: (pageProps) => ,
})
);
================================================
FILE: client/app/pages/dashboards/DashboardPage.less
================================================
@import (reference, less) "~@/assets/less/inc/variables";
/****
grid bg - based on 6 cols, 35px rows and 15px spacing
****/
// let the bg go all the way to the bottom
.dashboard-page,
.dashboard-page .container {
display: flex;
flex-grow: 1;
flex-direction: column;
width: 100%;
}
#dashboard-container {
position: relative;
flex-grow: 1;
display: flex;
}
.add-widget-container {
background: #fff;
border-radius: @redash-radius;
padding: 15px;
position: fixed;
bottom: 20px;
z-index: 99;
box-shadow: fade(@redash-gray, 50%) 0px 7px 29px -3px;
display: flex;
justify-content: space-between;
h2 {
margin: 0;
font-size: 14px;
line-height: 2.1;
font-weight: 400;
.zmdi {
margin: 0;
margin-right: 5px;
font-size: 24px;
position: absolute;
bottom: 18px;
}
span {
vertical-align: middle;
padding-left: 30px;
}
}
.btn {
align-self: center;
}
}
================================================
FILE: client/app/pages/dashboards/PublicDashboardPage.jsx
================================================
import { isEmpty } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import routeWithApiKeySession from "@/components/ApplicationArea/routeWithApiKeySession";
import Link from "@/components/Link";
import BigMessage from "@/components/BigMessage";
import PageHeader from "@/components/PageHeader";
import Parameters from "@/components/Parameters";
import DashboardGrid from "@/components/dashboards/DashboardGrid";
import Filters from "@/components/Filters";
import { Dashboard } from "@/services/dashboard";
import routes from "@/services/routes";
import logoUrl from "@/assets/images/redash_icon_small.png";
import useDashboard from "./hooks/useDashboard";
import "./PublicDashboardPage.less";
function PublicDashboard({ dashboard }) {
const { globalParameters, filters, setFilters, refreshDashboard, loadWidget, refreshWidget } = useDashboard(
dashboard
);
return (
{!isEmpty(globalParameters) && (
)}
{!isEmpty(filters) && (
)}
);
}
PublicDashboard.propTypes = {
dashboard: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
};
class PublicDashboardPage extends React.Component {
static propTypes = {
token: PropTypes.string.isRequired,
onError: PropTypes.func,
};
static defaultProps = {
onError: () => {},
};
state = {
loading: true,
dashboard: null,
};
componentDidMount() {
Dashboard.getByToken({ token: this.props.token })
.then(dashboard => this.setState({ dashboard, loading: false }))
.catch(error => this.props.onError(error));
}
render() {
const { loading, dashboard } = this.state;
return (
);
}
}
routes.register(
"Dashboards.ViewShared",
routeWithApiKeySession({
path: "/public/dashboards/:token",
render: pageProps => ,
getApiKey: currentRoute => currentRoute.routeParams.token,
})
);
================================================
FILE: client/app/pages/dashboards/PublicDashboardPage.less
================================================
.public-dashboard-page {
width: 100%;
.page-header-wrapper {
margin-top: 0;
margin-left: 15px;
margin-right: 15px;
}
> .container {
min-height: calc(100% - 95px);
}
.loading-message {
display: flex;
align-items: center;
justify-content: center;
}
#footer {
height: 95px;
text-align: center;
}
}
================================================
FILE: client/app/pages/dashboards/components/DashboardHeader.jsx
================================================
import React from "react";
import cx from "classnames";
import PropTypes from "prop-types";
import { map, includes } from "lodash";
import Button from "antd/lib/button";
import Dropdown from "antd/lib/dropdown";
import Menu from "antd/lib/menu";
import EllipsisOutlinedIcon from "@ant-design/icons/EllipsisOutlined";
import Modal from "antd/lib/modal";
import Tooltip from "@/components/Tooltip";
import FavoritesControl from "@/components/FavoritesControl";
import EditInPlace from "@/components/EditInPlace";
import PlainButton from "@/components/PlainButton";
import { DashboardTagsControl } from "@/components/tags-control/TagsControl";
import getTags from "@/services/getTags";
import { clientConfig } from "@/services/auth";
import { policy } from "@/services/policy";
import recordEvent from "@/services/recordEvent";
import { durationHumanize } from "@/lib/utils";
import { DashboardStatusEnum } from "../hooks/useDashboard";
import "./DashboardHeader.less";
function getDashboardTags() {
return getTags("api/dashboards/tags").then((tags) => map(tags, (t) => t.name));
}
function buttonType(value) {
return value ? "primary" : "default";
}
function DashboardPageTitle({ dashboardConfiguration }) {
const { dashboard, canEditDashboard, updateDashboard, editingLayout } = dashboardConfiguration;
return (
updateDashboard({ name })}
value={dashboard.name}
ignoreBlanks
/>
updateDashboard({ tags })}
/>
);
}
DashboardPageTitle.propTypes = {
dashboardConfiguration: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
};
function RefreshButton({ dashboardConfiguration }) {
const { refreshRate, setRefreshRate, disableRefreshRate, refreshing, refreshDashboard } = dashboardConfiguration;
const allowedIntervals = policy.getDashboardRefreshIntervals();
const refreshRateOptions = clientConfig.dashboardRefreshIntervals;
const onRefreshRateSelected = ({ key }) => {
const parsedRefreshRate = parseFloat(key);
if (parsedRefreshRate) {
setRefreshRate(parsedRefreshRate);
refreshDashboard();
} else {
disableRefreshRate();
}
};
return (
refreshDashboard()}>
{refreshRate ? durationHumanize(refreshRate) : "Refresh"}
{refreshRateOptions.map((option) => (
{durationHumanize(option)}
))}
{refreshRate && Disable auto refresh }
}
>
Split button!
);
}
RefreshButton.propTypes = {
dashboardConfiguration: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
};
function DashboardMoreOptionsButton({ dashboardConfiguration }) {
const {
dashboard,
setEditingLayout,
togglePublished,
archiveDashboard,
managePermissions,
gridDisabled,
isDashboardOwnerOrAdmin,
isDuplicating,
duplicateDashboard,
} = dashboardConfiguration;
const archive = () => {
Modal.confirm({
title: "Archive Dashboard",
content: `Are you sure you want to archive the "${dashboard.name}" dashboard?`,
okText: "Archive",
okType: "danger",
onOk: archiveDashboard,
maskClosable: true,
autoFocusButton: null,
});
};
return (
setEditingLayout(true)}>Edit
{!isDuplicating && dashboard.canEdit() && (
Fork
(opens in a new tab)
)}
{clientConfig.showPermissionsControl && isDashboardOwnerOrAdmin && (
Manage Permissions
)}
{!clientConfig.disablePublish && !dashboard.is_draft && (
Unpublish
)}
Archive
}
>
);
}
DashboardMoreOptionsButton.propTypes = {
dashboardConfiguration: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
};
function DashboardControl({ dashboardConfiguration, headerExtra }) {
const {
dashboard,
togglePublished,
canEditDashboard,
fullscreen,
toggleFullscreen,
showShareDashboardDialog,
updateDashboard,
} = dashboardConfiguration;
const showPublishButton = dashboard.is_draft;
const showRefreshButton = true;
const showFullscreenButton = !dashboard.is_draft;
const canShareDashboard = canEditDashboard && !dashboard.is_draft;
const showShareButton = !clientConfig.disablePublicUrls && (dashboard.publicAccessEnabled || canShareDashboard);
const showMoreOptionsButton = canEditDashboard;
const unarchiveDashboard = () => {
recordEvent("unarchive", "dashboard", dashboard.id);
updateDashboard({ is_archived: false }, false);
};
return (
{dashboard.can_edit && dashboard.is_archived && Unarchive }
{!dashboard.is_archived && (
{showPublishButton && (
Publish
)}
{showRefreshButton && }
{showFullscreenButton && (
)}
{headerExtra}
{showShareButton && (
)}
{showMoreOptionsButton && }
)}
);
}
DashboardControl.propTypes = {
dashboardConfiguration: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
headerExtra: PropTypes.node,
};
function DashboardEditControl({ dashboardConfiguration, headerExtra }) {
const {
setEditingLayout,
doneBtnClickedWhileSaving,
dashboardStatus,
retrySaveDashboardLayout,
saveDashboardParameters,
} = dashboardConfiguration;
const handleDoneEditing = () => {
saveDashboardParameters().then(() => setEditingLayout(false));
};
let status;
if (dashboardStatus === DashboardStatusEnum.SAVED) {
status = Saved ;
} else if (dashboardStatus === DashboardStatusEnum.SAVING) {
status = (
Saving
);
} else {
status = (
Saving Failed
);
}
return (
{status}
{dashboardStatus === DashboardStatusEnum.SAVING_FAILED ? (
Retry
) : (
{!doneBtnClickedWhileSaving && } Done Editing
)}
{headerExtra}
);
}
DashboardEditControl.propTypes = {
dashboardConfiguration: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
headerExtra: PropTypes.node,
};
export default function DashboardHeader({ dashboardConfiguration, headerExtra }) {
const { editingLayout } = dashboardConfiguration;
const DashboardControlComponent = editingLayout ? DashboardEditControl : DashboardControl;
return (
);
}
DashboardHeader.propTypes = {
dashboardConfiguration: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
headerExtra: PropTypes.node,
};
================================================
FILE: client/app/pages/dashboards/components/DashboardHeader.less
================================================
@import (reference, less) "~@/components/ApplicationArea/ApplicationLayout/index.less";
.dashboard-header {
display: flex;
flex-wrap: wrap;
align-items: stretch;
position: -webkit-sticky; // required for Safari
position: sticky;
background: #f6f7f9;
z-index: 99;
width: 100%;
top: 0;
padding-top: 10px;
margin-bottom: 10px;
& > div {
padding: 5px 0;
}
.title-with-tags {
flex: 1 1;
display: flex;
flex-wrap: wrap;
align-items: center;
margin: -5px 0;
& > div {
padding: 5px 0;
}
h3 {
margin: 0;
@media (max-width: 767px) {
font-size: 18px;
}
}
}
@media @mobileBreakpoint {
& {
position: static;
}
}
.profile-image {
width: 16px;
height: 16px;
border-radius: 100%;
margin: 3px 5px 0 5px;
}
.tags-control > .label-tag {
opacity: 0;
transition: opacity 0.2s ease-in-out;
}
&:hover,
&:focus,
&:active,
&:focus-within {
.tags-control > .label-tag {
opacity: 1;
}
}
.dashboard-control {
.icon-button {
width: 32px;
padding: 0 10px;
}
.save-status {
vertical-align: middle;
margin-right: 7px;
font-size: 12px;
text-align: left;
display: inline-block;
&[data-saving] {
opacity: 0.6;
width: 45px;
&:after {
content: "";
animation: saving 2s linear infinite;
}
}
&[data-error] {
color: #f44336;
}
}
@media (max-width: 515px) {
flex-basis: 100%;
}
}
@keyframes saving {
0%,
100% {
content: ".";
}
33% {
content: "..";
}
66% {
content: "...";
}
}
}
================================================
FILE: client/app/pages/dashboards/components/DashboardListEmptyState.tsx
================================================
import * as React from "react";
import * as PropTypes from "prop-types";
import Button from "antd/lib/button";
import BigMessage from "@/components/BigMessage";
import NoTaggedObjectsFound from "@/components/NoTaggedObjectsFound";
import EmptyState, { EmptyStateHelpMessage } from "@/components/empty-state/EmptyState";
import DynamicComponent from "@/components/DynamicComponent";
import CreateDashboardDialog from "@/components/dashboards/CreateDashboardDialog";
import { currentUser } from "@/services/auth";
import HelpTrigger from "@/components/HelpTrigger";
export interface DashboardListEmptyStateProps {
page: string;
searchTerm: string;
selectedTags: string[];
}
export default function DashboardListEmptyState({ page, searchTerm, selectedTags }: DashboardListEmptyStateProps) {
if (searchTerm !== "") {
return ;
}
if (selectedTags.length > 0) {
return ;
}
switch (page) {
case "favorites":
return ;
case "my":
const my_msg = currentUser.hasPermission("create_dashboard") ? (
CreateDashboardDialog.showModal()}>
Create your first dashboard!
{" "}
Need help?
) : (
Sorry, we couldn't find anything.
);
return {my_msg} ;
default:
return (
}
showDashboardStep
/>
);
}
}
DashboardListEmptyState.propTypes = {
page: PropTypes.string.isRequired,
searchTerm: PropTypes.string.isRequired,
selectedTags: PropTypes.array.isRequired,
};
================================================
FILE: client/app/pages/dashboards/components/ShareDashboardDialog.jsx
================================================
import { replace } from "lodash";
import React from "react";
import { axios } from "@/services/axios";
import PropTypes from "prop-types";
import Switch from "antd/lib/switch";
import Modal from "antd/lib/modal";
import Form from "antd/lib/form";
import Alert from "antd/lib/alert";
import notification from "@/services/notification";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import InputWithCopy from "@/components/InputWithCopy";
import HelpTrigger from "@/components/HelpTrigger";
const API_SHARE_URL = "api/dashboards/{id}/share";
class ShareDashboardDialog extends React.Component {
static propTypes = {
dashboard: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
hasOnlySafeQueries: PropTypes.bool.isRequired,
dialog: DialogPropType.isRequired,
};
formItemProps = {
labelCol: { span: 8 },
wrapperCol: { span: 16 },
style: { marginBottom: 7 },
};
constructor(props) {
super(props);
const { dashboard } = this.props;
this.state = {
saving: false,
};
this.apiUrl = replace(API_SHARE_URL, "{id}", dashboard.id);
this.enabled = this.props.hasOnlySafeQueries || dashboard.publicAccessEnabled;
}
static get headerContent() {
return (
Share Dashboard
Allow public access to this dashboard with a secret address.
);
}
enableAccess = () => {
const { dashboard } = this.props;
this.setState({ saving: true });
axios
.post(this.apiUrl)
.then(data => {
dashboard.publicAccessEnabled = true;
dashboard.public_url = data.public_url;
})
.catch(() => {
notification.error("Failed to turn on sharing for this dashboard");
})
.finally(() => {
this.setState({ saving: false });
});
};
disableAccess = () => {
const { dashboard } = this.props;
this.setState({ saving: true });
axios
.delete(this.apiUrl)
.then(() => {
dashboard.publicAccessEnabled = false;
delete dashboard.public_url;
})
.catch(() => {
notification.error("Failed to turn off sharing for this dashboard");
})
.finally(() => {
this.setState({ saving: false });
});
};
onChange = checked => {
if (checked) {
this.enableAccess();
} else {
this.disableAccess();
}
};
render() {
const { dialog, dashboard, hasOnlySafeQueries } = this.props;
const headerContent = this.constructor.headerContent;
return (
)}
{dashboard.public_url && (
)}
);
}
}
export default wrapDialog(ShareDashboardDialog);
================================================
FILE: client/app/pages/dashboards/dashboard-list.css
================================================
/* Prevent text selection on shift+click. */
div.tags-list {
-webkit-user-select: none; /* webkit (safari, chrome) browsers */
-moz-user-select: none; /* mozilla browsers */
-khtml-user-select: none; /* webkit (konqueror) browsers */
-ms-user-select: none; /* IE10+ */
}
/* same rule as for sidebar */
@media (max-width: 990px) {
.page-dashboard-list .page-header-actions {
width: auto;
}
}
================================================
FILE: client/app/pages/dashboards/hooks/useDashboard.js
================================================
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { isEmpty, includes, compact, map, has, pick, keys, extend, every, get } from "lodash";
import notification from "@/services/notification";
import location from "@/services/location";
import url from "@/services/url";
import { Dashboard, collectDashboardFilters } from "@/services/dashboard";
import { currentUser } from "@/services/auth";
import recordEvent from "@/services/recordEvent";
import { QueryResultError } from "@/services/query";
import AddWidgetDialog from "@/components/dashboards/AddWidgetDialog";
import TextboxDialog from "@/components/dashboards/TextboxDialog";
import PermissionsEditorDialog from "@/components/PermissionsEditorDialog";
import { editableMappingsToParameterMappings, synchronizeWidgetTitles } from "@/components/ParameterMappingInput";
import ShareDashboardDialog from "../components/ShareDashboardDialog";
import useFullscreenHandler from "../../../lib/hooks/useFullscreenHandler";
import useRefreshRateHandler from "./useRefreshRateHandler";
import useEditModeHandler from "./useEditModeHandler";
import useDuplicateDashboard from "./useDuplicateDashboard";
import { policy } from "@/services/policy";
export { DashboardStatusEnum } from "./useEditModeHandler";
function getAffectedWidgets(widgets, updatedParameters = []) {
return !isEmpty(updatedParameters)
? widgets.filter((widget) =>
Object.values(widget.getParameterMappings())
.filter(({ type }) => type === "dashboard-level")
.some(({ mapTo }) =>
includes(
updatedParameters.map((p) => p.name),
mapTo
)
)
)
: widgets;
}
function useDashboard(dashboardData) {
const [dashboard, setDashboard] = useState(dashboardData);
const [filters, setFilters] = useState([]);
const [refreshing, setRefreshing] = useState(false);
const [gridDisabled, setGridDisabled] = useState(false);
const globalParameters = useMemo(() => dashboard.getParametersDefs(), [dashboard]);
const canEditDashboard = !dashboard.is_archived && policy.canEdit(dashboard);
const isDashboardOwnerOrAdmin = useMemo(
() =>
!dashboard.is_archived &&
has(dashboard, "user.id") &&
(currentUser.id === dashboard.user.id || currentUser.isAdmin),
[dashboard]
);
const hasOnlySafeQueries = useMemo(
() => every(dashboard.widgets, (w) => (w.getQuery() ? w.getQuery().is_safe : true)),
[dashboard]
);
const [isDuplicating, duplicateDashboard] = useDuplicateDashboard(dashboard);
const managePermissions = useCallback(() => {
const aclUrl = `api/dashboards/${dashboard.id}/acl`;
PermissionsEditorDialog.showModal({
aclUrl,
context: "dashboard",
author: dashboard.user,
});
}, [dashboard]);
const updateDashboard = useCallback(
(data, includeVersion = true) => {
setDashboard((currentDashboard) => extend({}, currentDashboard, data));
data = { ...data, id: dashboard.id };
if (includeVersion) {
data = { ...data, version: dashboard.version };
}
return Dashboard.save(data)
.then((updatedDashboard) => {
setDashboard((currentDashboard) => extend({}, currentDashboard, pick(updatedDashboard, keys(data))));
if (has(data, "name")) {
location.setPath(url.parse(updatedDashboard.url).pathname, true);
}
})
.catch((error) => {
const status = get(error, "response.status");
if (status === 403) {
notification.error("Dashboard update failed", "Permission Denied.");
} else if (status === 409) {
notification.error(
"It seems like the dashboard has been modified by another user. ",
"Please copy/backup your changes and reload this page.",
{ duration: null }
);
}
});
},
[dashboard]
);
const togglePublished = useCallback(() => {
recordEvent("toggle_published", "dashboard", dashboard.id);
updateDashboard({ is_draft: !dashboard.is_draft }, false);
}, [dashboard, updateDashboard]);
const loadWidget = useCallback((widget, forceRefresh = false) => {
widget.getParametersDefs(); // Force widget to read parameters values from URL
setDashboard((currentDashboard) => extend({}, currentDashboard));
return widget
.load(forceRefresh)
.catch((error) => {
// QueryResultErrors are expected
if (error instanceof QueryResultError) {
return;
}
return Promise.reject(error);
})
.finally(() => setDashboard((currentDashboard) => extend({}, currentDashboard)));
}, []);
const refreshWidget = useCallback((widget) => loadWidget(widget, true), [loadWidget]);
const removeWidget = useCallback((widgetId) => {
setDashboard((currentDashboard) =>
extend({}, currentDashboard, {
widgets: currentDashboard.widgets.filter((widget) => widget.id !== undefined && widget.id !== widgetId),
})
);
}, []);
const dashboardRef = useRef();
dashboardRef.current = dashboard;
const loadDashboard = useCallback(
(forceRefresh = false, updatedParameters = []) => {
const affectedWidgets = getAffectedWidgets(dashboardRef.current.widgets, updatedParameters);
const loadWidgetPromises = compact(
affectedWidgets.map((widget) => loadWidget(widget, forceRefresh).catch((error) => error))
);
return Promise.all(loadWidgetPromises).then(() => {
const queryResults = compact(map(dashboardRef.current.widgets, (widget) => widget.getQueryResult()));
const updatedFilters = collectDashboardFilters(dashboardRef.current, queryResults, location.search);
setFilters(updatedFilters);
});
},
[loadWidget]
);
const refreshDashboard = useCallback(
(updatedParameters) => {
if (!refreshing) {
setRefreshing(true);
loadDashboard(true, updatedParameters).finally(() => setRefreshing(false));
}
},
[refreshing, loadDashboard]
);
const saveDashboardParameters = useCallback(() => {
const currentDashboard = dashboardRef.current;
return updateDashboard({
options: {
...currentDashboard.options,
parameters: map(globalParameters, (p) => p.toSaveableObject()),
},
}).catch((error) => {
console.error("Failed to persist parameter values:", error);
notification.error("Parameter values could not be saved. Your changes may not be persisted.");
throw error;
});
}, [globalParameters, updateDashboard]);
const archiveDashboard = useCallback(() => {
recordEvent("archive", "dashboard", dashboard.id);
Dashboard.delete(dashboard).then((updatedDashboard) =>
setDashboard((currentDashboard) => extend({}, currentDashboard, pick(updatedDashboard, ["is_archived"])))
);
}, [dashboard]); // eslint-disable-line react-hooks/exhaustive-deps
const showShareDashboardDialog = useCallback(() => {
const handleDialogClose = () => setDashboard((currentDashboard) => extend({}, currentDashboard));
ShareDashboardDialog.showModal({
dashboard,
hasOnlySafeQueries,
})
.onClose(handleDialogClose)
.onDismiss(handleDialogClose);
}, [dashboard, hasOnlySafeQueries]);
const showAddTextboxDialog = useCallback(() => {
TextboxDialog.showModal({
isNew: true,
}).onClose((text) =>
dashboard.addWidget(text).then(() => setDashboard((currentDashboard) => extend({}, currentDashboard)))
);
}, [dashboard]);
const showAddWidgetDialog = useCallback(() => {
AddWidgetDialog.showModal({
dashboard,
}).onClose(({ visualization, parameterMappings }) =>
dashboard
.addWidget(visualization, {
parameterMappings: editableMappingsToParameterMappings(parameterMappings),
})
.then((widget) => {
const widgetsToSave = [
widget,
...synchronizeWidgetTitles(widget.options.parameterMappings, dashboard.widgets),
];
return Promise.all(widgetsToSave.map((w) => w.save())).then(() =>
setDashboard((currentDashboard) => extend({}, currentDashboard))
);
})
);
}, [dashboard]);
const [refreshRate, setRefreshRate, disableRefreshRate] = useRefreshRateHandler(refreshDashboard);
const [fullscreen, toggleFullscreen] = useFullscreenHandler();
const editModeHandler = useEditModeHandler(!gridDisabled && canEditDashboard, dashboard.widgets);
useEffect(() => {
setDashboard(dashboardData);
loadDashboard();
}, [dashboardData]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
document.title = dashboard.name;
}, [dashboard.name]);
// reload dashboard when filter option changes
useEffect(() => {
loadDashboard();
}, [dashboard.dashboard_filters_enabled]); // eslint-disable-line react-hooks/exhaustive-deps
return {
dashboard,
globalParameters,
refreshing,
filters,
setFilters,
loadDashboard,
refreshDashboard,
updateDashboard,
togglePublished,
archiveDashboard,
loadWidget,
refreshWidget,
removeWidget,
canEditDashboard,
isDashboardOwnerOrAdmin,
refreshRate,
setRefreshRate,
disableRefreshRate,
...editModeHandler,
saveDashboardParameters,
gridDisabled,
setGridDisabled,
fullscreen,
toggleFullscreen,
showShareDashboardDialog,
showAddTextboxDialog,
showAddWidgetDialog,
managePermissions,
isDuplicating,
duplicateDashboard,
};
}
export default useDashboard;
================================================
FILE: client/app/pages/dashboards/hooks/useDataSources.js
================================================
import { filter } from "lodash";
import { useState, useEffect } from "react";
import DataSource from "@/services/data-source";
/**
* Provides a list of all data sources, as well as a boolean to say whether they've been loaded
*/
export default function useDataSources() {
const [allDataSources, setAllDataSources] = useState([]);
const [dataSourcesLoaded, setDataSourcesLoaded] = useState(false);
const dataSources = filter(allDataSources, ds => !ds.view_only);
useEffect(() => {
let cancelDataSourceLoading = false;
DataSource.query().then(data => {
if (!cancelDataSourceLoading) {
setDataSourcesLoaded(true);
setAllDataSources(data);
}
});
return () => {
cancelDataSourceLoading = true;
};
}, []);
return { dataSourcesLoaded, dataSources };
}
================================================
FILE: client/app/pages/dashboards/hooks/useDuplicateDashboard.js
================================================
import { noop, extend, pick } from "lodash";
import { useCallback, useState } from "react";
import url from "url";
import qs from "query-string";
import { Dashboard } from "@/services/dashboard";
function keepCurrentUrlParams(targetUrl) {
const currentUrlParams = qs.parse(window.location.search);
targetUrl = url.parse(targetUrl);
const targetUrlParams = qs.parse(targetUrl.search);
return url.format(
extend(pick(targetUrl, ["protocol", "auth", "host", "pathname"]), {
search: qs.stringify(extend(currentUrlParams, targetUrlParams)),
})
);
}
export default function useDuplicateDashboard(dashboard) {
const [isDuplicating, setIsDuplicating] = useState(false);
const duplicateDashboard = useCallback(() => {
// To prevent opening the same tab, name must be unique for each browser
const tabName = `duplicatedDashboardTab/${Math.random().toString()}`;
// We should open tab here because this moment is a part of user interaction;
// later browser will block such attempts
const tab = window.open("", tabName);
setIsDuplicating(true);
Dashboard.fork({ id: dashboard.id })
.then(newDashboard => {
tab.location = keepCurrentUrlParams(newDashboard.getUrl());
})
.finally(() => {
setIsDuplicating(false);
});
}, [dashboard.id]);
return [isDuplicating, isDuplicating ? noop : duplicateDashboard];
}
================================================
FILE: client/app/pages/dashboards/hooks/useEditModeHandler.js
================================================
import { debounce, find, has, isMatch, map, pickBy } from "lodash";
import { useCallback, useEffect, useState } from "react";
import location from "@/services/location";
import notification from "@/services/notification";
export const DashboardStatusEnum = {
SAVED: "saved",
SAVING: "saving",
SAVING_FAILED: "saving_failed",
};
function getChangedPositions(widgets, nextPositions = {}) {
return pickBy(nextPositions, (nextPos, widgetId) => {
const widget = find(widgets, { id: Number(widgetId) });
const prevPos = widget.options.position;
return !isMatch(prevPos, nextPos);
});
}
export default function useEditModeHandler(canEditDashboard, widgets) {
const [editingLayout, setEditingLayout] = useState(canEditDashboard && has(location.search, "edit"));
const [dashboardStatus, setDashboardStatus] = useState(DashboardStatusEnum.SAVED);
const [recentPositions, setRecentPositions] = useState([]);
const [doneBtnClickedWhileSaving, setDoneBtnClickedWhileSaving] = useState(false);
useEffect(() => {
location.setSearch({ edit: editingLayout ? true : null }, true);
}, [editingLayout]);
useEffect(() => {
if (doneBtnClickedWhileSaving && dashboardStatus === DashboardStatusEnum.SAVED) {
setDoneBtnClickedWhileSaving(false);
setEditingLayout(false);
}
}, [doneBtnClickedWhileSaving, dashboardStatus]);
const saveDashboardLayout = useCallback(
positions => {
if (!canEditDashboard) {
setDashboardStatus(DashboardStatusEnum.SAVED);
return;
}
const changedPositions = getChangedPositions(widgets, positions);
setDashboardStatus(DashboardStatusEnum.SAVING);
setRecentPositions(positions);
const saveChangedWidgets = map(changedPositions, (position, id) => {
// find widget
const widget = find(widgets, { id: Number(id) });
// skip already deleted widget
if (!widget) {
return Promise.resolve();
}
return widget.save("options", { position });
});
return Promise.all(saveChangedWidgets)
.then(() => setDashboardStatus(DashboardStatusEnum.SAVED))
.catch(() => {
setDashboardStatus(DashboardStatusEnum.SAVING_FAILED);
notification.error("Error saving changes.");
});
},
[canEditDashboard, widgets]
);
const saveDashboardLayoutDebounced = useCallback(
(...args) => {
setDashboardStatus(DashboardStatusEnum.SAVING);
return debounce(() => saveDashboardLayout(...args), 2000)();
},
[saveDashboardLayout]
);
const retrySaveDashboardLayout = useCallback(() => saveDashboardLayout(recentPositions), [
recentPositions,
saveDashboardLayout,
]);
const setEditing = useCallback(
editing => {
if (!editing && dashboardStatus !== DashboardStatusEnum.SAVED) {
setDoneBtnClickedWhileSaving(true);
return;
}
setEditingLayout(canEditDashboard && editing);
},
[dashboardStatus, canEditDashboard]
);
return {
editingLayout: canEditDashboard && editingLayout,
setEditingLayout: setEditing,
saveDashboardLayout: editingLayout ? saveDashboardLayoutDebounced : saveDashboardLayout,
retrySaveDashboardLayout,
doneBtnClickedWhileSaving,
dashboardStatus,
};
}
================================================
FILE: client/app/pages/dashboards/hooks/useRefreshRateHandler.js
================================================
import { isNaN, max, min } from "lodash";
import { useEffect, useState, useMemo } from "react";
import location from "@/services/location";
import { policy } from "@/services/policy";
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
function getLimitedRefreshRate(refreshRate) {
const allowedIntervals = policy.getDashboardRefreshIntervals();
return max([30, min(allowedIntervals), refreshRate]);
}
function getRefreshRateFromUrl() {
const refreshRate = parseFloat(location.search.refresh);
return isNaN(refreshRate) ? null : getLimitedRefreshRate(refreshRate);
}
export default function useRefreshRateHandler(refreshDashboard) {
const [refreshRate, setRefreshRate] = useState(getRefreshRateFromUrl());
// `refreshDashboard` may change quite frequently (on every update of `dashboard` instance), but we
// have to keep the same timer running, because timer will restart when re-creating, and instead of
// running refresh every N seconds - it will run refresh every N seconds after last dashboard update
// (which is not right obviously)
const doRefreshDashboard = useImmutableCallback(refreshDashboard);
// URL and timer should be updated only when `refreshRate` changes
useEffect(() => {
location.setSearch({ refresh: refreshRate || null }, true);
if (refreshRate) {
const refreshTimer = setInterval(doRefreshDashboard, refreshRate * 1000);
return () => clearInterval(refreshTimer);
}
}, [refreshRate, doRefreshDashboard]);
return useMemo(() => [refreshRate, rate => setRefreshRate(getLimitedRefreshRate(rate)), () => setRefreshRate(null)], [
refreshRate,
]);
}
================================================
FILE: client/app/pages/data-sources/DataSourcesList.jsx
================================================
import { isEmpty, reject } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import Button from "antd/lib/button";
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
import navigateTo from "@/components/ApplicationArea/navigateTo";
import CardsList from "@/components/cards-list/CardsList";
import LoadingState from "@/components/items-list/components/LoadingState";
import CreateSourceDialog from "@/components/CreateSourceDialog";
import DynamicComponent, { registerComponent } from "@/components/DynamicComponent";
import helper from "@/components/dynamic-form/dynamicFormHelper";
import wrapSettingsTab from "@/components/SettingsWrapper";
import PlainButton from "@/components/PlainButton";
import DataSource, { IMG_ROOT } from "@/services/data-source";
import { policy } from "@/services/policy";
import recordEvent from "@/services/recordEvent";
import routes from "@/services/routes";
export function DataSourcesListComponent({ dataSources, onClickCreate }) {
const items = dataSources.map(dataSource => ({
title: dataSource.name,
imgSrc: `${IMG_ROOT}/${dataSource.type}.png`,
href: `data_sources/${dataSource.id}`,
}));
return isEmpty(dataSources) ? (
There are no data sources yet.
{policy.isCreateDataSourceEnabled() && (
Click here
{" "}
to add one.
)}
) : (
);
}
registerComponent("DataSourcesListComponent", DataSourcesListComponent);
class DataSourcesList extends React.Component {
static propTypes = {
isNewDataSourcePage: PropTypes.bool,
onError: PropTypes.func,
};
static defaultProps = {
isNewDataSourcePage: false,
onError: () => {},
};
state = {
dataSourceTypes: [],
dataSources: [],
loading: true,
};
newDataSourceDialog = null;
componentDidMount() {
Promise.all([DataSource.query(), DataSource.types()])
.then(values =>
this.setState(
{
dataSources: values[0],
dataSourceTypes: values[1],
loading: false,
},
() => {
// all resources are loaded in state
if (this.props.isNewDataSourcePage) {
if (policy.canCreateDataSource()) {
this.showCreateSourceDialog();
} else {
navigateTo("data_sources", true);
}
}
}
)
)
.catch(error => this.props.onError(error));
}
componentWillUnmount() {
if (this.newDataSourceDialog) {
this.newDataSourceDialog.dismiss();
}
}
createDataSource = (selectedType, values) => {
const target = { options: {}, type: selectedType.type };
helper.updateTargetWithValues(target, values);
return DataSource.create(target).then(dataSource => {
this.setState({ loading: true });
DataSource.query().then(dataSources => this.setState({ dataSources, loading: false }));
return dataSource;
});
};
showCreateSourceDialog = () => {
recordEvent("view", "page", "data_sources/new");
this.newDataSourceDialog = CreateSourceDialog.showModal({
types: reject(this.state.dataSourceTypes, "deprecated"),
sourceType: "Data Source",
imageFolder: IMG_ROOT,
helpTriggerPrefix: "DS_",
onCreate: this.createDataSource,
});
this.newDataSourceDialog
.onClose((result = {}) => {
this.newDataSourceDialog = null;
if (result.success) {
navigateTo(`data_sources/${result.data.id}`);
}
})
.onDismiss(() => {
this.newDataSourceDialog = null;
navigateTo("data_sources", true);
});
};
render() {
const newDataSourceProps = {
type: "primary",
onClick: policy.isCreateDataSourceEnabled() ? this.showCreateSourceDialog : null,
disabled: !policy.isCreateDataSourceEnabled(),
"data-test": "CreateDataSourceButton",
};
return (
New Data Source
{this.state.loading ? (
) : (
)}
);
}
}
const DataSourcesListPage = wrapSettingsTab(
"DataSources.List",
{
permission: "admin",
title: "Data Sources",
path: "data_sources",
order: 1,
},
DataSourcesList
);
routes.register(
"DataSources.List",
routeWithUserSession({
path: "/data_sources",
title: "Data Sources",
render: pageProps => ,
})
);
routes.register(
"DataSources.New",
routeWithUserSession({
path: "/data_sources/new",
title: "Data Sources",
render: pageProps => ,
})
);
================================================
FILE: client/app/pages/data-sources/EditDataSource.jsx
================================================
import { get, find, toUpper } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import Modal from "antd/lib/modal";
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
import navigateTo from "@/components/ApplicationArea/navigateTo";
import LoadingState from "@/components/items-list/components/LoadingState";
import DynamicForm from "@/components/dynamic-form/DynamicForm";
import helper from "@/components/dynamic-form/dynamicFormHelper";
import HelpTrigger, { TYPES as HELP_TRIGGER_TYPES } from "@/components/HelpTrigger";
import wrapSettingsTab from "@/components/SettingsWrapper";
import DataSource, { IMG_ROOT } from "@/services/data-source";
import notification from "@/services/notification";
import routes from "@/services/routes";
class EditDataSource extends React.Component {
static propTypes = {
dataSourceId: PropTypes.string.isRequired,
onError: PropTypes.func,
};
static defaultProps = {
onError: () => {},
};
state = {
dataSource: null,
type: null,
loading: true,
};
componentDidMount() {
DataSource.get({ id: this.props.dataSourceId })
.then(dataSource => {
const { type } = dataSource;
this.setState({ dataSource });
DataSource.types().then(types => this.setState({ type: find(types, { type }), loading: false }));
})
.catch(error => this.props.onError(error));
}
saveDataSource = (values, successCallback, errorCallback) => {
const { dataSource } = this.state;
helper.updateTargetWithValues(dataSource, values);
DataSource.save(dataSource)
.then(() => successCallback("Saved."))
.catch(error => {
const message = get(error, "response.data.message", "Failed saving.");
errorCallback(message);
});
};
deleteDataSource = callback => {
const { dataSource } = this.state;
const doDelete = () => {
DataSource.delete(dataSource)
.then(() => {
notification.success("Data source deleted successfully.");
navigateTo("data_sources");
})
.catch(() => {
callback();
});
};
Modal.confirm({
title: "Delete Data Source",
content: "Are you sure you want to delete this data source?",
okText: "Delete",
okType: "danger",
onOk: doDelete,
onCancel: callback,
maskClosable: true,
autoFocusButton: null,
});
};
testConnection = callback => {
const { dataSource } = this.state;
DataSource.test({ id: dataSource.id })
.then(httpResponse => {
if (httpResponse.ok) {
notification.success("Success");
} else {
notification.error("Connection Test Failed:", httpResponse.message, { duration: 10 });
}
callback();
})
.catch(() => {
notification.error(
"Connection Test Failed:",
"Unknown error occurred while performing connection test. Please try again later.",
{ duration: 10 }
);
callback();
});
};
renderForm() {
const { dataSource, type } = this.state;
const fields = helper.getFields(type, dataSource);
const helpTriggerType = `DS_${toUpper(type.type)}`;
const formProps = {
fields,
type,
actions: [
{ name: "Delete", type: "danger", callback: this.deleteDataSource },
{ name: "Test Connection", pullRight: true, callback: this.testConnection, disableWhenDirty: true },
],
onSubmit: this.saveDataSource,
feedbackIcons: true,
defaultShowExtraFields: helper.hasFilledExtraField(type, dataSource),
};
return (
{HELP_TRIGGER_TYPES[helpTriggerType] && (
Setup Instructions
(help)
)}
{type.name}
);
}
render() {
return this.state.loading ? : this.renderForm();
}
}
const EditDataSourcePage = wrapSettingsTab("DataSources.Edit", null, EditDataSource);
routes.register(
"DataSources.Edit",
routeWithUserSession({
path: "/data_sources/:dataSourceId",
title: "Data Sources",
render: pageProps => ,
})
);
================================================
FILE: client/app/pages/destinations/DestinationsList.jsx
================================================
import { isEmpty, reject } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import Button from "antd/lib/button";
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
import navigateTo from "@/components/ApplicationArea/navigateTo";
import CardsList from "@/components/cards-list/CardsList";
import LoadingState from "@/components/items-list/components/LoadingState";
import CreateSourceDialog from "@/components/CreateSourceDialog";
import helper from "@/components/dynamic-form/dynamicFormHelper";
import wrapSettingsTab from "@/components/SettingsWrapper";
import PlainButton from "@/components/PlainButton";
import Destination, { IMG_ROOT } from "@/services/destination";
import { policy } from "@/services/policy";
import routes from "@/services/routes";
class DestinationsList extends React.Component {
static propTypes = {
isNewDestinationPage: PropTypes.bool,
onError: PropTypes.func,
};
static defaultProps = {
isNewDestinationPage: false,
onError: () => {},
};
state = {
destinationTypes: [],
destinations: [],
loading: true,
};
componentDidMount() {
Promise.all([Destination.query(), Destination.types()])
.then(values =>
this.setState(
{
destinations: values[0],
destinationTypes: values[1],
loading: false,
},
() => {
// all resources are loaded in state
if (this.props.isNewDestinationPage) {
if (policy.canCreateDestination()) {
this.showCreateSourceDialog();
} else {
navigateTo("destinations", true);
}
}
}
)
)
.catch(error => this.props.onError(error));
}
createDestination = (selectedType, values) => {
const target = { options: {}, type: selectedType.type };
helper.updateTargetWithValues(target, values);
return Destination.create(target).then(destination => {
this.setState({ loading: true });
Destination.query().then(destinations => this.setState({ destinations, loading: false }));
return destination;
});
};
showCreateSourceDialog = () => {
CreateSourceDialog.showModal({
types: reject(this.state.destinationTypes, "deprecated"),
sourceType: "Alert Destination",
imageFolder: IMG_ROOT,
onCreate: this.createDestination,
})
.onClose((result = {}) => {
if (result.success) {
navigateTo(`destinations/${result.data.id}`);
}
})
.onDismiss(() => {
navigateTo("destinations", true);
});
};
renderDestinations() {
const { destinations } = this.state;
const items = destinations.map(destination => ({
title: destination.name,
imgSrc: `${IMG_ROOT}/${destination.type}.png`,
href: `destinations/${destination.id}`,
}));
return isEmpty(destinations) ? (
There are no alert destinations yet.
{policy.isCreateDestinationEnabled() && (
Click here
{" "}
to add one.
)}
) : (
);
}
render() {
const newDestinationProps = {
type: "primary",
onClick: policy.isCreateDestinationEnabled() ? this.showCreateSourceDialog : null,
disabled: !policy.isCreateDestinationEnabled(),
};
return (
New Alert Destination
{this.state.loading ?
: this.renderDestinations()}
);
}
}
const DestinationsListPage = wrapSettingsTab(
"AlertDestinations.List",
{
permission: "admin",
title: "Alert Destinations",
path: "destinations",
order: 4,
},
DestinationsList
);
routes.register(
"AlertDestinations.List",
routeWithUserSession({
path: "/destinations",
title: "Alert Destinations",
render: pageProps => ,
})
);
routes.register(
"AlertDestinations.New",
routeWithUserSession({
path: "/destinations/new",
title: "Alert Destinations",
render: pageProps => ,
})
);
================================================
FILE: client/app/pages/destinations/EditDestination.jsx
================================================
import { get, find } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import Modal from "antd/lib/modal";
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
import navigateTo from "@/components/ApplicationArea/navigateTo";
import LoadingState from "@/components/items-list/components/LoadingState";
import DynamicForm from "@/components/dynamic-form/DynamicForm";
import helper from "@/components/dynamic-form/dynamicFormHelper";
import wrapSettingsTab from "@/components/SettingsWrapper";
import Destination, { IMG_ROOT } from "@/services/destination";
import notification from "@/services/notification";
import routes from "@/services/routes";
class EditDestination extends React.Component {
static propTypes = {
destinationId: PropTypes.string.isRequired,
onError: PropTypes.func,
};
static defaultProps = {
onError: () => {},
};
state = {
destination: null,
type: null,
loading: true,
};
componentDidMount() {
Destination.get({ id: this.props.destinationId })
.then(destination => {
const { type } = destination;
this.setState({ destination });
Destination.types().then(types => this.setState({ type: find(types, { type }), loading: false }));
})
.catch(error => this.props.onError(error));
}
saveDestination = (values, successCallback, errorCallback) => {
const { destination } = this.state;
helper.updateTargetWithValues(destination, values);
Destination.save(destination)
.then(() => successCallback("Saved."))
.catch(error => {
const message = get(error, "response.data.message", "Failed saving.");
errorCallback(message);
});
};
deleteDestination = callback => {
const { destination } = this.state;
const doDelete = () => {
Destination.delete(destination)
.then(() => {
notification.success("Alert destination deleted successfully.");
navigateTo("destinations");
})
.catch(() => {
callback();
});
};
Modal.confirm({
title: "Delete Alert Destination",
content: "Are you sure you want to delete this alert destination?",
okText: "Delete",
okType: "danger",
onOk: doDelete,
onCancel: callback,
maskClosable: true,
autoFocusButton: null,
});
};
renderForm() {
const { destination, type } = this.state;
const fields = helper.getFields(type, destination);
const formProps = {
fields,
type,
actions: [{ name: "Delete", type: "danger", callback: this.deleteDestination }],
onSubmit: this.saveDestination,
defaultShowExtraFields: helper.hasFilledExtraField(type, destination),
feedbackIcons: true,
};
return (
{type.name}
);
}
render() {
return this.state.loading ? : this.renderForm();
}
}
const EditDestinationPage = wrapSettingsTab("AlertDestinations.Edit", null, EditDestination);
routes.register(
"AlertDestinations.Edit",
routeWithUserSession({
path: "/destinations/:destinationId",
title: "Alert Destinations",
render: pageProps => ,
})
);
================================================
FILE: client/app/pages/groups/GroupDataSources.jsx
================================================
import { filter, map, includes, toLower } from "lodash";
import React from "react";
import Button from "antd/lib/button";
import Dropdown from "antd/lib/dropdown";
import Menu from "antd/lib/menu";
import DownOutlinedIcon from "@ant-design/icons/DownOutlined";
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
import navigateTo from "@/components/ApplicationArea/navigateTo";
import Paginator from "@/components/Paginator";
import { wrap as itemsList, ControllerType } from "@/components/items-list/ItemsList";
import { ResourceItemsSource } from "@/components/items-list/classes/ItemsSource";
import { StateStorage } from "@/components/items-list/classes/StateStorage";
import LoadingState from "@/components/items-list/components/LoadingState";
import ItemsTable, { Columns } from "@/components/items-list/components/ItemsTable";
import SelectItemsDialog from "@/components/SelectItemsDialog";
import { DataSourcePreviewCard } from "@/components/PreviewCard";
import GroupName from "@/components/groups/GroupName";
import ListItemAddon from "@/components/groups/ListItemAddon";
import Sidebar from "@/components/groups/DetailsPageSidebar";
import Layout from "@/components/layouts/ContentWithSidebar";
import wrapSettingsTab from "@/components/SettingsWrapper";
import notification from "@/services/notification";
import { currentUser } from "@/services/auth";
import Group from "@/services/group";
import DataSource from "@/services/data-source";
import routes from "@/services/routes";
class GroupDataSources extends React.Component {
static propTypes = {
controller: ControllerType.isRequired,
};
groupId = parseInt(this.props.controller.params.groupId, 10);
group = null;
sidebarMenu = [
{
key: "users",
href: `groups/${this.groupId}`,
title: "Members",
},
{
key: "datasources",
href: `groups/${this.groupId}/data_sources`,
title: "Data Sources",
isAvailable: () => currentUser.isAdmin,
},
];
listColumns = [
Columns.custom((text, datasource) => , {
title: "Name",
field: "name",
width: null,
}),
Columns.custom(
(text, datasource) => {
const menu = (
this.setDataSourcePermissions(datasource, item.key)}>
Full Access
View Only
);
return (
{datasource.view_only ? "View Only" : "Full Access"}
);
},
{
width: "1%",
className: "p-r-0",
isAvailable: () => currentUser.isAdmin,
}
),
Columns.custom(
(text, datasource) => (
this.removeGroupDataSource(datasource)}>
Remove
),
{
width: "1%",
isAvailable: () => currentUser.isAdmin,
}
),
];
componentDidMount() {
Group.get({ id: this.groupId })
.then(group => {
this.group = group;
this.forceUpdate();
})
.catch(error => {
this.props.controller.handleError(error);
});
}
removeGroupDataSource = datasource => {
Group.removeDataSource({ id: this.groupId, dataSourceId: datasource.id })
.then(() => {
this.props.controller.updatePagination({ page: 1 });
this.props.controller.update();
})
.catch(() => {
notification.error("Failed to remove data source from group.");
});
};
setDataSourcePermissions = (datasource, permission) => {
const viewOnly = permission !== "full";
Group.updateDataSource({ id: this.groupId, dataSourceId: datasource.id }, { view_only: viewOnly })
.then(() => {
datasource.view_only = viewOnly;
this.forceUpdate();
})
.catch(() => {
notification.error("Failed change data source permissions.");
});
};
addDataSources = () => {
const allDataSources = DataSource.query();
const alreadyAddedDataSources = map(this.props.controller.allItems, ds => ds.id);
SelectItemsDialog.showModal({
dialogTitle: "Add Data Sources",
inputPlaceholder: "Search data sources...",
selectedItemsTitle: "New Data Sources",
searchItems: searchTerm => {
searchTerm = toLower(searchTerm);
return allDataSources.then(items => filter(items, ds => includes(toLower(ds.name), searchTerm)));
},
renderItem: (item, { isSelected }) => {
const alreadyInGroup = includes(alreadyAddedDataSources, item.id);
return {
content: (
),
isDisabled: alreadyInGroup,
className: isSelected || alreadyInGroup ? "selected" : "",
};
},
renderStagedItem: (item, { isSelected }) => ({
content: (
),
}),
}).onClose(items => {
const promises = map(items, ds => Group.addDataSource({ id: this.groupId }, { data_source_id: ds.id }));
return Promise.all(promises).then(() => this.props.controller.update());
});
};
render() {
const { controller } = this.props;
return (
this.forceUpdate()} />
navigateTo("groups")}
/>
{!controller.isLoaded && }
{controller.isLoaded && controller.isEmpty && (
There are no data sources in this group yet.
{currentUser.isAdmin && (
Add Data Sources
)}
)}
{controller.isLoaded && !controller.isEmpty && (
controller.updatePagination({ itemsPerPage })}
page={controller.page}
onChange={page => controller.updatePagination({ page })}
/>
)}
);
}
}
const GroupDataSourcesPage = wrapSettingsTab(
"Groups.DataSources",
null,
itemsList(
GroupDataSources,
() =>
new ResourceItemsSource({
isPlainList: true,
getRequest(unused, { params: { groupId } }) {
return { id: groupId };
},
getResource() {
return Group.dataSources.bind(Group);
},
}),
() => new StateStorage({ orderByField: "name" })
)
);
routes.register(
"Groups.DataSources",
routeWithUserSession({
path: "/groups/:groupId/data_sources",
title: "Group Data Sources",
render: pageProps => ,
})
);
================================================
FILE: client/app/pages/groups/GroupMembers.jsx
================================================
import { includes, map } from "lodash";
import React from "react";
import Button from "antd/lib/button";
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
import navigateTo from "@/components/ApplicationArea/navigateTo";
import Paginator from "@/components/Paginator";
import { wrap as itemsList, ControllerType } from "@/components/items-list/ItemsList";
import { ResourceItemsSource } from "@/components/items-list/classes/ItemsSource";
import { StateStorage } from "@/components/items-list/classes/StateStorage";
import LoadingState from "@/components/items-list/components/LoadingState";
import ItemsTable, { Columns } from "@/components/items-list/components/ItemsTable";
import SelectItemsDialog from "@/components/SelectItemsDialog";
import { UserPreviewCard } from "@/components/PreviewCard";
import GroupName from "@/components/groups/GroupName";
import ListItemAddon from "@/components/groups/ListItemAddon";
import Sidebar from "@/components/groups/DetailsPageSidebar";
import Layout from "@/components/layouts/ContentWithSidebar";
import wrapSettingsTab from "@/components/SettingsWrapper";
import notification from "@/services/notification";
import { currentUser } from "@/services/auth";
import Group from "@/services/group";
import User from "@/services/user";
import routes from "@/services/routes";
class GroupMembers extends React.Component {
static propTypes = {
controller: ControllerType.isRequired,
};
groupId = parseInt(this.props.controller.params.groupId, 10);
group = null;
sidebarMenu = [
{
key: "users",
href: `groups/${this.groupId}`,
title: "Members",
},
{
key: "datasources",
href: `groups/${this.groupId}/data_sources`,
title: "Data Sources",
isAvailable: () => currentUser.isAdmin,
},
];
listColumns = [
Columns.custom((text, user) => , {
title: "Name",
field: "name",
width: null,
}),
Columns.custom(
(text, user) => {
if (!this.group) {
return null;
}
// cannot remove self from built-in groups
if (this.group.type === "builtin" && currentUser.id === user.id) {
return null;
}
return (
this.removeGroupMember(event, user)}>
Remove
);
},
{
width: "1%",
isAvailable: () => currentUser.isAdmin,
}
),
];
componentDidMount() {
Group.get({ id: this.groupId })
.then(group => {
this.group = group;
this.forceUpdate();
})
.catch(error => {
this.props.controller.handleError(error);
});
}
removeGroupMember = (event, user) =>
Group.removeMember({ id: this.groupId, userId: user.id })
.then(() => {
this.props.controller.updatePagination({ page: 1 });
this.props.controller.update();
})
.catch(() => {
notification.error("Failed to remove member from group.");
});
addMembers = () => {
const alreadyAddedUsers = map(this.props.controller.allItems, u => u.id);
SelectItemsDialog.showModal({
dialogTitle: "Add Members",
inputPlaceholder: "Search users...",
selectedItemsTitle: "New Members",
searchItems: searchTerm => User.query({ q: searchTerm }).then(({ results }) => results),
renderItem: (item, { isSelected }) => {
const alreadyInGroup = includes(alreadyAddedUsers, item.id);
return {
content: (
),
isDisabled: alreadyInGroup,
className: isSelected || alreadyInGroup ? "selected" : "",
};
},
renderStagedItem: (item, { isSelected }) => ({
content: (
),
}),
}).onClose(items => {
const promises = map(items, u => Group.addMember({ id: this.groupId }, { user_id: u.id }));
return Promise.all(promises).then(() => this.props.controller.update());
});
};
render() {
const { controller } = this.props;
return (
this.forceUpdate()} />
navigateTo("groups")}
/>
{!controller.isLoaded && }
{controller.isLoaded && controller.isEmpty && (
There are no members in this group yet.
{currentUser.isAdmin && (
Add Members
)}
)}
{controller.isLoaded && !controller.isEmpty && (
controller.updatePagination({ itemsPerPage })}
page={controller.page}
onChange={page => controller.updatePagination({ page })}
/>
)}
);
}
}
const GroupMembersPage = wrapSettingsTab(
"Groups.Members",
null,
itemsList(
GroupMembers,
() =>
new ResourceItemsSource({
isPlainList: true,
getRequest(unused, { params: { groupId } }) {
return { id: groupId };
},
getResource() {
return Group.members.bind(Group);
},
}),
() => new StateStorage({ orderByField: "name" })
)
);
routes.register(
"Groups.Members",
routeWithUserSession({
path: "/groups/:groupId",
title: "Group Members",
render: pageProps => ,
})
);
================================================
FILE: client/app/pages/groups/GroupsList.jsx
================================================
import React from "react";
import Button from "antd/lib/button";
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
import Link from "@/components/Link";
import navigateTo from "@/components/ApplicationArea/navigateTo";
import Paginator from "@/components/Paginator";
import { wrap as itemsList, ControllerType } from "@/components/items-list/ItemsList";
import { ResourceItemsSource } from "@/components/items-list/classes/ItemsSource";
import { StateStorage } from "@/components/items-list/classes/StateStorage";
import LoadingState from "@/components/items-list/components/LoadingState";
import EmptyState from "@/components/items-list/components/EmptyState";
import ItemsTable, { Columns } from "@/components/items-list/components/ItemsTable";
import CreateGroupDialog from "@/components/groups/CreateGroupDialog";
import DeleteGroupButton from "@/components/groups/DeleteGroupButton";
import wrapSettingsTab from "@/components/SettingsWrapper";
import Group from "@/services/group";
import { currentUser } from "@/services/auth";
import routes from "@/services/routes";
class GroupsList extends React.Component {
static propTypes = {
controller: ControllerType.isRequired,
};
listColumns = [
Columns.custom(
(text, group) => (
{group.name}
{group.type === "builtin" && built-in }
),
{
field: "name",
width: null,
}
),
Columns.custom(
(text, group) => (
Members
{currentUser.isAdmin && Data Sources }
),
{
width: "1%",
className: "text-nowrap",
}
),
Columns.custom(
(text, group) => {
const canRemove = group.type !== "builtin";
return (
this.onGroupDeleted()}>
Delete
);
},
{
width: "1%",
className: "text-nowrap p-l-0",
isAvailable: () => currentUser.isAdmin,
}
),
];
createGroup = () => {
CreateGroupDialog.showModal().onClose(group =>
Group.create(group).then(newGroup => navigateTo(`groups/${newGroup.id}`))
);
};
onGroupDeleted = () => {
this.props.controller.updatePagination({ page: 1 });
this.props.controller.update();
};
render() {
const { controller } = this.props;
return (
{currentUser.isAdmin && (
New Group
)}
{!controller.isLoaded &&
}
{controller.isLoaded && controller.isEmpty &&
}
{controller.isLoaded && !controller.isEmpty && (
controller.updatePagination({ itemsPerPage })}
page={controller.page}
onChange={page => controller.updatePagination({ page })}
/>
)}
);
}
}
const GroupsListPage = wrapSettingsTab(
"Groups.List",
{
permission: "list_users",
title: "Groups",
path: "groups",
order: 3,
},
itemsList(
GroupsList,
() =>
new ResourceItemsSource({
isPlainList: true,
getRequest() {
return {};
},
getResource() {
return Group.query.bind(Group);
},
}),
() => new StateStorage({ orderByField: "name", itemsPerPage: 10 })
)
);
routes.register(
"Groups.List",
routeWithUserSession({
path: "/groups",
title: "Groups",
render: pageProps => ,
})
);
================================================
FILE: client/app/pages/home/Home.jsx
================================================
import { includes } from "lodash";
import React, { useEffect } from "react";
import Alert from "antd/lib/alert";
import Link from "@/components/Link";
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
import EmptyState, { EmptyStateHelpMessage } from "@/components/empty-state/EmptyState";
import DynamicComponent from "@/components/DynamicComponent";
import BeaconConsent from "@/components/BeaconConsent";
import PlainButton from "@/components/PlainButton";
import { axios } from "@/services/axios";
import recordEvent from "@/services/recordEvent";
import { messages } from "@/services/auth";
import notification from "@/services/notification";
import routes from "@/services/routes";
import { DashboardAndQueryFavoritesList } from "./components/FavoritesList";
import "./Home.less";
function DeprecatedEmbedFeatureAlert() {
return (
You have enabled ALLOW_PARAMETERS_IN_EMBEDS. This setting is now deprecated and should be turned
off. Parameters in embeds are supported by default.{" "}
Read more
.
>
}
/>
);
}
function EmailNotVerifiedAlert() {
const verifyEmail = () => {
axios.post("verification_email/").then((data) => {
notification.success(data.message);
});
};
return (
We have sent an email with a confirmation link to your email address. Please follow the link to verify your
email address.{" "}
Resend email
.
>
}
/>
);
}
export default function Home() {
useEffect(() => {
recordEvent("view", "page", "personal_homepage");
}, []);
return (
{includes(messages, "using-deprecated-embed-feature") && }
{includes(messages, "email-not-verified") && }
}
showDashboardStep
showInviteStep
onboardingMode
/>
);
}
routes.register(
"Home",
routeWithUserSession({
path: "/",
title: "Redash",
render: (pageProps) => ,
})
);
================================================
FILE: client/app/pages/home/Home.less
================================================
.home-page {
padding-top: 15px;
}
.home-favorites-list {
margin-top: -20px;
}
================================================
FILE: client/app/pages/home/components/FavoritesList.jsx
================================================
import { isEmpty } from "lodash";
import React, { useEffect, useState } from "react";
import PropTypes from "prop-types";
import Link from "@/components/Link";
import LoadingOutlinedIcon from "@ant-design/icons/LoadingOutlined";
import { Dashboard } from "@/services/dashboard";
import { Query } from "@/services/query";
export function FavoriteList({ title, resource, itemUrl, emptyState }) {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
resource
.favorites({ order: "-starred_at" })
.then(({ results }) => setItems(results))
.finally(() => setLoading(false));
}, [resource]);
return (
<>
{!isEmpty(items) && (
{items.map((item) => (
{item.name}
{item.is_draft && Unpublished }
))}
)}
{isEmpty(items) && !loading && emptyState}
>
);
}
FavoriteList.propTypes = {
title: PropTypes.string.isRequired,
resource: PropTypes.func.isRequired, // eslint-disable-line react/forbid-prop-types
itemUrl: PropTypes.func.isRequired,
emptyState: PropTypes.node,
};
FavoriteList.defaultProps = { emptyState: null };
export function DashboardAndQueryFavoritesList() {
return (
dashboard.url}
emptyState={
Favorite Dashboards will appear here
}
/>
`queries/${query.id}`}
emptyState={
Favorite Queries will appear here
}
/>
);
}
================================================
FILE: client/app/pages/index.js
================================================
import "./home/Home";
import "./admin/Jobs";
import "./admin/OutdatedQueries";
import "./admin/SystemStatus";
import "./alerts/AlertsList";
import "./alert/Alert";
import "./dashboards/DashboardList";
import "./dashboards/DashboardPage";
import "./dashboards/PublicDashboardPage";
import "./data-sources/DataSourcesList";
import "./data-sources/EditDataSource";
import "./destinations/DestinationsList";
import "./destinations/EditDestination";
import "./groups/GroupsList";
import "./groups/GroupDataSources";
import "./groups/GroupMembers";
import "./queries-list/QueriesList";
import "./queries/QuerySource";
import "./queries/QueryView";
import "./queries/VisualizationEmbed";
import "./query-snippets/QuerySnippetsList";
import "./settings/OrganizationSettings";
import "./users/UsersList";
import "./users/UserProfile";
================================================
FILE: client/app/pages/queries/QuerySource.jsx
================================================
import { extend, find, includes, isEmpty, map } from "lodash";
import React, { useCallback, useEffect, useRef, useState } from "react";
import PropTypes from "prop-types";
import cx from "classnames";
import { useDebouncedCallback } from "use-debounce";
import useMedia from "use-media";
import Button from "antd/lib/button";
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
import Resizable from "@/components/Resizable";
import Parameters from "@/components/Parameters";
import EditInPlace from "@/components/EditInPlace";
import DynamicComponent from "@/components/DynamicComponent";
import recordEvent from "@/services/recordEvent";
import { ExecutionStatus } from "@/services/query-result";
import routes from "@/services/routes";
import notification from "@/services/notification";
import * as queryFormat from "@/lib/queryFormat";
import QueryPageHeader from "./components/QueryPageHeader";
import QueryMetadata from "./components/QueryMetadata";
import QueryVisualizationTabs from "./components/QueryVisualizationTabs";
import QueryExecutionStatus from "./components/QueryExecutionStatus";
import QuerySourceAlerts from "./components/QuerySourceAlerts";
import wrapQueryPage from "./components/wrapQueryPage";
import QueryExecutionMetadata from "./components/QueryExecutionMetadata";
import { getEditorComponents } from "@/components/queries/editor-components";
import useQuery from "./hooks/useQuery";
import useVisualizationTabHandler from "./hooks/useVisualizationTabHandler";
import useAutocompleteFlags from "./hooks/useAutocompleteFlags";
import useAutoLimitFlags from "./hooks/useAutoLimitFlags";
import useQueryExecute from "./hooks/useQueryExecute";
import useQueryResultData from "@/lib/useQueryResultData";
import useQueryDataSources from "./hooks/useQueryDataSources";
import useQueryFlags from "./hooks/useQueryFlags";
import useQueryParameters from "./hooks/useQueryParameters";
import useAddNewParameterDialog from "./hooks/useAddNewParameterDialog";
import useEditScheduleDialog from "./hooks/useEditScheduleDialog";
import useAddVisualizationDialog from "./hooks/useAddVisualizationDialog";
import useEditVisualizationDialog from "./hooks/useEditVisualizationDialog";
import useDeleteVisualization from "./hooks/useDeleteVisualization";
import useUpdateQuery from "./hooks/useUpdateQuery";
import useUpdateQueryDescription from "./hooks/useUpdateQueryDescription";
import useUnsavedChangesAlert from "./hooks/useUnsavedChangesAlert";
import "./components/QuerySourceDropdown"; // register QuerySourceDropdown
import "./QuerySource.less";
function chooseDataSourceId(dataSourceIds, availableDataSources) {
availableDataSources = map(availableDataSources, ds => ds.id);
return find(dataSourceIds, id => includes(availableDataSources, id)) || null;
}
function QuerySource(props) {
const { query, setQuery, isDirty, saveQuery } = useQuery(props.query);
const { dataSourcesLoaded, dataSources, dataSource } = useQueryDataSources(query);
const [schema, setSchema] = useState([]);
const queryFlags = useQueryFlags(query, dataSource);
const [parameters, areParametersDirty, updateParametersDirtyFlag] = useQueryParameters(query);
const [selectedVisualization, setSelectedVisualization] = useVisualizationTabHandler(query.visualizations);
const { QueryEditor, SchemaBrowser } = getEditorComponents(dataSource && dataSource.type);
const isMobile = !useMedia({ minWidth: 768 });
useUnsavedChangesAlert(isDirty);
const {
queryResult,
isExecuting: isQueryExecuting,
executionStatus,
executeQuery,
error: executionError,
cancelCallback: cancelExecution,
isCancelling: isExecutionCancelling,
updatedAt,
loadedInitialResults,
} = useQueryExecute(query);
const queryResultData = useQueryResultData(queryResult);
const editorRef = useRef(null);
const [autocompleteAvailable, autocompleteEnabled, toggleAutocomplete] = useAutocompleteFlags(schema);
const [autoLimitAvailable, autoLimitChecked, setAutoLimit] = useAutoLimitFlags(dataSource, query, setQuery);
const [handleQueryEditorChange] = useDebouncedCallback(queryText => {
setQuery(extend(query.clone(), { query: queryText }));
}, 100);
useEffect(() => {
// TODO: ignore new pages?
recordEvent("view_source", "query", query.id);
}, [query.id]);
useEffect(() => {
document.title = query.name;
}, [query.name]);
const updateQuery = useUpdateQuery(query, setQuery);
const updateQueryDescription = useUpdateQueryDescription(query, setQuery);
const querySyntax = dataSource ? dataSource.syntax || "sql" : null;
const isFormatQueryAvailable = queryFormat.isFormatQueryAvailable(querySyntax);
const formatQuery = () => {
try {
const formattedQueryText = queryFormat.formatQuery(query.query, querySyntax);
setQuery(extend(query.clone(), { query: formattedQueryText }));
} catch (err) {
notification.error(String(err));
}
};
const handleDataSourceChange = useCallback(
dataSourceId => {
if (dataSourceId) {
try {
localStorage.setItem("lastSelectedDataSourceId", dataSourceId);
} catch (e) {
// `localStorage.setItem` may throw exception if there are no enough space - in this case it could be ignored
}
}
if (query.data_source_id !== dataSourceId) {
recordEvent("update_data_source", "query", query.id, { dataSourceId });
const updates = {
data_source_id: dataSourceId,
latest_query_data_id: null,
latest_query_data: null,
};
setQuery(extend(query.clone(), updates));
updateQuery(updates, { successMessage: null }); // show message only on error
}
},
[query, setQuery, updateQuery]
);
useEffect(() => {
// choose data source id for new queries
if (dataSourcesLoaded && queryFlags.isNew) {
const firstDataSourceId = dataSources.length > 0 ? dataSources[0].id : null;
const selectedDataSourceId = parseInt(localStorage.getItem("lastSelectedDataSourceId")) || null;
handleDataSourceChange(
chooseDataSourceId([query.data_source_id, selectedDataSourceId, firstDataSourceId], dataSources)
);
}
}, [query.data_source_id, queryFlags.isNew, dataSourcesLoaded, dataSources, handleDataSourceChange]);
const editSchedule = useEditScheduleDialog(query, setQuery);
const openAddNewParameterDialog = useAddNewParameterDialog(query, (newQuery, param) => {
if (editorRef.current) {
editorRef.current.paste(param.toQueryTextFragment());
editorRef.current.focus();
}
setQuery(newQuery);
});
const handleSchemaItemSelect = useCallback(schemaItem => {
if (editorRef.current) {
editorRef.current.paste(schemaItem);
}
}, []);
const [selectedText, setSelectedText] = useState(null);
const doExecuteQuery = useCallback(
(skipParametersDirtyFlag = false) => {
if (!queryFlags.canExecute || (!skipParametersDirtyFlag && (areParametersDirty || isQueryExecuting))) {
return;
}
if (isDirty || !isEmpty(selectedText)) {
executeQuery(null, () => {
return query.getQueryResultByText(0, selectedText);
});
} else {
executeQuery();
}
},
[query, queryFlags.canExecute, areParametersDirty, isQueryExecuting, isDirty, selectedText, executeQuery]
);
const [isQuerySaving, setIsQuerySaving] = useState(false);
const doSaveQuery = useCallback(() => {
if (!isQuerySaving) {
setIsQuerySaving(true);
saveQuery().finally(() => setIsQuerySaving(false));
}
}, [isQuerySaving, saveQuery]);
const addVisualization = useAddVisualizationDialog(query, queryResult, doSaveQuery, (newQuery, visualization) => {
setQuery(newQuery);
setSelectedVisualization(visualization.id);
});
const editVisualization = useEditVisualizationDialog(query, queryResult, newQuery => setQuery(newQuery));
const deleteVisualization = useDeleteVisualization(query, setQuery);
return (
0} />
}
onChange={setQuery}
/>
{dataSourcesLoaded && (
)}
setQuery(extend(query.clone(), { options: { ...query.options, schemaOptions } }))
}
onSchemaUpdate={setSchema}
onItemSelect={handleSchemaItemSelect}
/>
{!query.isNew() && (
)}
{!query.isNew() && }
Save
{isDirty && !isQuerySaving ? "*" : null}
),
shortcut: "mod+s",
onClick: doSaveQuery,
loading: isQuerySaving,
}
}
executeButtonProps={{
disabled: !queryFlags.canExecute || isQueryExecuting || areParametersDirty,
shortcut: "mod+enter, alt+enter, ctrl+enter, shift+enter",
onClick: doExecuteQuery,
text: (
{selectedText === null ? "Execute" : "Execute Selected"}
),
}}
autocompleteToggleProps={{
available: autocompleteAvailable,
enabled: autocompleteEnabled,
onToggle: toggleAutocomplete,
}}
autoLimitCheckboxProps={{
available: autoLimitAvailable,
checked: autoLimitChecked,
onChange: setAutoLimit,
}}
dataSourceSelectorProps={
dataSource
? {
disabled: !queryFlags.canEdit,
value: dataSource.id,
onChange: handleDataSourceChange,
options: map(dataSources, ds => ({ value: ds.id, label: ds.name })),
}
: false
}
/>
{!queryFlags.isNew &&
}
{query.hasParameters() && (
updateParametersDirtyFlag()}
onValuesChange={() => {
updateParametersDirtyFlag(false);
doExecuteQuery(true);
}}
onParametersEdit={() => {
// save if query clean
// https://discuss.redash.io/t/query-unsaved-changes-indication/3302/5
if (!isDirty) {
saveQuery();
}
}}
/>
)}
{(executionError || isQueryExecuting) && (
)}
{queryResultData.log.length > 0 && (
Log Information:
{map(queryResultData.log, (line, index) => (
{line}
))}
)}
{loadedInitialResults && !(queryFlags.isNew && !queryResult) && (
{!isQueryExecuting && }
Refresh Now
}
/>
)}
{queryResult && !queryResult.getError() && (
)}
);
}
QuerySource.propTypes = {
query: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
};
const QuerySourcePage = wrapQueryPage(QuerySource);
routes.register(
"Queries.New",
routeWithUserSession({
path: "/queries/new",
render: pageProps => ,
bodyClass: "fixed-layout",
})
);
routes.register(
"Queries.Edit",
routeWithUserSession({
path: "/queries/:queryId/source",
render: pageProps => ,
bodyClass: "fixed-layout",
})
);
================================================
FILE: client/app/pages/queries/QuerySource.less
================================================
.query-fullscreen {
.query-editor-wrapper {
padding: 15px;
margin-bottom: 10px;
height: 100%;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
.query-editor-container {
flex: 1 1 auto;
&[data-executing] {
.ace_marker-layer {
.ace_selection {
background-color: rgb(255, 210, 181);
}
}
}
}
.query-editor-controls {
flex: 0 0 auto;
margin-top: 10px;
}
}
.query-page-query-description {
border-top: 1px solid #efefef;
padding: 0 15px 0 0;
.edit-in-place {
display: block;
max-height: 150px;
overflow: auto;
padding: 15px 5px 15px 15px;
&.active {
overflow: visible;
max-height: unset !important;
.ant-input {
resize: vertical;
height: 30vh;
}
}
}
}
.query-results-wrapper {
display: flex;
flex-direction: column;
margin: 15px 0 15px 0;
.query-parameters-wrapper {
flex: 0 0 auto;
}
.query-alerts {
margin: 15px 0;
flex: 0 0 auto;
}
.query-results-log {
padding: 10px;
flex: 0 0 auto;
}
.ant-tabs {
flex: 1 1 auto;
display: flex;
flex-direction: column;
.ant-tabs-bar {
flex: 0 0 auto;
}
.ant-tabs-content-holder {
flex: 1 1 auto;
position: relative;
@media (min-width: 880px) {
.ant-tabs-tabpane {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
overflow: auto;
}
}
}
}
}
}
================================================
FILE: client/app/pages/queries/QueryView.jsx
================================================
import React, { useState, useEffect, useCallback } from "react";
import PropTypes from "prop-types";
import cx from "classnames";
import useMedia from "use-media";
import Button from "antd/lib/button";
import FullscreenOutlinedIcon from "@ant-design/icons/FullscreenOutlined";
import FullscreenExitOutlinedIcon from "@ant-design/icons/FullscreenExitOutlined";
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
import EditInPlace from "@/components/EditInPlace";
import Parameters from "@/components/Parameters";
import DynamicComponent from "@/components/DynamicComponent";
import PlainButton from "@/components/PlainButton";
import DataSource from "@/services/data-source";
import { ExecutionStatus } from "@/services/query-result";
import routes from "@/services/routes";
import { policy } from "@/services/policy";
import useQueryResultData from "@/lib/useQueryResultData";
import QueryPageHeader from "./components/QueryPageHeader";
import QueryVisualizationTabs from "./components/QueryVisualizationTabs";
import QueryExecutionStatus from "./components/QueryExecutionStatus";
import QueryMetadata from "./components/QueryMetadata";
import wrapQueryPage from "./components/wrapQueryPage";
import QueryViewButton from "./components/QueryViewButton";
import QueryExecutionMetadata from "./components/QueryExecutionMetadata";
import useVisualizationTabHandler from "./hooks/useVisualizationTabHandler";
import useQueryExecute from "./hooks/useQueryExecute";
import useUpdateQueryDescription from "./hooks/useUpdateQueryDescription";
import useQueryFlags from "./hooks/useQueryFlags";
import useQueryParameters from "./hooks/useQueryParameters";
import useEditScheduleDialog from "./hooks/useEditScheduleDialog";
import useEditVisualizationDialog from "./hooks/useEditVisualizationDialog";
import useDeleteVisualization from "./hooks/useDeleteVisualization";
import useFullscreenHandler from "../../lib/hooks/useFullscreenHandler";
import "./QueryView.less";
function QueryView(props) {
const [query, setQuery] = useState(props.query);
const [dataSource, setDataSource] = useState();
const queryFlags = useQueryFlags(query, dataSource);
const [parameters, areParametersDirty, updateParametersDirtyFlag] = useQueryParameters(query);
const [selectedVisualization, setSelectedVisualization] = useVisualizationTabHandler(query.visualizations);
const isDesktop = useMedia({ minWidth: 768 });
const isFixedLayout = useMedia({ minHeight: 500 }) && isDesktop;
const [fullscreen, toggleFullscreen] = useFullscreenHandler(isDesktop);
const [addingDescription, setAddingDescription] = useState(false);
const {
queryResult,
loadedInitialResults,
isExecuting,
executionStatus,
executeQuery,
error: executionError,
cancelCallback: cancelExecution,
isCancelling: isExecutionCancelling,
updatedAt,
} = useQueryExecute(query);
const queryResultData = useQueryResultData(queryResult);
const updateQueryDescription = useUpdateQueryDescription(query, setQuery);
const editSchedule = useEditScheduleDialog(query, setQuery);
const addVisualization = useEditVisualizationDialog(query, queryResult, (newQuery, visualization) => {
setQuery(newQuery);
setSelectedVisualization(visualization.id);
});
const editVisualization = useEditVisualizationDialog(query, queryResult, newQuery => setQuery(newQuery));
const deleteVisualization = useDeleteVisualization(query, setQuery);
const doExecuteQuery = useCallback(
(skipParametersDirtyFlag = false) => {
if (!queryFlags.canExecute || (!skipParametersDirtyFlag && (areParametersDirty || isExecuting))) {
return;
}
executeQuery();
},
[areParametersDirty, executeQuery, isExecuting, queryFlags.canExecute]
);
useEffect(() => {
document.title = query.name;
}, [query.name]);
useEffect(() => {
DataSource.get({ id: query.data_source_id }).then(setDataSource);
}, [query.data_source_id]);
return (
{policy.canRun(query) && (
Refresh
)}
}
tagsExtra={
!query.description &&
queryFlags.canEdit &&
!addingDescription &&
!fullscreen && (
setAddingDescription(true)}>
Add description
)
}
/>
{(query.description || addingDescription) && (
setAddingDescription(false)}
placeholder="Add description"
ignoreBlanks={false}
editorProps={{ autoSize: { minRows: 2, maxRows: 4 } }}
defaultEditing={addingDescription}
multiline
/>
)}
{query.hasParameters() && (
{
updateParametersDirtyFlag(false);
doExecuteQuery(true);
}}
onPendingValuesChange={() => updateParametersDirtyFlag()}
/>
)}
{loadedInitialResults && (
{!isExecuting && }
Refresh Now
)
}
canRefresh={policy.canRun(query)}
/>
)}
{queryResult && !queryResult.getError() && (
{fullscreen ? : }
}
/>
)}
{(executionError || isExecuting) && (
)}
);
}
QueryView.propTypes = { query: PropTypes.object.isRequired }; // eslint-disable-line react/forbid-prop-types
const QueryViewPage = wrapQueryPage(QueryView);
routes.register(
"Queries.View",
routeWithUserSession({
path: "/queries/:queryId",
render: pageProps => ,
})
);
================================================
FILE: client/app/pages/queries/QueryView.less
================================================
page-query-view {
display: flex;
flex-grow: 1;
}
.query-page-wrapper {
width: 100%;
.query-view-content {
.query-results {
margin: 0px 15px 0px;
.query-results-footer {
position: relative;
.query-execution-status {
position: fixed;
min-width: 250px;
bottom: 0;
right: 0;
padding: 15px;
z-index: 1000;
@media (min-width: 768px) {
display: flex;
min-height: 100%;
position: absolute;
padding: 3px;
.ant-alert {
flex: 1 1;
}
}
}
}
.query-execution-metadata {
border: 1px solid #d9d9d9;
border-top-width: 0px;
box-sizing: border-box;
border-radius: 0px 0px 4px 4px;
background: white;
}
.query-parameters-wrapper {
margin: 15px 0 5px 0;
}
.query-alerts {
margin: 15px 0;
}
.query-results-log {
padding: 10px;
}
}
}
&.query-fixed-layout .query-view-content {
display: flex;
flex-direction: column;
flex-grow: 1;
.query-results {
display: flex;
flex-direction: column;
flex-grow: 1;
overflow: auto;
}
.query-parameters-wrapper {
flex: 0 0 auto;
}
.query-alerts {
flex: 0 0 auto;
}
.query-results-log {
flex: 0 0 auto;
}
.ant-tabs {
flex: 1 1 auto;
display: flex;
flex-direction: column;
.ant-tabs-bar {
flex: 0 0 auto;
}
.ant-tabs-content-holder {
flex: 1 1 auto;
position: relative;
.ant-tabs-tabpane {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
overflow: auto;
}
}
}
}
}
================================================
FILE: client/app/pages/queries/VisualizationEmbed.jsx
================================================
import { find, has } from "lodash";
import React, { useState, useEffect, useCallback } from "react";
import PropTypes from "prop-types";
import moment from "moment";
import { markdown } from "markdown";
import Button from "antd/lib/button";
import Dropdown from "antd/lib/dropdown";
import Menu from "antd/lib/menu";
import Tooltip from "@/components/Tooltip";
import Link from "@/components/Link";
import routeWithApiKeySession from "@/components/ApplicationArea/routeWithApiKeySession";
import Parameters from "@/components/Parameters";
import { Moment } from "@/components/proptypes";
import TimeAgo from "@/components/TimeAgo";
import Timer from "@/components/Timer";
import QueryResultsLink from "@/components/EditVisualizationButton/QueryResultsLink";
import VisualizationName from "@/components/visualizations/VisualizationName";
import VisualizationRenderer from "@/components/visualizations/VisualizationRenderer";
import FileOutlinedIcon from "@ant-design/icons/FileOutlined";
import FileExcelOutlinedIcon from "@ant-design/icons/FileExcelOutlined";
import { VisualizationType } from "@redash/viz/lib";
import HtmlContent from "@redash/viz/lib/components/HtmlContent";
import { formatDateTime } from "@/lib/utils";
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
import { Query } from "@/services/query";
import location from "@/services/location";
import routes from "@/services/routes";
import logoUrl from "@/assets/images/redash_icon_small.png";
function VisualizationEmbedHeader({ queryName, queryDescription, visualization }) {
return (
{queryName}
{queryDescription && (
{markdown.toHTML(queryDescription || "")}
)}
);
}
VisualizationEmbedHeader.propTypes = {
queryName: PropTypes.string.isRequired,
queryDescription: PropTypes.string,
visualization: VisualizationType.isRequired,
};
VisualizationEmbedHeader.defaultProps = { queryDescription: "" };
function VisualizationEmbedFooter({
query,
queryResults,
updatedAt,
refreshStartedAt,
queryUrl,
hideTimestamp,
apiKey,
}) {
const downloadMenu = (
Download as CSV File
Download as TSV File
Download as Excel File
);
return (
{!hideTimestamp && (
{" "}
{refreshStartedAt ? : }
{formatDateTime(updatedAt)}
)}
{queryUrl && (
Open in Redash
{!query.hasParameters() && (
Download Dataset
)}
)}
);
}
VisualizationEmbedFooter.propTypes = {
query: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
queryResults: PropTypes.object, // eslint-disable-line react/forbid-prop-types
updatedAt: PropTypes.string,
refreshStartedAt: Moment,
queryUrl: PropTypes.string,
hideTimestamp: PropTypes.bool,
apiKey: PropTypes.string,
};
VisualizationEmbedFooter.defaultProps = {
queryResults: null,
updatedAt: null,
refreshStartedAt: null,
queryUrl: null,
hideTimestamp: false,
apiKey: null,
};
function VisualizationEmbed({ queryId, visualizationId, apiKey, onError }) {
const [query, setQuery] = useState(null);
const [error, setError] = useState(null);
const [refreshStartedAt, setRefreshStartedAt] = useState(null);
const [queryResults, setQueryResults] = useState(null);
const handleError = useImmutableCallback(onError);
useEffect(() => {
let isCancelled = false;
Query.get({ id: queryId })
.then(result => {
if (!isCancelled) {
setQuery(result);
}
})
.catch(handleError);
return () => {
isCancelled = true;
};
}, [queryId, handleError]);
const refreshQueryResults = useCallback(() => {
if (query) {
setError(null);
setRefreshStartedAt(moment());
query
.getQueryResultPromise()
.then(result => {
setQueryResults(result);
})
.catch(err => {
setError(err.getError());
})
.finally(() => setRefreshStartedAt(null));
}
}, [query]);
useEffect(() => {
document.querySelector("body").classList.add("headless");
refreshQueryResults();
}, [refreshQueryResults]);
if (!query) {
return null;
}
const hideHeader = has(location.search, "hide_header");
const hideParametersUI = has(location.search, "hide_parameters");
const hideQueryLink = has(location.search, "hide_link");
const hideTimestamp = has(location.search, "hide_timestamp");
const showQueryDescription = has(location.search, "showDescription");
visualizationId = parseInt(visualizationId, 10);
const visualization = find(query.visualizations, vis => vis.id === visualizationId);
if (!visualization) {
// call error handler async, otherwise it will destroy the component on render phase
setTimeout(() => {
onError(new Error("Visualization does not exist"));
}, 10);
return null;
}
return (
{!hideHeader && (
)}
{!hideParametersUI && query.hasParameters() && (
)}
{error &&
{`Error: ${error}`}
}
{!error && queryResults && (
)}
{!queryResults && refreshStartedAt && (
)}
);
}
VisualizationEmbed.propTypes = {
queryId: PropTypes.string.isRequired,
visualizationId: PropTypes.string,
apiKey: PropTypes.string.isRequired,
onError: PropTypes.func,
};
VisualizationEmbed.defaultProps = {
onError: () => {},
};
routes.register(
"Visualizations.ViewShared",
routeWithApiKeySession({
path: "/embed/query/:queryId/visualization/:visualizationId",
render: pageProps => ,
getApiKey: () => location.search.api_key,
})
);
================================================
FILE: client/app/pages/queries/components/QueryExecutionMetadata.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
import WarningTwoTone from "@ant-design/icons/WarningTwoTone";
import TimeAgo from "@/components/TimeAgo";
import Tooltip from "@/components/Tooltip";
import useAddToDashboardDialog from "../hooks/useAddToDashboardDialog";
import useEmbedDialog from "../hooks/useEmbedDialog";
import QueryControlDropdown from "@/components/EditVisualizationButton/QueryControlDropdown";
import EditVisualizationButton from "@/components/EditVisualizationButton";
import useQueryResultData from "@/lib/useQueryResultData";
import { durationHumanize, pluralize, prettySize } from "@/lib/utils";
import { isUndefined } from "lodash";
import "./QueryExecutionMetadata.less";
export default function QueryExecutionMetadata({
query,
queryResult,
isQueryExecuting,
selectedVisualization,
showEditVisualizationButton,
onEditVisualization,
extraActions,
}) {
const queryResultData = useQueryResultData(queryResult);
const openAddToDashboardDialog = useAddToDashboardDialog(query);
const openEmbedDialog = useEmbedDialog(query);
return (
{extraActions}
{showEditVisualizationButton && (
)}
{queryResultData.truncated === true && (
)}
{queryResultData.rows.length} {pluralize("row", queryResultData.rows.length)}
{!isQueryExecuting && (
{durationHumanize(queryResultData.runtime)}
runtime
)}
{isQueryExecuting && Running… }
{!isUndefined(queryResultData.metadata.data_scanned) && !isQueryExecuting && (
Data Scanned {prettySize(queryResultData.metadata.data_scanned)}
)}
Refreshed
);
}
QueryExecutionMetadata.propTypes = {
query: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
queryResult: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
isQueryExecuting: PropTypes.bool,
selectedVisualization: PropTypes.number,
showEditVisualizationButton: PropTypes.bool,
onEditVisualization: PropTypes.func,
extraActions: PropTypes.node,
};
QueryExecutionMetadata.defaultProps = {
isQueryExecuting: false,
selectedVisualization: null,
showEditVisualizationButton: false,
onEditVisualization: () => {},
extraActions: null,
};
================================================
FILE: client/app/pages/queries/components/QueryExecutionMetadata.less
================================================
.query-execution-metadata {
padding: 10px 15px;
background: #fff;
display: flex;
align-items: center;
button,
div,
span {
position: relative;
}
div:last-child {
flex-grow: 1;
text-align: right;
}
&:before {
content: "";
height: 50px;
position: fixed;
bottom: 0;
width: 100%;
pointer-events: none;
left: 0;
}
}
================================================
FILE: client/app/pages/queries/components/QueryExecutionStatus.jsx
================================================
import { includes } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import Alert from "antd/lib/alert";
import Button from "antd/lib/button";
import Timer from "@/components/Timer";
export default function QueryExecutionStatus({ status, updatedAt, error, isCancelling, onCancel }) {
const alertType = status === "failed" ? "error" : "info";
const showTimer = status !== "failed" && updatedAt;
const isCancelButtonAvailable = includes(["waiting", "processing"], status);
let message = isCancelling ? Cancelling… : null;
switch (status) {
case "waiting":
if (!isCancelling) {
message = Query in queue… ;
}
break;
case "processing":
if (!isCancelling) {
message = Executing query… ;
}
break;
case "loading-result":
message = Loading results… ;
break;
case "failed":
message = (
Error running query: {error}
);
break;
// no default
}
return (
{message} {showTimer && }
{isCancelButtonAvailable && (
Cancel
)}
}
/>
);
}
QueryExecutionStatus.propTypes = {
status: PropTypes.string,
updatedAt: PropTypes.any,
error: PropTypes.string,
isCancelling: PropTypes.bool,
onCancel: PropTypes.func,
};
QueryExecutionStatus.defaultProps = {
status: "waiting",
updatedAt: null,
error: null,
isCancelling: true,
onCancel: () => {},
};
================================================
FILE: client/app/pages/queries/components/QueryMetadata.jsx
================================================
import { isFunction, has } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import cx from "classnames";
import { Moment } from "@/components/proptypes";
import TimeAgo from "@/components/TimeAgo";
import SchedulePhrase from "@/components/queries/SchedulePhrase";
import { IMG_ROOT } from "@/services/data-source";
import "./QueryMetadata.less";
export default function QueryMetadata({ query, dataSource, layout, onEditSchedule }) {
return (
{query.user.name}
created{" "}
{query.last_modified_by.name}
updated{" "}
{has(dataSource, "name") && has(dataSource, "type") && (
Data Source:
)}
);
}
QueryMetadata.propTypes = {
layout: PropTypes.oneOf(["table", "horizontal"]),
query: PropTypes.shape({
created_at: PropTypes.oneOfType([PropTypes.string, Moment]).isRequired,
updated_at: PropTypes.oneOfType([PropTypes.string, Moment]).isRequired,
user: PropTypes.shape({
name: PropTypes.string.isRequired,
profile_image_url: PropTypes.string.isRequired,
is_disabled: PropTypes.bool,
}).isRequired,
last_modified_by: PropTypes.shape({
name: PropTypes.string.isRequired,
profile_image_url: PropTypes.string.isRequired,
is_disabled: PropTypes.bool,
}).isRequired,
schedule: PropTypes.object,
}).isRequired,
dataSource: PropTypes.shape({
type: PropTypes.string,
name: PropTypes.string,
}),
onEditSchedule: PropTypes.func,
};
QueryMetadata.defaultProps = {
layout: "table",
dataSource: null,
onEditSchedule: null,
};
================================================
FILE: client/app/pages/queries/components/QueryMetadata.less
================================================
.query-metadata {
.query-metadata-item {
display: flex;
flex-wrap: nowrap;
align-items: center;
margin: 0;
img {
margin: 0 5px 0 0;
}
.query-metadata-property {
flex: 1 1 auto;
.query-metadata-label {
margin: 0 5px 0 0;
&:only-child {
margin-right: 0;
}
}
.query-metadata-value {
margin: 0;
}
}
}
.query-metadata-space {
display: none;
}
&.query-metadata-table {
padding: 15px;
border-top: 1px solid #efefef;
.query-metadata-item {
margin-bottom: 8px;
&:last-child {
margin-top: 20px;
margin-bottom: 0;
}
.query-metadata-property {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
}
}
}
&.query-metadata-horizontal {
padding: 5px 0;
margin: 0 -5px;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
@media (max-width: 500px) {
& {
flex-direction: column;
justify-content: stretch;
}
}
@media (min-width: 1000px) {
justify-content: flex-start;
.query-metadata-space {
display: block;
flex: 1 1 auto;
text-align: right;
}
}
.query-metadata-item {
padding: 5px;
&:last-child {
.query-metadata-property {
.query-metadata-label {
white-space: nowrap;
&:after {
content: ":";
}
}
}
}
.query-metadata-property {
.query-metadata-label {
.zmdi {
display: none;
}
}
.query-metadata-value {
strong {
font-weight: normal;
}
}
}
}
}
}
================================================
FILE: client/app/pages/queries/components/QueryPageHeader.jsx
================================================
import { extend, map, filter, reduce } from "lodash";
import React, { useMemo } from "react";
import PropTypes from "prop-types";
import Button from "antd/lib/button";
import Dropdown from "antd/lib/dropdown";
import Menu from "antd/lib/menu";
import EllipsisOutlinedIcon from "@ant-design/icons/EllipsisOutlined";
import useMedia from "use-media";
import Link from "@/components/Link";
import EditInPlace from "@/components/EditInPlace";
import FavoritesControl from "@/components/FavoritesControl";
import { QueryTagsControl } from "@/components/tags-control/TagsControl";
import getTags from "@/services/getTags";
import { clientConfig } from "@/services/auth";
import useQueryFlags from "../hooks/useQueryFlags";
import useArchiveQuery from "../hooks/useArchiveQuery";
import usePublishQuery from "../hooks/usePublishQuery";
import useUnpublishQuery from "../hooks/useUnpublishQuery";
import useUpdateQueryTags from "../hooks/useUpdateQueryTags";
import useRenameQuery from "../hooks/useRenameQuery";
import useDuplicateQuery from "../hooks/useDuplicateQuery";
import useApiKeyDialog from "../hooks/useApiKeyDialog";
import usePermissionsEditorDialog from "../hooks/usePermissionsEditorDialog";
import "./QueryPageHeader.less";
function getQueryTags() {
return getTags("api/queries/tags").then(tags => map(tags, t => t.name));
}
function createMenu(menu) {
const handlers = {};
const groups = map(menu, group =>
filter(
map(group, (props, key) => {
props = extend({ isAvailable: true, isEnabled: true, onClick: () => {} }, props);
if (props.isAvailable) {
handlers[key] = props.onClick;
return (
{props.title}
);
}
return null;
})
)
);
return (
handlers[key]()}>
{reduce(
filter(groups, group => group.length > 0),
(result, items, key) => {
const divider = result.length > 0 ? : null;
return [...result, divider, ...items];
},
[]
)}
);
}
export default function QueryPageHeader({
query,
dataSource,
sourceMode,
selectedVisualization,
headerExtra,
tagsExtra,
onChange,
}) {
const isDesktop = useMedia({ minWidth: 768 });
const queryFlags = useQueryFlags(query, dataSource);
const updateName = useRenameQuery(query, onChange);
const updateTags = useUpdateQueryTags(query, onChange);
const archiveQuery = useArchiveQuery(query, onChange);
const publishQuery = usePublishQuery(query, onChange);
const unpublishQuery = useUnpublishQuery(query, onChange);
const [isDuplicating, duplicateQuery] = useDuplicateQuery(query);
const openApiKeyDialog = useApiKeyDialog(query, onChange);
const openPermissionsEditorDialog = usePermissionsEditorDialog(query);
const moreActionsMenu = useMemo(
() =>
createMenu([
{
fork: {
isEnabled: !queryFlags.isNew && queryFlags.canFork && !isDuplicating,
title: (
Fork
(opens in a new tab)
),
onClick: duplicateQuery,
},
},
{
archive: {
isAvailable: !queryFlags.isNew && queryFlags.canEdit && !queryFlags.isArchived,
title: "Archive",
onClick: archiveQuery,
},
managePermissions: {
isAvailable:
!queryFlags.isNew && queryFlags.canEdit && !queryFlags.isArchived && clientConfig.showPermissionsControl,
title: "Manage Permissions",
onClick: openPermissionsEditorDialog,
},
publish: {
isAvailable:
!isDesktop && queryFlags.isDraft && !queryFlags.isArchived && !queryFlags.isNew && queryFlags.canEdit,
title: "Publish",
onClick: publishQuery,
},
unpublish: {
isAvailable: !clientConfig.disablePublish && !queryFlags.isNew && queryFlags.canEdit && !queryFlags.isDraft,
title: "Unpublish",
onClick: unpublishQuery,
},
},
{
showAPIKey: {
isAvailable: !clientConfig.disablePublicUrls && !queryFlags.isNew,
title: "Show API Key",
onClick: openApiKeyDialog,
},
},
]),
[
queryFlags.isNew,
queryFlags.canFork,
queryFlags.canEdit,
queryFlags.isArchived,
queryFlags.isDraft,
isDuplicating,
duplicateQuery,
archiveQuery,
openPermissionsEditorDialog,
isDesktop,
publishQuery,
unpublishQuery,
openApiKeyDialog,
]
);
return (
{headerExtra}
{isDesktop && queryFlags.isDraft && !queryFlags.isArchived && !queryFlags.isNew && queryFlags.canEdit && (
Publish
)}
{!queryFlags.isNew && queryFlags.canViewSource && (
{!sourceMode && (
Edit Source
)}
{sourceMode && (
Show Results Only
)}
)}
{!queryFlags.isNew && (
)}
);
}
QueryPageHeader.propTypes = {
query: PropTypes.shape({
id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
name: PropTypes.string,
tags: PropTypes.arrayOf(PropTypes.string),
}).isRequired,
dataSource: PropTypes.object,
sourceMode: PropTypes.bool,
selectedVisualization: PropTypes.number,
headerExtra: PropTypes.node,
tagsExtra: PropTypes.node,
onChange: PropTypes.func,
};
QueryPageHeader.defaultProps = {
dataSource: null,
sourceMode: false,
selectedVisualization: null,
headerExtra: null,
tagsExtra: null,
onChange: () => {},
};
================================================
FILE: client/app/pages/queries/components/QueryPageHeader.less
================================================
.query-page-header {
display: flex;
flex-wrap: wrap;
align-items: stretch;
margin-top: 10px;
& > div {
padding: 5px 0;
}
.title-with-tags {
display: flex;
flex-wrap: wrap;
align-items: center;
flex: 1 1;
margin: -5px 0;
& > div {
padding: 5px 0;
}
.page-title {
h3 {
margin: 0 10px 0 0 !important;
@media (max-width: 767px) {
font-size: 18px;
}
}
}
}
.query-tags {
display: inline-block;
vertical-align: middle;
}
.tags-control > .label-tag {
opacity: 0;
transition: opacity 0.2s ease-in-out;
}
&:hover,
&:focus,
&:active,
&:focus-within {
.tags-control > .label-tag {
opacity: 1;
}
}
.header-actions {
display: flex;
flex-wrap: nowrap;
@media (max-width: 515px) {
flex-basis: 100%;
}
}
}
================================================
FILE: client/app/pages/queries/components/QuerySourceAlerts.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
import Card from "antd/lib/card";
import WarningFilledIcon from "@ant-design/icons/WarningFilled";
import Typography from "antd/lib/typography";
import Link from "@/components/Link";
import DynamicComponent from "@/components/DynamicComponent";
import { currentUser } from "@/services/auth";
import useQueryFlags from "../hooks/useQueryFlags";
import "./QuerySourceAlerts.less";
export default function QuerySourceAlerts({ query, dataSourcesAvailable }) {
const queryFlags = useQueryFlags(query); // we don't use flags that depend on data source
let message = null;
if (queryFlags.isNew && !queryFlags.canCreate) {
message = (
You don't have permission to create new queries on any of the data sources available to you.
You can either browse existing queries, or ask for additional permissions from
your Redash admin.
);
} else if (!dataSourcesAvailable) {
if (currentUser.isAdmin) {
message = (
Looks like no data sources were created yet or none of them available to the group(s) you're member of.
Please create one first, and then start querying.
Create Data Source
Manage Group Permissions
);
} else {
message = (
Looks like no data sources were created yet or none of them available to the group(s) you're member of.
Please ask your Redash admin to create one first.
);
}
}
if (!message) {
return null;
}
return (
);
}
QuerySourceAlerts.propTypes = {
query: PropTypes.object.isRequired,
dataSourcesAvailable: PropTypes.bool,
};
QuerySourceAlerts.defaultProps = {
dataSourcesAvailable: false,
};
================================================
FILE: client/app/pages/queries/components/QuerySourceAlerts.less
================================================
@import (reference, less) "~@/assets/less/inc/variables.less";
.query-source-alerts {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
z-index: 2000;
background: rgba(0, 0, 0, 0.45); // same as Ant drawer
display: flex;
justify-content: center;
align-items: flex-start;
padding-top: 10vh;
@large-spacing: 20px;
@small-spacing: 10px;
@icon-size: 60px;
.ant-card {
width: 50%;
min-width: 300px;
height: auto;
}
.ant-card-body {
.query-source-alerts-icon {
font-size: @icon-size;
line-height: @icon-size;
margin: @large-spacing 0;
text-align: center;
color: @brand-warning;
}
h4 {
text-align: center;
margin: @large-spacing 0;
font-weight: normal;
}
p {
text-align: center;
margin: @small-spacing 0;
font-size: 1.1em;
}
.query-source-alerts-actions {
text-align: center;
margin: @large-spacing 0;
.ant-btn {
margin: 0 15px 0 0;
&:last-child {
margin-right: 0;
}
}
}
:first-child {
margin-top: 0;
}
:last-child {
margin-bottom: 0;
}
}
}
================================================
FILE: client/app/pages/queries/components/QuerySourceDropdown.jsx
================================================
import Select from "antd/lib/select";
import { map } from "lodash";
import DynamicComponent, { registerComponent } from "@/components/DynamicComponent";
import PropTypes from "prop-types";
import React from "react";
import "./QuerySourceDropdownItem"; // register QuerySourceDropdownItem
export function QuerySourceDropdown(props) {
return (
{map(props.dataSources, ds => (
))}
);
}
QuerySourceDropdown.propTypes = {
dataSources: PropTypes.any,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
disabled: PropTypes.bool,
loading: PropTypes.bool,
onChange: PropTypes.func,
};
registerComponent("QuerySourceDropdown", QuerySourceDropdown);
================================================
FILE: client/app/pages/queries/components/QuerySourceDropdownItem.jsx
================================================
import PropTypes from "prop-types";
import React from "react";
import { registerComponent } from "@/components/DynamicComponent";
import { QuerySourceTypeIcon } from "@/pages/queries/components/QuerySourceTypeIcon";
export function QuerySourceDropdownItem({ dataSource, children }) {
return (
{children ? children : {dataSource.name} }
);
}
QuerySourceDropdownItem.propTypes = {
dataSource: PropTypes.shape({
name: PropTypes.string,
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
type: PropTypes.string,
}).isRequired,
children: PropTypes.element,
};
registerComponent("QuerySourceDropdownItem", QuerySourceDropdownItem);
================================================
FILE: client/app/pages/queries/components/QuerySourceTypeIcon.jsx
================================================
import PropTypes from "prop-types";
import React from "react";
export function QuerySourceTypeIcon(props) {
return ;
}
QuerySourceTypeIcon.propTypes = {
type: PropTypes.string,
alt: PropTypes.string,
};
================================================
FILE: client/app/pages/queries/components/QueryViewButton.jsx
================================================
import React, { useState, useMemo, useEffect } from "react";
import PropTypes from "prop-types";
import Button from "antd/lib/button";
import KeyboardShortcuts from "@/services/KeyboardShortcuts";
import { ButtonTooltip } from "@/components/queries/QueryEditor/QueryEditorControls";
export default function QueryViewButton({ title, shortcut, disabled, children, onClick, ...props }) {
const [tooltipVisible, setTooltipVisible] = useState(false);
const eventHandlers = useMemo(
() => ({
onMouseEnter: () => setTooltipVisible(true),
onMouseLeave: () => setTooltipVisible(false),
}),
[]
);
useEffect(() => {
if (disabled) {
setTooltipVisible(false);
}
}, [disabled]);
useEffect(() => {
if (shortcut) {
const shortcuts = {
[shortcut]: onClick,
};
KeyboardShortcuts.bind(shortcuts);
return () => {
KeyboardShortcuts.unbind(shortcuts);
};
}
}, [shortcut, onClick]);
return (
{children}
);
}
QueryViewButton.propTypes = {
className: PropTypes.string,
shortcut: PropTypes.string,
disabled: PropTypes.bool,
children: PropTypes.node,
onClick: PropTypes.func,
};
QueryViewButton.defaultProps = {
className: null,
shortcut: null,
disabled: false,
children: null,
onClick: () => {},
};
================================================
FILE: client/app/pages/queries/components/QueryVisualizationTabs.jsx
================================================
import React, { useState, useMemo, useCallback } from "react";
import PropTypes from "prop-types";
import cx from "classnames";
import { find, orderBy } from "lodash";
import useMedia from "use-media";
import Tabs from "antd/lib/tabs";
import Button from "antd/lib/button";
import Modal from "antd/lib/modal";
import VisualizationRenderer from "@/components/visualizations/VisualizationRenderer";
import PlainButton from "@/components/PlainButton";
import "./QueryVisualizationTabs.less";
const { TabPane } = Tabs;
function EmptyState({ title, message, refreshButton }) {
return (
{title}
{message}
{refreshButton}
);
}
EmptyState.propTypes = {
title: PropTypes.string.isRequired,
message: PropTypes.string.isRequired,
refreshButton: PropTypes.node,
};
EmptyState.defaultProps = {
refreshButton: null,
};
function TabWithDeleteButton({ visualizationName, canDelete, onDelete, ...props }) {
const handleDelete = useCallback(
(e) => {
e.stopPropagation();
Modal.confirm({
title: "Delete Visualization",
content: "Are you sure you want to delete this visualization?",
okText: "Delete",
okType: "danger",
onOk: onDelete,
maskClosable: true,
autoFocusButton: null,
});
},
[onDelete]
);
return (
{visualizationName}
{canDelete && (
)}
);
}
TabWithDeleteButton.propTypes = {
visualizationName: PropTypes.string.isRequired,
canDelete: PropTypes.bool,
onDelete: PropTypes.func,
};
TabWithDeleteButton.defaultProps = { canDelete: false, onDelete: () => {} };
const defaultVisualizations = [
{
type: "TABLE",
name: "Table",
id: null,
options: {},
},
];
export default function QueryVisualizationTabs({
queryResult,
selectedTab,
showNewVisualizationButton,
canDeleteVisualizations,
onChangeTab,
onAddVisualization,
onDeleteVisualization,
refreshButton,
canRefresh,
...props
}) {
const visualizations = useMemo(
() => (props.visualizations.length > 0 ? props.visualizations : defaultVisualizations),
[props.visualizations]
);
const tabsProps = {};
if (find(visualizations, { id: selectedTab })) {
tabsProps.activeKey = `${selectedTab}`;
}
if (showNewVisualizationButton) {
tabsProps.tabBarExtraContent = (
onAddVisualization()}
>
Add Visualization
);
}
const orderedVisualizations = useMemo(() => orderBy(visualizations, ["id"]), [visualizations]);
const isFirstVisualization = useCallback((visId) => visId === orderedVisualizations[0].id, [orderedVisualizations]);
const isMobile = useMedia({ maxWidth: 768 });
const [filters, setFilters] = useState([]);
return (
onChangeTab(+activeKey)}
destroyInactiveTabPane
>
{orderedVisualizations.map((visualization) => (
onDeleteVisualization(visualization.id)}
/>
}
>
{queryResult ? (
) : (
)}
))}
);
}
QueryVisualizationTabs.propTypes = {
queryResult: PropTypes.object, // eslint-disable-line react/forbid-prop-types
visualizations: PropTypes.arrayOf(PropTypes.object),
selectedTab: PropTypes.number,
showNewVisualizationButton: PropTypes.bool,
canDeleteVisualizations: PropTypes.bool,
onChangeTab: PropTypes.func,
onAddVisualization: PropTypes.func,
onDeleteVisualization: PropTypes.func,
refreshButton: PropTypes.node,
canRefresh: PropTypes.bool,
};
QueryVisualizationTabs.defaultProps = {
queryResult: null,
visualizations: [],
selectedTab: null,
showNewVisualizationButton: false,
canDeleteVisualizations: false,
onChangeTab: () => {},
onAddVisualization: () => {},
onDeleteVisualization: () => {},
refreshButton: null,
canRefresh: true,
};
================================================
FILE: client/app/pages/queries/components/QueryVisualizationTabs.less
================================================
.query-visualization-tabs {
.query-results-empty-state {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
padding: 15px;
.empty-state-content {
max-width: 280px;
text-align: center;
}
img {
max-width: 100%;
}
}
.ant-tabs-nav-wrap,
.ant-tabs-extra-content {
flex: initial !important;
}
.ant-tabs-nav-wrap {
z-index: 1;
}
.ant-tabs-tab {
background: #f6f8f9 !important;
border-color: #d9d9d9 !important;
border-bottom: 0px !important;
border-radius: 0 !important;
// border-width animation makes it flicker on Firefox
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), border-width 0s !important;
&:first-child {
border-radius: 2px 0 0 0 !important;
}
&:last-child {
border-radius: 0 2px 0 0 !important;
}
&:not(:first-child) {
margin-left: -1px !important;
}
&.ant-tabs-tab-active {
background: white !important;
font-weight: normal;
border-top: 2px solid #2196f3 !important;
.ant-tabs-tab-btn {
font-weight: normal;
}
}
// add internal bottom border to non-active tabs
&:not(.ant-tabs-tab-active) {
box-shadow: 0px -1px 0px #d9d9d9 inset;
}
}
.ant-tabs-content-holder {
margin-top: -17px;
border: 1px solid #d9d9d9;
box-sizing: border-box;
border-radius: 0px 4px 0px 0px;
.ant-tabs-tabpane {
padding: 16px;
background: white;
}
}
.add-visualization-button {
span {
color: #767676;
}
}
.delete-visualization-button {
height: 1.5rem;
width: 1.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: 5px;
color: #a09797;
font-size: 11px;
border-radius: 100%;
&:hover,
&:focus {
color: white;
background-color: #ff8080;
}
&:active {
filter: brightness(80%);
}
}
}
// hide delete button when it in the dropdown
.ant-tabs-dropdown-menu-item .delete-visualization-button {
display: none;
}
.query-fixed-layout .query-visualization-tabs .visualization-renderer {
padding: 15px;
}
================================================
FILE: client/app/pages/queries/components/wrapQueryPage.jsx
================================================
import React, { useState, useEffect } from "react";
import PropTypes from "prop-types";
import LoadingState from "@/components/items-list/components/LoadingState";
import { Query } from "@/services/query";
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
export default function wrapQueryPage(WrappedComponent) {
function QueryPageWrapper({ queryId, onError, ...props }) {
const [query, setQuery] = useState(null);
const handleError = useImmutableCallback(onError);
useEffect(() => {
let isCancelled = false;
const promise = queryId ? Query.get({ id: queryId }) : Promise.resolve(Query.newQuery());
promise
.then(result => {
if (!isCancelled) {
setQuery(result);
}
})
.catch(handleError);
return () => {
isCancelled = true;
};
}, [queryId, handleError]);
if (!query) {
return ;
}
return ;
}
QueryPageWrapper.propTypes = {
queryId: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
};
QueryPageWrapper.defaultProps = {
queryId: null,
};
return QueryPageWrapper;
}
================================================
FILE: client/app/pages/queries/hooks/useAddNewParameterDialog.js
================================================
import { map } from "lodash";
import { useCallback } from "react";
import EditParameterSettingsDialog from "@/components/EditParameterSettingsDialog";
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
export default function useAddNewParameterDialog(query, onParameterAdded) {
const handleParameterAdded = useImmutableCallback(onParameterAdded);
return useCallback(() => {
EditParameterSettingsDialog.showModal({
parameter: {
title: null,
name: "",
type: "text",
value: null,
},
existingParams: map(query.getParameters().get(), p => p.name),
}).onClose(param => {
const newQuery = query.clone();
param = newQuery.getParameters().add(param);
handleParameterAdded(newQuery, param);
});
}, [query, handleParameterAdded]);
}
================================================
FILE: client/app/pages/queries/hooks/useAddToDashboardDialog.js
================================================
import { find } from "lodash";
import { useCallback } from "react";
import AddToDashboardDialog from "@/components/queries/AddToDashboardDialog";
export default function useAddToDashboardDialog(query) {
return useCallback(
visualizationId => {
const visualization = find(query.visualizations, { id: visualizationId });
AddToDashboardDialog.showModal({ visualization });
},
[query.visualizations]
);
}
================================================
FILE: client/app/pages/queries/hooks/useAddVisualizationDialog.js
================================================
import { useState, useCallback, useEffect } from "react";
import useQueryFlags from "./useQueryFlags";
import useEditVisualizationDialog from "./useEditVisualizationDialog";
export default function useAddVisualizationDialog(query, queryResult, saveQuery, onChange) {
const queryFlags = useQueryFlags(query);
const editVisualization = useEditVisualizationDialog(query, queryResult, onChange);
const [shouldOpenDialog, setShouldOpenDialog] = useState(false);
useEffect(() => {
if (!queryFlags.isNew && shouldOpenDialog) {
setShouldOpenDialog(false);
editVisualization();
}
}, [queryFlags.isNew, shouldOpenDialog, editVisualization]);
return useCallback(() => {
if (queryFlags.isNew) {
setShouldOpenDialog(true);
saveQuery();
} else {
editVisualization();
}
}, [queryFlags.isNew, saveQuery, editVisualization]);
}
================================================
FILE: client/app/pages/queries/hooks/useApiKeyDialog.js
================================================
import { useCallback } from "react";
import ApiKeyDialog from "@/components/queries/ApiKeyDialog";
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
export default function useApiKeyDialog(query, onChange) {
const handleChange = useImmutableCallback(onChange);
return useCallback(() => {
ApiKeyDialog.showModal({ query }).onClose(handleChange);
}, [query, handleChange]);
}
================================================
FILE: client/app/pages/queries/hooks/useArchiveQuery.jsx
================================================
import { extend } from "lodash";
import React, { useCallback } from "react";
import Modal from "antd/lib/modal";
import { Query } from "@/services/query";
import notification from "@/services/notification";
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
function confirmArchive() {
return new Promise((resolve, reject) => {
Modal.confirm({
title: "Archive Query",
content: (
Are you sure you want to archive this query?
All alerts and dashboard widgets created with its visualizations will be deleted.
),
okText: "Archive",
okType: "danger",
onOk: () => {
resolve();
},
onCancel: () => {
reject();
},
maskClosable: true,
autoFocusButton: null,
});
});
}
function doArchiveQuery(query) {
return Query.delete({ id: query.id })
.then(() => {
return extend(query.clone(), { is_archived: true, schedule: null });
})
.catch(error => {
notification.error("Query could not be archived.");
return Promise.reject(error);
});
}
export default function useArchiveQuery(query, onChange) {
const handleChange = useImmutableCallback(onChange);
return useCallback(() => {
confirmArchive()
.then(() => doArchiveQuery(query))
.then(handleChange);
}, [query, handleChange]);
}
================================================
FILE: client/app/pages/queries/hooks/useAutoLimitFlags.js
================================================
import { useCallback, useState } from "react";
import localOptions from "@/lib/localOptions";
import { get, extend } from "lodash";
function isAutoLimitAvailable(dataSource) {
return get(dataSource, "supports_auto_limit", false);
}
export default function useAutoLimitFlags(dataSource, query, setQuery) {
const isAvailable = isAutoLimitAvailable(dataSource);
const [isChecked, setIsChecked] = useState(query.options.apply_auto_limit);
query.options.apply_auto_limit = isChecked;
const setAutoLimit = useCallback(
state => {
setIsChecked(state);
localOptions.set("applyAutoLimit", state);
setQuery(extend(query.clone(), { options: { ...query.options, apply_auto_limit: state } }));
},
[query, setQuery]
);
return [isAvailable, isChecked, setAutoLimit];
}
================================================
FILE: client/app/pages/queries/hooks/useAutocompleteFlags.js
================================================
import { useCallback, useMemo, useState } from "react";
import localOptions from "@/lib/localOptions";
export default function useAutocompleteFlags(schema) {
const isAvailable = true;
const [isEnabled, setIsEnabled] = useState(localOptions.get("liveAutocomplete", true));
const toggleAutocomplete = useCallback((state) => {
setIsEnabled(state);
localOptions.set("liveAutocomplete", state);
}, []);
return useMemo(() => [isAvailable, isEnabled, toggleAutocomplete], [isAvailable, isEnabled, toggleAutocomplete]);
}
================================================
FILE: client/app/pages/queries/hooks/useDataSourceSchema.js
================================================
import { useCallback, useEffect, useRef, useState, useMemo } from "react";
import DataSource from "@/services/data-source";
import notification from "@/services/notification";
function getSchema(dataSource, refresh = undefined) {
if (!dataSource) {
return Promise.resolve([]);
}
return DataSource.fetchSchema(dataSource, refresh).catch(() => {
notification.error("Schema refresh failed.", "Please try again later.");
return Promise.resolve([]);
});
}
export default function useDataSourceSchema(dataSource) {
const [schema, setSchema] = useState([]);
const [loadingSchema, setLoadingSchema] = useState(true);
const refreshSchemaTokenRef = useRef(null);
const reloadSchema = useCallback(
(refresh = undefined) => {
setLoadingSchema(true);
const refreshToken = Math.random()
.toString(36)
.substr(2);
refreshSchemaTokenRef.current = refreshToken;
getSchema(dataSource, refresh)
.then(data => {
if (refreshSchemaTokenRef.current === refreshToken) {
setSchema(data);
}
})
.finally(() => {
if (refreshSchemaTokenRef.current === refreshToken) {
setLoadingSchema(false);
}
});
},
[dataSource]
);
useEffect(() => {
reloadSchema();
}, [reloadSchema]);
useEffect(() => {
return () => {
// cancel pending operations
refreshSchemaTokenRef.current = null;
};
}, []);
return useMemo(() => [schema, loadingSchema, reloadSchema], [schema, loadingSchema, reloadSchema]);
}
================================================
FILE: client/app/pages/queries/hooks/useDeleteVisualization.js
================================================
import { extend, filter } from "lodash";
import { useCallback } from "react";
import Visualization from "@/services/visualization";
import notification from "@/services/notification";
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
export default function useDeleteVisualization(query, onChange) {
const handleChange = useImmutableCallback(onChange);
return useCallback(
visualizationId =>
Visualization.delete({ id: visualizationId })
.then(() => {
const filteredVisualizations = filter(query.visualizations, v => v.id !== visualizationId);
handleChange(extend(query.clone(), { visualizations: filteredVisualizations }));
})
.catch(() => {
notification.error("Error deleting visualization.", "Maybe it's used in a dashboard?");
}),
[query, handleChange]
);
}
================================================
FILE: client/app/pages/queries/hooks/useDuplicateQuery.js
================================================
import { noop, extend, pick } from "lodash";
import { useCallback, useState } from "react";
import url from "url";
import qs from "query-string";
import { Query } from "@/services/query";
function keepCurrentUrlParams(targetUrl) {
const currentUrlParams = qs.parse(window.location.search);
targetUrl = url.parse(targetUrl);
const targetUrlParams = qs.parse(targetUrl.search);
return url.format(
extend(pick(targetUrl, ["protocol", "auth", "host", "pathname", "hash"]), {
search: qs.stringify(extend(currentUrlParams, targetUrlParams)),
})
);
}
export default function useDuplicateQuery(query) {
const [isDuplicating, setIsDuplicating] = useState(false);
const duplicateQuery = useCallback(() => {
// To prevent opening the same tab, name must be unique for each browser
const tabName = `duplicatedQueryTab/${Math.random().toString()}`;
// We should open tab here because this moment is a part of user interaction;
// later browser will block such attempts
const tab = window.open("", tabName);
setIsDuplicating(true);
Query.fork({ id: query.id })
.then(newQuery => {
tab.location = keepCurrentUrlParams(newQuery.getUrl(true));
})
.finally(() => {
setIsDuplicating(false);
});
}, [query.id]);
return [isDuplicating, isDuplicating ? noop : duplicateQuery];
}
================================================
FILE: client/app/pages/queries/hooks/useEditScheduleDialog.js
================================================
import { isArray, intersection } from "lodash";
import { useCallback } from "react";
import ScheduleDialog from "@/components/queries/ScheduleDialog";
import { clientConfig } from "@/services/auth";
import { policy } from "@/services/policy";
import useUpdateQuery from "./useUpdateQuery";
import useQueryFlags from "./useQueryFlags";
import recordEvent from "@/services/recordEvent";
export default function useEditScheduleDialog(query, onChange) {
// We won't use flags that depend on data source
const queryFlags = useQueryFlags(query);
const updateQuery = useUpdateQuery(query, onChange);
return useCallback(() => {
if (!queryFlags.canEdit || !queryFlags.canSchedule) {
return;
}
const intervals = clientConfig.queryRefreshIntervals;
const allowedIntervals = policy.getQueryRefreshIntervals();
const refreshOptions = isArray(allowedIntervals) ? intersection(intervals, allowedIntervals) : intervals;
ScheduleDialog.showModal({
schedule: query.schedule,
refreshOptions,
}).onClose(schedule => {
recordEvent("edit_schedule", "query", query.id);
updateQuery({ schedule });
});
}, [query.id, query.schedule, queryFlags.canEdit, queryFlags.canSchedule, updateQuery]);
}
================================================
FILE: client/app/pages/queries/hooks/useEditVisualizationDialog.js
================================================
import { extend, filter, find } from "lodash";
import { useCallback } from "react";
import EditVisualizationDialog from "@/components/visualizations/EditVisualizationDialog";
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
export default function useEditVisualizationDialog(query, queryResult, onChange) {
const handleChange = useImmutableCallback(onChange);
return useCallback(
(visualizationId = null) => {
const visualization = find(query.visualizations, { id: visualizationId }) || null;
EditVisualizationDialog.showModal({
query,
visualization,
queryResult,
}).onClose(updatedVisualization => {
const filteredVisualizations = filter(query.visualizations, v => v.id !== updatedVisualization.id);
handleChange(
extend(query.clone(), { visualizations: [...filteredVisualizations, updatedVisualization] }),
updatedVisualization
);
});
},
[query, queryResult, handleChange]
);
}
================================================
FILE: client/app/pages/queries/hooks/useEmbedDialog.js
================================================
import { find } from "lodash";
import { useCallback } from "react";
import EmbedQueryDialog from "@/components/queries/EmbedQueryDialog";
export default function useEmbedDialog(query) {
return useCallback(
(unusedQuery, visualizationId) => {
const visualization = find(query.visualizations, { id: visualizationId });
EmbedQueryDialog.showModal({ query, visualization });
},
[query]
);
}
================================================
FILE: client/app/pages/queries/hooks/usePermissionsEditorDialog.js
================================================
import { useCallback } from "react";
import PermissionsEditorDialog from "@/components/PermissionsEditorDialog";
export default function usePermissionsEditorDialog(query) {
return useCallback(() => {
PermissionsEditorDialog.showModal({
aclUrl: `api/queries/${query.id}/acl`,
context: "query",
author: query.user,
});
}, [query.id, query.user]);
}
================================================
FILE: client/app/pages/queries/hooks/usePublishQuery.js
================================================
import { useCallback } from "react";
import useUpdateQuery from "./useUpdateQuery";
import recordEvent from "@/services/recordEvent";
export default function usePublishQuery(query, onChange) {
const updateQuery = useUpdateQuery(query, onChange);
return useCallback(() => {
recordEvent("toggle_published", "query", query.id);
updateQuery({ is_draft: false });
}, [query.id, updateQuery]);
}
================================================
FILE: client/app/pages/queries/hooks/useQuery.js
================================================
import { isEmpty } from "lodash";
import { useState, useMemo } from "react";
import useUpdateQuery from "./useUpdateQuery";
import navigateTo from "@/components/ApplicationArea/navigateTo";
export default function useQuery(originalQuery) {
const [query, setQuery] = useState(originalQuery);
const [originalQuerySource, setOriginalQuerySource] = useState(originalQuery.query);
const [originalAutoLimit, setOriginalAutoLimit] = useState(query.options.apply_auto_limit);
const updateQuery = useUpdateQuery(query, updatedQuery => {
// It's important to update URL first, and only then update state
if (updatedQuery.id !== query.id) {
// Don't reload page when saving new query
navigateTo(updatedQuery.getUrl(true), true);
}
setQuery(updatedQuery);
setOriginalQuerySource(updatedQuery.query);
setOriginalAutoLimit(updatedQuery.options.apply_auto_limit);
});
return useMemo(
() => ({
query,
setQuery,
isDirty:
query.query !== originalQuerySource ||
(!isEmpty(query.query) && query.options.apply_auto_limit !== originalAutoLimit),
saveQuery: () => updateQuery(),
}),
[query, originalQuerySource, updateQuery, originalAutoLimit]
);
}
================================================
FILE: client/app/pages/queries/hooks/useQueryDataSources.js
================================================
import { filter, find, toString } from "lodash";
import { useState, useMemo, useEffect } from "react";
import DataSource from "@/services/data-source";
export default function useQueryDataSources(query) {
const [allDataSources, setAllDataSources] = useState([]);
const [dataSourcesLoaded, setDataSourcesLoaded] = useState(false);
const dataSources = useMemo(() => filter(allDataSources, ds => !ds.view_only || ds.id === query.data_source_id), [
allDataSources,
query.data_source_id,
]);
const dataSource = useMemo(
() => find(dataSources, ds => toString(ds.id) === toString(query.data_source_id)) || null,
[query.data_source_id, dataSources]
);
useEffect(() => {
let cancelDataSourceLoading = false;
DataSource.query().then(data => {
if (!cancelDataSourceLoading) {
setDataSourcesLoaded(true);
setAllDataSources(data);
}
});
return () => {
cancelDataSourceLoading = true;
};
}, []);
return useMemo(() => ({ dataSourcesLoaded, dataSources, dataSource }), [dataSourcesLoaded, dataSources, dataSource]);
}
================================================
FILE: client/app/pages/queries/hooks/useQueryExecute.js
================================================
import { useReducer, useEffect, useRef } from "react";
import location from "@/services/location";
import recordEvent from "@/services/recordEvent";
import { ExecutionStatus } from "@/services/query-result";
import notifications from "@/services/notifications";
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
function getMaxAge() {
const { maxAge } = location.search;
return maxAge !== undefined ? maxAge : -1;
}
const reducer = (prevState, updatedProperty) => ({
...prevState,
...updatedProperty,
});
// This is currently specific to a Query page, we can refactor
// it slightly to make it suitable for dashboard widgets instead of the other solution it
// has in there.
export default function useQueryExecute(query) {
const [executionState, setExecutionState] = useReducer(reducer, {
queryResult: null,
isExecuting: false,
loadedInitialResults: false,
executionStatus: null,
isCancelling: false,
cancelCallback: null,
error: null,
});
const queryResultInExecution = useRef(null);
// Clear executing queryResult when component is unmounted to avoid errors
useEffect(() => {
return () => {
queryResultInExecution.current = null;
};
}, []);
const executeQuery = useImmutableCallback((maxAge = 0, queryExecutor) => {
let newQueryResult;
if (queryExecutor) {
newQueryResult = queryExecutor();
} else {
newQueryResult = query.getQueryResult(maxAge);
}
recordEvent("execute", "query", query.id);
notifications.getPermissions();
queryResultInExecution.current = newQueryResult;
setExecutionState({
updatedAt: newQueryResult.getUpdatedAt(),
executionStatus: newQueryResult.getStatus(),
isExecuting: true,
cancelCallback: () => {
recordEvent("cancel_execute", "query", query.id);
setExecutionState({ isCancelling: true });
newQueryResult.cancelExecution();
},
});
const onStatusChange = status => {
if (queryResultInExecution.current === newQueryResult) {
setExecutionState({ updatedAt: newQueryResult.getUpdatedAt(), executionStatus: status });
}
};
newQueryResult
.toPromise(onStatusChange)
.then(queryResult => {
if (queryResultInExecution.current === newQueryResult) {
// TODO: this should probably belong in the QueryEditor page.
if (queryResult && queryResult.query_result.query === query.query) {
query.latest_query_data_id = queryResult.getId();
query.queryResult = queryResult;
}
if (executionState.loadedInitialResults) {
notifications.showNotification("Redash", `${query.name} updated.`);
}
setExecutionState({
queryResult,
loadedInitialResults: true,
error: null,
isExecuting: false,
isCancelling: false,
executionStatus: null,
});
}
})
.catch(queryResult => {
if (queryResultInExecution.current === newQueryResult) {
if (executionState.loadedInitialResults) {
notifications.showNotification("Redash", `${query.name} failed to run: ${queryResult.getError()}`);
}
setExecutionState({
queryResult,
loadedInitialResults: true,
error: queryResult.getError(),
isExecuting: false,
isCancelling: false,
executionStatus: ExecutionStatus.FAILED,
});
}
});
});
const queryRef = useRef(query);
queryRef.current = query;
useEffect(() => {
// TODO: this belongs on the query page?
// loadedInitialResults can be removed if so
if (queryRef.current.hasResult() || queryRef.current.paramsRequired()) {
executeQuery(getMaxAge());
} else {
setExecutionState({ loadedInitialResults: true });
}
}, [executeQuery]);
return { ...executionState, ...{ executeQuery } };
}
================================================
FILE: client/app/pages/queries/hooks/useQueryFlags.js
================================================
import { isNil, isEmpty } from "lodash";
import { useMemo } from "react";
import { currentUser } from "@/services/auth";
import { policy } from "@/services/policy";
export default function useQueryFlags(query, dataSource = null) {
dataSource = dataSource || { view_only: true };
return useMemo(
() => ({
// state flags
isNew: isNil(query.id),
isDraft: query.is_draft,
isArchived: query.is_archived,
// permissions flags
canCreate: currentUser.hasPermission("create_query"),
canView: currentUser.hasPermission("view_query"),
canEdit: currentUser.hasPermission("edit_query") && policy.canEdit(query),
canViewSource: currentUser.hasPermission("view_source"),
canExecute:
!isEmpty(query.query) &&
policy.canRun(query) &&
(query.is_safe || (currentUser.hasPermission("execute_query") && !dataSource.view_only)),
canFork: currentUser.hasPermission("edit_query") && !dataSource.view_only,
canSchedule: currentUser.hasPermission("schedule_query"),
}),
[query, dataSource.view_only]
);
}
================================================
FILE: client/app/pages/queries/hooks/useQueryParameters.js
================================================
import { isUndefined } from "lodash";
import { useEffect, useMemo, useState, useCallback } from "react";
export default function useQueryParameters(query) {
const parameters = useMemo(() => query.getParametersDefs(), [query]);
const [dirtyFlag, setDirtyFlag] = useState(query.getParameters().hasPendingValues());
const updateDirtyFlag = useCallback(
flag => {
flag = isUndefined(flag) ? query.getParameters().hasPendingValues() : flag;
setDirtyFlag(flag);
},
[query]
);
useEffect(() => {
const updatedDirtyParameters = query.getParameters().hasPendingValues();
if (updatedDirtyParameters !== dirtyFlag) {
setDirtyFlag(updatedDirtyParameters);
}
}, [query, parameters, dirtyFlag]);
return useMemo(() => [parameters, dirtyFlag, updateDirtyFlag], [parameters, dirtyFlag, updateDirtyFlag]);
}
================================================
FILE: client/app/pages/queries/hooks/useRenameQuery.js
================================================
import { useCallback } from "react";
import useUpdateQuery from "./useUpdateQuery";
import recordEvent from "@/services/recordEvent";
import { clientConfig } from "@/services/auth";
export default function useRenameQuery(query, onChange) {
const updateQuery = useUpdateQuery(query, onChange);
return useCallback(
name => {
recordEvent("edit_name", "query", query.id);
const changes = { name };
const options = {};
if (query.is_draft && clientConfig.autoPublishNamedQueries && name !== "New Query") {
changes.is_draft = false;
options.successMessage = "Query saved and published";
}
updateQuery(changes, options);
},
[query.id, query.is_draft, updateQuery]
);
}
================================================
FILE: client/app/pages/queries/hooks/useUnpublishQuery.js
================================================
import { useCallback } from "react";
import useUpdateQuery from "./useUpdateQuery";
import recordEvent from "@/services/recordEvent";
export default function useUnpublishQuery(query, onChange) {
const updateQuery = useUpdateQuery(query, onChange);
return useCallback(() => {
recordEvent("toggle_published", "query", query.id);
updateQuery({ is_draft: true });
}, [query.id, updateQuery]);
}
================================================
FILE: client/app/pages/queries/hooks/useUnsavedChangesAlert.js
================================================
import { useRef, useEffect } from "react";
import location from "@/services/location";
export default function useUnsavedChangesAlert(shouldShowAlert = false) {
const shouldShowAlertRef = useRef();
shouldShowAlertRef.current = shouldShowAlert;
useEffect(() => {
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 savedOnBeforeUnload = window.onbeforeunload;
window.onbeforeunload = function onbeforeunload() {
return shouldShowAlertRef.current ? unloadMessage : undefined;
};
const unsubscribe = location.confirmChange((nextLocation, currentLocation) => {
if (shouldShowAlertRef.current && nextLocation.path !== currentLocation.path) {
return confirmMessage;
}
});
return () => {
window.onbeforeunload = savedOnBeforeUnload;
unsubscribe();
};
}, []);
}
================================================
FILE: client/app/pages/queries/hooks/useUpdateQuery.jsx
================================================
import { isNil, isObject, extend, keys, map, omit, pick, uniq, get } from "lodash";
import React, { useCallback } from "react";
import Modal from "antd/lib/modal";
import { Query } from "@/services/query";
import notification from "@/services/notification";
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
import { policy } from "@/services/policy";
class SaveQueryError extends Error {
constructor(message, detailedMessage = null) {
super(message);
this.detailedMessage = detailedMessage;
}
}
class SaveQueryConflictError extends SaveQueryError {
constructor() {
super(
"Changes not saved",
It seems like the query has been modified by another user.
Please copy/backup your changes and reload this page.
);
}
}
function confirmOverwrite() {
return new Promise((resolve, reject) => {
Modal.confirm({
title: "Overwrite Query",
content: (
It seems like the query has been modified by another user.
Are you sure you want to overwrite the query with your version?
),
okText: "Overwrite",
okType: "danger",
onOk: () => {
resolve();
},
onCancel: () => {
reject();
},
maskClosable: true,
autoFocusButton: null,
});
});
}
function doSaveQuery(data, { canOverwrite = false } = {}) {
// omit parameter properties that don't need to be stored
if (isObject(data.options) && data.options.parameters) {
data.options = {
...data.options,
parameters: map(data.options.parameters, p => p.toSaveableObject()),
};
}
return Query.save(data).catch(error => {
if (get(error, "response.status") === 409) {
if (canOverwrite) {
return confirmOverwrite()
.then(() => Query.save(omit(data, ["version"])))
.catch(() => Promise.reject(new SaveQueryConflictError()));
}
return Promise.reject(new SaveQueryConflictError());
}
return Promise.reject(new SaveQueryError("Query could not be saved"));
});
}
export default function useUpdateQuery(query, onChange) {
const handleChange = useImmutableCallback(onChange);
return useCallback(
(data = null, { successMessage = "Query saved" } = {}) => {
if (isObject(data)) {
// Don't save new query with partial data
if (query.isNew()) {
handleChange(extend(query.clone(), data));
return;
}
data = { ...data, id: query.id, version: query.version };
} else {
data = pick(query, [
"id",
"version",
"schedule",
"query",
"description",
"name",
"data_source_id",
"options",
"latest_query_data_id",
"is_draft",
"tags",
]);
}
return doSaveQuery(data, { canOverwrite: policy.canEdit(query) })
.then(updatedQuery => {
if (!isNil(successMessage)) {
notification.success(successMessage);
}
handleChange(
extend(
query.clone(),
// if server returned completely new object (currently possible only when saving new query) -
// update all fields; otherwise pick only changed fields
updatedQuery.id !== query.id ? updatedQuery : pick(updatedQuery, uniq(["id", "version", ...keys(data)]))
)
);
})
.catch(error => {
const notificationOptions = {};
if (error instanceof SaveQueryConflictError) {
notificationOptions.duration = null;
}
notification.error(error.message, error.detailedMessage, notificationOptions);
});
},
[query, handleChange]
);
}
================================================
FILE: client/app/pages/queries/hooks/useUpdateQueryDescription.js
================================================
import { useCallback } from "react";
import useUpdateQuery from "./useUpdateQuery";
import recordEvent from "@/services/recordEvent";
export default function useUpdateQueryDescription(query, onChange) {
const updateQuery = useUpdateQuery(query, onChange);
return useCallback(
description => {
recordEvent("edit_description", "query", query.id);
updateQuery({ description });
},
[query.id, updateQuery]
);
}
================================================
FILE: client/app/pages/queries/hooks/useUpdateQueryTags.js
================================================
import { useCallback } from "react";
import useUpdateQuery from "./useUpdateQuery";
import recordEvent from "@/services/recordEvent";
export default function useUpdateQueryTags(query, onChange) {
const updateQuery = useUpdateQuery(query, onChange);
return useCallback(
tags => {
recordEvent("edit_tags", "query", query.id);
updateQuery({ tags });
},
[query.id, updateQuery]
);
}
================================================
FILE: client/app/pages/queries/hooks/useVisualizationTabHandler.js
================================================
import { useState, useEffect, useMemo } from "react";
import { first, orderBy, find } from "lodash";
import location from "@/services/location";
export default function useVisualizationTabHandler(visualizations) {
const firstVisualization = useMemo(() => first(orderBy(visualizations, ["id"])) || {}, [visualizations]);
const [selectedTab, setSelectedTab] = useState(+location.hash || firstVisualization.id);
useEffect(() => {
const hashValue = selectedTab !== firstVisualization.id ? `${selectedTab}` : null;
if (location.hash !== hashValue) {
location.setHash(hashValue);
}
const unlisten = location.listen(() => {
if (location.hash !== hashValue) {
setSelectedTab(+location.hash || firstVisualization.id);
}
});
return unlisten;
}, [firstVisualization.id, selectedTab]);
// make sure selectedTab is in visualizations
useEffect(() => {
if (!find(visualizations, { id: selectedTab })) {
setSelectedTab(firstVisualization.id);
}
}, [firstVisualization.id, selectedTab, visualizations]);
return useMemo(() => [selectedTab, setSelectedTab], [selectedTab]);
}
================================================
FILE: client/app/pages/queries-list/QueriesList.jsx
================================================
import React, { useCallback, useEffect, useRef } from "react";
import cx from "classnames";
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
import Link from "@/components/Link";
import PageHeader from "@/components/PageHeader";
import Paginator from "@/components/Paginator";
import DynamicComponent from "@/components/DynamicComponent";
import { QueryTagsControl } from "@/components/tags-control/TagsControl";
import SchedulePhrase from "@/components/queries/SchedulePhrase";
import { wrap as itemsList, ControllerType } from "@/components/items-list/ItemsList";
import useItemsListExtraActions from "@/components/items-list/hooks/useItemsListExtraActions";
import { ResourceItemsSource } from "@/components/items-list/classes/ItemsSource";
import { UrlStateStorage } from "@/components/items-list/classes/StateStorage";
import * as Sidebar from "@/components/items-list/components/Sidebar";
import ItemsTable, { Columns } from "@/components/items-list/components/ItemsTable";
import Layout from "@/components/layouts/ContentWithSidebar";
import { Query } from "@/services/query";
import { clientConfig, currentUser } from "@/services/auth";
import location from "@/services/location";
import routes from "@/services/routes";
import QueriesListEmptyState from "./QueriesListEmptyState";
import "./queries-list.css";
const sidebarMenu = [
{
key: "all",
href: "queries",
title: "All Queries",
icon: () => ,
},
{
key: "my",
href: "queries/my",
title: "My Queries",
icon: () => ,
},
{
key: "favorites",
href: "queries/favorites",
title: "Favorites",
icon: () => ,
},
{
key: "archive",
href: "queries/archive",
title: "Archived",
icon: () => ,
},
];
const listColumns = [
Columns.favorites({ className: "p-r-0" }),
Columns.custom.sortable(
(text, item) => (
{item.name}
),
{
title: "Name",
field: "name",
width: null,
}
),
Columns.custom((text, item) => item.user.name, { title: "Created By", width: "1%" }),
Columns.dateTime.sortable({ title: "Created At", field: "created_at", width: "1%" }),
Columns.dateTime.sortable({
title: "Last Executed At",
field: "retrieved_at",
orderByField: "executed_at",
width: "1%",
}),
Columns.custom.sortable((text, item) => , {
title: "Refresh Schedule",
field: "schedule",
width: "1%",
}),
];
function QueriesListExtraActions(props) {
return ;
}
function QueriesList({ controller }) {
const controllerRef = useRef();
controllerRef.current = controller;
const updateSearch = useCallback(
(searchTemm) => {
controller.updateSearch(searchTemm, { isServerSideFTS: !clientConfig.multiByteSearchEnabled });
},
[controller]
);
useEffect(() => {
const unlistenLocationChanges = location.listen((unused, action) => {
const searchTerm = location.search.q || "";
if (action === "PUSH" && searchTerm !== controllerRef.current.searchTerm) {
updateSearch(searchTerm);
}
});
return () => {
unlistenLocationChanges();
};
}, [updateSearch]);
let usedListColumns = listColumns;
if (controller.params.currentPage === "favorites") {
usedListColumns = [
...usedListColumns,
Columns.dateTime.sortable({ title: "Starred At", field: "starred_at", width: "1%" }),
];
}
const {
areExtraActionsAvailable,
listColumns: tableColumns,
Component: ExtraActionsComponent,
selectedItems,
} = useItemsListExtraActions(controller, usedListColumns, QueriesListExtraActions);
return (
New Query
) : null
}
/>
{controller.isLoaded && controller.isEmpty ? (
) : (
controller.updatePagination({ itemsPerPage })}
page={controller.page}
onChange={(page) => controller.updatePagination({ page })}
/>
)}
);
}
QueriesList.propTypes = {
controller: ControllerType.isRequired,
};
const QueriesListPage = itemsList(
QueriesList,
() =>
new ResourceItemsSource({
getResource({ params: { currentPage } }) {
return {
all: Query.query.bind(Query),
my: Query.myQueries.bind(Query),
favorites: Query.favorites.bind(Query),
archive: Query.archive.bind(Query),
}[currentPage];
},
getItemProcessor() {
return (item) => new Query(item);
},
}),
({ ...props }) => new UrlStateStorage({ orderByField: props.orderByField ?? "created_at", orderByReverse: true })
);
routes.register(
"Queries.List",
routeWithUserSession({
path: "/queries",
title: "Queries",
render: (pageProps) => ,
})
);
routes.register(
"Queries.Favorites",
routeWithUserSession({
path: "/queries/favorites",
title: "Favorite Queries",
render: (pageProps) => ,
})
);
routes.register(
"Queries.Archived",
routeWithUserSession({
path: "/queries/archive",
title: "Archived Queries",
render: (pageProps) => ,
})
);
routes.register(
"Queries.My",
routeWithUserSession({
path: "/queries/my",
title: "My Queries",
render: (pageProps) => ,
})
);
================================================
FILE: client/app/pages/queries-list/QueriesListEmptyState.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
import Link from "@/components/Link";
import BigMessage from "@/components/BigMessage";
import NoTaggedObjectsFound from "@/components/NoTaggedObjectsFound";
import EmptyState, { EmptyStateHelpMessage } from "@/components/empty-state/EmptyState";
import DynamicComponent from "@/components/DynamicComponent";
import { currentUser } from "@/services/auth";
import HelpTrigger from "@/components/HelpTrigger";
export default function QueriesListEmptyState({ page, searchTerm, selectedTags }) {
if (searchTerm !== "") {
return ;
}
if (selectedTags.length > 0) {
return ;
}
switch (page) {
case "favorites":
return ;
case "archive":
return ;
case "my":
const my_msg = currentUser.hasPermission("create_query") ? (
Create your first query!
{" "}
Need help?
) : (
Sorry, we couldn't find anything.
);
return {my_msg} ;
default:
return (
}
/>
);
}
}
QueriesListEmptyState.propTypes = {
page: PropTypes.string.isRequired,
searchTerm: PropTypes.string.isRequired,
selectedTags: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
};
================================================
FILE: client/app/pages/queries-list/queries-list.css
================================================
.search input[type="text"],
.search button {
height: 35px;
}
/* same rule as for sidebar */
@media (max-width: 990px) {
.page-queries-list .page-header-actions {
width: auto;
}
}
================================================
FILE: client/app/pages/query-snippets/QuerySnippetsList.jsx
================================================
import { get } from "lodash";
import React from "react";
import Button from "antd/lib/button";
import Modal from "antd/lib/modal";
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
import navigateTo from "@/components/ApplicationArea/navigateTo";
import Paginator from "@/components/Paginator";
import QuerySnippetDialog from "@/components/query-snippets/QuerySnippetDialog";
import { wrap as itemsList, ControllerType } from "@/components/items-list/ItemsList";
import { ResourceItemsSource } from "@/components/items-list/classes/ItemsSource";
import { StateStorage } from "@/components/items-list/classes/StateStorage";
import LoadingState from "@/components/items-list/components/LoadingState";
import ItemsTable, { Columns } from "@/components/items-list/components/ItemsTable";
import wrapSettingsTab from "@/components/SettingsWrapper";
import PlainButton from "@/components/PlainButton";
import QuerySnippet from "@/services/query-snippet";
import { currentUser } from "@/services/auth";
import { policy } from "@/services/policy";
import notification from "@/services/notification";
import routes from "@/services/routes";
import "./QuerySnippetsList.less";
const canEditQuerySnippet = querySnippet => currentUser.isAdmin || currentUser.id === get(querySnippet, "user.id");
class QuerySnippetsList extends React.Component {
static propTypes = {
controller: ControllerType.isRequired,
};
listColumns = [
Columns.custom.sortable(
(text, querySnippet) => (
this.showSnippetDialog(querySnippet)}>
{querySnippet.trigger}
),
{
title: "Trigger",
field: "trigger",
className: "text-nowrap",
}
),
Columns.custom.sortable(text => text, {
title: "Description",
field: "description",
className: "text-nowrap",
}),
Columns.custom(snippet => {snippet}, {
title: "Snippet",
field: "snippet",
}),
Columns.avatar({ field: "user", className: "p-l-0 p-r-0" }, name => `Created by ${name}`),
Columns.date.sortable({
title: "Created At",
field: "created_at",
className: "text-nowrap",
width: "1%",
}),
Columns.custom(
(text, querySnippet) =>
canEditQuerySnippet(querySnippet) && (
this.deleteQuerySnippet(e, querySnippet)}>
Delete
),
{
width: "1%",
}
),
];
componentDidMount() {
const { isNewOrEditPage, querySnippetId } = this.props.controller.params;
if (isNewOrEditPage) {
if (querySnippetId === "new") {
if (policy.isCreateQuerySnippetEnabled()) {
this.showSnippetDialog();
} else {
navigateTo("query_snippets", true);
}
} else {
QuerySnippet.get({ id: querySnippetId })
.then(this.showSnippetDialog)
.catch(error => {
this.props.controller.handleError(error);
});
}
}
}
saveQuerySnippet = querySnippet => {
const saveSnippet = querySnippet.id ? QuerySnippet.save : QuerySnippet.create;
return saveSnippet(querySnippet);
};
deleteQuerySnippet = (event, querySnippet) => {
Modal.confirm({
title: "Delete Query Snippet",
content: "Are you sure you want to delete this query snippet?",
okText: "Yes",
okType: "danger",
cancelText: "No",
onOk: () => {
QuerySnippet.delete(querySnippet)
.then(() => {
notification.success("Query snippet deleted successfully.");
this.props.controller.update();
})
.catch(() => {
notification.error("Failed deleting query snippet.");
});
},
});
};
showSnippetDialog = (querySnippet = null) => {
const canSave = !querySnippet || canEditQuerySnippet(querySnippet);
navigateTo("query_snippets/" + get(querySnippet, "id", "new"), true);
const goToSnippetsList = () => navigateTo("query_snippets", true);
QuerySnippetDialog.showModal({
querySnippet,
readOnly: !canSave,
})
.onClose(querySnippet =>
this.saveQuerySnippet(querySnippet).then(() => {
this.props.controller.update();
goToSnippetsList();
})
)
.onDismiss(goToSnippetsList);
};
render() {
const { controller } = this.props;
return (
this.showSnippetDialog()}
disabled={!policy.isCreateQuerySnippetEnabled()}>
New Query Snippet
{!controller.isLoaded &&
}
{controller.isLoaded && controller.isEmpty && (
There are no query snippets yet.
{policy.isCreateQuerySnippetEnabled() && (
this.showSnippetDialog()}>
Click here
{" "}
to add one.
)}
)}
{controller.isLoaded && !controller.isEmpty && (
controller.updatePagination({ itemsPerPage })}
page={controller.page}
onChange={page => controller.updatePagination({ page })}
/>
)}
);
}
}
const QuerySnippetsListPage = wrapSettingsTab(
"QuerySnippets.List",
{
permission: "create_query",
title: "Query Snippets",
path: "query_snippets",
order: 5,
},
itemsList(
QuerySnippetsList,
() =>
new ResourceItemsSource({
isPlainList: true,
getRequest() {
return {};
},
getResource() {
return QuerySnippet.query.bind(QuerySnippet);
},
}),
() => new StateStorage({ orderByField: "trigger", itemsPerPage: 10 })
)
);
routes.register(
"QuerySnippets.List",
routeWithUserSession({
path: "/query_snippets",
title: "Query Snippets",
render: pageProps => ,
})
);
routes.register(
"QuerySnippets.NewOrEdit",
routeWithUserSession({
path: "/query_snippets/:querySnippetId",
title: "Query Snippets",
render: pageProps => ,
})
);
================================================
FILE: client/app/pages/query-snippets/QuerySnippetsList.less
================================================
.snippet-content {
max-width: 500px;
max-height: 56px;
overflow: hidden;
white-space: pre-wrap;
/* autoprefixer: off */
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.query-snippets-table {
table {
height: 1px;
}
.ant-table-row {
height: 100%;
}
.ant-table-cell {
height: 100%;
& > .table-main-title {
display: inline-flex;
align-items: center;
height: 100%;
width: 100%;
}
}
}
================================================
FILE: client/app/pages/settings/OrganizationSettings.jsx
================================================
import React from "react";
import PropTypes from "prop-types";
import Button from "antd/lib/button";
import Form from "antd/lib/form";
import Skeleton from "antd/lib/skeleton";
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
import wrapSettingsTab from "@/components/SettingsWrapper";
import routes from "@/services/routes";
import { getHorizontalFormProps, getHorizontalFormItemWithoutLabelProps } from "@/styles/formStyle";
import useOrganizationSettings from "./hooks/useOrganizationSettings";
import GeneralSettings from "./components/GeneralSettings";
import AuthSettings from "./components/AuthSettings";
function OrganizationSettings({ onError }) {
const { settings, currentValues, isLoading, isSaving, handleSubmit, handleChange } = useOrganizationSettings(onError);
return (
{isLoading ? (
) : (
Save
)}
);
}
OrganizationSettings.propTypes = {
onError: PropTypes.func,
};
OrganizationSettings.defaultProps = {
onError: () => {},
};
const OrganizationSettingsPage = wrapSettingsTab(
"Settings.Organization",
{
permission: "admin",
title: "General",
path: "settings/general",
order: 6,
},
OrganizationSettings
);
routes.register(
"Settings.Organization",
routeWithUserSession({
path: "/settings/general",
title: "General Settings",
render: pageProps => ,
})
);
================================================
FILE: client/app/pages/settings/components/AuthSettings/GoogleLoginSettings.jsx
================================================
import { isEmpty, join } from "lodash";
import React from "react";
import Form from "antd/lib/form";
import Select from "antd/lib/select";
import Alert from "antd/lib/alert";
import DynamicComponent from "@/components/DynamicComponent";
import { clientConfig } from "@/services/auth";
import { SettingsEditorPropTypes, SettingsEditorDefaultProps } from "../prop-types";
export default function GoogleLoginSettings(props) {
const { values, onChange } = props;
if (!clientConfig.googleLoginEnabled) {
return null;
}
return (
Google Login
onChange({ auth_google_apps_domains: value })}
/>
{!isEmpty(values.auth_google_apps_domains) && (
Any user registered with a {join(values.auth_google_apps_domains, ", ")} Google Apps
account will be able to login. If they don't have an existing user, a new user will be created and join
the Default group.
}
className="m-t-15"
/>
)}
);
}
GoogleLoginSettings.propTypes = SettingsEditorPropTypes;
GoogleLoginSettings.defaultProps = SettingsEditorDefaultProps;
================================================
FILE: client/app/pages/settings/components/AuthSettings/PasswordLoginSettings.jsx
================================================
import React from "react";
import Alert from "antd/lib/alert";
import Form from "antd/lib/form";
import Checkbox from "antd/lib/checkbox";
import Tooltip from "@/components/Tooltip";
import Skeleton from "antd/lib/skeleton";
import DynamicComponent from "@/components/DynamicComponent";
import { clientConfig } from "@/services/auth";
import { SettingsEditorPropTypes, SettingsEditorDefaultProps } from "../prop-types";
export default function PasswordLoginSettings(props) {
const { settings, values, onChange, loading } = props;
const isTheOnlyAuthMethod =
!clientConfig.googleLoginEnabled && !clientConfig.ldapLoginEnabled && !values.auth_saml_enabled;
return (
{!loading && !settings.auth_password_login_enabled && (
)}
{loading ? (
) : (
onChange({ auth_password_login_enabled: e.target.checked })}>
Password Login Enabled
)}
);
}
PasswordLoginSettings.propTypes = SettingsEditorPropTypes;
PasswordLoginSettings.defaultProps = SettingsEditorDefaultProps;
================================================
FILE: client/app/pages/settings/components/AuthSettings/SAMLSettings.jsx
================================================
import React from "react";
import Form from "antd/lib/form";
import Input from "antd/lib/input";
import Skeleton from "antd/lib/skeleton";
import Radio from "antd/lib/radio";
import DynamicComponent from "@/components/DynamicComponent";
import { SettingsEditorPropTypes, SettingsEditorDefaultProps } from "../prop-types";
export default function SAMLSettings(props) {
const { values, onChange, loading } = props;
const onChangeEnabledStatus = e => {
const updates = { auth_saml_enabled: !!e.target.value };
if (e.target.value) {
updates.auth_saml_type = e.target.value;
}
onChange(updates);
};
return (
SAML
{loading ? (
) : (
Disabled
Enabled (Static)
Enabled (Dynamic)
)}
{values.auth_saml_enabled && (
<>
{values.auth_saml_type === "static" && (
<>
onChange({ auth_saml_sso_url: e.target.value })}
/>
onChange({ auth_saml_entity_id: e.target.value })}
/>
onChange({ auth_saml_x509_cert: e.target.value })}
/>
>
)}
{values.auth_saml_type === "dynamic" && (
<>
onChange({ auth_saml_metadata_url: e.target.value })}
/>
onChange({ auth_saml_entity_id: e.target.value })}
/>
onChange({ auth_saml_nameid_format: e.target.value })}
/>
>
)}
>
)}
);
}
SAMLSettings.propTypes = SettingsEditorPropTypes;
SAMLSettings.defaultProps = SettingsEditorDefaultProps;
================================================
FILE: client/app/pages/settings/components/AuthSettings/index.jsx
================================================
import React, { useCallback } from "react";
import HelpTrigger from "@/components/HelpTrigger";
import DynamicComponent from "@/components/DynamicComponent";
import { clientConfig } from "@/services/auth";
import { SettingsEditorPropTypes, SettingsEditorDefaultProps } from "../prop-types";
import PasswordLoginSettings from "./PasswordLoginSettings";
import GoogleLoginSettings from "./GoogleLoginSettings";
import SAMLSettings from "./SAMLSettings";
export default function AuthSettings(props) {
const { values, onChange } = props;
const handleChange = useCallback(
changes => {
const allSettings = { ...values, ...changes };
const allAuthMethodsDisabled =
!clientConfig.googleLoginEnabled && !clientConfig.ldapLoginEnabled && !allSettings.auth_saml_enabled;
if (allAuthMethodsDisabled) {
changes = { ...changes, auth_password_login_enabled: true };
}
onChange(changes);
},
[values, onChange]
);
return (
Authentication
);
}
AuthSettings.propTypes = SettingsEditorPropTypes;
AuthSettings.defaultProps = SettingsEditorDefaultProps;
================================================
FILE: client/app/pages/settings/components/GeneralSettings/BeaconConsentSettings.jsx
================================================
import React from "react";
import Form from "antd/lib/form";
import Checkbox from "antd/lib/checkbox";
import Skeleton from "antd/lib/skeleton";
import HelpTrigger from "@/components/HelpTrigger";
import DynamicComponent from "@/components/DynamicComponent";
import { SettingsEditorPropTypes, SettingsEditorDefaultProps } from "../prop-types";
export default function BeaconConsentSettings(props) {
const { values, onChange, loading } = props;
return (
Anonymous Usage Data Sharing
}
>
{loading ? (
) : (
onChange({ beacon_consent: e.target.checked })}
>
Help Redash improve by automatically sending anonymous usage data
)}
);
}
BeaconConsentSettings.propTypes = SettingsEditorPropTypes;
BeaconConsentSettings.defaultProps = SettingsEditorDefaultProps;
================================================
FILE: client/app/pages/settings/components/GeneralSettings/FeatureFlagsSettings.jsx
================================================
import React from "react";
import Checkbox from "antd/lib/checkbox";
import Form from "antd/lib/form";
import Row from "antd/lib/row";
import Skeleton from "antd/lib/skeleton";
import DynamicComponent from "@/components/DynamicComponent";
import { SettingsEditorPropTypes, SettingsEditorDefaultProps } from "../prop-types";
export default function FeatureFlagsSettings(props) {
const { values, onChange, loading } = props;
return (
{loading ? (
<>
>
) : (
<>
onChange({ feature_show_permissions_control: e.target.checked })}>
Enable experimental multiple owners support
onChange({ send_email_on_failed_scheduled_queries: e.target.checked })}>
Email query owners when scheduled queries fail
onChange({ multi_byte_search_enabled: e.target.checked })}>
Enable multi-byte (Chinese, Japanese, and Korean) search for query names and descriptions (slower)
>
)}
);
}
FeatureFlagsSettings.propTypes = SettingsEditorPropTypes;
FeatureFlagsSettings.defaultProps = SettingsEditorDefaultProps;
================================================
FILE: client/app/pages/settings/components/GeneralSettings/FormatSettings.jsx
================================================
import React from "react";
import { SettingsEditorPropTypes, SettingsEditorDefaultProps } from "../prop-types";
import Form from "antd/lib/form";
import Select from "antd/lib/select";
import Skeleton from "antd/lib/skeleton";
import DynamicComponent from "@/components/DynamicComponent";
import { clientConfig } from "@/services/auth";
export default function FormatSettings(props) {
const { values, onChange, loading } = props;
return (
{loading ? (
) : (
onChange({ date_format: value })}
data-test="DateFormatSelect">
{clientConfig.dateFormatList.map(dateFormat => (
{dateFormat}
))}
)}
{loading ? (
) : (
onChange({ time_format: value })}
data-test="TimeFormatSelect">
{clientConfig.timeFormatList.map(timeFormat => (
{timeFormat}
))}
)}
);
}
FormatSettings.propTypes = SettingsEditorPropTypes;
FormatSettings.defaultProps = SettingsEditorDefaultProps;
================================================
FILE: client/app/pages/settings/components/GeneralSettings/PlotlySettings.jsx
================================================
import React from "react";
import Checkbox from "antd/lib/checkbox";
import Form from "antd/lib/form";
import Skeleton from "antd/lib/skeleton";
import DynamicComponent from "@/components/DynamicComponent";
import { SettingsEditorPropTypes, SettingsEditorDefaultProps } from "../prop-types";
export default function PlotlySettings(props) {
const { values, onChange, loading } = props;
return (
{loading ? (
) : (
onChange({ hide_plotly_mode_bar: e.target.checked })}>
Hide Plotly mode bar
)}
);
}
PlotlySettings.propTypes = SettingsEditorPropTypes;
PlotlySettings.defaultProps = SettingsEditorDefaultProps;
================================================
FILE: client/app/pages/settings/components/GeneralSettings/index.jsx
================================================
import React from "react";
import DynamicComponent from "@/components/DynamicComponent";
import FormatSettings from "./FormatSettings";
import PlotlySettings from "./PlotlySettings";
import FeatureFlagsSettings from "./FeatureFlagsSettings";
import BeaconConsentSettings from "./BeaconConsentSettings";
export default function GeneralSettings(props) {
return (
General
);
}
================================================
FILE: client/app/pages/settings/components/prop-types.js
================================================
import PropTypes from "prop-types";
export const SettingsEditorPropTypes = {
settings: PropTypes.object,
values: PropTypes.object,
onChange: PropTypes.func, // (key, value) => void
loading: PropTypes.bool,
};
export const SettingsEditorDefaultProps = {
settings: {},
values: {},
onChange: () => {},
loading: false,
};
================================================
FILE: client/app/pages/settings/hooks/useOrganizationSettings.js
================================================
import { get } from "lodash";
import { useState, useEffect, useCallback } from "react";
import recordEvent from "@/services/recordEvent";
import OrgSettings from "@/services/organizationSettings";
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
import { updateClientConfig } from "@/services/auth";
export default function useOrganizationSettings({ onError }) {
const [settings, setSettings] = useState({});
const [currentValues, setCurrentValues] = useState({});
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const handleError = useImmutableCallback(onError);
useEffect(() => {
recordEvent("view", "page", "org_settings");
let isCancelled = false;
OrgSettings.get()
.then(response => {
if (!isCancelled) {
const settings = get(response, "settings");
setSettings(settings);
setCurrentValues({ ...settings });
setIsLoading(false);
}
})
.catch(error => {
if (!isCancelled) {
handleError(error);
}
});
return () => {
isCancelled = true;
};
}, [handleError]);
const handleChange = useCallback(changes => {
setCurrentValues(currentValues => ({ ...currentValues, ...changes }));
}, []);
const handleSubmit = useCallback(() => {
if (!isSaving) {
setIsSaving(true);
OrgSettings.save(currentValues)
.then(response => {
const settings = get(response, "settings");
setSettings(settings);
setCurrentValues({ ...settings });
updateClientConfig({
dateFormat: currentValues.date_format,
timeFormat: currentValues.time_format,
dateTimeFormat: `${currentValues.date_format} ${currentValues.time_format}`,
});
})
.catch(handleError)
.finally(() => setIsSaving(false));
}
}, [isSaving, currentValues, handleError]);
return { settings, currentValues, isLoading, isSaving, handleSubmit, handleChange };
}
================================================
FILE: client/app/pages/users/UserProfile.jsx
================================================
import React, { useState, useEffect } from "react";
import PropTypes from "prop-types";
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
import EmailSettingsWarning from "@/components/EmailSettingsWarning";
import DynamicComponent from "@/components/DynamicComponent";
import LoadingState from "@/components/items-list/components/LoadingState";
import wrapSettingsTab from "@/components/SettingsWrapper";
import User from "@/services/user";
import { currentUser } from "@/services/auth";
import routes from "@/services/routes";
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
import EditableUserProfile from "./components/EditableUserProfile";
import ReadOnlyUserProfile from "./components/ReadOnlyUserProfile";
import "./settings.less";
function UserProfile({ userId, onError }) {
const [user, setUser] = useState(null);
const handleError = useImmutableCallback(onError);
useEffect(() => {
let isCancelled = false;
User.get({ id: userId || currentUser.id })
.then(user => {
if (!isCancelled) {
setUser(User.convertUserInfo(user));
}
})
.catch(error => {
if (!isCancelled) {
handleError(error);
}
});
return () => {
isCancelled = true;
};
}, [userId, handleError]);
const canEdit = user && (currentUser.isAdmin || currentUser.id === user.id);
return (
{!user && }
{user && (
{!canEdit && }
{canEdit && }
)}
);
}
UserProfile.propTypes = {
userId: PropTypes.string,
onError: PropTypes.func,
};
UserProfile.defaultProps = {
userId: null, // defaults to `currentUser.id`
onError: () => {},
};
const UserProfilePage = wrapSettingsTab(
"Users.Account",
{
title: "Account",
path: "users/me",
order: 7,
},
UserProfile
);
routes.register(
"Users.Account",
routeWithUserSession({
path: "/users/me",
title: "Account",
render: pageProps => ,
})
);
routes.register(
"Users.ViewOrEdit",
routeWithUserSession({
path: "/users/:userId",
title: "Users",
render: pageProps => ,
})
);
================================================
FILE: client/app/pages/users/UsersList.jsx
================================================
import { isString, map, get, find } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import Button from "antd/lib/button";
import Modal from "antd/lib/modal";
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
import Link from "@/components/Link";
import Paginator from "@/components/Paginator";
import DynamicComponent from "@/components/DynamicComponent";
import { UserPreviewCard } from "@/components/PreviewCard";
import InputWithCopy from "@/components/InputWithCopy";
import { wrap as itemsList, ControllerType } from "@/components/items-list/ItemsList";
import { ResourceItemsSource } from "@/components/items-list/classes/ItemsSource";
import { UrlStateStorage } from "@/components/items-list/classes/StateStorage";
import LoadingState from "@/components/items-list/components/LoadingState";
import EmptyState from "@/components/items-list/components/EmptyState";
import * as Sidebar from "@/components/items-list/components/Sidebar";
import ItemsTable, { Columns } from "@/components/items-list/components/ItemsTable";
import Layout from "@/components/layouts/ContentWithSidebar";
import wrapSettingsTab from "@/components/SettingsWrapper";
import { currentUser } from "@/services/auth";
import { policy } from "@/services/policy";
import User from "@/services/user";
import navigateTo from "@/components/ApplicationArea/navigateTo";
import notification from "@/services/notification";
import { absoluteUrl } from "@/services/utils";
import routes from "@/services/routes";
import CreateUserDialog from "./components/CreateUserDialog";
function UsersListActions({ user, enableUser, disableUser, deleteUser }) {
if (user.id === currentUser.id) {
return null;
}
if (user.is_invitation_pending) {
return (
deleteUser(event, user)}>
Delete
);
}
return user.is_disabled ? (
enableUser(event, user)}>
Enable
) : (
disableUser(event, user)}>
Disable
);
}
UsersListActions.propTypes = {
user: PropTypes.shape({
id: PropTypes.number,
is_invitation_pending: PropTypes.bool,
is_disabled: PropTypes.bool,
}).isRequired,
enableUser: PropTypes.func.isRequired,
disableUser: PropTypes.func.isRequired,
deleteUser: PropTypes.func.isRequired,
};
class UsersList extends React.Component {
static propTypes = {
controller: ControllerType.isRequired,
};
sidebarMenu = [
{
key: "active",
href: "users",
title: "Active Users",
},
{
key: "pending",
href: "users/pending",
title: "Pending Invitations",
},
{
key: "disabled",
href: "users/disabled",
title: "Disabled Users",
isAvailable: () => policy.canCreateUser(),
},
];
listColumns = [
Columns.custom.sortable((text, user) => , {
title: "Name",
field: "name",
width: null,
}),
Columns.custom.sortable(
(text, user) =>
map(user.groups, group => (
{group.name}
)),
{
title: "Groups",
field: "groups",
}
),
Columns.timeAgo.sortable({
title: "Joined",
field: "created_at",
className: "text-nowrap",
width: "1%",
}),
Columns.timeAgo.sortable({
title: "Last Active At",
field: "active_at",
className: "text-nowrap",
width: "1%",
}),
Columns.custom(
(text, user) => (
),
{
width: "1%",
isAvailable: () => policy.canCreateUser(),
}
),
];
componentDidMount() {
if (this.props.controller.params.isNewUserPage) {
this.showCreateUserDialog();
}
}
createUser = values =>
User.create(values)
.then(user => {
notification.success("Saved.");
if (user.invite_link) {
Modal.warning({
title: "Email not sent!",
content: (
The mail server is not configured, please send the following link to {user.name} :
),
});
}
})
.catch(error => {
const message = find([get(error, "response.data.message"), get(error, "message"), "Failed saving."], isString);
return Promise.reject(new Error(message));
});
showCreateUserDialog = () => {
if (policy.isCreateUserEnabled()) {
const goToUsersList = () => {
if (this.props.controller.params.isNewUserPage) {
navigateTo("users");
}
};
CreateUserDialog.showModal()
.onClose(values =>
this.createUser(values).then(() => {
this.props.controller.update();
goToUsersList();
})
)
.onDismiss(goToUsersList);
}
};
enableUser = (event, user) => User.enableUser(user).then(() => this.props.controller.update());
disableUser = (event, user) => User.disableUser(user).then(() => this.props.controller.update());
deleteUser = (event, user) => User.deleteUser(user).then(() => this.props.controller.update());
// eslint-disable-next-line class-methods-use-this
renderPageHeader() {
if (!policy.canCreateUser()) {
return null;
}
return (
New User
);
}
render() {
const { controller } = this.props;
return (
{this.renderPageHeader()}
{!controller.isLoaded && }
{controller.isLoaded && controller.isEmpty && }
{controller.isLoaded && !controller.isEmpty && (
controller.updatePagination({ itemsPerPage })}
page={controller.page}
onChange={page => controller.updatePagination({ page })}
/>
)}
);
}
}
const UsersListPage = wrapSettingsTab(
"Users.List",
{
permission: "list_users",
title: "Users",
path: "users",
isActive: path => path.startsWith("/users") && path !== "/users/me",
order: 2,
},
itemsList(
UsersList,
() =>
new ResourceItemsSource({
getRequest(request, { params: { currentPage } }) {
switch (currentPage) {
case "active":
request.pending = false;
break;
case "pending":
request.pending = true;
break;
case "disabled":
request.disabled = true;
break;
// no default
}
return request;
},
getResource() {
return User.query.bind(User);
},
}),
() => new UrlStateStorage({ orderByField: "created_at", orderByReverse: true })
)
);
routes.register(
"Users.New",
routeWithUserSession({
path: "/users/new",
title: "Users",
render: pageProps => ,
})
);
routes.register(
"Users.List",
routeWithUserSession({
path: "/users",
title: "Users",
render: pageProps => ,
})
);
routes.register(
"Users.Pending",
routeWithUserSession({
path: "/users/pending",
title: "Pending Invitations",
render: pageProps => ,
})
);
routes.register(
"Users.Disabled",
routeWithUserSession({
path: "/users/disabled",
title: "Disabled Users",
render: pageProps => ,
})
);
================================================
FILE: client/app/pages/users/components/ApiKeyForm.jsx
================================================
import React, { useState, useCallback } from "react";
import PropTypes from "prop-types";
import Button from "antd/lib/button";
import Form from "antd/lib/form";
import Modal from "antd/lib/modal";
import DynamicComponent from "@/components/DynamicComponent";
import InputWithCopy from "@/components/InputWithCopy";
import { UserProfile } from "@/components/proptypes";
import User from "@/services/user";
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
import { useUniqueId } from "@/lib/hooks/useUniqueId";
export default function ApiKeyForm(props) {
const { user, onChange } = props;
const [loading, setLoading] = useState(false);
const handleChange = useImmutableCallback(onChange);
const apiKeyInputId = useUniqueId("apiKey");
const regenerateApiKey = useCallback(() => {
const doRegenerate = () => {
setLoading(true);
User.regenerateApiKey(user)
.then(apiKey => {
if (apiKey) {
handleChange({ ...user, apiKey });
}
})
.finally(() => {
setLoading(false);
});
};
Modal.confirm({
title: "Regenerate API Key",
content: "Are you sure you want to regenerate?",
okText: "Regenerate",
onOk: doRegenerate,
maskClosable: true,
autoFocusButton: null,
});
}, [user, handleChange]);
return (
Regenerate
);
}
ApiKeyForm.propTypes = {
user: UserProfile.isRequired,
onChange: PropTypes.func,
};
ApiKeyForm.defaultProps = {
onChange: () => {},
};
================================================
FILE: client/app/pages/users/components/CreateUserDialog.jsx
================================================
import React, { useState, useEffect, useCallback } from "react";
import Button from "antd/lib/button";
import Modal from "antd/lib/modal";
import Alert from "antd/lib/alert";
import DynamicForm from "@/components/dynamic-form/DynamicForm";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import recordEvent from "@/services/recordEvent";
import { useUniqueId } from "@/lib/hooks/useUniqueId";
const formFields = [
{ required: true, name: "name", title: "Name", type: "text", autoFocus: true },
{ required: true, name: "email", title: "Email", type: "email" },
];
function CreateUserDialog({ dialog }) {
const [error, setError] = useState(null);
useEffect(() => {
recordEvent("view", "page", "users/new");
}, []);
const handleSubmit = useCallback(values => dialog.close(values).catch(setError), [dialog]);
const formId = useUniqueId("userForm");
return (
Cancel
,
Create
,
]}
wrapProps={{
"data-test": "CreateUserDialog",
}}>
{error && }
);
}
CreateUserDialog.propTypes = {
dialog: DialogPropType.isRequired,
};
export default wrapDialog(CreateUserDialog);
================================================
FILE: client/app/pages/users/components/EditableUserProfile.jsx
================================================
import React, { useState, useEffect } from "react";
import { UserProfile } from "@/components/proptypes";
import UserInfoForm from "./UserInfoForm";
import ApiKeyForm from "./ApiKeyForm";
import PasswordForm from "./PasswordForm";
import ToggleUserForm from "./ToggleUserForm";
export default function EditableUserProfile(props) {
const [user, setUser] = useState(props.user);
useEffect(() => {
setUser(props.user);
}, [props.user]);
return (
{user.name}
{!user.isDisabled && (
)}
);
}
EditableUserProfile.propTypes = {
user: UserProfile.isRequired,
};
================================================
FILE: client/app/pages/users/components/PasswordForm/ChangePasswordDialog.jsx
================================================
import { isFunction, get } from "lodash";
import React from "react";
import Form from "antd/lib/form";
import Modal from "antd/lib/modal";
import Input from "antd/lib/input";
import { UserProfile } from "@/components/proptypes";
import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper";
import User from "@/services/user";
import notification from "@/services/notification";
class ChangePasswordDialog extends React.Component {
static propTypes = {
user: UserProfile.isRequired,
dialog: DialogPropType.isRequired,
};
constructor(props) {
super(props);
this.state = {
currentPassword: { value: "", error: null, touched: false },
newPassword: { value: "", error: null, touched: false },
repeatPassword: { value: "", error: null, touched: false },
updatingPassword: false,
};
}
fieldError = (name, value) => {
if (value.length === 0) return "This field is required.";
if (name !== "currentPassword" && value.length < 6) return "This field is too short.";
if (name === "repeatPassword" && value !== this.state.newPassword.value) return "Passwords don't match";
return null;
};
validateFields = callback => {
const { currentPassword, newPassword, repeatPassword } = this.state;
const errors = {
currentPassword: this.fieldError("currentPassword", currentPassword.value),
newPassword: this.fieldError("newPassword", newPassword.value),
repeatPassword: this.fieldError("repeatPassword", repeatPassword.value),
};
this.setState({
currentPassword: { ...currentPassword, error: errors.currentPassword },
newPassword: { ...newPassword, error: errors.newPassword },
repeatPassword: { ...repeatPassword, error: errors.repeatPassword },
});
if (isFunction(callback)) {
if (errors.currentPassword || errors.newPassword || errors.repeatPassword) {
callback(errors);
} else callback(null);
}
};
updatePassword = () => {
const { currentPassword, newPassword, updatingPassword } = this.state;
if (!updatingPassword) {
this.validateFields(err => {
if (!err) {
const userData = {
id: this.props.user.id,
old_password: currentPassword.value,
password: newPassword.value,
};
this.setState({ updatingPassword: true });
User.save(userData)
.then(() => {
notification.success("Saved.");
this.props.dialog.close({ success: true });
})
.catch(error => {
notification.error(get(error, "response.data.message", "Failed saving."));
this.setState({ updatingPassword: false });
});
} else {
this.setState(prevState => ({
currentPassword: { ...prevState.currentPassword, touched: true },
newPassword: { ...prevState.newPassword, touched: true },
repeatPassword: { ...prevState.repeatPassword, touched: true },
}));
}
});
}
};
handleChange = e => {
const { name, value } = e.target;
const { error } = this.state[name];
this.setState({ [name]: { value, error, touched: true } }, () => {
this.validateFields();
});
};
render() {
const { dialog } = this.props;
const { currentPassword, newPassword, repeatPassword, updatingPassword } = this.state;
const formItemProps = { className: "m-b-10", required: true };
const inputProps = {
onChange: this.handleChange,
onPressEnter: this.updatePassword,
};
return (
);
}
}
export default wrapDialog(ChangePasswordDialog);
================================================
FILE: client/app/pages/users/components/PasswordForm/PasswordLinkAlert.jsx
================================================
import { isString } from "lodash";
import React from "react";
import PropTypes from "prop-types";
import Alert from "antd/lib/alert";
import DynamicComponent from "@/components/DynamicComponent";
import InputWithCopy from "@/components/InputWithCopy";
import { UserProfile } from "@/components/proptypes";
import { absoluteUrl } from "@/services/utils";
export default function PasswordLinkAlert(props) {
const { user, passwordLink, ...restProps } = props;
if (!isString(passwordLink)) {
return null;
}
return (
The mail server is not configured, please send the following link to {user.name} :
}
type="warning"
className="m-t-20"
closable
{...restProps}
/>
);
}
PasswordLinkAlert.propTypes = {
user: UserProfile.isRequired,
passwordLink: PropTypes.string,
};
PasswordLinkAlert.defaultProps = {
passwordLink: null,
};
================================================
FILE: client/app/pages/users/components/PasswordForm/PasswordResetForm.jsx
================================================
import React, { useState, useCallback } from "react";
import Button from "antd/lib/button";
import DynamicComponent from "@/components/DynamicComponent";
import { UserProfile } from "@/components/proptypes";
import User from "@/services/user";
import PasswordLinkAlert from "./PasswordLinkAlert";
export default function PasswordResetForm(props) {
const { user } = props;
const [loading, setLoading] = useState(false);
const [passwordLink, setPasswordLink] = useState(null);
const sendPasswordReset = useCallback(() => {
setLoading(true);
User.sendPasswordReset(user)
.then(passwordLink => {
setPasswordLink(passwordLink);
})
.finally(() => {
setLoading(false);
});
}, [user]);
return (
Send Password Reset Email
setPasswordLink(null)} />
);
}
PasswordResetForm.propTypes = {
user: UserProfile.isRequired,
};
================================================
FILE: client/app/pages/users/components/PasswordForm/ResendInvitationForm.jsx
================================================
import React, { useState, useCallback } from "react";
import Button from "antd/lib/button";
import DynamicComponent from "@/components/DynamicComponent";
import { UserProfile } from "@/components/proptypes";
import User from "@/services/user";
import PasswordLinkAlert from "./PasswordLinkAlert";
export default function ResendInvitationForm(props) {
const { user } = props;
const [loading, setLoading] = useState(false);
const [passwordLink, setPasswordLink] = useState(null);
const resendInvitation = useCallback(() => {
setLoading(true);
User.resendInvitation(user)
.then(passwordLink => {
setPasswordLink(passwordLink);
})
.finally(() => {
setLoading(false);
});
}, [user]);
return (
Resend Invitation
setPasswordLink(null)} />
);
}
ResendInvitationForm.propTypes = {
user: UserProfile.isRequired,
};
================================================
FILE: client/app/pages/users/components/PasswordForm/index.jsx
================================================
import React, { useCallback } from "react";
import Button from "antd/lib/button";
import DynamicComponent from "@/components/DynamicComponent";
import { UserProfile } from "@/components/proptypes";
import { currentUser } from "@/services/auth";
import ChangePasswordDialog from "./ChangePasswordDialog";
import PasswordResetForm from "./PasswordResetForm";
import ResendInvitationForm from "./ResendInvitationForm";
export default function PasswordForm(props) {
const { user } = props;
const changePassword = useCallback(() => {
ChangePasswordDialog.showModal({ user });
}, [user]);
return (
Password
{user.id === currentUser.id && (
Change Password
)}
{user.id !== currentUser.id && currentUser.isAdmin && (
{user.isInvitationPending ? : }
)}
);
}
PasswordForm.propTypes = {
user: UserProfile.isRequired,
};
================================================
FILE: client/app/pages/users/components/ReadOnlyUserProfile.jsx
================================================
import React from "react";
import { UserProfile } from "@/components/proptypes";
import UserGroups from "@/components/UserGroups";
import useUserGroups from "../hooks/useUserGroups";
export default function ReadOnlyUserProfile({ user }) {
const { groups, isLoading: isLoadingGroups } = useUserGroups(user);
return (
{user.name}
Name:
{user.name}
Email:
{user.email}
Groups:
{isLoadingGroups ? "Loading..." : }
);
}
ReadOnlyUserProfile.propTypes = {
user: UserProfile.isRequired,
};
================================================
FILE: client/app/pages/users/components/ReadOnlyUserProfile.test.js
================================================
import React from "react";
import renderer from "react-test-renderer";
import Group from "@/services/group";
import ReadOnlyUserProfile from "./ReadOnlyUserProfile";
beforeEach(() => {
Group.query = jest.fn().mockResolvedValue([]);
});
test("renders correctly", () => {
const user = {
id: 2,
name: "John Doe",
email: "john@doe.com",
groupIds: [],
profileImageUrl: "http://www.images.com/llama.jpg",
};
const component = renderer.create( );
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
================================================
FILE: client/app/pages/users/components/ToggleUserForm.jsx
================================================
import React, { useState, useCallback } from "react";
import PropTypes from "prop-types";
import Button from "antd/lib/button";
import DynamicComponent from "@/components/DynamicComponent";
import { UserProfile } from "@/components/proptypes";
import { currentUser } from "@/services/auth";
import User from "@/services/user";
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
export default function ToggleUserForm(props) {
const { user, onChange } = props;
const [loading, setLoading] = useState(false);
const handleChange = useImmutableCallback(onChange);
const toggleUser = useCallback(() => {
const action = user.isDisabled ? User.enableUser : User.disableUser;
setLoading(true);
action(user)
.then(data => {
if (data) {
handleChange(User.convertUserInfo(data));
}
})
.finally(() => {
setLoading(false);
});
}, [user, handleChange]);
if (!currentUser.isAdmin || user.id === currentUser.id) {
return null;
}
const buttonProps = {
type: user.isDisabled ? "primary" : "danger",
children: user.isDisabled ? "Enable User" : "Disable User",
};
return (
);
}
ToggleUserForm.propTypes = {
user: UserProfile.isRequired,
onChange: PropTypes.func,
};
ToggleUserForm.defaultProps = {
onChange: () => {},
};
================================================
FILE: client/app/pages/users/components/UserInfoForm.jsx
================================================
import { get, map } from "lodash";
import React, { useMemo, useCallback } from "react";
import PropTypes from "prop-types";
import { UserProfile } from "@/components/proptypes";
import DynamicComponent from "@/components/DynamicComponent";
import DynamicForm from "@/components/dynamic-form/DynamicForm";
import UserGroups from "@/components/UserGroups";
import User from "@/services/user";
import { currentUser } from "@/services/auth";
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
import useUserGroups from "../hooks/useUserGroups";
export default function UserInfoForm(props) {
const { user, onChange } = props;
const { groups, allGroups, isLoading: isLoadingGroups } = useUserGroups(user);
const handleChange = useImmutableCallback(onChange);
const saveUser = useCallback(
(values, successCallback, errorCallback) => {
const data = {
...values,
id: user.id,
};
User.save(data)
.then(user => {
successCallback("Saved.");
handleChange(User.convertUserInfo(user));
})
.catch(error => {
errorCallback(get(error, "response.data.message", "Failed saving."));
});
},
[user, handleChange]
);
const formFields = useMemo(
() =>
map(
[
{
name: "name",
title: "Name",
type: "text",
initialValue: user.name,
},
{
name: "email",
title: "Email",
type: "email",
initialValue: user.email,
},
!user.isDisabled && currentUser.id !== user.id
? {
name: "group_ids",
title: "Groups",
type: "select",
mode: "multiple",
options: map(allGroups, group => ({ name: group.name, value: group.id })),
initialValue: user.groupIds,
loading: isLoadingGroups,
placeholder: isLoadingGroups ? "Loading..." : "",
}
: {
name: "group_ids",
title: "Groups",
type: "content",
required: false,
content: isLoadingGroups ? "Loading..." : ,
},
],
field => ({ readOnly: user.isDisabled, required: true, ...field })
),
[user, groups, allGroups, isLoadingGroups]
);
return (
);
}
UserInfoForm.propTypes = {
user: UserProfile.isRequired,
onChange: PropTypes.func,
};
UserInfoForm.defaultProps = {
onChange: () => {},
};
================================================
FILE: client/app/pages/users/components/__snapshots__/ReadOnlyUserProfile.test.js.snap
================================================
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`renders correctly 1`] = `
John Doe
Name:
John Doe
Email:
john@doe.com
Groups:
Loading...
`;
================================================
FILE: client/app/pages/users/hooks/useUserGroups.js
================================================
import { filter, includes, isArray } from "lodash";
import { useEffect, useMemo, useState } from "react";
import Group from "@/services/group";
export default function useUserGroups(user) {
const [allGroups, setAllGroups] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const groups = useMemo(() => filter(allGroups, group => includes(user.groupIds, group.id)), [allGroups, user]);
useEffect(() => {
let isCancelled = false;
Group.query().then(groups => {
if (!isCancelled) {
setAllGroups(isArray(groups) ? groups : []);
setIsLoading(false);
}
});
}, []);
return useMemo(() => ({ groups, allGroups, isLoading }), [groups, allGroups, isLoading]);
}
================================================
FILE: client/app/pages/users/settings.less
================================================
.profile__image {
float: left;
margin-right: 10px;
border-radius: 100%;
}
.profile__h3 {
margin: 8px 0 0 0;
}
.profile__container {
.well {
.form-group:last-of-type {
margin-bottom: 0;
}
}
}
.profile__dl {
dd {
margin-bottom: 12px;
}
}
.alert-invited {
.form-control {
cursor: text !important;
background: #fff !important;
}
}
================================================
FILE: client/app/redash-font/style.less
================================================
@import "./variables";
@font-face {
font-family: "@{icomoon-font-family}";
src: url("@{icomoon-font-path}/@{icomoon-font-family}.eot?ehpufm");
src: url("@{icomoon-font-path}/@{icomoon-font-family}.eot?ehpufm#iefix") format("embedded-opentype"),
url("@{icomoon-font-path}/@{icomoon-font-family}.ttf?ehpufm") format("truetype"),
url("@{icomoon-font-path}/@{icomoon-font-family}.woff?ehpufm") format("woff"),
url("@{icomoon-font-path}/@{icomoon-font-family}.svg?ehpufm#@{icomoon-font-family}") format("svg");
font-weight: normal;
font-style: normal;
}
i.icon {
/* use !important to prevent issues with browser extensions that change fonts */
font-family: "@{icomoon-font-family}" !important;
speak: none;
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
/* Better Font Rendering =========== */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-flash-off {
&:before {
content: @icon-flash-off;
}
}
.icon-flash {
&:before {
content: @icon-flash;
}
}
================================================
FILE: client/app/redash-font/variables.less
================================================
@icomoon-font-family: "redash-icons";
@icomoon-font-path: "fonts";
@icon-flash-off: "\e900";
@icon-flash: "\e901";
================================================
FILE: client/app/services/KeyboardShortcuts.js
================================================
import { each, filter, map, toLower, toString, trim, upperFirst, without } from "lodash";
import Mousetrap from "mousetrap";
import "mousetrap/plugins/global-bind/mousetrap-global-bind";
const modKey = /Mac|iPod|iPhone|iPad/.test(navigator.platform) ? "Cmd" : "Ctrl";
const altKey = /Mac|iPod|iPhone|iPad/.test(navigator.platform) ? "Option" : "Alt";
export function humanReadableShortcut(shortcut, limit = Infinity) {
const modifiers = {
mod: upperFirst(modKey),
alt: upperFirst(altKey),
};
shortcut = toLower(toString(shortcut));
shortcut = filter(map(shortcut.split(","), trim), s => s !== "").slice(0, limit);
shortcut = map(shortcut, sc => {
sc = filter(map(sc.split("+")), s => s !== "");
return map(sc, s => modifiers[s] || upperFirst(s)).join(" + ");
}).join(", ");
return shortcut !== "" ? shortcut : null;
}
const handlers = {};
function onShortcut(event, shortcut) {
event.preventDefault();
event.retunValue = false;
each(handlers[shortcut], fn => fn());
}
const KeyboardShortcuts = {
modKey,
altKey,
bind: keymap => {
each(keymap, (fn, key) => {
const keys = key
.toLowerCase()
.split(",")
.map(trim);
each(keys, k => {
handlers[k] = [...without(handlers[k], fn), fn];
Mousetrap.bindGlobal(k, onShortcut);
});
});
},
unbind: keymap => {
each(keymap, (fn, key) => {
const keys = key
.toLowerCase()
.split(",")
.map(trim);
each(keys, k => {
handlers[k] = without(handlers[k], fn);
if (handlers[k].length === 0) {
handlers[k] = undefined;
Mousetrap.unbind(k);
}
});
});
},
};
export default KeyboardShortcuts;
================================================
FILE: client/app/services/alert-subscription.js
================================================
import { axios } from "@/services/axios";
const AlertSubscription = {
query: ({ alertId }) => axios.get(`api/alerts/${alertId}/subscriptions`),
create: data => axios.post(`api/alerts/${data.alert_id}/subscriptions`, data),
delete: data => axios.delete(`api/alerts/${data.alert_id}/subscriptions/${data.id}`),
};
export default AlertSubscription;
================================================
FILE: client/app/services/alert.js
================================================
import { axios } from "@/services/axios";
import { merge } from "lodash";
// backwards compatibility
const normalizeCondition = {
"greater than": ">",
"less than": "<",
equals: "=",
};
const transformResponse = data =>
merge({}, data, {
options: {
op: normalizeCondition[data.options.op] || data.options.op,
},
});
const transformRequest = data => {
const newData = Object.assign({}, data);
if (newData.query_id === undefined) {
newData.query_id = newData.query.id;
newData.destination_id = newData.destinations;
delete newData.query;
delete newData.destinations;
}
return newData;
};
const saveOrCreateUrl = data => (data.id ? `api/alerts/${data.id}` : "api/alerts");
const Alert = {
query: () => axios.get("api/alerts"),
get: ({ id }) => axios.get(`api/alerts/${id}`).then(transformResponse),
save: data => axios.post(saveOrCreateUrl(data), transformRequest(data)),
delete: data => axios.delete(`api/alerts/${data.id}`),
mute: data => axios.post(`api/alerts/${data.id}/mute`),
unmute: data => axios.delete(`api/alerts/${data.id}/mute`),
evaluate: data => axios.post(`api/alerts/${data.id}/eval`),
};
export default Alert;
================================================
FILE: client/app/services/auth.js
================================================
import debug from "debug";
import { includes, extend } from "lodash";
import location from "@/services/location";
import { axios } from "@/services/axios";
import { notifySessionRestored } from "@/services/restoreSession";
export const currentUser = {
_isAdmin: undefined,
canEdit(object) {
const userId = object.user_id || (object.user && object.user.id);
return this.isAdmin || (userId && userId === this.id);
},
canCreate() {
return (
this.hasPermission("create_query") || this.hasPermission("create_dashboard") || this.hasPermission("list_alerts")
);
},
hasPermission(permission) {
if (permission === "admin" && this._isAdmin !== undefined) {
return this._isAdmin;
}
return includes(this.permissions, permission);
},
get isAdmin() {
return this.hasPermission("admin");
},
set isAdmin(isAdmin) {
this._isAdmin = isAdmin;
},
};
export const clientConfig = {};
export const messages = [];
const logger = debug("redash:auth");
const session = { loaded: false };
const AuthUrls = {
Login: "login",
};
export function updateClientConfig(newClientConfig) {
extend(clientConfig, newClientConfig);
}
function updateSession(sessionData) {
logger("Updating session to be:", sessionData);
extend(session, sessionData, { loaded: true });
extend(currentUser, session.user);
extend(clientConfig, session.client_config);
extend(messages, session.messages);
}
export const Auth = {
isAuthenticated() {
return session.loaded && session.user.id;
},
getLoginUrl() {
return AuthUrls.Login;
},
setLoginUrl(loginUrl) {
AuthUrls.Login = loginUrl;
},
login() {
const next = encodeURI(location.url);
logger("Calling login with next = %s", next);
window.location.href = `${AuthUrls.Login}?next=${next}`;
},
logout() {
logger("Logout.");
window.location.href = "logout";
},
loadSession() {
logger("Loading session");
if (session.loaded && session.user.id) {
logger("Resolving with local value.");
return Promise.resolve(session);
}
Auth.setApiKey(null);
return axios.get("api/session").then(data => {
updateSession(data);
return session;
});
},
loadConfig() {
logger("Loading config");
return axios.get("/api/config").then(data => {
updateSession({ client_config: data.client_config, user: { permissions: [] }, messages: [] });
return data;
});
},
setApiKey(apiKey) {
logger("Set API key to: %s", apiKey);
Auth.apiKey = apiKey;
},
getApiKey() {
return Auth.apiKey;
},
requireSession() {
logger("Requested authentication");
if (Auth.isAuthenticated()) {
return Promise.resolve(session);
}
return Auth.loadSession()
.then(() => {
if (Auth.isAuthenticated()) {
logger("Loaded session");
notifySessionRestored();
return session;
}
logger("Need to login, redirecting");
Auth.login();
})
.catch(() => {
logger("Need to login, redirecting");
Auth.login();
});
},
};
================================================
FILE: client/app/services/auth.test.js
================================================
import { currentUser } from "./auth";
describe("currentUser", () => {
describe("currentUser.isAdmin", () => {
it("returns state based on permission", () => {
currentUser.permissions = ["admin"];
expect(currentUser.isAdmin).toBeTruthy();
currentUser.permissions = [];
expect(currentUser.isAdmin).toBeFalsy();
});
it("allows setting admin status explicitly", () => {
currentUser.permissions = [];
currentUser.isAdmin = true;
expect(currentUser.isAdmin).toBeTruthy();
currentUser.permissions = ["admin"];
currentUser.isAdmin = true;
expect(currentUser.isAdmin).toBeTruthy();
currentUser.permissions = ["admin"];
currentUser.isAdmin = false;
expect(currentUser.isAdmin).toBeFalsy();
currentUser.permissions = [];
currentUser.isAdmin = false;
expect(currentUser.isAdmin).toBeFalsy();
});
});
describe("currentUser.hasPermission", () => {
it("let's override admin status", () => {
currentUser.permissions = [""];
currentUser.isAdmin = true;
expect(currentUser.hasPermission("admin")).toBeTruthy();
currentUser.permissions = [""];
currentUser.isAdmin = false;
expect(currentUser.hasPermission("admin")).toBeFalsy();
});
});
});
================================================
FILE: client/app/services/axios.js
================================================
import { get, includes } from "lodash";
import axiosLib from "axios";
import createAuthRefreshInterceptor from "axios-auth-refresh";
import { Auth } from "@/services/auth";
import qs from "query-string";
import { restoreSession } from "@/services/restoreSession";
export const axios = axiosLib.create({
paramsSerializer: params => qs.stringify(params),
xsrfCookieName: "csrf_token",
xsrfHeaderName: "X-CSRF-TOKEN",
});
axios.interceptors.response.use(response => response.data);
export const csrfRefreshInterceptor = createAuthRefreshInterceptor(
axios,
error => {
const message = get(error, "response.data.message");
if (error.isAxiosError && includes(message, "CSRF")) {
return axios.get("/ping");
} else {
return Promise.reject(error);
}
},
{ statusCodes: [400] }
);
export const sessionRefreshInterceptor = createAuthRefreshInterceptor(
axios,
error => {
const status = parseInt(get(error, "response.status"));
const message = get(error, "response.data.message");
// TODO: In axios@0.9.1 this check could be replaced with { skipAuthRefresh: true } flag. See axios-auth-refresh docs
const requestUrl = get(error, "config.url");
if (error.isAxiosError && (status === 401 || includes(message, "Please login")) && requestUrl !== "api/session") {
return restoreSession();
}
return Promise.reject(error);
},
{
statusCodes: [401, 404],
pauseInstanceWhileRefreshing: false, // According to docs, `false` is default value, but in fact it's not :-)
}
);
axios.interceptors.request.use(config => {
const apiKey = Auth.getApiKey();
if (apiKey) {
config.headers.Authorization = `Key ${apiKey}`;
}
return config;
});
================================================
FILE: client/app/services/dashboard.js
================================================
import _ from "lodash";
import { axios } from "@/services/axios";
import dashboardGridOptions from "@/config/dashboard-grid-options";
import Widget from "./widget";
import location from "@/services/location";
import { cloneParameter } from "@/services/parameters";
import { policy } from "@/services/policy";
export const urlForDashboard = ({ id, slug }) => `dashboards/${id}-${slug}`;
export function collectDashboardFilters(dashboard, queryResults, urlParams) {
const filters = {};
_.each(queryResults, (queryResult) => {
const queryFilters = queryResult && queryResult.getFilters ? queryResult.getFilters() : [];
_.each(queryFilters, (queryFilter) => {
const hasQueryStringValue = _.has(urlParams, queryFilter.name);
if (!(hasQueryStringValue || dashboard.dashboard_filters_enabled)) {
// If dashboard filters not enabled, or no query string value given,
// skip filters linking.
return;
}
if (hasQueryStringValue) {
queryFilter.current = urlParams[queryFilter.name];
}
const filter = { ...queryFilter };
if (!_.has(filters, queryFilter.name)) {
filters[filter.name] = filter;
} else {
filters[filter.name].values = _.union(filters[filter.name].values, filter.values);
}
});
});
return _.values(filters);
}
function prepareWidgetsForDashboard(widgets) {
// Default height for auto-height widgets.
// Compute biggest widget size and choose between it and some magic number.
// This value should be big enough so auto-height widgets will not overlap other ones.
const defaultWidgetSizeY =
Math.max(
_.chain(widgets)
.map((w) => w.options.position.sizeY)
.max()
.value(),
20
) + 5;
// Fix layout:
// 1. sort and group widgets by row
// 2. update position of widgets in each row - place it right below
// biggest widget from previous row
_.chain(widgets)
.sortBy((widget) => widget.options.position.row)
.groupBy((widget) => widget.options.position.row)
.reduce((row, widgetsAtRow) => {
let height = 1;
_.each(widgetsAtRow, (widget) => {
height = Math.max(
height,
widget.options.position.autoHeight ? defaultWidgetSizeY : widget.options.position.sizeY
);
widget.options.position.row = row;
if (widget.options.position.sizeY < 1) {
widget.options.position.sizeY = defaultWidgetSizeY;
}
});
return row + height;
}, 0)
.value();
// Sort widgets by updated column and row value
widgets = _.sortBy(widgets, (widget) => widget.options.position.col);
widgets = _.sortBy(widgets, (widget) => widget.options.position.row);
return widgets;
}
function calculateNewWidgetPosition(existingWidgets, newWidget) {
const width = _.extend({ sizeX: dashboardGridOptions.defaultSizeX }, _.extend({}, newWidget.options).position).sizeX;
// Find first free row for each column
const bottomLine = _.chain(existingWidgets)
.map((w) => {
const options = _.extend({}, w.options);
const position = _.extend({ row: 0, sizeY: 0 }, options.position);
return {
left: position.col,
top: position.row,
right: position.col + position.sizeX,
bottom: position.row + position.sizeY,
width: position.sizeX,
height: position.sizeY,
};
})
.reduce(
(result, item) => {
const from = Math.max(item.left, 0);
const to = Math.min(item.right, result.length + 1);
for (let i = from; i < to; i += 1) {
result[i] = Math.max(result[i], item.bottom);
}
return result;
},
_.map(new Array(dashboardGridOptions.columns), _.constant(0))
)
.value();
// Go through columns, pick them by count necessary to hold new block,
// and calculate bottom-most free row per group.
// Choose group with the top-most free row (comparing to other groups)
return _.chain(_.range(0, dashboardGridOptions.columns - width + 1))
.map((col) => ({
col,
row: _.chain(bottomLine)
.slice(col, col + width)
.max()
.value(),
}))
.sortBy("row")
.first()
.value();
}
export function Dashboard(dashboard) {
_.extend(this, dashboard);
Object.defineProperty(this, "url", {
get: function () {
return urlForDashboard(this);
},
});
}
function prepareDashboardWidgets(widgets) {
return prepareWidgetsForDashboard(_.map(widgets, (widget) => new Widget(widget)));
}
function transformSingle(dashboard) {
dashboard = new Dashboard(dashboard);
if (dashboard.widgets) {
dashboard.widgets = prepareDashboardWidgets(dashboard.widgets);
}
dashboard.publicAccessEnabled = dashboard.public_url !== undefined;
return dashboard;
}
function transformResponse(data) {
if (data.results) {
data = { ...data, results: _.map(data.results, transformSingle) };
} else {
data = transformSingle(data);
}
return data;
}
const saveOrCreateUrl = (data) => (data.id ? `api/dashboards/${data.id}` : "api/dashboards");
const DashboardService = {
get: ({ id, slug }) => {
const params = {};
if (!id) {
params.legacy = null;
}
return axios.get(`api/dashboards/${id || slug}`, { params }).then(transformResponse);
},
getByToken: ({ token }) => axios.get(`api/dashboards/public/${token}`).then(transformResponse),
save: (data) => axios.post(saveOrCreateUrl(data), data).then(transformResponse),
delete: ({ id }) => axios.delete(`api/dashboards/${id}`).then(transformResponse),
query: (params) => axios.get("api/dashboards", { params }).then(transformResponse),
recent: (params) => axios.get("api/dashboards/recent", { params }).then(transformResponse),
myDashboards: (params) => axios.get("api/dashboards/my", { params }).then(transformResponse),
favorites: (params) => axios.get("api/dashboards/favorites", { params }).then(transformResponse),
favorite: ({ id }) => axios.post(`api/dashboards/${id}/favorite`),
unfavorite: ({ id }) => axios.delete(`api/dashboards/${id}/favorite`),
fork: ({ id }) => axios.post(`api/dashboards/${id}/fork`, { id }).then(transformResponse),
};
_.extend(Dashboard, DashboardService);
Dashboard.prepareDashboardWidgets = prepareDashboardWidgets;
Dashboard.prepareWidgetsForDashboard = prepareWidgetsForDashboard;
Dashboard.prototype.canEdit = function canEdit() {
return policy.canEdit(this);
};
Dashboard.prototype.getParametersDefs = function getParametersDefs() {
const globalParams = {};
const queryParams = location.search;
_.each(this.widgets, (widget) => {
if (widget.getQuery()) {
const mappings = widget.getParameterMappings();
widget
.getQuery()
.getParametersDefs(false)
.forEach((param) => {
const mapping = mappings[param.name];
if (mapping.type === Widget.MappingType.DashboardLevel) {
// create global param
if (!globalParams[mapping.mapTo]) {
globalParams[mapping.mapTo] = cloneParameter(param);
globalParams[mapping.mapTo].name = mapping.mapTo;
globalParams[mapping.mapTo].title = mapping.title || param.title;
globalParams[mapping.mapTo].locals = [];
}
// add to locals list
globalParams[mapping.mapTo].locals.push(param);
}
});
}
});
const mergedValues = {
..._.mapValues(globalParams, (p) => p.value),
...Object.fromEntries((this.options.parameters || []).map((param) => [param.name, param.value])),
};
const resultingGlobalParams = _.values(
_.each(globalParams, (param) => {
param.setValue(mergedValues[param.name]); // apply merged value
param.fromUrlParams(queryParams); // allow param-specific parsing logic
})
);
// order dashboard params using paramOrder
return _.sortBy(resultingGlobalParams, (param) =>
_.includes(this.options.globalParamOrder, param.name)
? _.indexOf(this.options.globalParamOrder, param.name)
: _.size(this.options.globalParamOrder)
);
};
Dashboard.prototype.addWidget = function addWidget(textOrVisualization, options = {}) {
const props = {
dashboard_id: this.id,
options: {
...options,
isHidden: false,
position: {},
},
text: "",
visualization_id: null,
visualization: null,
};
if (_.isString(textOrVisualization)) {
props.text = textOrVisualization;
} else if (_.isObject(textOrVisualization)) {
props.visualization_id = textOrVisualization.id;
props.visualization = textOrVisualization;
} else {
// TODO: Throw an error?
}
const widget = new Widget(props);
const position = calculateNewWidgetPosition(this.widgets, widget);
widget.options.position.col = position.col;
widget.options.position.row = position.row;
return widget.save().then(() => {
this.widgets = [...this.widgets, widget];
return widget;
});
};
Dashboard.prototype.favorite = function favorite() {
return Dashboard.favorite(this);
};
Dashboard.prototype.unfavorite = function unfavorite() {
return Dashboard.unfavorite(this);
};
Dashboard.prototype.getUrl = function getUrl() {
return urlForDashboard(this);
};
================================================
FILE: client/app/services/data-source.js
================================================
import { has, map, isObject } from "lodash";
import { axios } from "@/services/axios";
import { fetchDataFromJob } from "@/services/query-result";
export const SCHEMA_NOT_SUPPORTED = 1;
export const SCHEMA_LOAD_ERROR = 2;
export const IMG_ROOT = "/static/images/db-logos";
function mapSchemaColumnsToObject(columns) {
return map(columns, (column) => (isObject(column) ? column : { name: column }));
}
const DataSource = {
query: () => axios.get("api/data_sources"),
get: ({ id }) => axios.get(`api/data_sources/${id}`),
types: () => axios.get("api/data_sources/types"),
create: (data) => axios.post(`api/data_sources`, data),
save: (data) => axios.post(`api/data_sources/${data.id}`, data),
test: (data) => axios.post(`api/data_sources/${data.id}/test`),
delete: ({ id }) => axios.delete(`api/data_sources/${id}`),
fetchSchema: (data, refresh = false) => {
const params = {};
if (refresh) {
params.refresh = true;
}
return axios
.get(`api/data_sources/${data.id}/schema`, { params })
.then((data) => {
if (has(data, "job")) {
return fetchDataFromJob(data.job.id).catch((error) =>
error.code === SCHEMA_NOT_SUPPORTED ? [] : Promise.reject(new Error(data.job.error))
);
}
return has(data, "schema") ? data.schema : Promise.reject();
})
.then((tables) => map(tables, (table) => ({ ...table, columns: mapSchemaColumnsToObject(table.columns) })));
},
};
export default DataSource;
================================================
FILE: client/app/services/databricks-data-source.js
================================================
import { has } from "lodash";
import { axios } from "@/services/axios";
import DataSource from "@/services/data-source";
import { fetchDataFromJob } from "@/services/query-result";
function fetchDataFromJobOrReturnData(data) {
return has(data, "job.id") ? fetchDataFromJob(data.job.id, 1000) : data;
}
function rejectErrorResponse(data) {
return has(data, "error") ? Promise.reject(new Error(data.error.message)) : data;
}
export default {
...DataSource,
getDatabases: ({ id }, refresh = false) => {
const params = {};
if (refresh) {
params.refresh = true;
}
return axios
.get(`api/databricks/databases/${id}`, { params })
.then(fetchDataFromJobOrReturnData)
.then(rejectErrorResponse);
},
getDatabaseTables: (data, databaseName, refresh = false) => {
const params = {};
if (refresh) {
params.refresh = true;
}
return axios
.get(`api/databricks/databases/${data.id}/${databaseName}/tables`, { params })
.then(fetchDataFromJobOrReturnData)
.then(rejectErrorResponse);
},
getTableColumns: (data, databaseName, tableName) =>
axios
.get(`api/databricks/databases/${data.id}/${databaseName}/columns/${tableName}`)
.then(fetchDataFromJobOrReturnData)
.then(rejectErrorResponse),
};
================================================
FILE: client/app/services/destination.js
================================================
import { axios } from "@/services/axios";
export const IMG_ROOT = "static/images/destinations";
const Destination = {
query: () => axios.get("api/destinations"),
get: ({ id }) => axios.get(`api/destinations/${id}`),
types: () => axios.get("api/destinations/types"),
create: data => axios.post(`api/destinations`, data),
save: data => axios.post(`api/destinations/${data.id}`, data),
delete: ({ id }) => axios.delete(`api/destinations/${id}`),
};
export default Destination;
================================================
FILE: client/app/services/getTags.js
================================================
import { axios } from "@/services/axios";
function processTags(data) {
return data.tags || [];
}
export default function getTags(url) {
return axios.get(url).then(processTags);
}
================================================
FILE: client/app/services/group.js
================================================
import { axios } from "@/services/axios";
const Group = {
query: () => axios.get("api/groups"),
get: ({ id }) => axios.get(`api/groups/${id}`),
create: data => axios.post(`api/groups`, data),
save: data => axios.post(`api/groups/${data.id}`, data),
delete: data => axios.delete(`api/groups/${data.id}`),
members: ({ id }) => axios.get(`api/groups/${id}/members`),
addMember: ({ id }, data) => axios.post(`api/groups/${id}/members`, data),
removeMember: ({ id, userId }) => axios.delete(`api/groups/${id}/members/${userId}`),
dataSources: ({ id }) => axios.get(`api/groups/${id}/data_sources`),
addDataSource: ({ id }, data) => axios.post(`api/groups/${id}/data_sources`, data),
removeDataSource: ({ id, dataSourceId }) => axios.delete(`api/groups/${id}/data_sources/${dataSourceId}`),
updateDataSource: ({ id, dataSourceId }, data) => axios.post(`api/groups/${id}/data_sources/${dataSourceId}`, data),
};
export default Group;
================================================
FILE: client/app/services/location.js
================================================
import { isNil, isUndefined, isFunction, isObject, trimStart, mapValues, omitBy, extend } from "lodash";
import qs from "query-string";
import { createBrowserHistory } from "history";
const history = createBrowserHistory();
function normalizeLocation(rawLocation) {
const { pathname, search, hash } = rawLocation;
const result = {};
result.path = pathname;
result.search = mapValues(qs.parse(search), (value) => (isNil(value) ? true : value));
result.hash = trimStart(hash, "#");
result.url = `${pathname}${search}${hash}`;
return result;
}
const location = {
listen(handler) {
if (isFunction(handler)) {
return history.listen((unused, action) => handler(location, action));
} else {
return () => {};
}
},
confirmChange(handler) {
if (isFunction(handler)) {
return history.block((nextLocation) => {
return handler(normalizeLocation(nextLocation), location);
});
} else {
return () => {};
}
},
update(newLocation, replace = false) {
if (isObject(newLocation)) {
// remap fields and remove undefined ones
newLocation = omitBy(
{
pathname: newLocation.path,
search: newLocation.search,
hash: newLocation.hash,
},
isUndefined
);
// keep existing fields (!)
newLocation = extend(
{
pathname: location.path,
search: location.search,
hash: location.hash,
},
newLocation
);
// serialize search and keep existing search parameters (!)
if (isObject(newLocation.search)) {
newLocation.search = omitBy(extend({}, location.search, newLocation.search), isNil);
newLocation.search = mapValues(newLocation.search, (value) => (value === true ? null : value));
newLocation.search = qs.stringify(newLocation.search);
}
}
if (replace) {
if (
newLocation.pathname !== location.path ||
newLocation.search !== qs.stringify(location.search) ||
newLocation.hash !== location.hash
) {
history.replace(newLocation);
}
} else {
history.push(newLocation);
}
},
url: undefined,
path: undefined,
setPath(path, replace = false) {
location.update({ path }, replace);
},
search: undefined,
setSearch(search, replace = false) {
location.update({ search }, replace);
},
hash: undefined,
setHash(hash, replace = false) {
location.update({ hash }, replace);
},
};
function locationChanged() {
extend(location, normalizeLocation(history.location));
}
history.listen(locationChanged);
locationChanged(); // init service
export default location;
================================================
FILE: client/app/services/notification.d.ts
================================================
import { NotificationApi, ArgsProps } from "antd/lib/notification";
export type NotificationConfig = Omit | null;
type NotificationFunction = (
message: ArgsProps["message"],
description?: ArgsProps["description"],
args?: NotificationConfig
) => void;
declare const notification: NotificationApi & {
success: NotificationFunction;
error: NotificationFunction;
info: NotificationFunction;
warning: NotificationFunction;
warn: NotificationFunction;
};
export default notification;
================================================
FILE: client/app/services/notification.js
================================================
import notification from "antd/lib/notification";
notification.config({
placement: "bottomRight",
duration: 3,
});
const simpleNotification = {};
["success", "error", "info", "warning", "warn"].forEach(action => {
// eslint-disable-next-line arrow-body-style
simpleNotification[action] = (message, description = null, props = null) => {
return notification[action]({ ...props, message, description });
};
});
export default {
// export Ant's notification and replace actions
...notification,
...simpleNotification,
};
================================================
FILE: client/app/services/notifications.js
================================================
import { find } from "lodash";
import debug from "debug";
import recordEvent from "@/services/recordEvent";
import redashIconUrl from "@/assets/images/redash_icon_small.png";
const logger = debug("redash:notifications");
const Notification = window.Notification || null;
if (!Notification) {
logger("HTML5 notifications are not supported.");
}
const hidden = find(["hidden", "webkitHidden", "mozHidden", "msHidden"], prop => prop in document);
function isPageVisible() {
return !document[hidden];
}
function getPermissions() {
if (Notification && Notification.permission === "default") {
Notification.requestPermission();
}
}
function showNotification(title, content) {
if (!Notification || isPageVisible() || Notification.permission !== "granted") {
return;
}
// using the 'tag' to avoid showing duplicate notifications
const notification = new Notification(title, {
tag: title + content,
body: content,
icon: redashIconUrl,
});
notification.onclick = function onClick() {
window.focus();
this.close();
recordEvent("click", "notification");
};
}
export default {
getPermissions,
showNotification,
};
================================================
FILE: client/app/services/offline-listener.js
================================================
import notification from "@/services/notification";
function addOnlineListener(notificationKey) {
function onlineStateHandler() {
notification.close(notificationKey);
window.removeEventListener("online", onlineStateHandler);
}
window.addEventListener("online", onlineStateHandler);
}
export default {
init() {
window.addEventListener("offline", () => {
notification.warning("Please check your Internet connection.", null, {
key: "connectionNotification",
duration: null,
});
addOnlineListener("connectionNotification");
});
},
};
================================================
FILE: client/app/services/organizationSettings.js
================================================
import { axios } from "@/services/axios";
import notification from "@/services/notification";
export default {
get: () => axios.get("api/settings/organization"),
save: (data, message = "Settings changes saved.") =>
axios
.post("api/settings/organization", data)
.then(data => {
notification.success(message);
return data;
})
.catch(() => {
notification.error("Failed saving changes.");
}),
};
================================================
FILE: client/app/services/organizationStatus.js
================================================
import { axios } from "@/services/axios";
class OrganizationStatus {
constructor() {
this.objectCounters = {};
}
refresh() {
return axios.get("api/organization/status").then(data => {
this.objectCounters = data.object_counters;
return this;
});
}
}
export default new OrganizationStatus();
================================================
FILE: client/app/services/parameters/DateParameter.js
================================================
import { findKey, startsWith, has, includes, isNull, values } from "lodash";
import moment from "moment";
import PropTypes from "prop-types";
import Parameter from "./Parameter";
const DATETIME_FORMATS = {
// eslint-disable-next-line quote-props
date: "YYYY-MM-DD",
"datetime-local": "YYYY-MM-DD HH:mm",
"datetime-with-seconds": "YYYY-MM-DD HH:mm:ss",
};
const DYNAMIC_PREFIX = "d_";
const DYNAMIC_DATES = {
now: {
name: "Today/Now",
value: () => moment(),
},
yesterday: {
name: "Yesterday",
value: () => moment().subtract(1, "day"),
},
};
export const DynamicDateType = PropTypes.oneOf(values(DYNAMIC_DATES));
function isDynamicDateString(value) {
return startsWith(value, DYNAMIC_PREFIX) && has(DYNAMIC_DATES, value.substring(DYNAMIC_PREFIX.length));
}
export function isDynamicDate(value) {
return includes(DYNAMIC_DATES, value);
}
export function getDynamicDateFromString(value) {
if (!isDynamicDateString(value)) {
return null;
}
return DYNAMIC_DATES[value.substring(DYNAMIC_PREFIX.length)];
}
class DateParameter extends Parameter {
constructor(parameter, parentQueryId) {
super(parameter, parentQueryId);
this.useCurrentDateTime = parameter.useCurrentDateTime;
this.setValue(parameter.value);
}
get hasDynamicValue() {
return isDynamicDate(this.normalizedValue);
}
// eslint-disable-next-line class-methods-use-this
normalizeValue(value) {
if (isDynamicDateString(value)) {
return getDynamicDateFromString(value);
}
if (isDynamicDate(value)) {
return value;
}
const normalizedValue = moment(value, moment.ISO_8601, true);
return normalizedValue.isValid() ? normalizedValue : null;
}
setValue(value) {
const normalizedValue = this.normalizeValue(value);
if (isDynamicDate(normalizedValue)) {
this.value = DYNAMIC_PREFIX + findKey(DYNAMIC_DATES, normalizedValue);
} else if (moment.isMoment(normalizedValue)) {
this.value = normalizedValue.format(DATETIME_FORMATS[this.type]);
} else {
this.value = normalizedValue;
}
this.$$value = normalizedValue;
this.updateLocals();
this.clearPendingValue();
return this;
}
getExecutionValue() {
if (this.hasDynamicValue) {
return this.normalizedValue.value().format(DATETIME_FORMATS[this.type]);
}
if (isNull(this.value) && this.useCurrentDateTime) {
return moment().format(DATETIME_FORMATS[this.type]);
}
return this.value;
}
}
export default DateParameter;
================================================
FILE: client/app/services/parameters/DateRangeParameter.js
================================================
import { startsWith, has, includes, findKey, values, isObject, isArray } from "lodash";
import moment from "moment";
import PropTypes from "prop-types";
import Parameter from "./Parameter";
const DATETIME_FORMATS = {
"date-range": "YYYY-MM-DD",
"datetime-range": "YYYY-MM-DD HH:mm",
"datetime-range-with-seconds": "YYYY-MM-DD HH:mm:ss",
};
const DYNAMIC_PREFIX = "d_";
/**
* Dynamic date range preset value with end set to current time
* @param from {function(): moment.Moment}
* @param now {function(): moment.Moment=} moment - defaults to now
* @returns {function(withNow: boolean): [moment.Moment, moment.Moment|undefined]}
*/
const untilNow =
(from, now = () => moment()) =>
(withNow = true) => [from(), withNow ? now() : undefined];
const DYNAMIC_DATE_RANGES = {
today: {
name: "Today",
value: () => [moment().startOf("day"), moment().endOf("day")],
},
yesterday: {
name: "Yesterday",
value: () => [moment().subtract(1, "day").startOf("day"), moment().subtract(1, "day").endOf("day")],
},
this_week: {
name: "This week",
value: () => [moment().startOf("week"), moment().endOf("week")],
},
this_month: {
name: "This month",
value: () => [moment().startOf("month"), moment().endOf("month")],
},
this_year: {
name: "This year",
value: () => [moment().startOf("year"), moment().endOf("year")],
},
last_week: {
name: "Last week",
value: () => [moment().subtract(1, "week").startOf("week"), moment().subtract(1, "week").endOf("week")],
},
last_month: {
name: "Last month",
value: () => [moment().subtract(1, "month").startOf("month"), moment().subtract(1, "month").endOf("month")],
},
last_year: {
name: "Last year",
value: () => [moment().subtract(1, "year").startOf("year"), moment().subtract(1, "year").endOf("year")],
},
last_hour: {
name: "Last hour",
value: untilNow(() => moment().subtract(1, "hour")),
},
last_8_hours: {
name: "Last 8 hours",
value: untilNow(() => moment().subtract(8, "hour")),
},
last_24_hours: {
name: "Last 24 hours",
value: untilNow(() => moment().subtract(24, "hour")),
},
last_7_days: {
name: "Last 7 days",
value: untilNow(() => moment().subtract(7, "days").startOf("day")),
},
last_14_days: {
name: "Last 14 days",
value: untilNow(() => moment().subtract(14, "days").startOf("day")),
},
last_30_days: {
name: "Last 30 days",
value: untilNow(() => moment().subtract(30, "days").startOf("day")),
},
last_60_days: {
name: "Last 60 days",
value: untilNow(() => moment().subtract(60, "days").startOf("day")),
},
last_90_days: {
name: "Last 90 days",
value: untilNow(() => moment().subtract(90, "days").startOf("day")),
},
last_12_months: {
name: "Last 12 months",
value: untilNow(() => moment().subtract(12, "months").startOf("day")),
},
last_2_years: {
name: "Last 2 years",
value: untilNow(() => moment().subtract(2, "years").startOf("day")),
},
last_3_years: {
name: "Last 3 years",
value: untilNow(() => moment().subtract(3, "years").startOf("day")),
},
last_10_years: {
name: "Last 10 years",
value: untilNow(() => moment().subtract(10, "years").startOf("day")),
},
};
export const DynamicDateRangeType = PropTypes.oneOf(values(DYNAMIC_DATE_RANGES));
export function isDynamicDateRangeString(value) {
if (!startsWith(value, DYNAMIC_PREFIX)) {
return false;
}
return !!DYNAMIC_DATE_RANGES[value.substring(DYNAMIC_PREFIX.length)];
}
export function getDynamicDateRangeStringFromName(dynamicRangeName) {
const key = findKey(DYNAMIC_DATE_RANGES, (range) => range.name === dynamicRangeName);
return key ? DYNAMIC_PREFIX + key : undefined;
}
export function isDynamicDateRange(value) {
return includes(DYNAMIC_DATE_RANGES, value);
}
export function getDynamicDateRangeFromString(value) {
if (!isDynamicDateRangeString(value)) {
return null;
}
return DYNAMIC_DATE_RANGES[value.substring(DYNAMIC_PREFIX.length)];
}
class DateRangeParameter extends Parameter {
constructor(parameter, parentQueryId) {
super(parameter, parentQueryId);
this.setValue(parameter.value);
}
get hasDynamicValue() {
return isDynamicDateRange(this.normalizedValue);
}
// eslint-disable-next-line class-methods-use-this
normalizeValue(value) {
if (isDynamicDateRangeString(value)) {
return getDynamicDateRangeFromString(value);
}
if (isDynamicDateRange(value)) {
return value;
}
if (isObject(value) && !isArray(value)) {
value = [value.start, value.end];
}
if (isArray(value) && value.length === 2) {
value = [moment(value[0]), moment(value[1])];
if (value[0].isValid() && value[1].isValid()) {
return value;
}
}
return null;
}
setValue(value) {
const normalizedValue = this.normalizeValue(value);
if (isDynamicDateRange(normalizedValue)) {
this.value = DYNAMIC_PREFIX + findKey(DYNAMIC_DATE_RANGES, normalizedValue);
} else if (isArray(normalizedValue)) {
this.value = {
start: normalizedValue[0].format(DATETIME_FORMATS[this.type]),
end: normalizedValue[1].format(DATETIME_FORMATS[this.type]),
};
} else {
this.value = normalizedValue;
}
this.$$value = normalizedValue;
this.updateLocals();
this.clearPendingValue();
return this;
}
getExecutionValue() {
if (this.hasDynamicValue) {
const format = (date) => date.format(DATETIME_FORMATS[this.type]);
const [start, end] = this.normalizedValue.value().map(format);
return { start, end };
}
return this.value;
}
toUrlParams() {
const prefix = this.urlPrefix;
if (isObject(this.value) && this.value.start && this.value.end) {
return {
[`${prefix}${this.name}`]: `${this.value.start}--${this.value.end}`,
};
}
return super.toUrlParams();
}
fromUrlParams(query) {
const prefix = this.urlPrefix;
const key = `${prefix}${this.name}`;
// backward compatibility
const keyStart = `${prefix}${this.name}.start`;
const keyEnd = `${prefix}${this.name}.end`;
if (has(query, key)) {
const dates = query[key].split("--");
if (dates.length === 2) {
this.setValue(dates);
} else {
this.setValue(query[key]);
}
} else if (has(query, keyStart) && has(query, keyEnd)) {
this.setValue([query[keyStart], query[keyEnd]]);
}
}
toQueryTextFragment() {
return `{{ ${this.name}.start }} {{ ${this.name}.end }}`;
}
}
export default DateRangeParameter;
================================================
FILE: client/app/services/parameters/EnumParameter.js
================================================
import { isArray, isEmpty, includes, intersection, get, map, join, has } from "lodash";
import Parameter from "./Parameter";
class EnumParameter extends Parameter {
constructor(parameter, parentQueryId) {
super(parameter, parentQueryId);
this.enumOptions = parameter.enumOptions;
this.multiValuesOptions = parameter.multiValuesOptions;
this.setValue(parameter.value);
}
normalizeValue(value) {
if (isEmpty(this.enumOptions)) {
return null;
}
const enumOptionsArray = this.enumOptions.split("\n") || [];
if (this.multiValuesOptions) {
if (!isArray(value)) {
value = [value];
}
value = intersection(value, enumOptionsArray);
} else if (!value || isArray(value) || !includes(enumOptionsArray, value)) {
value = enumOptionsArray[0];
}
if (isArray(value) && isEmpty(value)) {
return null;
}
return value;
}
getExecutionValue(extra = {}) {
const { joinListValues } = extra;
if (joinListValues && isArray(this.value)) {
const separator = get(this.multiValuesOptions, "separator", ",");
const prefix = get(this.multiValuesOptions, "prefix", "");
const suffix = get(this.multiValuesOptions, "suffix", "");
const parameterValues = map(this.value, v => `${prefix}${v}${suffix}`);
return join(parameterValues, separator);
}
return this.value;
}
toUrlParams() {
const prefix = this.urlPrefix;
let urlParam = this.value;
if (this.multiValuesOptions && isArray(this.value)) {
urlParam = JSON.stringify(this.value);
}
return {
[`${prefix}${this.name}`]: !this.isEmpty ? urlParam : null,
};
}
fromUrlParams(query) {
const prefix = this.urlPrefix;
const key = `${prefix}${this.name}`;
if (has(query, key)) {
if (this.multiValuesOptions) {
try {
const valueFromJson = JSON.parse(query[key]);
this.setValue(isArray(valueFromJson) ? valueFromJson : query[key]);
} catch (e) {
this.setValue(query[key]);
}
} else {
this.setValue(query[key]);
}
}
}
}
export default EnumParameter;
================================================
FILE: client/app/services/parameters/NumberParameter.js
================================================
import { toNumber, isNull } from "lodash";
import Parameter from "./Parameter";
class NumberParameter extends Parameter {
constructor(parameter, parentQueryId) {
super(parameter, parentQueryId);
this.setValue(parameter.value);
}
// eslint-disable-next-line class-methods-use-this
normalizeValue(value) {
if (isNull(value)) {
return null;
}
const normalizedValue = toNumber(value);
return !isNaN(normalizedValue) ? normalizedValue : null;
}
}
export default NumberParameter;
================================================
FILE: client/app/services/parameters/Parameter.js
================================================
import { isNull, isObject, isFunction, isUndefined, isEqual, has, omit, isArray, each } from "lodash";
class Parameter {
constructor(parameter, parentQueryId) {
this.title = parameter.title;
this.name = parameter.name;
this.type = parameter.type;
this.global = parameter.global; // backward compatibility in Widget service
this.parentQueryId = parentQueryId;
// Used for meta-parameters (i.e. dashboard-level params)
this.locals = [];
// Used for URL serialization
this.urlPrefix = "p_";
}
static getExecutionValue(param, extra = {}) {
if (!isObject(param) || !isFunction(param.getExecutionValue)) {
return null;
}
return param.getExecutionValue(extra);
}
static setValue(param, value) {
if (!isObject(param) || !isFunction(param.setValue)) {
return null;
}
return param.setValue(value);
}
get isEmpty() {
return isNull(this.normalizedValue);
}
get hasPendingValue() {
return this.pendingValue !== undefined && !isEqual(this.pendingValue, this.normalizedValue);
}
/** Get normalized value to be used in inputs */
get normalizedValue() {
return this.$$value;
}
isEmptyValue(value) {
return isNull(this.normalizeValue(value));
}
// eslint-disable-next-line class-methods-use-this
normalizeValue(value) {
if (isUndefined(value)) {
return null;
}
return value;
}
updateLocals() {
if (isArray(this.locals)) {
each(this.locals, (local) => {
local.setValue(this.value);
});
}
}
setValue(value) {
const normalizedValue = this.normalizeValue(value);
this.value = normalizedValue;
this.$$value = normalizedValue;
this.updateLocals();
this.clearPendingValue();
return this;
}
/** Get execution value for a query */
getExecutionValue() {
return this.value;
}
setPendingValue(value) {
this.pendingValue = this.normalizeValue(value);
}
applyPendingValue() {
if (this.hasPendingValue) {
this.setValue(this.pendingValue);
}
}
clearPendingValue() {
this.pendingValue = undefined;
}
/** Update URL with Parameter value */
toUrlParams() {
const prefix = this.urlPrefix;
// `null` removes the parameter from the URL in case it exists
return {
[`${prefix}${this.name}`]: !this.isEmpty ? this.value : null,
};
}
/** Set parameter value from the URL */
fromUrlParams(query) {
const prefix = this.urlPrefix;
const key = `${prefix}${this.name}`;
if (has(query, key)) {
this.setValue(query[key]);
}
}
toQueryTextFragment() {
return `{{ ${this.name} }}`;
}
/** Get a saveable version of the Parameter by omitting unnecessary props */
toSaveableObject() {
return omit(this, ["$$value", "urlPrefix", "pendingValue", "parentQueryId", "locals"]);
}
}
export default Parameter;
================================================
FILE: client/app/services/parameters/QueryBasedDropdownParameter.js
================================================
import { isNull, isUndefined, isArray, isEmpty, get, map, join, has } from "lodash";
import { Query } from "@/services/query";
import Parameter from "./Parameter";
class QueryBasedDropdownParameter extends Parameter {
constructor(parameter, parentQueryId) {
super(parameter, parentQueryId);
this.queryId = parameter.queryId;
this.multiValuesOptions = parameter.multiValuesOptions;
this.setValue(parameter.value);
}
normalizeValue(value) {
if (isUndefined(value) || isNull(value) || (isArray(value) && isEmpty(value))) {
return null;
}
if (this.multiValuesOptions) {
value = isArray(value) ? value : [value];
} else {
value = isArray(value) ? value[0] : value;
}
return value;
}
getExecutionValue(extra = {}) {
const { joinListValues } = extra;
if (joinListValues && isArray(this.value)) {
const separator = get(this.multiValuesOptions, "separator", ",");
const prefix = get(this.multiValuesOptions, "prefix", "");
const suffix = get(this.multiValuesOptions, "suffix", "");
const parameterValues = map(this.value, v => `${prefix}${v}${suffix}`);
return join(parameterValues, separator);
}
return this.value;
}
toUrlParams() {
const prefix = this.urlPrefix;
let urlParam = this.value;
if (this.multiValuesOptions && isArray(this.value)) {
urlParam = JSON.stringify(this.value);
}
return {
[`${prefix}${this.name}`]: !this.isEmpty ? urlParam : null,
};
}
fromUrlParams(query) {
const prefix = this.urlPrefix;
const key = `${prefix}${this.name}`;
if (has(query, key)) {
if (this.multiValuesOptions) {
try {
const valueFromJson = JSON.parse(query[key]);
this.setValue(isArray(valueFromJson) ? valueFromJson : query[key]);
} catch (e) {
this.setValue(query[key]);
}
} else {
this.setValue(query[key]);
}
}
}
loadDropdownValues() {
if (this.parentQueryId) {
return Query.associatedDropdown({ queryId: this.parentQueryId, dropdownQueryId: this.queryId }).catch(() =>
Promise.resolve([])
);
}
return Query.asDropdown({ id: this.queryId }).catch(Promise.resolve([]));
}
}
export default QueryBasedDropdownParameter;
================================================
FILE: client/app/services/parameters/TextParameter.js
================================================
import { toString, isEmpty } from "lodash";
import Parameter from "./Parameter";
class TextParameter extends Parameter {
constructor(parameter, parentQueryId) {
super(parameter, parentQueryId);
this.setValue(parameter.value);
}
// eslint-disable-next-line class-methods-use-this
normalizeValue(value) {
const normalizedValue = toString(value);
if (isEmpty(normalizedValue)) {
return null;
}
return normalizedValue;
}
}
export default TextParameter;
================================================
FILE: client/app/services/parameters/TextPatternParameter.js
================================================
import { toString, isNull } from "lodash";
import Parameter from "./Parameter";
class TextPatternParameter extends Parameter {
constructor(parameter, parentQueryId) {
super(parameter, parentQueryId);
this.regex = parameter.regex;
this.setValue(parameter.value);
}
// eslint-disable-next-line class-methods-use-this
normalizeValue(value) {
const normalizedValue = toString(value);
if (isNull(normalizedValue)) {
return null;
}
var re = new RegExp(this.regex);
if (re !== null) {
if (re.test(normalizedValue)) {
return normalizedValue;
}
}
return null;
}
}
export default TextPatternParameter;
================================================
FILE: client/app/services/parameters/index.js
================================================
import Parameter from "./Parameter";
import TextParameter from "./TextParameter";
import NumberParameter from "./NumberParameter";
import EnumParameter from "./EnumParameter";
import QueryBasedDropdownParameter from "./QueryBasedDropdownParameter";
import DateParameter from "./DateParameter";
import DateRangeParameter from "./DateRangeParameter";
import TextPatternParameter from "./TextPatternParameter";
function createParameter(param, parentQueryId) {
switch (param.type) {
case "number":
return new NumberParameter(param, parentQueryId);
case "enum":
return new EnumParameter(param, parentQueryId);
case "query":
return new QueryBasedDropdownParameter(param, parentQueryId);
case "date":
case "datetime-local":
case "datetime-with-seconds":
return new DateParameter(param, parentQueryId);
case "date-range":
case "datetime-range":
case "datetime-range-with-seconds":
return new DateRangeParameter(param, parentQueryId);
case "text-pattern":
return new TextPatternParameter({ ...param, type: "text-pattern" }, parentQueryId);
default:
return new TextParameter({ ...param, type: "text" }, parentQueryId);
}
}
function cloneParameter(param) {
return createParameter(param, param.parentQueryId);
}
export {
Parameter,
TextParameter,
TextPatternParameter,
NumberParameter,
EnumParameter,
QueryBasedDropdownParameter,
DateParameter,
DateRangeParameter,
createParameter,
cloneParameter,
};
================================================
FILE: client/app/services/parameters/tests/DateParameter.test.js
================================================
import { createParameter } from "..";
import { getDynamicDateFromString } from "../DateParameter";
import moment from "moment";
describe("DateParameter", () => {
let type = "date";
let param;
beforeEach(() => {
param = createParameter({ name: "param", title: "Param", type });
});
describe("getExecutionValue", () => {
beforeEach(() => {
param.setValue(moment("2019-10-06 10:00:00"));
});
test("formats value as a string date", () => {
const executionValue = param.getExecutionValue();
expect(executionValue).toBe("2019-10-06");
});
describe("type is datetime-local", () => {
beforeAll(() => {
type = "datetime-local";
});
test("formats value as a string datetime", () => {
const executionValue = param.getExecutionValue();
expect(executionValue).toBe("2019-10-06 10:00");
});
});
describe("type is datetime-with-seconds", () => {
beforeAll(() => {
type = "datetime-with-seconds";
});
test("formats value as a string datetime with seconds", () => {
const executionValue = param.getExecutionValue();
expect(executionValue).toBe("2019-10-06 10:00:00");
});
});
});
describe("normalizeValue", () => {
test("recognizes dates from strings", () => {
const normalizedValue = param.normalizeValue("2019-10-06");
expect(moment.isMoment(normalizedValue)).toBeTruthy();
expect(normalizedValue.format("YYYY-MM-DD")).toBe("2019-10-06");
});
test("recognizes dates from moment values", () => {
const normalizedValue = param.normalizeValue(moment("2019-10-06"));
expect(moment.isMoment(normalizedValue)).toBeTruthy();
expect(normalizedValue.format("YYYY-MM-DD")).toBe("2019-10-06");
});
test("normalizes unrecognized values as null", () => {
const normalizedValue = param.normalizeValue("value");
expect(normalizedValue).toBeNull();
});
describe("Dynamic values", () => {
test("recognizes dynamic values from string index", () => {
const normalizedValue = param.normalizeValue("d_now");
expect(normalizedValue).not.toBeNull();
expect(normalizedValue).toEqual(getDynamicDateFromString("d_now"));
});
test("recognizes dynamic values from a dynamic date", () => {
const dynamicDate = getDynamicDateFromString("d_now");
const normalizedValue = param.normalizeValue(dynamicDate);
expect(normalizedValue).not.toBeNull();
expect(normalizedValue).toEqual(dynamicDate);
});
});
});
});
================================================
FILE: client/app/services/parameters/tests/DateRangeParameter.test.js
================================================
import { createParameter } from "..";
import { getDynamicDateRangeFromString } from "../DateRangeParameter";
import moment from "moment";
describe("DateRangeParameter", () => {
let type = "date-range";
let param;
beforeEach(() => {
param = createParameter({ name: "param", title: "Param", type });
});
describe("getExecutionValue", () => {
beforeEach(() => {
param.setValue({ start: "2019-10-05 10:00:00", end: "2019-10-06 09:59:59" });
});
test("formats value as a string date", () => {
const executionValue = param.getExecutionValue();
expect(executionValue).toEqual({ start: "2019-10-05", end: "2019-10-06" });
});
describe("type is datetime-range", () => {
beforeAll(() => {
type = "datetime-range";
});
test("formats value as a string datetime", () => {
const executionValue = param.getExecutionValue();
expect(executionValue).toEqual({ start: "2019-10-05 10:00", end: "2019-10-06 09:59" });
});
});
describe("type is datetime-range-with-seconds", () => {
beforeAll(() => {
type = "datetime-range-with-seconds";
});
test("formats value as a string datetime with seconds", () => {
const executionValue = param.getExecutionValue();
expect(executionValue).toEqual({ start: "2019-10-05 10:00:00", end: "2019-10-06 09:59:59" });
});
});
});
describe("normalizeValue", () => {
test("recognizes dates from moment arrays", () => {
const normalizedValue = param.normalizeValue([moment("2019-10-05"), moment("2019-10-06")]);
expect(normalizedValue).toHaveLength(2);
expect(normalizedValue[0].format("YYYY-MM-DD")).toBe("2019-10-05");
expect(normalizedValue[1].format("YYYY-MM-DD")).toBe("2019-10-06");
});
test("recognizes dates from object", () => {
const normalizedValue = param.normalizeValue({ start: "2019-10-05", end: "2019-10-06" });
expect(normalizedValue).toHaveLength(2);
expect(normalizedValue[0].format("YYYY-MM-DD")).toBe("2019-10-05");
expect(normalizedValue[1].format("YYYY-MM-DD")).toBe("2019-10-06");
});
describe("Dynamic values", () => {
test("recognizes dynamic values from string index", () => {
const normalizedValue = param.normalizeValue("d_last_week");
expect(normalizedValue).not.toBeNull();
expect(normalizedValue).toEqual(getDynamicDateRangeFromString("d_last_week"));
});
test("recognizes dynamic values from a dynamic date range", () => {
const dynamicDateRange = getDynamicDateRangeFromString("d_last_week");
const normalizedValue = param.normalizeValue(dynamicDateRange);
expect(normalizedValue).not.toBeNull();
expect(normalizedValue).toEqual(dynamicDateRange);
});
});
});
});
================================================
FILE: client/app/services/parameters/tests/EnumParameter.test.js
================================================
import { createParameter } from "..";
describe("EnumParameter", () => {
let param;
let multiValuesOptions = null;
const enumOptions = "value1\nvalue2\nvalue3\nvalue4";
beforeEach(() => {
const paramOptions = {
name: "param",
title: "Param",
type: "enum",
enumOptions,
multiValuesOptions,
};
param = createParameter(paramOptions);
});
describe("normalizeValue", () => {
test("returns the value when the input in the enum options", () => {
const normalizedValue = param.normalizeValue("value2");
expect(normalizedValue).toBe("value2");
});
test("returns the first value when the input is not in the enum options", () => {
const normalizedValue = param.normalizeValue("anything");
expect(normalizedValue).toBe("value1");
});
});
describe("Multi-valued", () => {
beforeAll(() => {
multiValuesOptions = { prefix: '"', suffix: '"', separator: "," };
});
describe("normalizeValue", () => {
test("returns only valid values", () => {
const normalizedValue = param.normalizeValue(["value3", "anything", null]);
expect(normalizedValue).toEqual(["value3"]);
});
test("normalizes empty values as null", () => {
const normalizedValue = param.normalizeValue([]);
expect(normalizedValue).toBeNull();
});
});
describe("getExecutionValue", () => {
test("joins values when joinListValues is truthy", () => {
param.setValue(["value1", "value3"]);
const executionValue = param.getExecutionValue({ joinListValues: true });
expect(executionValue).toBe('"value1","value3"');
});
});
});
});
================================================
FILE: client/app/services/parameters/tests/NumberParameter.test.js
================================================
import { createParameter } from "..";
describe("NumberParameter", () => {
let param;
beforeEach(() => {
param = createParameter({ name: "param", title: "Param", type: "number" });
});
describe("normalizeValue", () => {
test("converts Strings", () => {
const normalizedValue = param.normalizeValue("15");
expect(normalizedValue).toBe(15);
});
test("converts Numbers", () => {
const normalizedValue = param.normalizeValue(42);
expect(normalizedValue).toBe(42);
});
test("returns null when not possible to convert to number", () => {
const normalizedValue = param.normalizeValue("notanumber");
expect(normalizedValue).toBeNull();
});
});
});
================================================
FILE: client/app/services/parameters/tests/Parameter.test.js
================================================
import {
createParameter,
TextParameter,
TextPatternParameter,
NumberParameter,
EnumParameter,
QueryBasedDropdownParameter,
DateParameter,
DateRangeParameter,
} from "..";
describe("Parameter", () => {
describe("create", () => {
const parameterTypes = [
["text", TextParameter],
["text-pattern", TextPatternParameter],
["number", NumberParameter],
["enum", EnumParameter],
["query", QueryBasedDropdownParameter],
["date", DateParameter],
["datetime-local", DateParameter],
["datetime-with-seconds", DateParameter],
["date-range", DateRangeParameter],
["datetime-range", DateRangeParameter],
["datetime-range-with-seconds", DateRangeParameter],
[null, TextParameter],
];
test.each(parameterTypes)("when type is '%s' creates a %p", (type, expectedClass) => {
const parameter = createParameter({ name: "param", title: "Param", type });
expect(parameter).toBeInstanceOf(expectedClass);
});
});
});
================================================
FILE: client/app/services/parameters/tests/QueryBasedDropdownParameter.test.js
================================================
import { createParameter } from "..";
describe("QueryBasedDropdownParameter", () => {
let param;
let multiValuesOptions = null;
beforeEach(() => {
const paramOptions = {
name: "param",
title: "Param",
type: "query",
queryId: 1,
multiValuesOptions,
};
param = createParameter(paramOptions);
});
describe("normalizeValue", () => {
test("returns the value when the input in the enum options", () => {
const normalizedValue = param.normalizeValue("value2");
expect(normalizedValue).toBe("value2");
});
describe("Empty values", () => {
const emptyValues = [null, undefined, []];
test.each(emptyValues)("normalizes empty value '%s' as null", emptyValue => {
const normalizedValue = param.normalizeValue(emptyValue);
expect(normalizedValue).toBeNull();
});
});
});
describe("Multi-valued", () => {
beforeAll(() => {
multiValuesOptions = { prefix: '"', suffix: '"', separator: "," };
});
describe("normalizeValue", () => {
test("returns an array with the input when input is not an array", () => {
const normalizedValue = param.normalizeValue("value");
expect(normalizedValue).toEqual(["value"]);
});
});
describe("getExecutionValue", () => {
test("joins values when joinListValues is truthy", () => {
param.setValue(["value1", "value3"]);
const executionValue = param.getExecutionValue({ joinListValues: true });
expect(executionValue).toBe('"value1","value3"');
});
});
});
});
================================================
FILE: client/app/services/parameters/tests/TextParameter.test.js
================================================
import { createParameter } from "..";
describe("TextParameter", () => {
let param;
beforeEach(() => {
param = createParameter({ name: "param", title: "Param", type: "text" });
});
describe("normalizeValue", () => {
test("converts Strings", () => {
const normalizedValue = param.normalizeValue("exampleString");
expect(normalizedValue).toBe("exampleString");
});
test("converts Numbers", () => {
const normalizedValue = param.normalizeValue(3);
expect(normalizedValue).toBe("3");
});
describe("Empty values", () => {
const emptyValues = [null, undefined, ""];
test.each(emptyValues)("normalizes empty value '%s' as null", emptyValue => {
const normalizedValue = param.normalizeValue(emptyValue);
expect(normalizedValue).toBeNull();
});
});
});
});
================================================
FILE: client/app/services/parameters/tests/TextPatternParameter.test.js
================================================
import { createParameter } from "..";
describe("TextPatternParameter", () => {
let param;
beforeEach(() => {
param = createParameter({ name: "param", title: "Param", type: "text-pattern", regex: "a+" });
});
describe("noramlizeValue", () => {
test("converts matching strings", () => {
const normalizedValue = param.normalizeValue("art");
expect(normalizedValue).toBe("art");
});
test("returns null when string does not match pattern", () => {
const normalizedValue = param.normalizeValue("brt");
expect(normalizedValue).toBeNull();
});
});
});
================================================
FILE: client/app/services/policy/DefaultPolicy.js
================================================
import { get, isArray } from "lodash";
import { currentUser, clientConfig } from "@/services/auth";
/* eslint-disable class-methods-use-this */
export default class DefaultPolicy {
refresh() {
return Promise.resolve(this);
}
canCreateDataSource() {
return currentUser.isAdmin;
}
isCreateDataSourceEnabled() {
return currentUser.isAdmin;
}
canCreateDestination() {
return currentUser.isAdmin;
}
isCreateDestinationEnabled() {
return currentUser.isAdmin;
}
canCreateDashboard() {
return currentUser.hasPermission("create_dashboard");
}
isCreateDashboardEnabled() {
return currentUser.hasPermission("create_dashboard");
}
canCreateAlert() {
return true;
}
canCreateUser() {
return currentUser.isAdmin;
}
isCreateUserEnabled() {
return currentUser.isAdmin;
}
isCreateQuerySnippetEnabled() {
return true;
}
getDashboardRefreshIntervals() {
const result = clientConfig.dashboardRefreshIntervals;
return isArray(result) ? result : null;
}
getQueryRefreshIntervals() {
const result = clientConfig.queryRefreshIntervals;
return isArray(result) ? result : null;
}
canEdit(object) {
return get(object, "can_edit", false);
}
canRun() {
return true;
}
}
================================================
FILE: client/app/services/policy/index.js
================================================
import DefaultPolicy from "./DefaultPolicy";
// eslint-disable-next-line import/no-mutable-exports
export let policy = new DefaultPolicy();
export function setPolicy(newPolicy) {
policy = newPolicy;
}
================================================
FILE: client/app/services/query-result.js
================================================
import debug from "debug";
import moment from "moment";
import { axios } from "@/services/axios";
import { QueryResultError } from "@/services/query";
import { Auth } from "@/services/auth";
import { isString, uniqBy, each, isNumber, includes, extend, forOwn, get } from "lodash";
const logger = debug("redash:services:QueryResult");
const filterTypes = ["filter", "multi-filter", "multiFilter"];
function defer() {
const result = { onStatusChange: (status) => {} };
result.promise = new Promise((resolve, reject) => {
result.resolve = resolve;
result.reject = reject;
});
return result;
}
function getColumnNameWithoutType(column) {
let typeSplit;
if (column.indexOf("::") !== -1) {
typeSplit = "::";
} else if (column.indexOf("__") !== -1) {
typeSplit = "__";
} else {
return column;
}
const parts = column.split(typeSplit);
if (parts[0] === "" && parts.length === 2) {
return parts[1];
}
if (!includes(filterTypes, parts[1])) {
return column;
}
return parts[0];
}
function getColumnFriendlyName(column) {
return getColumnNameWithoutType(column).replace(/(?:^|\s)\S/g, (a) => a.toUpperCase());
}
const createOrSaveUrl = (data) => (data.id ? `api/query_results/${data.id}` : "api/query_results");
const QueryResultResource = {
get: ({ id }) => axios.get(`api/query_results/${id}`),
post: (data) => axios.post(createOrSaveUrl(data), data),
};
export const ExecutionStatus = {
WAITING: "waiting",
PROCESSING: "processing",
DONE: "done",
FAILED: "failed",
LOADING_RESULT: "loading-result",
};
const statuses = {
1: ExecutionStatus.WAITING,
2: ExecutionStatus.PROCESSING,
3: ExecutionStatus.DONE,
4: ExecutionStatus.FAILED,
};
function handleErrorResponse(queryResult, error) {
const status = get(error, "response.status");
switch (status) {
case 403:
queryResult.update(error.response.data);
return;
case 400:
if ("job" in error.response.data) {
queryResult.update(error.response.data);
return;
}
break;
case 404:
queryResult.update({
job: {
error: "cached query result unavailable, please execute again.",
status: 4,
},
});
return;
// no default
}
logger("Unknown error", error);
queryResult.update({
job: {
error: get(error, "response.data.message", "Unknown error occurred. Please try again later."),
status: 4,
},
});
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function fetchDataFromJob(jobId, interval = 1000) {
return axios.get(`api/jobs/${jobId}`).then((data) => {
const status = statuses[data.job.status];
if (status === ExecutionStatus.WAITING || status === ExecutionStatus.PROCESSING) {
return sleep(interval).then(() => fetchDataFromJob(data.job.id));
} else if (status === ExecutionStatus.DONE) {
return data.job.result;
} else if (status === ExecutionStatus.FAILED) {
return Promise.reject(data.job.error);
}
});
}
export function isDateTime(v) {
return isString(v) && moment(v, moment.ISO_8601, true).isValid() && /^\d{4}-\d{2}-\d{2}T/.test(v);
}
class QueryResult {
constructor(props) {
this.deferred = defer();
this.job = {};
this.query_result = {};
this.status = "waiting";
this.updatedAt = moment();
// extended status flags
this.isLoadingResult = false;
if (props) {
this.update(props);
}
}
update(props) {
extend(this, props);
if ("query_result" in props) {
this.status = ExecutionStatus.DONE;
this.deferred.onStatusChange(ExecutionStatus.DONE);
const columnTypes = {};
// TODO: we should stop manipulating incoming data, and switch to relaying
// on the column type set by the backend. This logic is prone to errors,
// and better be removed. Kept for now, for backward compatability.
each(this.query_result.data.rows, (row) => {
forOwn(row, (v, k) => {
let newType = null;
if (isNumber(v)) {
newType = "float";
} else if (isDateTime(v)) {
row[k] = moment.utc(v);
newType = "datetime";
} else if (isString(v) && v.match(/^\d{4}-\d{2}-\d{2}$/)) {
row[k] = moment.utc(v);
newType = "date";
} else if (typeof v === "object" && v !== null) {
row[k] = JSON.stringify(v);
} else {
newType = "string";
}
if (newType !== null) {
if (columnTypes[k] !== undefined && columnTypes[k] !== newType) {
columnTypes[k] = "string";
} else {
columnTypes[k] = newType;
}
}
});
});
each(this.query_result.data.columns, (column) => {
column.name = "" + column.name;
if (columnTypes[column.name]) {
if (column.type == null || column.type === "string") {
column.type = columnTypes[column.name];
}
}
});
this.deferred.resolve(this);
} else if (this.job.status === 3 || this.job.status === 2) {
this.deferred.onStatusChange(ExecutionStatus.PROCESSING);
this.status = "processing";
} else if (this.job.status === 4) {
this.status = statuses[this.job.status];
this.deferred.reject(new QueryResultError(this.job.error));
} else {
this.deferred.onStatusChange(undefined);
this.status = undefined;
}
}
getId() {
let id = null;
if ("query_result" in this) {
id = this.query_result.id;
}
return id;
}
cancelExecution() {
axios.delete(`api/jobs/${this.job.id}`);
}
getStatus() {
if (this.isLoadingResult) {
return ExecutionStatus.LOADING_RESULT;
}
return this.status || statuses[this.job.status];
}
getError() {
// TODO: move this logic to the server...
if (this.job.error === "None") {
return undefined;
}
return this.job.error;
}
getLog() {
if (!this.query_result.data || !this.query_result.data.log || this.query_result.data.log.length === 0) {
return null;
}
return this.query_result.data.log;
}
getUpdatedAt() {
return this.query_result.retrieved_at || this.job.updated_at * 1000.0 || this.updatedAt;
}
getRuntime() {
return this.query_result.runtime;
}
getRawData() {
if (!this.query_result.data) {
return null;
}
return this.query_result.data.rows;
}
getData() {
return this.query_result.data ? this.query_result.data.rows : null;
}
isEmpty() {
return this.getData() === null || this.getData().length === 0;
}
getColumns() {
if (this.columns === undefined && this.query_result.data) {
this.columns = this.query_result.data.columns;
}
return this.columns;
}
getColumnNames() {
if (this.columnNames === undefined && this.query_result.data) {
this.columnNames = this.query_result.data.columns.map((v) => v.name);
}
return this.columnNames;
}
getColumnFriendlyNames() {
return this.getColumnNames().map((col) => getColumnFriendlyName(col));
}
getTruncated() {
return this.query_result.data ? this.query_result.data.truncated : null;
}
getFilters() {
if (!this.getColumns()) {
return [];
}
const filters = [];
this.getColumns().forEach((col) => {
const name = col.name;
const type = name.split("::")[1] || name.split("__")[1];
if (includes(filterTypes, type)) {
// filter found
const filter = {
name,
friendlyName: getColumnFriendlyName(name),
column: col,
values: [],
multiple: type === "multiFilter" || type === "multi-filter",
};
filters.push(filter);
}
}, this);
this.getRawData().forEach((row) => {
filters.forEach((filter) => {
filter.values.push(row[filter.name]);
if (filter.values.length === 1) {
if (filter.multiple) {
filter.current = [row[filter.name]];
} else {
filter.current = row[filter.name];
}
}
});
});
filters.forEach((filter) => {
filter.values = uniqBy(filter.values, (v) => {
if (moment.isMoment(v)) {
return v.unix();
}
return v;
});
if (filter.values.length > 1 && filter.multiple) {
filter.current = filter.values.slice();
}
});
return filters;
}
toPromise(statusCallback) {
if (statusCallback) {
this.deferred.onStatusChange = statusCallback;
}
return this.deferred.promise;
}
static getById(queryId, id) {
const queryResult = new QueryResult();
queryResult.isLoadingResult = true;
queryResult.deferred.onStatusChange(ExecutionStatus.LOADING_RESULT);
axios
.get(`api/queries/${queryId}/results/${id}.json`)
.then((response) => {
// Success handler
queryResult.isLoadingResult = false;
queryResult.update(response);
})
.catch((error) => {
// Error handler
queryResult.isLoadingResult = false;
handleErrorResponse(queryResult, error);
});
return queryResult;
}
loadLatestCachedResult(queryId, parameters) {
axios
.post(`api/queries/${queryId}/results`, { queryId, parameters })
.then((response) => {
this.update(response);
})
.catch((error) => {
handleErrorResponse(this, error);
});
}
loadResult(tryCount) {
this.isLoadingResult = true;
this.deferred.onStatusChange(ExecutionStatus.LOADING_RESULT);
QueryResultResource.get({ id: this.job.query_result_id })
.then((response) => {
this.update(response);
this.isLoadingResult = false;
})
.catch((error) => {
if (tryCount === undefined) {
tryCount = 0;
}
if (tryCount > 3) {
logger("Connection error while trying to load result", error);
this.update({
job: {
error: "failed communicating with server. Please check your Internet connection and try again.",
status: 4,
},
});
this.isLoadingResult = false;
} else {
setTimeout(
() => {
this.loadResult(tryCount + 1);
},
1000 * Math.pow(2, tryCount)
);
}
});
}
refreshStatus(query, parameters, tryNumber = 1) {
const loadResult = () =>
Auth.isAuthenticated() ? this.loadResult() : this.loadLatestCachedResult(query, parameters);
const request = Auth.isAuthenticated()
? axios.get(`api/jobs/${this.job.id}`)
: axios.get(`api/queries/${query}/jobs/${this.job.id}`);
request
.then((jobResponse) => {
this.update(jobResponse);
if (this.getStatus() === "processing" && this.job.query_result_id && this.job.query_result_id !== "None") {
loadResult();
} else if (this.getStatus() !== "failed") {
let waitTime;
if (tryNumber <= 10) {
waitTime = 500;
} else if (tryNumber <= 50) {
waitTime = 1000;
} else {
waitTime = 3000;
}
setTimeout(() => {
this.refreshStatus(query, parameters, tryNumber + 1);
}, waitTime);
}
})
.catch((error) => {
logger("Connection error", error);
// TODO: use QueryResultError, or better yet: exception/reject of promise.
this.update({
job: {
error: "failed communicating with server. Please check your Internet connection and try again.",
status: 4,
},
});
});
}
getLink(queryId, fileType, apiKey) {
let link = `api/queries/${queryId}/results/${this.getId()}.${fileType}`;
if (apiKey) {
link = `${link}?api_key=${apiKey}`;
}
return link;
}
getName(queryName, fileType) {
return `${queryName.replace(/ /g, "_") + moment(this.getUpdatedAt()).format("_YYYY_MM_DD")}.${fileType}`;
}
static getByQueryId(id, parameters, applyAutoLimit, maxAge) {
const queryResult = new QueryResult();
axios
.post(`api/queries/${id}/results`, { id, parameters, apply_auto_limit: applyAutoLimit, max_age: maxAge })
.then((response) => {
queryResult.update(response);
if ("job" in response) {
queryResult.refreshStatus(id, parameters);
}
})
.catch((error) => {
handleErrorResponse(queryResult, error);
});
return queryResult;
}
static get(dataSourceId, query, parameters, applyAutoLimit, maxAge, queryId) {
const queryResult = new QueryResult();
const params = {
data_source_id: dataSourceId,
parameters,
query,
apply_auto_limit: applyAutoLimit,
max_age: maxAge,
};
if (queryId !== undefined) {
params.query_id = queryId;
}
QueryResultResource.post(params)
.then((response) => {
queryResult.update(response);
if ("job" in response) {
queryResult.refreshStatus(query, parameters);
}
})
.catch((error) => {
handleErrorResponse(queryResult, error);
});
return queryResult;
}
}
export default QueryResult;
================================================
FILE: client/app/services/query-result.test.js
================================================
import { isDateTime } from "@/services/query-result";
describe("isDateTime", () => {
it.each([
["2022-01-01T00:00:00", true],
["2022-01-01T00:00:00+09:00", true],
["2021-01-27T00:00:01.733983944+03:00 stderr F {", false],
["2021-01-27Z00:00:00+09:00", false],
["2021-01-27", false],
["foo bar", false],
[2022, false],
[null, false],
["", false],
])("isDateTime('%s'). expected '%s'.", (value, expected) => {
expect(isDateTime(value)).toBe(expected);
});
});
================================================
FILE: client/app/services/query-snippet.js
================================================
import { axios } from "@/services/axios";
import { extend, map } from "lodash";
class QuerySnippet {
constructor(querySnippet) {
extend(this, querySnippet);
}
getSnippet() {
let name = this.trigger;
if (this.description !== "") {
name = `${this.trigger}: ${this.description}`;
}
return {
name,
content: this.snippet,
tabTrigger: this.trigger,
};
}
}
const getQuerySnippet = querySnippet => new QuerySnippet(querySnippet);
const QuerySnippetService = {
get: data => axios.get(`api/query_snippets/${data.id}`).then(getQuerySnippet),
query: () => axios.get("api/query_snippets").then(data => map(data, getQuerySnippet)),
create: data => axios.post("api/query_snippets", data).then(getQuerySnippet),
save: data => axios.post(`api/query_snippets/${data.id}`, data).then(getQuerySnippet),
delete: data => axios.delete(`api/query_snippets/${data.id}`),
};
export default QuerySnippetService;
================================================
FILE: client/app/services/query.js
================================================
import moment from "moment";
import debug from "debug";
import Mustache from "mustache";
import { axios } from "@/services/axios";
import {
zipObject,
isEmpty,
isArray,
map,
filter,
includes,
union,
uniq,
has,
identity,
extend,
each,
some,
clone,
find,
} from "lodash";
import location from "@/services/location";
import { Parameter, createParameter } from "./parameters";
import { currentUser } from "./auth";
import QueryResult from "./query-result";
import localOptions from "@/lib/localOptions";
Mustache.escape = identity; // do not html-escape values
const logger = debug("redash:services:query");
function collectParams(parts) {
let parameters = [];
parts.forEach(part => {
if (part[0] === "name" || part[0] === "&") {
parameters.push(part[1].split(".")[0]);
} else if (part[0] === "#") {
parameters = union(parameters, collectParams(part[4]));
}
});
return parameters;
}
export class Query {
constructor(query) {
extend(this, query);
if (!has(this, "options")) {
this.options = {};
}
this.options.apply_auto_limit = !!this.options.apply_auto_limit;
if (!isArray(this.options.parameters)) {
this.options.parameters = [];
}
}
isNew() {
return this.id === undefined;
}
hasDailySchedule() {
return this.schedule && this.schedule.match(/\d\d:\d\d/) !== null;
}
scheduleInLocalTime() {
const parts = this.schedule.split(":");
return moment
.utc()
.hour(parts[0])
.minute(parts[1])
.local()
.format("HH:mm");
}
hasResult() {
return !!(this.latest_query_data || this.latest_query_data_id);
}
paramsRequired() {
return this.getParameters().isRequired();
}
hasParameters() {
return this.getParametersDefs().length > 0;
}
prepareQueryResultExecution(execute, maxAge) {
const parameters = this.getParameters();
const missingParams = parameters.getMissing();
if (missingParams.length > 0) {
let paramsWord = "parameter";
let valuesWord = "value";
if (missingParams.length > 1) {
paramsWord = "parameters";
valuesWord = "values";
}
return new QueryResult({
job: {
error: `missing ${valuesWord} for ${missingParams.join(", ")} ${paramsWord}.`,
status: 4,
},
});
}
if (parameters.isRequired()) {
// Need to clear latest results, to make sure we don't use results for different params.
this.latest_query_data = null;
this.latest_query_data_id = null;
}
if (this.latest_query_data && maxAge !== 0) {
if (!this.queryResult) {
this.queryResult = new QueryResult({
query_result: this.latest_query_data,
});
}
} else if (this.latest_query_data_id && maxAge !== 0) {
if (!this.queryResult) {
this.queryResult = QueryResult.getById(this.id, this.latest_query_data_id);
}
} else {
this.queryResult = execute();
}
return this.queryResult;
}
getQueryResult(maxAge) {
const execute = () =>
QueryResult.getByQueryId(this.id, this.getParameters().getExecutionValues(), this.getAutoLimit(), maxAge);
return this.prepareQueryResultExecution(execute, maxAge);
}
getQueryResultByText(maxAge, selectedQueryText) {
const queryText = selectedQueryText || this.query;
if (!queryText) {
return new QueryResultError("Can't execute empty query.");
}
const parameters = this.getParameters().getExecutionValues({ joinListValues: true });
const execute = () =>
QueryResult.get(this.data_source_id, queryText, parameters, this.getAutoLimit(), maxAge, this.id);
return this.prepareQueryResultExecution(execute, maxAge);
}
getUrl(source, hash) {
let url = `queries/${this.id}`;
if (source) {
url += "/source";
}
let params = {};
if (this.getParameters().isRequired()) {
this.getParametersDefs().forEach(param => {
extend(params, param.toUrlParams());
});
}
Object.keys(params).forEach(key => params[key] == null && delete params[key]);
params = map(params, (value, name) => `${encodeURIComponent(name)}=${encodeURIComponent(value)}`).join("&");
if (params !== "") {
url += `?${params}`;
}
if (hash) {
url += `#${hash}`;
}
return url;
}
getQueryResultPromise() {
return this.getQueryResult().toPromise();
}
getParameters() {
if (!this.$parameters) {
this.$parameters = new Parameters(this, location.search);
}
return this.$parameters;
}
getAutoLimit() {
return this.options.apply_auto_limit;
}
getParametersDefs(update = true) {
return this.getParameters().get(update);
}
favorite() {
return Query.favorite(this);
}
unfavorite() {
return Query.unfavorite(this);
}
clone() {
const newQuery = clone(this);
newQuery.$parameters = null;
newQuery.getParameters();
return newQuery;
}
}
class Parameters {
constructor(query, queryString) {
this.query = query;
this.updateParameters();
this.initFromQueryString(queryString);
}
parseQuery() {
const fallback = () => map(this.query.options.parameters, i => i.name);
let parameters = [];
if (this.query.query !== undefined) {
try {
const parts = Mustache.parse(this.query.query);
parameters = uniq(collectParams(parts));
} catch (e) {
logger("Failed parsing parameters: ", e);
// Return current parameters so we don't reset the list
parameters = fallback();
}
} else {
parameters = fallback();
}
return parameters;
}
updateParameters(update) {
if (this.query.query === this.cachedQueryText) {
const parameters = this.query.options.parameters;
const hasUnprocessedParameters = find(parameters, p => !(p instanceof Parameter));
if (hasUnprocessedParameters) {
this.query.options.parameters = map(parameters, p =>
p instanceof Parameter ? p : createParameter(p, this.query.id)
);
}
return;
}
this.cachedQueryText = this.query.query;
const parameterNames = update ? this.parseQuery() : map(this.query.options.parameters, p => p.name);
this.query.options.parameters = this.query.options.parameters || [];
const parametersMap = {};
this.query.options.parameters.forEach(param => {
parametersMap[param.name] = param;
});
parameterNames.forEach(param => {
if (!has(parametersMap, param)) {
this.query.options.parameters.push(
createParameter({
title: param,
name: param,
type: "text",
value: null,
global: false,
})
);
}
});
const parameterExists = p => includes(parameterNames, p.name);
const parameters = this.query.options.parameters;
this.query.options.parameters = parameters
.filter(parameterExists)
.map(p => (p instanceof Parameter ? p : createParameter(p, this.query.id)));
}
initFromQueryString(query) {
this.get().forEach(param => {
param.fromUrlParams(query);
});
}
get(update = true) {
this.updateParameters(update);
return this.query.options.parameters;
}
add(parameterDef) {
this.query.options.parameters = this.query.options.parameters.filter(p => p.name !== parameterDef.name);
const param = createParameter(parameterDef);
this.query.options.parameters.push(param);
return param;
}
getMissing() {
return map(
filter(this.get(), p => p.isEmpty),
i => i.title
);
}
isRequired() {
return !isEmpty(this.get());
}
getExecutionValues(extra = {}) {
const params = this.get();
return zipObject(
map(params, i => i.name),
map(params, i => i.getExecutionValue(extra))
);
}
hasPendingValues() {
return some(this.get(), p => p.hasPendingValue);
}
applyPendingValues() {
each(this.get(), p => p.applyPendingValue());
}
toUrlParams() {
if (this.get().length === 0) {
return "";
}
const params = Object.assign(...this.get().map(p => p.toUrlParams()));
Object.keys(params).forEach(key => params[key] == null && delete params[key]);
return Object.keys(params)
.map(k => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`)
.join("&");
}
}
export class QueryResultError {
constructor(errorMessage) {
this.errorMessage = errorMessage;
this.updatedAt = moment.utc();
}
getUpdatedAt() {
return this.updatedAt;
}
getError() {
return this.errorMessage;
}
toPromise() {
return Promise.reject(this);
}
// eslint-disable-next-line class-methods-use-this
getStatus() {
return "failed";
}
// eslint-disable-next-line class-methods-use-this
getData() {
return null;
}
// eslint-disable-next-line class-methods-use-this
getLog() {
return null;
}
}
const getQuery = query => new Query(query);
const saveOrCreateUrl = data => (data.id ? `api/queries/${data.id}` : "api/queries");
const mapResults = data => ({ ...data, results: map(data.results, getQuery) });
const QueryService = {
query: params => axios.get("api/queries", { params }).then(mapResults),
get: data => axios.get(`api/queries/${data.id}`, data).then(getQuery),
save: data => axios.post(saveOrCreateUrl(data), data).then(getQuery),
delete: data => axios.delete(`api/queries/${data.id}`),
recent: params => axios.get(`api/queries/recent`, { params }).then(data => map(data, getQuery)),
archive: params => axios.get(`api/queries/archive`, { params }).then(mapResults),
myQueries: params => axios.get("api/queries/my", { params }).then(mapResults),
fork: ({ id }) => axios.post(`api/queries/${id}/fork`, { id }).then(getQuery),
resultById: data => axios.get(`api/queries/${data.id}/results.json`),
asDropdown: data => axios.get(`api/queries/${data.id}/dropdown`),
associatedDropdown: ({ queryId, dropdownQueryId }) =>
axios.get(`api/queries/${queryId}/dropdowns/${dropdownQueryId}`),
favorites: params => axios.get("api/queries/favorites", { params }).then(mapResults),
favorite: data => axios.post(`api/queries/${data.id}/favorite`),
unfavorite: data => axios.delete(`api/queries/${data.id}/favorite`),
};
QueryService.newQuery = function newQuery() {
return new Query({
query: "",
name: "New Query",
schedule: null,
user: currentUser,
options: { apply_auto_limit: localOptions.get("applyAutoLimit", true) },
tags: [],
can_edit: true,
});
};
extend(Query, QueryService);
================================================
FILE: client/app/services/recordEvent.js
================================================
import { axios } from "@/services/axios";
import { debounce, extend } from "lodash";
let events = [];
const post = debounce(() => {
const eventsToSend = events;
events = [];
axios.post("api/events", eventsToSend);
}, 1000);
export default function recordEvent(action, objectType, objectId, additionalProperties) {
const event = {
action,
object_type: objectType,
object_id: objectId,
timestamp: Date.now() / 1000.0,
screen_resolution: `${window.screen.width}x${window.screen.height}`,
};
extend(event, additionalProperties);
events.push(event);
post();
}
================================================
FILE: client/app/services/resizeObserver.js
================================================
const items = new Map();
function checkItems() {
if (items.size > 0) {
items.forEach((item, node) => {
const bounds = node.getBoundingClientRect();
// convert to int (because these numbers needed for comparisons), but preserve 1 decimal point
const width = Math.round(bounds.width * 10);
const height = Math.round(bounds.height * 10);
if (item.width !== width || item.height !== height) {
item.width = width;
item.height = height;
item.callback(node);
}
});
setTimeout(checkItems, 100);
}
}
export default function observe(node, callback) {
if (node && !items.has(node)) {
const shouldTrigger = items.size === 0;
items.set(node, { callback });
if (shouldTrigger) {
checkItems();
}
return () => items.delete(node);
}
return () => {};
}
================================================
FILE: client/app/services/restoreSession.jsx
================================================
import { map } from "lodash";
import React from "react";
import Modal from "antd/lib/modal";
import { Auth } from "@/services/auth";
const SESSION_RESTORED_MESSAGE = "redash_session_restored";
export function notifySessionRestored() {
if (window.opener) {
window.opener.postMessage({ type: SESSION_RESTORED_MESSAGE }, window.location.origin);
}
}
function getPopupPosition(width, height) {
const windowLeft = window.screenX;
const windowTop = window.screenY;
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
return {
left: Math.floor((windowWidth - width) / 2 + windowLeft),
top: Math.floor((windowHeight - height) / 2 + windowTop),
width: Math.floor(width),
height: Math.floor(height),
};
}
function showRestoreSessionPrompt(loginUrl, onSuccess) {
let popup = null;
Modal.warning({
content: "Your session has expired. Please login to continue.",
okText: (
Login
(opens in a new tab)
),
centered: true,
mask: true,
maskClosable: false,
keyboard: false,
onOk: closeModal => {
if (popup && !popup.closed) {
popup.focus();
return; // popup already shown
}
const popupOptions = {
...getPopupPosition(640, 640),
menubar: "no",
toolbar: "no",
location: "yes",
resizable: "yes",
scrollbars: "yes",
status: "yes",
};
popup = window.open(loginUrl, "Restore Session", map(popupOptions, (value, key) => `${key}=${value}`).join(","));
const handlePostMessage = event => {
if (event.data.type === SESSION_RESTORED_MESSAGE) {
if (popup) {
popup.close();
}
popup = null;
window.removeEventListener("message", handlePostMessage);
closeModal();
onSuccess();
}
};
window.addEventListener("message", handlePostMessage, false);
},
});
}
let restoreSessionPromise = null;
export function restoreSession() {
if (!restoreSessionPromise) {
restoreSessionPromise = new Promise(resolve => {
showRestoreSessionPrompt(Auth.getLoginUrl(), () => {
restoreSessionPromise = null;
resolve();
});
});
}
return restoreSessionPromise;
}
================================================
FILE: client/app/services/routes.ts
================================================
import { isString, isObject, filter, sortBy } from "lodash";
import React from "react";
import { Context, Route as UniversalRouterRoute } from "universal-router";
import pathToRegexp from "path-to-regexp";
export interface CurrentRoute {
id: string | null;
key?: string;
title: string;
routeParams: P;
}
export interface RedashRoute
extends UniversalRouterRoute {
path: string; // we don't use other UniversalRouterRoute options, path should be available and should be a string
key?: string; // generated in Router.jsx
title: string;
render?: (currentRoute: CurrentRoute) => React.ReactNode;
getApiKey?: () => string;
}
interface RouteItem extends RedashRoute {
id: string | null;
}
function getRouteParamsCount(path: string) {
const tokens = pathToRegexp.parse(path);
return filter(tokens, isObject).length;
}
class Routes {
_items: RouteItem[] = [];
_sorted = false;
get items(): RouteItem[] {
if (!this._sorted) {
this._items = sortBy(this._items, [
item => getRouteParamsCount(item.path), // simple definitions first, with more params - last
item => -item.path.length, // longer first
item => item.path, // if same type and length - sort alphabetically
]);
this._sorted = true;
}
return this._items;
}
public register(id: string, route: RedashRoute
) {
const idOrNull = isString(id) ? id : null;
this.unregister(idOrNull);
if (isObject(route)) {
this._items = [...this.items, { ...route, id: idOrNull }];
this._sorted = false;
}
}
public unregister(id: string | null) {
if (isString(id)) {
// removing item does not break their order (if already sorted)
this._items = filter(this.items, item => item.id !== id);
}
}
}
export default new Routes();
================================================
FILE: client/app/services/sanitize.js
================================================
import { isString } from "lodash";
import DOMPurify from "dompurify";
DOMPurify.setConfig({
ADD_ATTR: ["target"],
});
DOMPurify.addHook("afterSanitizeAttributes", function(node) {
// Fix elements with `target` attribute:
// - allow only `target="_blank"
// - add `rel="noopener noreferrer"` to prevent https://www.owasp.org/index.php/Reverse_Tabnabbing
const target = node.getAttribute("target");
if (isString(target) && target.toLowerCase() === "_blank") {
node.setAttribute("rel", "noopener noreferrer");
} else {
node.removeAttribute("target");
}
});
export { DOMPurify };
export default DOMPurify.sanitize;
================================================
FILE: client/app/services/settingsMenu.js
================================================
import { isString, isObject, isFunction, extend, omit, sortBy, find, filter } from "lodash";
import { stripBase } from "@/components/ApplicationArea/Router";
import { currentUser } from "@/services/auth";
class SettingsMenuItem {
constructor(menuItem) {
extend(this, { pathPrefix: `/${menuItem.path}` }, omit(menuItem, ["isActive", "isAvailable"]));
if (isFunction(menuItem.isActive)) {
this.isActive = menuItem.isActive;
}
if (isFunction(menuItem.isAvailable)) {
this.isAvailable = menuItem.isAvailable;
}
}
isActive(path) {
return path.startsWith(this.pathPrefix);
}
isAvailable() {
return this.permission === undefined || currentUser.hasPermission(this.permission);
}
}
class SettingsMenu {
items = [];
add(id, item) {
id = isString(id) ? id : null;
this.remove(id);
if (isObject(item)) {
this.items.push(new SettingsMenuItem({ ...item, id }));
this.items = sortBy(this.items, "order");
}
}
remove(id) {
if (isString(id)) {
this.items = filter(this.items, item => item.id !== id);
// removing item does not change order of other items, so no need to sort
}
}
getAvailableItems() {
return filter(this.items, item => item.isAvailable());
}
getActiveItem(path) {
const strippedPath = stripBase(path);
return find(this.items, item => item.isActive(strippedPath));
}
}
export default new SettingsMenu();
================================================
FILE: client/app/services/settingsMenu.test.js
================================================
import settingsMenu from "./settingsMenu";
const dataSourcesItem = {
permission: "admin",
title: "Data Sources",
path: "data_sources",
};
const usersItem = {
title: "Users",
path: "users",
};
settingsMenu.add(null, dataSourcesItem);
settingsMenu.add(null, usersItem);
describe("SettingsMenu", () => {
describe("isActive", () => {
test("works with non multi org paths", () => {
expect(settingsMenu.getActiveItem("/data_sources/").title).toBe(dataSourcesItem.title);
});
test("works with multi org paths", () => {
// Set base href:
const base = document.createElement("base");
base.setAttribute("href", "http://localhost/acme/");
document.head.appendChild(base);
expect(settingsMenu.getActiveItem("/acme/data_sources/")).toBeTruthy();
expect(settingsMenu.getActiveItem("/acme/data_sources/").title).toBe(dataSourcesItem.title);
});
});
});
================================================
FILE: client/app/services/url.js
================================================
import { pick, extend } from "lodash";
const link = document.createElement("a"); // the only way to get an instance of Location class
// add to document to apply href
link.style.display = "none";
document.body.appendChild(link);
const fragmentProps = ["origin", "protocol", "host", "hostname", "port", "pathname", "search", "hash", "href"];
export function parse(url) {
link.setAttribute("href", url);
return pick(link, fragmentProps);
}
export function stringify(fragments) {
extend(link, pick(fragments, fragmentProps));
return link.href; // absolute URL
}
export function normalize(url) {
link.setAttribute("href", url);
return link.href; // absolute URL
}
export default { parse, stringify, normalize };
================================================
FILE: client/app/services/user.js
================================================
import { isString, get, find } from "lodash";
import sanitize from "@/services/sanitize";
import { axios } from "@/services/axios";
import notification from "@/services/notification";
import { clientConfig } from "@/services/auth";
function getErrorMessage(error) {
return find([get(error, "response.data.message"), get(error, "response.statusText"), "Unknown error"], isString);
}
function disableResource(user) {
return `api/users/${user.id}/disable`;
}
function enableUser(user) {
const userName = sanitize(user.name);
return axios
.delete(disableResource(user))
.then(data => {
notification.success(`User ${userName} is now enabled.`);
user.is_disabled = false;
user.profile_image_url = data.profile_image_url;
return data;
})
.catch(error => {
notification.error("Cannot enable user", getErrorMessage(error));
});
}
function disableUser(user) {
const userName = sanitize(user.name);
return axios
.post(disableResource(user))
.then(data => {
notification.warning(`User ${userName} is now disabled.`);
user.is_disabled = true;
user.profile_image_url = data.profile_image_url;
return data;
})
.catch(error => {
notification.error("Cannot disable user", getErrorMessage(error));
});
}
function deleteUser(user) {
const userName = sanitize(user.name);
return axios
.delete(`api/users/${user.id}`)
.then(data => {
notification.warning(`User ${userName} has been deleted.`);
return data;
})
.catch(error => {
notification.error("Cannot delete user", getErrorMessage(error));
});
}
function convertUserInfo(user) {
return {
id: user.id,
name: user.name,
email: user.email,
profileImageUrl: user.profile_image_url,
apiKey: user.api_key,
groupIds: user.groups,
isDisabled: user.is_disabled,
isInvitationPending: user.is_invitation_pending,
};
}
function regenerateApiKey(user) {
return axios
.post(`api/users/${user.id}/regenerate_api_key`)
.then(data => {
notification.success("The API Key has been updated.");
return data.api_key;
})
.catch(error => {
notification.error("Failed regenerating API Key", getErrorMessage(error));
});
}
function sendPasswordReset(user) {
return axios
.post(`api/users/${user.id}/reset_password`)
.then(data => {
if (clientConfig.mailSettingsMissing) {
notification.warning("The mail server is not configured.");
return data.reset_link;
}
notification.success("Password reset email sent.");
})
.catch(error => {
notification.error("Failed to send password reset email", getErrorMessage(error));
});
}
function resendInvitation(user) {
return axios
.post(`api/users/${user.id}/invite`)
.then(data => {
if (clientConfig.mailSettingsMissing) {
notification.warning("The mail server is not configured.");
return data.invite_link;
}
notification.success("Invitation sent.");
})
.catch(error => {
notification.error("Failed to resend invitation", getErrorMessage(error));
});
}
const User = {
query: params => axios.get("api/users", { params }),
get: ({ id }) => axios.get(`api/users/${id}`),
create: data => axios.post(`api/users`, data),
save: data => axios.post(`api/users/${data.id}`, data),
enableUser,
disableUser,
deleteUser,
convertUserInfo,
regenerateApiKey,
sendPasswordReset,
resendInvitation,
};
export default User;
================================================
FILE: client/app/services/utils.js
================================================
// eslint-disable-next-line import/prefer-default-export
export function absoluteUrl(url) {
const urlObj = new URL(url, window.location);
urlObj.protocol = window.location.protocol;
urlObj.host = window.location.host;
return urlObj.toString();
}
================================================
FILE: client/app/services/visualization.js
================================================
import { axios } from "@/services/axios";
const saveOrCreateUrl = data => (data.id ? `api/visualizations/${data.id}` : "api/visualizations");
const Visualization = {
save: data => axios.post(saveOrCreateUrl(data), data),
delete: data => axios.delete(`api/visualizations/${data.id}`),
};
export default Visualization;
================================================
FILE: client/app/services/widget.js
================================================
import moment from "moment";
import { axios } from "@/services/axios";
import {
each,
pick,
extend,
isObject,
truncate,
keys,
difference,
filter,
map,
merge,
sortBy,
indexOf,
size,
includes,
} from "lodash";
import location from "@/services/location";
import { cloneParameter } from "@/services/parameters";
import dashboardGridOptions from "@/config/dashboard-grid-options";
import { registeredVisualizations } from "@redash/viz/lib";
import { Query } from "./query";
export const WidgetTypeEnum = {
TEXTBOX: "textbox",
VISUALIZATION: "visualization",
RESTRICTED: "restricted",
};
function calculatePositionOptions(widget) {
widget.width = 1; // Backward compatibility, user on back-end
const visualizationOptions = {
autoHeight: false,
sizeX: Math.round(dashboardGridOptions.columns / 2),
sizeY: dashboardGridOptions.defaultSizeY,
minSizeX: dashboardGridOptions.minSizeX,
maxSizeX: dashboardGridOptions.maxSizeX,
minSizeY: dashboardGridOptions.minSizeY,
maxSizeY: dashboardGridOptions.maxSizeY,
};
const config = widget.visualization ? registeredVisualizations[widget.visualization.type] : null;
if (isObject(config)) {
if (Object.prototype.hasOwnProperty.call(config, "autoHeight")) {
visualizationOptions.autoHeight = config.autoHeight;
}
// Width constraints
const minColumns = parseInt(config.minColumns, 10);
if (isFinite(minColumns) && minColumns >= 0) {
visualizationOptions.minSizeX = minColumns;
}
const maxColumns = parseInt(config.maxColumns, 10);
if (isFinite(maxColumns) && maxColumns >= 0) {
visualizationOptions.maxSizeX = Math.min(maxColumns, dashboardGridOptions.columns);
}
// Height constraints
// `minRows` is preferred, but it should be kept for backward compatibility
const height = parseInt(config.height, 10);
if (isFinite(height)) {
visualizationOptions.minSizeY = Math.ceil(height / dashboardGridOptions.rowHeight);
}
const minRows = parseInt(config.minRows, 10);
if (isFinite(minRows)) {
visualizationOptions.minSizeY = minRows;
}
const maxRows = parseInt(config.maxRows, 10);
if (isFinite(maxRows) && maxRows >= 0) {
visualizationOptions.maxSizeY = maxRows;
}
// Default dimensions
const defaultWidth = parseInt(config.defaultColumns, 10);
if (isFinite(defaultWidth) && defaultWidth > 0) {
visualizationOptions.sizeX = defaultWidth;
}
const defaultHeight = parseInt(config.defaultRows, 10);
if (isFinite(defaultHeight) && defaultHeight > 0) {
visualizationOptions.sizeY = defaultHeight;
}
}
return visualizationOptions;
}
export const ParameterMappingType = {
DashboardLevel: "dashboard-level",
WidgetLevel: "widget-level",
StaticValue: "static-value",
};
class Widget {
static MappingType = ParameterMappingType;
constructor(data) {
// Copy properties
extend(this, data);
const visualizationOptions = calculatePositionOptions(this);
this.options = this.options || {};
this.options.position = extend(
{},
visualizationOptions,
pick(this.options.position, ["col", "row", "sizeX", "sizeY", "autoHeight"])
);
if (this.options.position.sizeY < 0) {
this.options.position.autoHeight = true;
}
}
get type() {
if (this.visualization) {
return WidgetTypeEnum.VISUALIZATION;
} else if (this.restricted) {
return WidgetTypeEnum.RESTRICTED;
}
return WidgetTypeEnum.TEXTBOX;
}
getQuery() {
if (!this.query && this.visualization) {
this.query = new Query(this.visualization.query);
}
return this.query;
}
getQueryResult() {
return this.data;
}
getName() {
if (this.visualization) {
return `${this.visualization.query.name} (${this.visualization.name})`;
}
return truncate(this.text, 20);
}
load(force, maxAge) {
if (!this.visualization) {
return Promise.resolve();
}
// Both `this.data` and `this.queryResult` are query result objects;
// `this.data` is last loaded query result;
// `this.queryResult` is currently loading query result;
// while widget is refreshing, `this.data` !== `this.queryResult`
if (force || this.queryResult === undefined) {
this.loading = true;
this.refreshStartedAt = moment();
if (maxAge === undefined || force) {
maxAge = force ? 0 : undefined;
}
const queryResult = this.getQuery().getQueryResult(maxAge);
this.queryResult = queryResult;
queryResult
.toPromise()
.then(result => {
if (this.queryResult === queryResult) {
this.loading = false;
this.data = result;
}
return result;
})
.catch(error => {
if (this.queryResult === queryResult) {
this.loading = false;
this.data = error;
}
return error;
});
}
return this.queryResult.toPromise();
}
save(key, value) {
const data = pick(this, "options", "text", "id", "width", "dashboard_id", "visualization_id");
if (key && value) {
data[key] = merge({}, data[key], value); // done like this so `this.options` doesn't get updated by side-effect
}
let url = "api/widgets";
if (this.id) {
url = `${url}/${this.id}`;
}
return axios.post(url, data).then(data => {
each(data, (v, k) => {
this[k] = v;
});
return this;
});
}
delete() {
const url = `api/widgets/${this.id}`;
return axios.delete(url);
}
isStaticParam(param) {
const mappings = this.getParameterMappings();
const mappingType = mappings[param.name].type;
return mappingType === Widget.MappingType.StaticValue;
}
getParametersDefs() {
const mappings = this.getParameterMappings();
// textboxes does not have query
const params = this.getQuery() ? this.getQuery().getParametersDefs() : [];
const queryParams = location.search;
const localTypes = [Widget.MappingType.WidgetLevel, Widget.MappingType.StaticValue];
const localParameters = map(
filter(params, param => localTypes.indexOf(mappings[param.name].type) >= 0),
param => {
const mapping = mappings[param.name];
const result = cloneParameter(param);
result.title = mapping.title || param.title;
result.locals = [param];
result.urlPrefix = `p_w${this.id}_`;
if (mapping.type === Widget.MappingType.StaticValue) {
result.setValue(mapping.value);
} else {
result.fromUrlParams(queryParams);
}
return result;
}
);
// order widget params using paramOrder
return sortBy(localParameters, param =>
includes(this.options.paramOrder, param.name)
? indexOf(this.options.paramOrder, param.name)
: size(this.options.paramOrder)
);
}
getParameterMappings() {
if (!isObject(this.options.parameterMappings)) {
this.options.parameterMappings = {};
}
const existingParams = {};
// textboxes does not have query
const params = this.getQuery() ? this.getQuery().getParametersDefs(false) : [];
each(params, param => {
existingParams[param.name] = true;
if (!isObject(this.options.parameterMappings[param.name])) {
// "migration" for old dashboards: parameters with `global` flag
// should be mapped to a dashboard-level parameter with the same name
this.options.parameterMappings[param.name] = {
name: param.name,
type: param.global ? Widget.MappingType.DashboardLevel : Widget.MappingType.WidgetLevel,
mapTo: param.name, // map to param with the same name
value: null, // for StaticValue
title: "", // Use parameter's title
};
}
});
// Remove mappings for parameters that do not exists anymore
const removedParams = difference(keys(this.options.parameterMappings), keys(existingParams));
each(removedParams, name => {
delete this.options.parameterMappings[name];
});
return this.options.parameterMappings;
}
getLocalParameters() {
return filter(this.getParametersDefs(), param => !this.isStaticParam(param));
}
}
export default Widget;
================================================
FILE: client/app/styles/formStyle.less
================================================
.ant-form-horizontal--labels-left {
.ant-form-item-label {
text-align: left;
white-space: normal;
> label::after {
content: none; // Do not show ":" next to label when they are aligned on left side
}
}
}
================================================
FILE: client/app/styles/formStyle.ts
================================================
import { FormProps } from "antd/lib/form/Form";
import { FormItemProps } from "antd/lib/form/FormItem";
import "./formStyle.less";
export function getHorizontalFormProps(): FormProps {
return {
labelCol: { xs: { span: 24 }, sm: { span: 6 }, lg: { span: 4 } },
wrapperCol: { xs: { span: 24 }, sm: { span: 12 }, lg: { span: 10 } },
layout: "horizontal",
className: "ant-form-horizontal--labels-left",
};
}
export function getHorizontalFormItemWithoutLabelProps(): FormItemProps {
return {
wrapperCol: { xs: { span: 24 }, sm: { span: 12, offset: 6 }, lg: { span: 12, offset: 4 } },
};
}
================================================
FILE: client/app/unsupported.html
================================================
Redash doesn't support your browser
Whoops... Redash doesn't support your browser
Download one of these free and up-to-date browsers:
================================================
FILE: client/app/unsupportedRedirect.js
================================================
if (
navigator.appVersion.match("Trident/") || // IE8-11
"ActiveXObject" in window // IE<11
) {
window.location.href = "/static/unsupported.html";
}
================================================
FILE: client/app/version.json
================================================
"dev"
================================================
FILE: client/cypress/.eslintrc.js
================================================
module.exports = {
extends: ["plugin:cypress/recommended"],
plugins: ["cypress", "chai-friendly"],
env: {
"cypress/globals": true,
},
rules: {
"func-names": ["error", "never"],
"no-unused-expressions": 0,
"chai-friendly/no-unused-expressions": 2,
"no-redeclare": "off",
"cypress/unsafe-to-chain-command": "off",
},
};
================================================
FILE: client/cypress/cypress.js
================================================
/* eslint-disable import/no-extraneous-dependencies, no-console */
const { find } = require("lodash");
const { execSync } = require("child_process");
const { get, post } = require("request").defaults({ jar: true });
const { seedData } = require("./seed-data");
const fs = require("fs");
var Cookie = require("request-cookies").Cookie;
let cypressConfigBaseUrl;
try {
const cypressConfig = JSON.parse(fs.readFileSync("cypress.json"));
cypressConfigBaseUrl = cypressConfig.baseUrl;
} catch (e) {}
const baseUrl = process.env.CYPRESS_baseUrl || cypressConfigBaseUrl || "http://localhost:5001";
function seedDatabase(seedValues) {
get(baseUrl + "/login", (_, { headers }) => {
const request = seedValues.shift();
const data = request.type === "form" ? { formData: request.data } : { json: request.data };
if (headers["set-cookie"]) {
const cookies = headers["set-cookie"].map((cookie) => new Cookie(cookie));
const csrfCookie = find(cookies, { key: "csrf_token" });
if (csrfCookie) {
if (request.type === "form") {
data["formData"] = { ...data["formData"], csrf_token: csrfCookie.value };
} else {
data["headers"] = { "X-CSRFToken": csrfCookie.value };
}
}
}
post(baseUrl + request.route, data, (err, response) => {
const result = response ? response.statusCode : err;
console.log("POST " + request.route + " - " + result);
if (seedValues.length) {
seedDatabase(seedValues);
}
});
});
}
function buildServer() {
console.log("Building the server...");
execSync("docker compose -p cypress build", { stdio: "inherit" });
}
function startServer() {
console.log("Starting the server...");
execSync("docker compose -p cypress up -d", { stdio: "inherit" });
execSync("docker compose -p cypress run server create_db", { stdio: "inherit" });
}
function stopServer() {
console.log("Stopping the server...");
execSync("docker compose -p cypress down", { stdio: "inherit" });
}
function runCypressCI() {
const {
GITHUB_REPOSITORY,
CYPRESS_OPTIONS, // eslint-disable-line no-unused-vars
} = process.env;
if (GITHUB_REPOSITORY === "getredash/redash" && process.env.CYPRESS_RECORD_KEY) {
process.env.CYPRESS_OPTIONS = "--record";
}
execSync(
"COMMIT_INFO_MESSAGE=$(git show -s --format=%s) docker compose run --name cypress cypress ./node_modules/.bin/percy exec -t 300 -- ./node_modules/.bin/cypress run $CYPRESS_OPTIONS",
{ stdio: "inherit" }
);
}
const command = process.argv[2] || "all";
switch (command) {
case "build":
buildServer();
break;
case "start":
startServer();
if (!process.argv.includes("--skip-db-seed")) {
seedDatabase(seedData);
}
break;
case "db-seed":
seedDatabase(seedData);
break;
case "run":
execSync("cypress run", { stdio: "inherit" });
break;
case "open":
execSync("cypress open", { stdio: "inherit" });
break;
case "run-ci":
runCypressCI();
break;
case "stop":
stopServer();
break;
case "all":
startServer();
seedDatabase(seedData);
execSync("cypress run", { stdio: "inherit" });
stopServer();
break;
default:
console.log("Usage: pnpm run cypress [build|start|db-seed|open|run|stop]");
break;
}
================================================
FILE: client/cypress/integration/alert/create_alert_spec.js
================================================
describe("Create Alert", () => {
beforeEach(() => {
cy.login();
});
it("renders the initial page and takes a screenshot", () => {
cy.visit("/alerts/new");
cy.getByTestId("QuerySelector").should("exist");
cy.percySnapshot("Create Alert initial screen");
});
it("selects query and takes a screenshot", () => {
cy.createQuery({ name: "Create Alert Query" }).then(({ id: queryId }) => {
cy.visit("/alerts/new");
cy.getByTestId("QuerySelector")
.click()
.type("Create Alert Query");
cy.get(`.query-selector-result[data-test="QueryId${queryId}"]`).click();
cy.getByTestId("Criteria").should("exist");
cy.percySnapshot("Create Alert second screen");
});
});
});
================================================
FILE: client/cypress/integration/alert/edit_alert_spec.js
================================================
describe("Edit Alert", () => {
beforeEach(() => {
cy.login();
});
it("renders the page and takes a screenshot", () => {
cy.createQuery({ query: "select 1 as col_name" })
.then(({ id: queryId }) => cy.createAlert(queryId, { column: "col_name" }))
.then(({ id: alertId }) => {
cy.visit(`/alerts/${alertId}/edit`);
cy.getByTestId("Criteria").should("exist");
cy.percySnapshot("Edit Alert screen");
});
});
it("edits the notification template and takes a screenshot", () => {
cy.createQuery()
.then(({ id: queryId }) => cy.createAlert(queryId, { custom_subject: "FOO", custom_body: "BAR" }))
.then(({ id: alertId }) => {
cy.visit(`/alerts/${alertId}/edit`);
cy.getByTestId("AlertCustomTemplate").should("exist");
cy.percySnapshot("Alert Custom Template screen");
});
});
it("previews rendered template correctly", () => {
const options = {
value: "123",
op: "==",
custom_subject: "{{ ALERT_CONDITION }}",
custom_body: "{{ ALERT_THRESHOLD }}",
};
cy.createQuery()
.then(({ id: queryId }) => cy.createAlert(queryId, options))
.then(({ id: alertId }) => {
cy.visit(`/alerts/${alertId}/edit`);
cy.get(".alert-template-preview").click();
cy.getByTestId("CustomSubject").should("have.value", options.op);
cy.getByTestId("CustomBody").should("have.value", options.value);
});
});
});
================================================
FILE: client/cypress/integration/alert/view_alert_spec.js
================================================
describe("View Alert", () => {
beforeEach(function() {
cy.login().then(() => {
cy.createQuery({ query: "select 1 as col_name" })
.then(({ id: queryId }) => cy.createAlert(queryId, { column: "col_name" }))
.then(({ id: alertId }) => {
this.alertId = alertId;
this.alertUrl = `/alerts/${alertId}`;
});
});
});
it("renders the page and takes a screenshot", function() {
cy.visit(this.alertUrl);
cy.getByTestId("Criteria").should("exist");
cy.percySnapshot("View Alert screen");
});
it("allows adding new destinations", function() {
cy.visit(this.alertUrl);
cy.getByTestId("AlertDestinations")
.contains("Test Email Destination")
.should("not.exist");
cy.server();
cy.route("GET", "**/api/destinations").as("Destinations");
cy.route("GET", "**/api/alerts/*/subscriptions").as("Subscriptions");
cy.visit(this.alertUrl);
cy.wait(["@Destinations", "@Subscriptions"]);
cy.getByTestId("ShowAddAlertSubDialog").click();
cy.contains("Test Email Destination").click();
cy.contains("Save").click();
cy.getByTestId("AlertDestinations")
.contains("Test Email Destination")
.should("exist");
});
describe("Alert Destination permissions", () => {
before(() => {
cy.login();
cy.createUser({
name: "Example User",
email: "user@redash.io",
password: "password",
});
});
it("hides remove button from non-author", function() {
cy.server();
cy.route("GET", "**/api/alerts/*/subscriptions").as("Subscriptions");
cy.logout()
.then(() => cy.login()) // as admin
.then(() => cy.addDestinationSubscription(this.alertId, "Test Email Destination"))
.then(() => {
cy.visit(this.alertUrl);
// verify remove button appears for author
cy.wait(["@Subscriptions"]);
cy.getByTestId("AlertDestinations")
.contains("Test Email Destination")
.parent()
.within(() => {
cy.get(".remove-button")
.as("RemoveButton")
.should("exist");
});
return cy.logout().then(() => cy.login("user@redash.io", "password"));
})
.then(() => {
cy.visit(this.alertUrl);
// verify remove button not shown for non-author
cy.wait(["@Subscriptions"]);
cy.get("@RemoveButton").should("not.exist");
});
});
it("shows remove button for non-author admin", function() {
cy.server();
cy.route("GET", "**/api/alerts/*/subscriptions").as("Subscriptions");
cy.logout()
.then(() => cy.login("user@redash.io", "password"))
.then(() => cy.addDestinationSubscription(this.alertId, "Test Email Destination"))
.then(() => {
cy.visit(this.alertUrl);
// verify remove button appears for author
cy.wait(["@Subscriptions"]);
cy.getByTestId("AlertDestinations")
.contains("Test Email Destination")
.parent()
.within(() => {
cy.get(".remove-button")
.as("RemoveButton")
.should("exist");
});
return cy.logout().then(() => cy.login()); // as admin
})
.then(() => {
cy.visit(this.alertUrl);
// verify remove button also appears for admin
cy.wait(["@Subscriptions"]);
cy.get("@RemoveButton").should("exist");
});
});
});
});
================================================
FILE: client/cypress/integration/dashboard/dashboard_list.js
================================================
describe("Dashboard list sort", () => {
beforeEach(() => {
cy.login();
});
it("creates one dashboard", () => {
cy.visit("/dashboards");
cy.getByTestId("CreateButton").click();
cy.getByTestId("CreateDashboardMenuItem").click();
cy.getByTestId("CreateDashboardDialog").within(() => {
cy.get("input").type("A Foo Bar");
cy.getByTestId("DashboardSaveButton").click();
});
});
describe("Sorting table does not crash page ", () => {
it("sorts", () => {
cy.visit("/dashboards");
cy.contains("Name").click();
cy.wait(1000); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ErrorMessage").should("not.exist");
});
});
});
================================================
FILE: client/cypress/integration/dashboard/dashboard_spec.js
================================================
/* global cy, Cypress */
import { getWidgetTestId } from "../../support/dashboard";
const menuWidth = 80;
describe("Dashboard", () => {
beforeEach(() => {
cy.login();
});
it("creates new dashboard", () => {
cy.visit("/dashboards");
cy.getByTestId("CreateButton").click();
cy.getByTestId("CreateDashboardMenuItem").click();
cy.server();
cy.route("POST", "**/api/dashboards").as("NewDashboard");
cy.getByTestId("CreateDashboardDialog").within(() => {
cy.getByTestId("DashboardSaveButton").should("be.disabled");
cy.get("input").type("Foo Bar");
cy.getByTestId("DashboardSaveButton").click();
});
cy.wait("@NewDashboard").then((xhr) => {
const id = Cypress._.get(xhr, "response.body.id");
assert.isDefined(id, "Dashboard api call returns id");
cy.visit("/dashboards");
cy.getByTestId("DashboardLayoutContent").within(() => {
cy.getByTestId(`DashboardId${id}`).should("exist");
});
});
});
it("archives dashboard", () => {
cy.createDashboard("Foo Bar").then(({ id }) => {
cy.visit(`/dashboards/${id}`);
cy.getByTestId("DashboardMoreButton").click();
cy.getByTestId("DashboardMoreButtonMenu").contains("Archive").click();
cy.get(".ant-modal .ant-btn").contains("Archive").click({ force: true });
cy.get(".label-tag-archived").should("exist");
cy.visit("/dashboards");
cy.getByTestId("DashboardLayoutContent").within(() => {
cy.getByTestId(`DashboardId${id}`).should("not.exist");
});
});
});
it("is accessible through multiple urls", () => {
cy.server();
cy.route("GET", "**/api/dashboards/*").as("LoadDashboard");
cy.createDashboard("Dashboard multiple urls").then(({ id, slug }) => {
[`/dashboards/${id}`, `/dashboards/${id}-anything-here`, `/dashboard/${slug}`].forEach((url) => {
cy.visit(url);
cy.wait("@LoadDashboard");
cy.getByTestId(`DashboardId${id}Container`).should("exist");
// assert it always use the "/dashboards/{id}" path
cy.location("pathname").should("contain", `/dashboards/${id}`);
});
});
});
context("viewport width is at 800px", () => {
before(function () {
cy.login();
cy.createDashboard("Foo Bar")
.then(({ id }) => {
this.dashboardUrl = `/dashboards/${id}`;
this.dashboardEditUrl = `/dashboards/${id}?edit`;
return cy.addTextbox(id, "Hello World!").then(getWidgetTestId);
})
.then((elTestId) => {
cy.visit(this.dashboardUrl);
cy.getByTestId(elTestId).as("textboxEl");
});
});
beforeEach(function () {
cy.login();
cy.visit(this.dashboardUrl);
cy.viewport(800 + menuWidth, 800);
});
it("shows widgets with full width", () => {
cy.get("@textboxEl").should(($el) => {
expect($el.width()).to.eq(770);
});
cy.viewport(801 + menuWidth, 800);
cy.get("@textboxEl").should(($el) => {
expect($el.width()).to.eq(182);
});
});
it("hides edit option", () => {
cy.getByTestId("DashboardMoreButton").click().should("be.visible");
cy.getByTestId("DashboardMoreButtonMenu").contains("Edit").as("editButton").should("not.be.visible");
cy.viewport(801 + menuWidth, 800);
cy.get("@editButton").should("be.visible");
});
it("disables edit mode", function () {
cy.viewport(801 + menuWidth, 800);
cy.visit(this.dashboardEditUrl);
cy.contains("button", "Done Editing").as("saveButton").should("exist");
cy.viewport(800 + menuWidth, 800);
cy.contains("button", "Done Editing").should("not.exist");
});
});
context("viewport width is at 767px", () => {
before(function () {
cy.login();
cy.createDashboard("Foo Bar").then(({ id }) => {
this.dashboardUrl = `/dashboards/${id}`;
});
});
beforeEach(function () {
cy.visit(this.dashboardUrl);
cy.viewport(767, 800);
});
});
});
================================================
FILE: client/cypress/integration/dashboard/dashboard_tags_spec.js
================================================
import { expectTagsToContain, typeInTagsSelectAndSave } from "../../support/tags";
describe("Dashboard Tags", () => {
beforeEach(function() {
cy.login();
cy.createDashboard("Foo Bar").then(({ id }) => cy.visit(`/dashboards/${id}`));
});
it("is possible to add and edit tags", () => {
cy.server();
cy.route("POST", "**/api/dashboards/*").as("DashboardSave");
cy.getByTestId("TagsControl").contains(".label", "Unpublished");
cy.getByTestId("EditTagsButton")
.should("contain", "Add tag")
.click();
typeInTagsSelectAndSave("tag1{enter}tag2{enter}tag3{enter}");
cy.wait("@DashboardSave");
expectTagsToContain(["tag1", "tag2", "tag3"]);
cy.getByTestId("EditTagsButton").click();
typeInTagsSelectAndSave("tag4{enter}");
cy.wait("@DashboardSave");
cy.reload();
expectTagsToContain(["tag1", "tag2", "tag3", "tag4"]);
});
});
================================================
FILE: client/cypress/integration/dashboard/filters_spec.js
================================================
import { createQueryAndAddWidget, editDashboard } from "../../support/dashboard";
import { expectTableToHaveLength, expectFirstColumnToHaveMembers } from "../../support/visualizations/table";
const SQL = `
SELECT 'a' AS stage1, 'a1' AS stage2, 11 AS value UNION ALL
SELECT 'a' AS stage1, 'a2' AS stage2, 12 AS value UNION ALL
SELECT 'a' AS stage1, 'a3' AS stage2, 45 AS value UNION ALL
SELECT 'a' AS stage1, 'a4' AS stage2, 54 AS value UNION ALL
SELECT 'b' AS stage1, 'b1' AS stage2, 33 AS value UNION ALL
SELECT 'b' AS stage1, 'b2' AS stage2, 73 AS value UNION ALL
SELECT 'b' AS stage1, 'b3' AS stage2, 90 AS value UNION ALL
SELECT 'c' AS stage1, 'c1' AS stage2, 19 AS value UNION ALL
SELECT 'c' AS stage1, 'c2' AS stage2, 92 AS value UNION ALL
SELECT 'c' AS stage1, 'c3' AS stage2, 63 AS value UNION ALL
SELECT 'c' AS stage1, 'c4' AS stage2, 44 AS value\
`;
describe("Dashboard Filters", () => {
beforeEach(() => {
cy.login();
const queryData = {
name: "Query Filters",
query: `SELECT stage1 AS "stage1::filter", stage2, value FROM (${SQL}) q`,
};
cy.createDashboard("Dashboard Filters").then((dashboard) => {
createQueryAndAddWidget(dashboard.id, queryData)
.as("widget1TestId")
.then(() => createQueryAndAddWidget(dashboard.id, queryData, { position: { col: 4 } }))
.as("widget2TestId")
.then(() => cy.visit(`/dashboards/${dashboard.id}`));
});
});
it("filters rows in a Table Visualization", function () {
editDashboard();
cy.getByTestId("DashboardFilters").should("not.exist");
cy.getByTestId("DashboardFiltersCheckbox").click();
cy.getByTestId("DashboardFilters").within(() => {
cy.getByTestId("FilterName-stage1::filter").find(".ant-select-selection-item").should("have.text", "a");
});
cy.getByTestId(this.widget1TestId).within(() => {
expectTableToHaveLength(4);
expectFirstColumnToHaveMembers(["a", "a", "a", "a"]);
cy.getByTestId("FilterName-stage1::filter").find(".ant-select").click();
});
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.contains(".ant-select-item-option-content:visible", "b").click();
cy.getByTestId(this.widget1TestId).within(() => {
expectTableToHaveLength(3);
expectFirstColumnToHaveMembers(["b", "b", "b"]);
});
// assert that changing one widget filter doesn't affect another
cy.getByTestId(this.widget2TestId).within(() => {
expectTableToHaveLength(4);
expectFirstColumnToHaveMembers(["a", "a", "a", "a"]);
});
// assert that changing a global filter affects all widgets
cy.getByTestId("DashboardFilters").within(() => {
cy.getByTestId("FilterName-stage1::filter").find(".ant-select").click();
});
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.contains(".ant-select-item-option-content:visible", "c").click();
[this.widget1TestId, this.widget2TestId].forEach((widgetTestId) =>
cy.getByTestId(widgetTestId).within(() => {
expectTableToHaveLength(4);
expectFirstColumnToHaveMembers(["c", "c", "c", "c"]);
})
);
});
});
================================================
FILE: client/cypress/integration/dashboard/grid_compliant_widgets_spec.js
================================================
/* global cy */
import { getWidgetTestId, editDashboard, resizeBy } from "../../support/dashboard";
const menuWidth = 80;
describe("Grid compliant widgets", () => {
beforeEach(function () {
cy.login();
cy.viewport(1215 + menuWidth, 800);
cy.createDashboard("Foo Bar")
.then(({ id }) => {
this.dashboardUrl = `/dashboards/${id}`;
return cy.addTextbox(id, "Hello World!").then(getWidgetTestId);
})
.then((elTestId) => {
cy.visit(this.dashboardUrl);
cy.getByTestId(elTestId).as("textboxEl");
});
});
describe("Draggable", () => {
describe("Grid snap", () => {
beforeEach(() => {
editDashboard();
});
it("stays put when dragged under snap threshold", () => {
cy.get("@textboxEl")
.dragBy(30)
.invoke("offset")
.should("have.property", "left", 15 + menuWidth); // no change, 15 -> 15
});
it("moves one column when dragged over snap threshold", () => {
cy.get("@textboxEl")
.dragBy(110)
.invoke("offset")
.should("have.property", "left", 115 + menuWidth); // moved by 100, 15 -> 115
});
it("moves two columns when dragged over snap threshold", () => {
cy.get("@textboxEl")
.dragBy(200)
.invoke("offset")
.should("have.property", "left", 215 + menuWidth); // moved by 200, 15 -> 215
});
});
it("auto saves after drag", () => {
cy.server();
cy.route("POST", "**/api/widgets/*").as("WidgetSave");
editDashboard();
cy.get("@textboxEl").dragBy(100);
cy.wait("@WidgetSave");
});
});
describe("Resizeable", () => {
describe("Column snap", () => {
beforeEach(() => {
editDashboard();
});
it("stays put when dragged under snap threshold", () => {
resizeBy(cy.get("@textboxEl"), 30)
.then(() => cy.get("@textboxEl"))
.invoke("width")
.should("eq", 285); // no change, 285 -> 285
});
it("moves one column when dragged over snap threshold", () => {
resizeBy(cy.get("@textboxEl"), 110)
.then(() => cy.get("@textboxEl"))
.invoke("width")
.should("eq", 385); // resized by 200, 185 -> 385
});
it("moves two columns when dragged over snap threshold", () => {
resizeBy(cy.get("@textboxEl"), 400)
.then(() => cy.get("@textboxEl"))
.invoke("width")
.should("eq", 685); // resized by 400, 285 -> 685
});
});
describe("Row snap", () => {
beforeEach(() => {
editDashboard();
});
it("stays put when dragged under snap threshold", () => {
resizeBy(cy.get("@textboxEl"), 0, 10)
.then(() => cy.get("@textboxEl"))
.invoke("height")
.should("eq", 135); // no change, 135 -> 135
});
it("moves one row when dragged over snap threshold", () => {
resizeBy(cy.get("@textboxEl"), 0, 30)
.then(() => cy.get("@textboxEl"))
.invoke("height")
.should("eq", 185);
});
it("shrinks to minimum", () => {
cy.get("@textboxEl")
.then(($el) => resizeBy(cy.get("@textboxEl"), -$el.width(), -$el.height())) // resize to 0,0
.then(() => cy.get("@textboxEl"))
.should(($el) => {
expect($el.width()).to.eq(185); // min textbox width
expect($el.height()).to.eq(85); // min textbox height
});
});
});
it("auto saves after resize", () => {
cy.server();
cy.route("POST", "**/api/widgets/*").as("WidgetSave");
editDashboard();
resizeBy(cy.get("@textboxEl"), 200);
cy.wait("@WidgetSave");
});
});
});
================================================
FILE: client/cypress/integration/dashboard/parameter_spec.js
================================================
import { createQueryAndAddWidget } from "../../support/dashboard";
describe("Dashboard Parameters", () => {
const parameters = [
{ name: "param1", title: "Parameter 1", type: "text", value: "example1" },
{ name: "param2", title: "Parameter 2", type: "text", value: "example2" },
];
beforeEach(function() {
cy.login();
cy.createDashboard("Foo Bar")
.then(({ id }) => {
this.dashboardId = id;
this.dashboardUrl = `/dashboards/${id}`;
})
.then(() => {
const queryData = {
name: "Text Parameter",
query: "SELECT '{{param1}}', '{{param2}}' AS parameter",
options: {
parameters,
},
};
const widgetOptions = { position: { col: 0, row: 0, sizeX: 3, sizeY: 10, autoHeight: false } };
createQueryAndAddWidget(this.dashboardId, queryData, widgetOptions).then(widgetTestId => {
cy.visit(this.dashboardUrl);
this.widgetTestId = widgetTestId;
});
});
});
const openMappingOptions = widgetTestId => {
cy.getByTestId(widgetTestId).within(() => {
cy.getByTestId("WidgetDropdownButton").click();
});
cy.getByTestId("WidgetDropdownButtonMenu")
.contains("Edit Parameters")
.click();
};
const saveMappingOptions = (closeMappingMenu = false) => {
return cy
.getByTestId("EditParamMappingPopover")
.filter(":visible")
.as("Popover")
.within(() => {
// This is needed to grant the element will have finished loading
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(500);
cy.contains("button", "OK").click();
})
.then(() => {
if (closeMappingMenu) {
cy.contains("button", "OK").click();
}
return cy.get("@Popover").should("not.be.visible");
});
};
it("supports widget parameters", function() {
// widget parameter mapping is the default for the API
cy.getByTestId(this.widgetTestId).within(() => {
cy.getByTestId("TableVisualization").should("contain", "example1");
cy.getByTestId("ParameterName-param1")
.find("input")
.type("{selectall}Redash");
cy.getByTestId("ParameterApplyButton").click();
cy.getByTestId("TableVisualization").should("contain", "Redash");
});
cy.getByTestId("DashboardParameters").should("not.exist");
});
it("supports static values for parameters", function() {
openMappingOptions(this.widgetTestId);
cy.getByTestId("EditParamMappingButton-param1").click();
cy.getByTestId("StaticValueOption").click();
cy.getByTestId("EditParamMappingPopover").within(() => {
cy.getByTestId("ParameterValueInput")
.find("input")
.type("{selectall}StaticValue");
});
saveMappingOptions(true);
cy.getByTestId(this.widgetTestId).within(() => {
cy.getByTestId("ParameterName-param1").should("not.exist");
});
cy.getByTestId("DashboardParameters").should("not.exist");
cy.getByTestId(this.widgetTestId).within(() => {
cy.getByTestId("TableVisualization").should("contain", "StaticValue");
});
});
});
================================================
FILE: client/cypress/integration/dashboard/sharing_spec.js
================================================
/* global cy */
import { editDashboard, shareDashboard, createQueryAndAddWidget } from "../../support/dashboard";
describe("Dashboard Sharing", () => {
beforeEach(function() {
cy.login();
cy.createDashboard("Foo Bar").then(({ id }) => {
this.dashboardId = id;
this.dashboardUrl = `/dashboards/${id}`;
});
cy.updateOrgSettings({ disable_public_urls: false });
});
it("is unavailable when public urls feature is disabled", function() {
const queryData = {
query: "select 1",
};
const position = { autoHeight: false, sizeY: 6 };
createQueryAndAddWidget(this.dashboardId, queryData, { position })
.then(() => {
cy.visit(this.dashboardUrl);
return shareDashboard();
})
.then(secretAddress => {
// disable the feature
cy.updateOrgSettings({ disable_public_urls: true });
// check the feature is disabled
cy.visit(this.dashboardUrl);
cy.getByTestId("DashboardMoreButton").should("exist");
cy.getByTestId("OpenShareForm").should("not.exist");
cy.logout();
cy.visit(secretAddress);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("TableVisualization").should("not.exist");
cy.login();
cy.updateOrgSettings({ disable_public_urls: false });
});
});
it("is possible if all queries are safe", function() {
const options = {
parameters: [
{
name: "foo",
type: "number",
},
],
};
const dashboardUrl = this.dashboardUrl;
cy.createQuery({ options }).then(({ id: queryId }) => {
cy.visit(dashboardUrl);
editDashboard();
cy.getByTestId("AddWidgetButton").click();
cy.getByTestId("AddWidgetDialog").within(() => {
cy.get(`.query-selector-result[data-test="QueryId${queryId}"]`).click();
});
cy.contains("button", "Add to Dashboard").click();
cy.getByTestId("AddWidgetDialog").should("not.exist");
cy.clickThrough(
{
button: `
Done Editing
Publish
`,
},
`OpenShareForm
PublicAccessEnabled`
);
cy.getByTestId("SecretAddress").should("exist");
});
});
describe("is available to unauthenticated users", () => {
it("when there are no parameters", function() {
const queryData = {
query: "select 1",
};
const position = { autoHeight: false, sizeY: 6 };
createQueryAndAddWidget(this.dashboardId, queryData, { position }).then(() => {
cy.visit(this.dashboardUrl);
shareDashboard().then(secretAddress => {
cy.logout();
cy.visit(secretAddress);
cy.getByTestId("TableVisualization", { timeout: 10000 }).should("exist");
cy.percySnapshot("Successfully Shared Unparameterized Dashboard");
});
});
});
it("when there are only safe parameters", function() {
const queryData = {
query: "select '{{foo}}'",
options: {
parameters: [
{
name: "foo",
type: "number",
value: 1,
},
],
},
};
const position = { autoHeight: false, sizeY: 6 };
createQueryAndAddWidget(this.dashboardId, queryData, { position }).then(() => {
cy.visit(this.dashboardUrl);
shareDashboard().then(secretAddress => {
cy.logout();
cy.visit(secretAddress);
cy.getByTestId("TableVisualization", { timeout: 10000 }).should("exist");
cy.percySnapshot("Successfully Shared Parameterized Dashboard");
});
});
});
it("even when there are suddenly some unsafe parameters", function() {
const queryData = {
query: "select 1",
};
// start out by creating a dashboard with no parameters & share it
const position = { autoHeight: false, sizeY: 6 };
createQueryAndAddWidget(this.dashboardId, queryData, { position })
.then(() => {
cy.visit(this.dashboardUrl);
return shareDashboard();
})
.then(secretAddress => {
const unsafeQueryData = {
query: "select '{{foo}}'",
options: {
parameters: [
{
name: "foo",
type: "text",
value: "oh snap!",
},
],
},
};
// then, after it is shared, add an unsafe parameterized query to it
const secondWidgetPos = { autoHeight: false, col: 3, sizeY: 6 };
createQueryAndAddWidget(this.dashboardId, unsafeQueryData, { position: secondWidgetPos }).then(() => {
cy.logout();
cy.title().should("eq", "Login to Redash"); // Make sure it's logged out
cy.visit(secretAddress);
cy.getByTestId("TableVisualization", { timeout: 10000 }).should("exist");
cy.contains(
".alert",
"This query contains potentially unsafe parameters" +
" and cannot be executed on a shared dashboard or an embedded visualization."
);
cy.percySnapshot("Successfully Shared Parameterized Dashboard With Some Unsafe Queries");
});
});
});
});
it("is not possible if some queries are not safe", function() {
const options = {
parameters: [
{
name: "foo",
type: "text",
},
],
};
const dashboardUrl = this.dashboardUrl;
cy.createQuery({ options }).then(({ id: queryId }) => {
cy.visit(dashboardUrl);
editDashboard();
cy.getByTestId("AddWidgetButton").click();
cy.getByTestId("AddWidgetDialog").within(() => {
cy.get(`.query-selector-result[data-test="QueryId${queryId}"]`).click();
});
cy.contains("button", "Add to Dashboard").click();
cy.getByTestId("AddWidgetDialog").should("not.exist");
cy.clickThrough(
{
button: `
Done Editing
Publish
`,
},
"OpenShareForm"
);
cy.getByTestId("PublicAccessEnabled").should("be.disabled");
});
});
});
================================================
FILE: client/cypress/integration/dashboard/textbox_spec.js
================================================
/* global cy */
import { getWidgetTestId, editDashboard } from "../../support/dashboard";
describe("Textbox", () => {
beforeEach(function () {
cy.login();
cy.createDashboard("Foo Bar").then(({ id }) => {
this.dashboardId = id;
this.dashboardUrl = `/dashboards/${id}`;
});
});
const confirmDeletionInModal = () => {
cy.get(".ant-modal .ant-btn").contains("Delete").click({ force: true });
};
it("adds textbox", function () {
cy.visit(this.dashboardUrl);
editDashboard();
cy.getByTestId("AddTextboxButton").click();
cy.getByTestId("TextboxDialog").within(() => {
cy.get("textarea").type("Hello World!");
});
cy.contains("button", "Add to Dashboard").click();
cy.getByTestId("TextboxDialog").should("not.exist");
cy.get(".widget-text").should("exist");
});
it("removes textbox by X button", function () {
cy.addTextbox(this.dashboardId, "Hello World!")
.then(getWidgetTestId)
.then((elTestId) => {
cy.visit(this.dashboardUrl);
editDashboard();
cy.getByTestId(elTestId).within(() => {
cy.getByTestId("WidgetDeleteButton").click();
});
confirmDeletionInModal();
cy.getByTestId(elTestId).should("not.exist");
});
});
it("removes textbox by menu", function () {
cy.addTextbox(this.dashboardId, "Hello World!")
.then(getWidgetTestId)
.then((elTestId) => {
cy.visit(this.dashboardUrl);
cy.getByTestId(elTestId).within(() => {
cy.getByTestId("WidgetDropdownButton").click();
});
cy.getByTestId("WidgetDropdownButtonMenu").contains("Remove from Dashboard").click();
confirmDeletionInModal();
cy.getByTestId(elTestId).should("not.exist");
});
});
it("allows opening menu after removal", function () {
let elTestId1;
cy.addTextbox(this.dashboardId, "txb 1")
.then(getWidgetTestId)
.then((elTestId) => {
elTestId1 = elTestId;
return cy.addTextbox(this.dashboardId, "txb 2").then(getWidgetTestId);
})
.then((elTestId2) => {
cy.visit(this.dashboardUrl);
editDashboard();
// remove 1st textbox and make sure it's gone
cy.getByTestId(elTestId1)
.as("textbox1")
.within(() => {
cy.getByTestId("WidgetDeleteButton").click();
});
confirmDeletionInModal();
cy.get("@textbox1").should("not.exist");
// remove 2nd textbox and make sure it's gone
cy.getByTestId(elTestId2)
.as("textbox2")
.within(() => {
// unclickable https://github.com/getredash/redash/issues/3202
cy.getByTestId("WidgetDeleteButton").click();
});
confirmDeletionInModal();
cy.get("@textbox2").should("not.exist"); // <-- fails because of the bug
});
});
it("edits textbox", function () {
cy.addTextbox(this.dashboardId, "Hello World!")
.then(getWidgetTestId)
.then((elTestId) => {
cy.visit(this.dashboardUrl);
cy.getByTestId(elTestId)
.as("textboxEl")
.within(() => {
cy.getByTestId("WidgetDropdownButton").click();
});
cy.getByTestId("WidgetDropdownButtonMenu").contains("Edit").click();
const newContent = "[edited]";
cy.getByTestId("TextboxDialog")
.should("exist")
.within(() => {
cy.get("textarea").clear().type(newContent);
cy.contains("button", "Save").click();
});
cy.get("@textboxEl").should("contain", newContent);
});
});
it("renders textbox according to position configuration", function () {
const id = this.dashboardId;
const txb1Pos = { col: 0, row: 0, sizeX: 3, sizeY: 2 };
const txb2Pos = { col: 1, row: 1, sizeX: 3, sizeY: 4 };
cy.viewport(1215, 800);
cy.addTextbox(id, "x", { position: txb1Pos })
.then(() => cy.addTextbox(id, "x", { position: txb2Pos }))
.then(getWidgetTestId)
.then((elTestId) => {
cy.visit(this.dashboardUrl);
return cy.getByTestId(elTestId);
})
.should(($el) => {
const { top, left } = $el.offset();
expect(top).to.be.oneOf([162, 162.015625]);
expect(left).to.eq(188);
expect($el.width()).to.eq(265);
expect($el.height()).to.eq(185);
});
});
});
================================================
FILE: client/cypress/integration/dashboard/widget_spec.js
================================================
/* global cy */
import { createQueryAndAddWidget, editDashboard, resizeBy } from "../../support/dashboard";
describe("Widget", () => {
beforeEach(function() {
cy.login();
cy.createDashboard("Foo Bar").then(({ id }) => {
this.dashboardId = id;
this.dashboardUrl = `/dashboards/${id}`;
});
});
const confirmDeletionInModal = () => {
cy.get(".ant-modal .ant-btn")
.contains("Delete")
.click({ force: true });
};
it("adds widget", function() {
cy.createQuery().then(({ id: queryId }) => {
cy.visit(this.dashboardUrl);
editDashboard();
cy.getByTestId("AddWidgetButton").click();
cy.getByTestId("AddWidgetDialog").within(() => {
cy.get(`.query-selector-result[data-test="QueryId${queryId}"]`).click();
});
cy.contains("button", "Add to Dashboard").click();
cy.getByTestId("AddWidgetDialog").should("not.exist");
cy.get(".widget-wrapper").should("exist");
});
});
it("removes widget", function() {
createQueryAndAddWidget(this.dashboardId).then(elTestId => {
cy.visit(this.dashboardUrl);
editDashboard();
cy.getByTestId(elTestId).within(() => {
cy.getByTestId("WidgetDeleteButton").click();
});
confirmDeletionInModal();
cy.getByTestId(elTestId).should("not.exist");
});
});
describe("Auto height for table visualization", () => {
it("renders correct height for 2 table rows", function() {
const queryData = {
query: "select s.a FROM generate_series(1,2) AS s(a)",
};
createQueryAndAddWidget(this.dashboardId, queryData).then(elTestId => {
cy.visit(this.dashboardUrl);
cy.getByTestId(elTestId)
.its("0.offsetHeight")
.should("eq", 235);
});
});
it("renders correct height for 5 table rows", function() {
const queryData = {
query: "select s.a FROM generate_series(1,5) AS s(a)",
};
createQueryAndAddWidget(this.dashboardId, queryData).then(elTestId => {
cy.visit(this.dashboardUrl);
cy.getByTestId(elTestId)
.its("0.offsetHeight")
.should("eq", 335);
});
});
describe("Height behavior on refresh", () => {
const paramName = "count";
const queryData = {
query: `select s.a FROM generate_series(1,{{ ${paramName} }}) AS s(a)`,
options: {
parameters: [
{
title: paramName,
name: paramName,
type: "text",
},
],
},
};
beforeEach(function() {
createQueryAndAddWidget(this.dashboardId, queryData).then(elTestId => {
cy.visit(this.dashboardUrl);
cy.getByTestId(elTestId)
.as("widget")
.within(() => {
cy.getByTestId("RefreshButton").as("refreshButton");
});
cy.getByTestId(`ParameterName-${paramName}`).within(() => {
cy.getByTestId("TextParamInput").as("paramInput");
});
});
});
it("grows when dynamically adding table rows", () => {
// listen to results
cy.server();
cy.route("GET", "**/api/query_results/*").as("FreshResults");
// start with 1 table row
cy.get("@paramInput")
.clear()
.type("1");
cy.getByTestId("ParameterApplyButton").click();
cy.wait("@FreshResults", { timeout: 10000 });
cy.get("@widget")
.invoke("height")
.should("eq", 285);
// add 4 table rows
cy.get("@paramInput")
.clear()
.type("5");
cy.getByTestId("ParameterApplyButton").click();
cy.wait("@FreshResults", { timeout: 10000 });
// expect to height to grow by 1 grid grow
cy.get("@widget")
.invoke("height")
.should("eq", 435);
});
it("revokes auto height after manual height adjustment", () => {
// listen to results
cy.server();
cy.route("GET", "**/api/query_results/*").as("FreshResults");
editDashboard();
// start with 1 table row
cy.get("@paramInput")
.clear()
.type("1");
cy.getByTestId("ParameterApplyButton").click();
cy.wait("@FreshResults");
cy.get("@widget")
.invoke("height")
.should("eq", 285);
// resize height by 1 grid row
resizeBy(cy.get("@widget"), 0, 50)
.then(() => cy.get("@widget"))
.invoke("height")
.should("eq", 335); // resized by 50, , 135 -> 185
// add 4 table rows
cy.get("@paramInput")
.clear()
.type("5");
cy.getByTestId("ParameterApplyButton").click();
cy.wait("@FreshResults");
// expect height to stay unchanged (would have been 435)
cy.get("@widget")
.invoke("height")
.should("eq", 335);
});
});
});
it("sets the correct height of table visualization", function() {
const queryData = {
query: `select '${"loremipsum".repeat(15)}' FROM generate_series(1,15)`,
};
const widgetOptions = { position: { col: 0, row: 0, sizeX: 3, sizeY: 10, autoHeight: false } };
createQueryAndAddWidget(this.dashboardId, queryData, widgetOptions).then(() => {
cy.visit(this.dashboardUrl);
cy.getByTestId("TableVisualization")
.its("0.offsetHeight")
.should("be.oneOf", [380, 381]);
cy.percySnapshot("Shows correct height of table visualization");
});
});
it("shows fixed pagination for overflowing tabular content ", function() {
const queryData = {
query: "select 'lorem ipsum' FROM generate_series(1,50)",
};
const widgetOptions = { position: { col: 0, row: 0, sizeX: 3, sizeY: 10, autoHeight: false } };
createQueryAndAddWidget(this.dashboardId, queryData, widgetOptions).then(() => {
cy.visit(this.dashboardUrl);
cy.getByTestId("TableVisualization")
.next(".ant-pagination.mini")
.should("be.visible");
cy.percySnapshot("Shows fixed mini pagination for overflowing tabular content");
});
});
it("keeps results on screen while refreshing", function() {
const queryData = {
query: "select pg_sleep({{sleep-time}}), 'sleep time: {{sleep-time}}' as sleeptime",
options: { parameters: [{ name: "sleep-time", title: "Sleep time", type: "number", value: 0 }] },
};
createQueryAndAddWidget(this.dashboardId, queryData).then(elTestId => {
cy.visit(this.dashboardUrl);
cy.getByTestId(elTestId).within(() => {
cy.getByTestId("TableVisualization").should("contain", "sleep time: 0");
cy.get(".refresh-indicator").should("not.be.visible");
cy.getByTestId("ParameterName-sleep-time").type("10");
cy.getByTestId("ParameterApplyButton").click();
cy.get(".refresh-indicator").should("be.visible");
cy.getByTestId("TableVisualization").should("contain", "sleep time: 0");
});
});
});
});
================================================
FILE: client/cypress/integration/data-source/create_data_source_spec.js
================================================
describe("Create Data Source", () => {
beforeEach(() => {
cy.login();
});
it("opens the creation dialog when clicking in the create link or button", () => {
cy.visit("/data_sources");
cy.server();
cy.route("**/api/data_sources", []); // force an empty response
["CreateDataSourceButton", "CreateDataSourceLink"].forEach(createElementTestId => {
cy.getByTestId(createElementTestId).click();
cy.getByTestId("CreateSourceDialog").should("exist");
cy.getByTestId("CreateSourceCancelButton").click();
cy.getByTestId("CreateSourceDialog").should("not.exist");
});
});
it("renders the page and takes a screenshot", function() {
cy.visit("/data_sources/new");
cy.server();
cy.route("**/api/data_sources/types").as("DataSourceTypesRequest");
cy.wait("@DataSourceTypesRequest")
.then(({ response }) => response.body.filter(type => type.deprecated))
.then(deprecatedTypes => deprecatedTypes.map(type => type.type))
.as("deprecatedTypes");
cy.getByTestId("PreviewItem")
.then($previewItems => Cypress.$.map($previewItems, item => Cypress.$(item).attr("data-test-type")))
.then(availableTypes => expect(availableTypes).not.to.contain.members(this.deprecatedTypes));
cy.getByTestId("CreateSourceDialog").should("contain", "PostgreSQL");
cy.wait(1000); // eslint-disable-line cypress/no-unnecessary-waiting
cy.percySnapshot("Create Data Source - Types");
});
it("creates a new PostgreSQL data source", () => {
cy.visit("/data_sources/new");
cy.getByTestId("SearchSource").type("PostgreSQL");
cy.getByTestId("CreateSourceDialog")
.contains("PostgreSQL")
.click();
cy.getByTestId("Name").type("Redash");
cy.getByTestId("Host").type("postgres");
cy.getByTestId("User").type("postgres");
cy.getByTestId("Password").type("postgres");
cy.getByTestId("Database Name").type("postgres{enter}");
cy.getByTestId("CreateSourceSaveButton").click({ force: true });
cy.contains("Saved.");
});
});
================================================
FILE: client/cypress/integration/data-source/edit_data_source_spec.js
================================================
describe("Edit Data Source", () => {
beforeEach(() => {
cy.login();
cy.visit("/data_sources/1");
});
it("renders the page and takes a screenshot", () => {
cy.getByTestId("DataSource").within(() => {
cy.getByTestId("Name").should("have.value", "Test PostgreSQL");
cy.getByTestId("Host").should("have.value", "postgres");
});
cy.percySnapshot("Edit Data Source - PostgreSQL");
});
});
================================================
FILE: client/cypress/integration/destination/create_destination_spec.js
================================================
describe("Create Destination", () => {
beforeEach(() => {
cy.login();
});
it("renders the page and takes a screenshot", function() {
cy.visit("/destinations/new");
cy.server();
cy.route("**/api/destinations/types").as("DestinationTypesRequest");
cy.wait("@DestinationTypesRequest")
.then(({ response }) => response.body.filter(type => type.deprecated))
.then(deprecatedTypes => deprecatedTypes.map(type => type.type))
.as("deprecatedTypes");
cy.getByTestId("PreviewItem")
.then($previewItems => Cypress.$.map($previewItems, item => Cypress.$(item).attr("data-test-type")))
.then(availableTypes => expect(availableTypes).not.to.contain.oneOf(this.deprecatedTypes));
cy.getByTestId("CreateSourceDialog").should("contain", "Email");
cy.wait(1000); // eslint-disable-line cypress/no-unnecessary-waiting
cy.percySnapshot("Create Destination - Types");
});
it("shows a custom error message when destination name is already taken", () => {
cy.createDestination("Slack Destination", "slack").then(() => {
cy.visit("/destinations/new");
cy.getByTestId("SearchSource").type("Slack");
cy.getByTestId("CreateSourceDialog")
.contains("Slack")
.click();
cy.getByTestId("Name").type("Slack Destination");
cy.getByTestId("CreateSourceSaveButton").click();
cy.contains("Alert Destination with the name Slack Destination already exists.");
});
});
});
================================================
FILE: client/cypress/integration/embed/share_embed_spec.js
================================================
describe("Embedded Queries", () => {
beforeEach(() => {
cy.login();
cy.updateOrgSettings({ disable_public_urls: false });
});
it("is unavailable when public urls feature is disabled", () => {
cy.createQuery({ query: "select name from users order by name" }).then((query) => {
cy.visit(`/queries/${query.id}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click();
cy.getByTestId("QueryPageVisualizationTabs", { timeout: 10000 }).should("exist");
cy.clickThrough(`
QueryControlDropdownButton
ShowEmbedDialogButton
`);
cy.getByTestId("EmbedIframe")
.invoke("text")
.then((embedUrl) => {
// disable the feature
cy.updateOrgSettings({ disable_public_urls: true });
// check the feature is disabled
cy.visit(`/queries/${query.id}/source`);
cy.getByTestId("QueryPageVisualizationTabs", { timeout: 10000 }).should("exist");
cy.getByTestId("QueryPageHeaderMoreButton").click();
cy.get(".ant-dropdown-menu-item").should("exist").should("not.contain", "Show API Key");
cy.getByTestId("QueryControlDropdownButton").click();
cy.get(".ant-dropdown-menu-item").should("exist");
cy.getByTestId("ShowEmbedDialogButton").should("not.exist");
cy.logout();
cy.visit(embedUrl);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("TableVisualization").should("not.exist");
cy.login();
cy.updateOrgSettings({ disable_public_urls: false });
});
});
});
it("can be shared without parameters", () => {
cy.createQuery({ query: "select name from users order by name" }).then((query) => {
cy.visit(`/queries/${query.id}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click();
cy.getByTestId("QueryPageVisualizationTabs", { timeout: 10000 }).should("exist");
cy.clickThrough(`
QueryControlDropdownButton
ShowEmbedDialogButton
`);
cy.getByTestId("EmbedIframe")
.invoke("text")
.then((embedUrl) => {
cy.logout();
cy.visit(embedUrl);
cy.getByTestId("VisualizationEmbed", { timeout: 10000 }).should("exist");
cy.getByTestId("TimeAgo", { timeout: 10000 }).should("exist");
cy.getByTestId("TableVisualization").should("exist");
cy.percySnapshot("Successfully Embedded Non-Parameterized Query");
});
});
});
it("can be shared with safe parameters", () => {
cy.visit("/queries/new");
cy.getByTestId("QueryEditor")
.get(".ace_text-input")
.type("SELECT name, slug FROM organizations WHERE id='{{}{{}id}}'{esc}", { force: true });
cy.getByTestId("TextParamInput").type("1");
cy.getByTestId("ParameterApplyButton").click();
cy.clickThrough(`
ParameterSettings-id
ParameterTypeSelect
NumberParameterTypeOption
SaveParameterSettings
SaveButton
`);
// Add a little waiting - page is not updated fast enough
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.location("search").should("eq", "?p_id=1");
cy.clickThrough(`
QueryControlDropdownButton
ShowEmbedDialogButton
`);
cy.getByTestId("EmbedIframe")
.invoke("text")
.then((embedUrl) => {
cy.logout();
cy.visit(embedUrl);
cy.getByTestId("VisualizationEmbed", { timeout: 10000 }).should("exist");
cy.getByTestId("TimeAgo", { timeout: 10000 }).should("exist");
cy.getByTestId("TableVisualization").should("exist");
cy.percySnapshot("Successfully Embedded Parameterized Query");
});
});
it("cannot be shared with unsafe parameters", () => {
cy.visit("/queries/new");
cy.getByTestId("QueryEditor")
.get(".ace_text-input")
.type("SELECT name, slug FROM organizations WHERE name='{{}{{}name}}'{esc}", { force: true });
cy.getByTestId("TextParamInput").type("Redash");
cy.getByTestId("ParameterApplyButton").click();
cy.clickThrough(`
ParameterSettings-name
ParameterTypeSelect
TextParameterTypeOption
SaveParameterSettings
SaveButton
`);
// Add a little waiting - page is not updated fast enough
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.location("search").should("eq", "?p_name=Redash");
cy.clickThrough(`
QueryControlDropdownButton
ShowEmbedDialogButton
`);
cy.getByTestId("EmbedIframe").should("not.exist");
cy.getByTestId("EmbedErrorAlert").should("exist");
});
});
================================================
FILE: client/cypress/integration/group/edit_group_spec.js
================================================
describe("Edit Group", () => {
beforeEach(() => {
cy.login();
cy.visit("/groups/1");
});
it("renders the page and takes a screenshot", () => {
cy.getByTestId("Group").within(() => {
cy.get("h3").should("contain", "admin");
cy.get("td").should("contain", "Example Admin");
});
cy.percySnapshot("Group");
});
});
================================================
FILE: client/cypress/integration/group/group_list_spec.js
================================================
describe("Group List", () => {
beforeEach(() => {
cy.login();
cy.visit("/groups");
});
it("renders the page and takes a screenshot", () => {
cy.getByTestId("GroupList")
.should("exist")
.and("contain", "admin")
.and("contain", "default");
cy.percySnapshot("Groups");
});
});
================================================
FILE: client/cypress/integration/query/create_query_spec.js
================================================
describe("Create Query", () => {
beforeEach(() => {
cy.login();
cy.visit("/queries/new");
});
it("executes and saves a query", () => {
cy.clickThrough(`
SelectDataSource
SelectDataSource${Cypress.env("dataSourceId")}
`);
cy.getByTestId("QueryEditor")
.get(".ace_text-input")
.type("SELECT id, name FROM organizations{esc}", { force: true });
cy.getByTestId("ExecuteButton")
.should("be.enabled")
.click();
cy.getByTestId("TableVisualization").should("exist");
cy.percySnapshot("Edit Query");
cy.getByTestId("SaveButton").click();
cy.url().should("match", /\/queries\/.+\/source/);
});
});
================================================
FILE: client/cypress/integration/query/filters_spec.js
================================================
import { expectTableToHaveLength, expectFirstColumnToHaveMembers } from "../../support/visualizations/table";
const SQL = `
SELECT 'a' AS stage1, 'a1' AS stage2, 11 AS value UNION ALL
SELECT 'a' AS stage1, 'a2' AS stage2, 12 AS value UNION ALL
SELECT 'a' AS stage1, 'a3' AS stage2, 45 AS value UNION ALL
SELECT 'a' AS stage1, 'a4' AS stage2, 54 AS value UNION ALL
SELECT 'b' AS stage1, 'b1' AS stage2, 33 AS value UNION ALL
SELECT 'b' AS stage1, 'b2' AS stage2, 73 AS value UNION ALL
SELECT 'b' AS stage1, 'b3' AS stage2, 90 AS value UNION ALL
SELECT 'c' AS stage1, 'c1' AS stage2, 19 AS value UNION ALL
SELECT 'c' AS stage1, 'c2' AS stage2, 92 AS value UNION ALL
SELECT 'c' AS stage1, 'c3' AS stage2, 63 AS value UNION ALL
SELECT 'c' AS stage1, 'c4' AS stage2, 44 AS value\
`;
describe("Query Filters", () => {
beforeEach(() => {
cy.login();
});
describe("Simple Filter", () => {
beforeEach(() => {
const queryData = {
name: "Query Filters",
query: `SELECT stage1 AS "stage1::filter", stage2, value FROM (${SQL}) q`,
};
cy.createQuery(queryData).then(({ id }) => cy.visit(`/queries/${id}`));
cy.getByTestId("ExecuteButton").click();
});
it("filters rows in a Table Visualization", () => {
cy.getByTestId("FilterName-stage1::filter")
.find(".ant-select-selection-item")
.should("have.text", "a");
expectTableToHaveLength(4);
expectFirstColumnToHaveMembers(["a", "a", "a", "a"]);
cy.getByTestId("FilterName-stage1::filter")
.find(".ant-select")
.click();
cy.contains(".ant-select-item-option-content", "b").click();
expectTableToHaveLength(3);
expectFirstColumnToHaveMembers(["b", "b", "b"]);
});
});
describe("Multi Filter", () => {
beforeEach(() => {
const queryData = {
name: "Query Filters",
query: `SELECT stage1 AS "stage1::multi-filter", stage2, value FROM (${SQL}) q`,
};
cy.createQuery(queryData).then(({ id }) => cy.visit(`/queries/${id}`));
cy.getByTestId("ExecuteButton").click();
});
function expectSelectedOptionsToHaveMembers(values) {
cy.getByTestId("FilterName-stage1::multi-filter")
.find(".ant-select-selection-item-content")
.then($selectedOptions => Cypress.$.map($selectedOptions, item => Cypress.$(item).text()))
.then(selectedOptions => expect(selectedOptions).to.have.members(values));
}
it("filters rows in a Table Visualization", () => {
// Defaults to All Options Selected
expectSelectedOptionsToHaveMembers(["a", "b", "c"]);
expectTableToHaveLength(11);
expectFirstColumnToHaveMembers(["a", "a", "a", "a", "b", "b", "b", "c", "c", "c", "c"]);
// Clear Option
cy.getByTestId("FilterName-stage1::multi-filter")
.find(".ant-select-selector")
.click();
cy.getByTestId("ClearOption").click();
cy.getByTestId("FilterName-stage1::multi-filter").click(); // close dropdown
cy.getByTestId("TableVisualization").should("not.exist");
// Single Option selected
cy.getByTestId("FilterName-stage1::multi-filter")
.find(".ant-select-selector")
.click();
cy.contains(".ant-select-item-option-grouped > .ant-select-item-option-content", "a").click();
cy.getByTestId("FilterName-stage1::multi-filter").click(); // close dropdown
expectSelectedOptionsToHaveMembers(["a"]);
expectTableToHaveLength(4);
expectFirstColumnToHaveMembers(["a", "a", "a", "a"]);
// Two Options selected
cy.getByTestId("FilterName-stage1::multi-filter")
.find(".ant-select-selector")
.click();
cy.contains(".ant-select-item-option-content", "b").click();
cy.getByTestId("FilterName-stage1::multi-filter").click(); // close dropdown
expectSelectedOptionsToHaveMembers(["a", "b"]);
expectTableToHaveLength(7);
expectFirstColumnToHaveMembers(["a", "a", "a", "a", "b", "b", "b"]);
// Select All Option
cy.getByTestId("FilterName-stage1::multi-filter")
.find(".ant-select-selector")
.click();
cy.getByTestId("SelectAllOption").click();
cy.getByTestId("FilterName-stage1::multi-filter").click(); // close dropdown
expectSelectedOptionsToHaveMembers(["a", "b", "c"]);
expectTableToHaveLength(11);
expectFirstColumnToHaveMembers(["a", "a", "a", "a", "b", "b", "b", "c", "c", "c", "c"]);
});
});
});
================================================
FILE: client/cypress/integration/query/parameter_spec.js
================================================
import { dragParam } from "../../support/parameters";
import dayjs from "dayjs";
function openAndSearchAntdDropdown(testId, paramOption) {
cy.getByTestId(testId).find(".ant-select-selection-search-input").type(paramOption, { force: true });
}
describe("Parameter", () => {
const expectDirtyStateChange = (edit) => {
cy.getByTestId("ParameterName-test-parameter")
.find(".parameter-input")
.should(($el) => {
assert.isUndefined($el.data("dirty"));
});
edit();
cy.getByTestId("ParameterName-test-parameter")
.find(".parameter-input")
.should(($el) => {
assert.isTrue($el.data("dirty"));
});
};
beforeEach(() => {
cy.login();
});
describe("Text Parameter", () => {
beforeEach(() => {
const queryData = {
name: "Text Parameter",
query: "SELECT '{{test-parameter}}' AS parameter",
options: {
parameters: [{ name: "test-parameter", title: "Test Parameter", type: "text" }],
},
};
cy.createQuery(queryData, false).then(({ id }) => cy.visit(`/queries/${id}`));
});
it("updates the results after clicking Apply", () => {
cy.getByTestId("ParameterName-test-parameter").find("input").type("Redash");
cy.getByTestId("ParameterApplyButton").click();
cy.getByTestId("TableVisualization").should("contain", "Redash");
});
it("sets dirty state when edited", () => {
expectDirtyStateChange(() => {
cy.getByTestId("ParameterName-test-parameter").find("input").type("Redash");
});
});
});
describe("Text Pattern Parameter", () => {
beforeEach(() => {
const queryData = {
name: "Text Pattern Parameter",
query: "SELECT '{{test-parameter}}' AS parameter",
options: {
parameters: [{ name: "test-parameter", title: "Test Parameter", type: "text-pattern", regex: "a.*a" }],
},
};
cy.createQuery(queryData, false).then(({ id }) => cy.visit(`/queries/${id}/source`));
});
it("updates the results after clicking Apply", () => {
cy.getByTestId("ParameterName-test-parameter").find("input").type("{selectall}arta");
cy.getByTestId("ParameterApplyButton").click();
cy.getByTestId("TableVisualization").should("contain", "arta");
cy.getByTestId("ParameterName-test-parameter").find("input").type("{selectall}arounda");
cy.getByTestId("ParameterApplyButton").click();
cy.getByTestId("TableVisualization").should("contain", "arounda");
});
it("throws error message with invalid query request", () => {
cy.getByTestId("ParameterName-test-parameter").find("input").type("{selectall}arta");
cy.getByTestId("ParameterApplyButton").click();
cy.getByTestId("ParameterName-test-parameter").find("input").type("{selectall}abcab");
cy.getByTestId("ParameterApplyButton").click();
cy.getByTestId("QueryExecutionStatus").should("exist");
});
it("sets dirty state when edited", () => {
expectDirtyStateChange(() => {
cy.getByTestId("ParameterName-test-parameter").find("input").type("{selectall}arta");
});
});
it("doesn't let user save invalid regex", () => {
cy.get(".fa-cog").click();
cy.getByTestId("RegexPatternInput").type("{selectall}[");
cy.contains("Invalid Regex Pattern").should("exist");
cy.getByTestId("SaveParameterSettings").click();
cy.get(".fa-cog").click();
cy.getByTestId("RegexPatternInput").should("not.equal", "[");
});
});
describe("Number Parameter", () => {
beforeEach(() => {
const queryData = {
name: "Number Parameter",
query: "SELECT '{{test-parameter}}' AS parameter",
options: {
parameters: [{ name: "test-parameter", title: "Test Parameter", type: "number" }],
},
};
cy.createQuery(queryData, false).then(({ id }) => cy.visit(`/queries/${id}`));
});
it("updates the results after clicking Apply", () => {
cy.getByTestId("ParameterName-test-parameter").find("input").type("{selectall}42");
cy.getByTestId("ParameterApplyButton").click();
cy.getByTestId("TableVisualization").should("contain", 42);
cy.getByTestId("ParameterName-test-parameter").find("input").type("{selectall}31415");
cy.getByTestId("ParameterApplyButton").click();
cy.getByTestId("TableVisualization").should("contain", 31415);
});
it("sets dirty state when edited", () => {
expectDirtyStateChange(() => {
cy.getByTestId("ParameterName-test-parameter").find("input").type("{selectall}42");
});
});
});
describe("Dropdown Parameter", () => {
beforeEach(() => {
const queryData = {
name: "Dropdown Parameter",
query: "SELECT '{{test-parameter}}' AS parameter",
options: {
parameters: [
{ name: "test-parameter", title: "Test Parameter", type: "enum", enumOptions: "value1\nvalue2\nvalue3" },
],
},
};
cy.createQuery(queryData, false).then(({ id }) => cy.visit(`/queries/${id}/source`));
});
it("updates the results after selecting a value", () => {
openAndSearchAntdDropdown("ParameterName-test-parameter", "value2"); // asserts option filter prop
// only the filtered option should be on the DOM
cy.get(".ant-select-item-option").should("have.length", 1).and("contain", "value2").click();
cy.getByTestId("ParameterApplyButton").click();
// ensure that query is being executed
cy.getByTestId("QueryExecutionStatus").should("exist");
cy.getByTestId("TableVisualization").should("contain", "value2");
});
it("supports multi-selection", () => {
cy.clickThrough(`
ParameterSettings-test-parameter
AllowMultipleValuesCheckbox
QuotationSelect
DoubleQuotationMarkOption
SaveParameterSettings
`);
cy.getByTestId("ParameterName-test-parameter").find(".ant-select-selection-search").click();
// select all unselected options
cy.get(".ant-select-item-option").each(($option) => {
if (!$option.hasClass("ant-select-item-option-selected")) {
cy.wrap($option).click();
}
});
cy.getByTestId("QueryEditor").click(); // just to close the select menu
cy.getByTestId("ParameterApplyButton").click();
cy.getByTestId("TableVisualization").should("contain", '"value1","value2","value3"');
});
it("sets dirty state when edited", () => {
expectDirtyStateChange(() => {
cy.getByTestId("ParameterName-test-parameter").find(".ant-select").click();
cy.contains(".ant-select-item-option", "value2").click();
});
});
});
describe("Query Based Dropdown Parameter", () => {
context("based on a query with no results", () => {
beforeEach(() => {
const dropdownQueryData = {
name: "Dropdown Query",
query: "",
};
cy.createQuery(dropdownQueryData, true).then((dropdownQuery) => {
const queryData = {
name: "Query Based Dropdown Parameter",
query: "SELECT '{{test-parameter}}' AS parameter",
options: {
parameters: [
{ name: "test-parameter", title: "Test Parameter", type: "query", queryId: dropdownQuery.id },
],
},
};
cy.createQuery(queryData, false).then(({ id }) => cy.visit(`/queries/${id}/source`));
});
});
it("should show a 'No options available' message when you click", () => {
cy.getByTestId("ParameterName-test-parameter")
.find(".ant-select:not(.ant-select-disabled) .ant-select-selector")
.click();
cy.contains(".ant-select-item-empty", "No options available");
});
});
context("based on a query with 3 results", () => {
beforeEach(() => {
const dropdownQueryData = {
name: "Dropdown Query",
query: `SELECT 'value1' AS name, 1 AS value UNION ALL
SELECT 'value2' AS name, 2 AS value UNION ALL
SELECT 'value3' AS name, 3 AS value`,
};
cy.createQuery(dropdownQueryData, true).then((dropdownQuery) => {
const queryData = {
name: "Query Based Dropdown Parameter",
query: "SELECT '{{test-parameter}}' AS parameter",
options: {
parameters: [
{ name: "test-parameter", title: "Test Parameter", type: "query", queryId: dropdownQuery.id },
],
},
};
cy.visit(`/queries/${dropdownQuery.id}`);
cy.getByTestId("ExecuteButton").click();
cy.getByTestId("TableVisualization")
.should("contain", "value1")
.and("contain", "value2")
.and("contain", "value3");
cy.createQuery(queryData, false).then(({ id }) => cy.visit(`/queries/${id}/source`));
});
});
it("updates the results after selecting a value", () => {
openAndSearchAntdDropdown("ParameterName-test-parameter", "value2"); // asserts option filter prop
// only the filtered option should be on the DOM
cy.get(".ant-select-item-option").should("have.length", 1).and("contain", "value2").click();
cy.getByTestId("ParameterApplyButton").click();
// ensure that query is being executed
cy.getByTestId("QueryExecutionStatus").should("exist");
cy.getByTestId("TableVisualization").should("contain", "2");
});
it("supports multi-selection", () => {
cy.clickThrough(`
ParameterSettings-test-parameter
AllowMultipleValuesCheckbox
QuotationSelect
DoubleQuotationMarkOption
SaveParameterSettings
`);
cy.getByTestId("ParameterName-test-parameter").find(".ant-select").click();
// make sure all options are unselected and select all
cy.get(".ant-select-item-option").each(($option) => {
expect($option).not.to.have.class("ant-select-dropdown-menu-item-selected");
cy.wrap($option).click();
});
cy.getByTestId("QueryEditor").click(); // just to close the select menu
cy.getByTestId("ParameterApplyButton").click();
cy.getByTestId("TableVisualization").should("contain", '"1","2","3"');
});
});
});
const selectCalendarDate = (date) => {
cy.getByTestId("ParameterName-test-parameter").find("input").click();
cy.get(".ant-picker-panel").contains(".ant-picker-cell-inner", date).click();
};
describe("Date Parameter", () => {
beforeEach(() => {
const queryData = {
name: "Date Parameter",
query: "SELECT '{{test-parameter}}' AS parameter",
options: {
parameters: [{ name: "test-parameter", title: "Test Parameter", type: "date", value: null }],
},
};
const now = new Date();
now.setDate(1);
cy.wrap(now.getTime()).as("now");
cy.clock(now.getTime(), ["Date"]);
cy.createQuery(queryData, false).then(({ id }) => cy.visit(`/queries/${id}`));
});
afterEach(() => {
cy.clock().then((clock) => clock.restore());
});
it("updates the results after selecting a date", function () {
selectCalendarDate("15");
cy.getByTestId("ParameterApplyButton").click();
cy.getByTestId("TableVisualization").should("contain", dayjs(this.now).format("15/MM/YY"));
});
it("allows picking a dynamic date", function () {
cy.getByTestId("DynamicButton").click();
cy.getByTestId("DynamicButtonMenu").contains("Today/Now").click();
cy.getByTestId("ParameterApplyButton").click();
cy.getByTestId("TableVisualization").should("contain", dayjs(this.now).format("DD/MM/YY"));
});
it("sets dirty state when edited", () => {
expectDirtyStateChange(() => selectCalendarDate("15"));
});
});
describe("Date and Time Parameter", () => {
beforeEach(() => {
const queryData = {
name: "Date and Time Parameter",
query: "SELECT '{{test-parameter}}' AS parameter",
options: {
parameters: [{ name: "test-parameter", title: "Test Parameter", type: "datetime-local", value: null }],
},
};
const now = new Date();
now.setDate(1);
cy.wrap(now.getTime()).as("now");
cy.clock(now.getTime(), ["Date"]);
cy.createQuery(queryData, false).then(({ id }) => cy.visit(`/queries/${id}`));
});
afterEach(() => {
cy.clock().then((clock) => clock.restore());
});
it("updates the results after selecting a date and clicking in ok", function () {
cy.getByTestId("ParameterName-test-parameter").find("input").as("Input").click();
selectCalendarDate("15");
cy.get(".ant-picker-ok button").click();
cy.getByTestId("ParameterApplyButton").click();
cy.getByTestId("TableVisualization").should("contain", dayjs(this.now).format("YYYY-MM-15 HH:mm"));
});
it("shows the current datetime after clicking in Now", function () {
cy.getByTestId("ParameterName-test-parameter").find("input").as("Input").click();
cy.get(".ant-picker-panel").contains("Now").click();
cy.getByTestId("ParameterApplyButton").click();
cy.getByTestId("TableVisualization").should("contain", dayjs(this.now).format("YYYY-MM-DD HH:mm"));
});
it("allows picking a dynamic date", function () {
cy.getByTestId("DynamicButton").click();
cy.getByTestId("DynamicButtonMenu").contains("Today/Now").click();
cy.getByTestId("ParameterApplyButton").click();
cy.getByTestId("TableVisualization").should("contain", dayjs(this.now).format("YYYY-MM-DD HH:mm"));
});
it("sets dirty state when edited", () => {
expectDirtyStateChange(() => {
cy.getByTestId("ParameterName-test-parameter").find("input").click();
cy.get(".ant-picker-panel").contains("Now").click();
});
});
});
describe("Date Range Parameter", () => {
const selectCalendarDateRange = (startDate, endDate) => {
cy.getByTestId("ParameterName-test-parameter").find("input").first().click();
cy.get(".ant-picker-panel").contains(".ant-picker-cell-inner", startDate).click();
cy.get(".ant-picker-panel").contains(".ant-picker-cell-inner", endDate).click();
};
beforeEach(() => {
const queryData = {
name: "Date Range Parameter",
query: "SELECT '{{test-parameter.start}} - {{test-parameter.end}}' AS parameter",
options: {
parameters: [{ name: "test-parameter", title: "Test Parameter", type: "date-range" }],
},
};
const now = new Date();
now.setDate(1);
cy.wrap(now.getTime()).as("now");
cy.clock(now.getTime(), ["Date"]);
cy.createQuery(queryData, false).then(({ id }) => cy.visit(`/queries/${id}/source`));
});
afterEach(() => {
cy.clock().then((clock) => clock.restore());
});
it("updates the results after selecting a date range", function () {
selectCalendarDateRange("15", "20");
cy.getByTestId("ParameterApplyButton").click();
const now = dayjs(this.now);
cy.getByTestId("TableVisualization").should(
"contain",
now.format("YYYY-MM-15") + " - " + now.format("YYYY-MM-20")
);
});
it("allows picking a dynamic date range", function () {
cy.getByTestId("DynamicButton").click();
cy.getByTestId("DynamicButtonMenu").contains("Last month").click();
cy.getByTestId("ParameterApplyButton").click();
const lastMonth = dayjs(this.now).subtract(1, "month");
cy.getByTestId("TableVisualization").should(
"contain",
lastMonth.startOf("month").format("YYYY-MM-DD") + " - " + lastMonth.endOf("month").format("YYYY-MM-DD")
);
});
it("sets dirty state when edited", () => {
expectDirtyStateChange(() => selectCalendarDateRange("15", "20"));
});
});
describe("Apply Changes", () => {
const expectAppliedChanges = (apply) => {
cy.getByTestId("ParameterName-test-parameter-1").find("input").as("Input").type("Redash");
cy.getByTestId("ParameterName-test-parameter-2").find("input").type("Redash");
cy.location("search").should("not.contain", "Redash");
cy.server();
cy.route("POST", "**/api/queries/*/results").as("Results");
apply(cy.get("@Input"));
cy.location("search").should("contain", "Redash");
cy.wait("@Results");
};
beforeEach(() => {
const queryData = {
name: "Testing Apply Button",
query: "SELECT '{{test-parameter-1}} {{ test-parameter-2 }}'",
options: {
parameters: [
{ name: "test-parameter-1", title: "Test Parameter 1", type: "text" },
{ name: "test-parameter-2", title: "Test Parameter 2", type: "text" },
],
},
};
cy.server();
cy.route("GET", "**/api/data_sources/*/schema").as("Schema");
cy.createQuery(queryData, false)
.then(({ id }) => cy.visit(`/queries/${id}/source`))
.then(() => cy.wait("@Schema"));
});
it("shows and hides according to parameter dirty state", () => {
cy.getByTestId("ParameterApplyButton").should("not.be", "visible");
cy.getByTestId("ParameterName-test-parameter-1").find("input").as("Param").type("Redash");
cy.getByTestId("ParameterApplyButton").should("be.visible");
cy.get("@Param").clear();
cy.getByTestId("ParameterApplyButton").should("not.be", "visible");
});
it("updates dirty counter", () => {
cy.getByTestId("ParameterName-test-parameter-1").find("input").type("Redash");
cy.getByTestId("ParameterApplyButton").find(".ant-badge-count p.current").should("contain", "1");
cy.getByTestId("ParameterName-test-parameter-2").find("input").type("Redash");
cy.getByTestId("ParameterApplyButton").find(".ant-badge-count p.current").should("contain", "2");
});
it('applies changes from "Apply Changes" button', () => {
expectAppliedChanges(() => {
cy.getByTestId("ParameterApplyButton").click();
});
});
it('applies changes from "alt+enter" keyboard shortcut', () => {
expectAppliedChanges((input) => {
input.type("{alt}{enter}");
});
});
it('disables "Execute" button', () => {
cy.getByTestId("ParameterName-test-parameter-1").find("input").as("Input").type("Redash");
cy.getByTestId("ExecuteButton").should("be.disabled");
cy.get("@Input").clear();
cy.getByTestId("ExecuteButton").should("be.enabled");
});
});
describe("Draggable", () => {
beforeEach(() => {
const queryData = {
name: "Draggable",
query: "SELECT '{{param1}}', '{{param2}}', '{{param3}}', '{{param4}}' AS parameter",
options: {
parameters: [
{ name: "param1", title: "Parameter 1", type: "text" },
{ name: "param2", title: "Parameter 2", type: "text" },
{ name: "param3", title: "Parameter 3", type: "text" },
{ name: "param4", title: "Parameter 4", type: "text" },
],
},
};
cy.createQuery(queryData, false).then(({ id }) => cy.visit(`/queries/${id}/source`));
cy.get(".parameter-block").first().invoke("width").as("paramWidth");
cy.get("body").type("{alt}D"); // hide schema browser
});
it("is possible to rearrange parameters", function () {
cy.server();
cy.route("POST", "**/api/queries/*").as("QuerySave");
dragParam("param1", this.paramWidth, 1);
cy.wait("@QuerySave");
dragParam("param4", -this.paramWidth, 1);
cy.wait("@QuerySave");
cy.reload();
const expectedOrder = ["Parameter 2", "Parameter 1", "Parameter 4", "Parameter 3"];
cy.get(".parameter-container label").each(($label, index) => expect($label).to.have.text(expectedOrder[index]));
});
});
describe("Parameter Settings", () => {
beforeEach(() => {
const queryData = {
name: "Draggable",
query: "SELECT '{{parameter}}' AS parameter",
options: {
parameters: [{ name: "parameter", title: "Parameter", type: "text" }],
},
};
cy.createQuery(queryData, false).then(({ id }) => cy.visit(`/queries/${id}/source`));
cy.getByTestId("ParameterSettings-parameter").click();
});
it("changes the parameter title", () => {
cy.getByTestId("ParameterTitleInput").type("{selectall}New Parameter Name");
cy.getByTestId("SaveParameterSettings").click();
cy.contains("Query saved");
cy.reload();
cy.getByTestId("ParameterName-parameter").contains("label", "New Parameter Name");
});
});
});
================================================
FILE: client/cypress/integration/query/query_tags_spec.js
================================================
import { expectTagsToContain, typeInTagsSelectAndSave } from "../../support/tags";
describe("Query Tags", () => {
beforeEach(() => {
cy.login();
const queryData = {
name: "Query Tags",
query: "SELECT 1 as value",
};
cy.createQuery(queryData, false).then(({ id }) => cy.visit(`/queries/${id}`));
});
it("is possible to add and edit tags", () => {
cy.server();
cy.route("POST", "**/api/queries/*").as("QuerySave");
cy.getByTestId("TagsControl").contains(".label", "Unpublished");
cy.getByTestId("EditTagsButton")
.should("contain", "Add tag")
.click();
typeInTagsSelectAndSave("tag1{enter}tag2{enter}tag3{enter}");
cy.wait("@QuerySave");
expectTagsToContain(["tag1", "tag2", "tag3"]);
cy.getByTestId("EditTagsButton").click();
typeInTagsSelectAndSave("tag4{enter}");
cy.wait("@QuerySave");
cy.reload();
expectTagsToContain(["tag1", "tag2", "tag3", "tag4"]);
});
});
================================================
FILE: client/cypress/integration/query-snippets/create_query_snippet_spec.js
================================================
describe("Create Query Snippet", () => {
beforeEach(() => {
cy.login();
cy.visit("/query_snippets/new");
});
it("creates a query snippet with an empty description", () => {
// delete existing "example-snippet"
cy.request("GET", "api/query_snippets")
.then(({ body }) => body.filter(snippet => snippet.trigger === "example-snippet"))
.each(snippet => cy.request("DELETE", `api/query_snippets/${snippet.id}`));
cy.getByTestId("QuerySnippetDialog").within(() => {
cy.getByTestId("Trigger").type("example-snippet");
cy.getByTestId("Snippet")
.find(".ace_text-input")
.type("SELECT 1", { force: true });
});
cy.getByTestId("SaveQuerySnippetButton").click();
});
});
================================================
FILE: client/cypress/integration/settings/organization_settings_spec.js
================================================
describe("Settings", () => {
beforeEach(() => {
cy.login();
cy.visit("/settings/general");
});
it("renders the page and takes a screenshot", () => {
cy.getByTestId("OrganizationSettings").within(() => {
cy.getByTestId("TimeFormatSelect").should("contain", "HH:mm");
});
cy.percySnapshot("Organization Settings");
});
it("can set date format setting", () => {
cy.getByTestId("DateFormatSelect").click();
cy.getByTestId("DateFormatSelect:YYYY-MM-DD").click();
cy.getByTestId("OrganizationSettingsSaveButton").click();
cy.createQuery({
name: "test date format",
query: "SELECT NOW()",
}).then(({ id: queryId }) => {
cy.visit(`/queries/${queryId}`);
cy.findByText("Refresh Now").click();
// "created at" field is formatted with the date format.
cy.getByTestId("TableVisualization")
.findAllByText(/\d{4}-\d{2}-\d{2}/)
.should("exist");
// set to a different format and expect a different result in the table
cy.visit("/settings/general");
cy.getByTestId("DateFormatSelect").click();
cy.getByTestId("DateFormatSelect:MM/DD/YY").click();
cy.getByTestId("OrganizationSettingsSaveButton").click();
cy.visit(`/queries/${queryId}`);
cy.getByTestId("TableVisualization")
.findAllByText(/\d{2}\/\d{2}\/\d{2}/)
.should("exist");
});
});
});
================================================
FILE: client/cypress/integration/settings/settings_tabs_spec.js
================================================
describe("Settings Tabs", () => {
const regularUser = {
name: "Example User",
email: "user@redash.io",
password: "password",
};
const userTabs = ["Users", "Groups", "Query Snippets", "Account"];
const adminTabs = ["Data Sources", "Alert Destinations", "General"];
const expectSettingsTabsToBe = expectedTabs =>
cy.getByTestId("SettingsScreenItem").then($list => {
const listedPages = $list.toArray().map(el => el.text);
expect(listedPages).to.have.members(expectedTabs);
});
before(() => {
cy.login().then(() => cy.createUser(regularUser));
});
describe("For admin user", () => {
beforeEach(() => {
cy.logout();
cy.login();
cy.visit("/");
});
it("settings link should lead to Data Sources settings", () => {
cy.getByTestId("SettingsLink")
.should("exist")
.should("have.attr", "href", "data_sources");
});
it("all tabs should be available", () => {
cy.getByTestId("SettingsLink").click();
expectSettingsTabsToBe([...userTabs, ...adminTabs]);
});
});
describe("For regular user", () => {
beforeEach(() => {
cy.logout();
cy.login(regularUser.email, regularUser.password);
cy.visit("/");
});
it("settings link should lead to Users settings", () => {
cy.getByTestId("SettingsLink")
.should("exist")
.should("have.attr", "href", "users");
});
it("limited set of settings tabs should be available", () => {
cy.getByTestId("SettingsLink").click();
expectSettingsTabsToBe(userTabs);
});
});
});
================================================
FILE: client/cypress/integration/user/create_user_spec.js
================================================
describe("Create User", () => {
beforeEach(() => {
cy.login();
cy.visit("/users/new");
});
const fillUserFormAndSubmit = (name, email) => {
cy.getByTestId("CreateUserDialog").within(() => {
cy.getByTestId("Name").type(name);
cy.getByTestId("Email").type(email);
});
cy.getByTestId("SaveUserButton").click();
};
it("creates a new user", () => {
// delete existing "new-user@redash.io"
cy.request("GET", "api/users?q=new-user")
.then(({ body }) => body.results.filter(user => user.email === "new-user@redash.io"))
.each(user => cy.request("DELETE", `api/users/${user.id}`));
fillUserFormAndSubmit("New User", "admin@redash.io");
cy.getByTestId("CreateUserErrorAlert").should("contain", "Email already taken");
fillUserFormAndSubmit("{selectall}New User", "{selectall}new-user@redash.io");
cy.contains("Saved.");
});
});
================================================
FILE: client/cypress/integration/user/edit_profile_spec.js
================================================
function fillProfileDataAndSave(name, email) {
cy.getByTestId("Name").type(`{selectall}${name}`);
cy.getByTestId("Email").type(`{selectall}${email}{enter}`);
cy.contains("Saved.");
}
function fillChangePasswordAndSave(currentPassword, newPassword, repeatPassword) {
cy.getByTestId("CurrentPassword").type(currentPassword);
cy.getByTestId("NewPassword").type(newPassword);
cy.getByTestId("RepeatPassword").type(`${repeatPassword}{enter}`);
}
describe("Edit Profile", () => {
beforeEach(() => {
cy.login();
cy.visit("/users/me");
});
it("updates the user after Save", () => {
fillProfileDataAndSave("Jian Yang", "jian.yang@redash.io");
cy.logout();
cy.login("jian.yang@redash.io")
.its("status")
.should("eq", 200);
cy.visit("/users/me");
cy.contains("Jian Yang");
fillProfileDataAndSave("Example Admin", "admin@redash.io");
});
it("regenerates API Key", () => {
cy.getByTestId("ApiKey").then($apiKey => {
const previousApiKey = $apiKey.val();
cy.getByTestId("RegenerateApiKey").click();
cy.get(".ant-btn-primary")
.contains("Regenerate")
.click({ force: true });
cy.getByTestId("ApiKey").should("not.eq", previousApiKey);
});
});
it("renders the page and takes a screenshot", () => {
cy.getByTestId("Groups").should("contain", "admin");
cy.percySnapshot("User Profile");
});
context("changing password", () => {
beforeEach(() => {
cy.getByTestId("ChangePassword").click();
});
it("updates user password when password is correct", () => {
fillChangePasswordAndSave("password", "newpassword", "newpassword");
cy.contains("Saved.");
cy.logout();
cy.login(undefined, "newpassword")
.its("status")
.should("eq", 200);
cy.visit("/users/me");
cy.getByTestId("ChangePassword").click();
fillChangePasswordAndSave("newpassword", "password", "password");
cy.contains("Saved.");
});
it("shows an error when current password is wrong", () => {
fillChangePasswordAndSave("wrongpassword", "newpassword", "newpassword");
cy.contains("Incorrect current password.");
});
});
});
================================================
FILE: client/cypress/integration/user/login_spec.js
================================================
describe("Login", () => {
beforeEach(() => {
cy.visit("/login");
});
it("greets the user and take a screenshot", () => {
cy.contains("h3", "Login to Redash");
cy.wait(1000); // eslint-disable-line cypress/no-unnecessary-waiting
cy.percySnapshot("Login");
});
it("shows message on failed login", () => {
cy.getByTestId("Email").type("admin@redash.io");
cy.getByTestId("Password").type("wrongpassword{enter}");
cy.getByTestId("ErrorMessage").should("contain", "Wrong email or password.");
});
it("navigates to homepage with successful login", () => {
cy.getByTestId("Email").type("admin@redash.io");
cy.getByTestId("Password").type("password{enter}");
cy.title().should("eq", "Redash");
cy.get(`img.profile__image_thumb[alt="Example Admin"]`).should("exist");
cy.wait(1000); // eslint-disable-line cypress/no-unnecessary-waiting
cy.percySnapshot("Homepage");
});
});
================================================
FILE: client/cypress/integration/user/logout_spec.js
================================================
describe("Logout", () => {
beforeEach(() => {
cy.login();
cy.visit("/");
});
it("shows login page after logout", () => {
cy.getByTestId("ProfileDropdown").click();
// Wait until submenu appears and become interactive
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("LogOutButton")
.should("be.visible")
.click();
cy.title().should("eq", "Login to Redash");
});
});
================================================
FILE: client/cypress/integration/user/user_list_spec.js
================================================
describe("User List", () => {
beforeEach(() => {
cy.login();
cy.visit("/users");
});
it("renders the page and takes a screenshot", () => {
cy.getByTestId("UserList")
.should("exist")
.and("contain", "Example Admin");
cy.percySnapshot("Users");
});
});
================================================
FILE: client/cypress/integration/visualizations/box_plot_spec.js
================================================
/* global cy, Cypress */
const SQL = `
SELECT 12 AS mn, 4967 AS mx UNION ALL
SELECT 10 AS mn, 19430 AS mx UNION ALL
SELECT 3132 AS mn, 3275 AS mx UNION ALL
SELECT 2 AS mn, 19429 AS mx UNION ALL
SELECT 7 AS mn, 19433 AS mx UNION ALL
SELECT 4824 AS mn, 4824 AS mx UNION ALL
SELECT 11353 AS mn, 16565 AS mx UNION ALL
SELECT 551 AS mn, 19415 AS mx UNION ALL
SELECT 307 AS mn, 17918 AS mx UNION ALL
SELECT 25 AS mn, 19436 AS mx UNION ALL
SELECT 98 AS mn, 19230 AS mx UNION ALL
SELECT 1652 AS mn, 1667 AS mx UNION ALL
SELECT 4486 AS mn, 4486 AS mx UNION ALL
SELECT 5113 AS mn, 5120 AS mx UNION ALL
SELECT 1642 AS mn, 1678 AS mx UNION ALL
SELECT 1632 AS mn, 16183 AS mx UNION ALL
SELECT 8 AS mn, 19434 AS mx UNION ALL
SELECT 13149 AS mn, 16945 AS mx UNION ALL
SELECT 340 AS mn, 340 AS mx UNION ALL
SELECT 15495 AS mn, 16559 AS mx UNION ALL
SELECT 24 AS mn, 19266 AS mx UNION ALL
SELECT 532 AS mn, 19283 AS mx UNION ALL
SELECT 4958 AS mn, 4958 AS mx UNION ALL
SELECT 10078 AS mn, 10079 AS mx UNION ALL
SELECT 102 AS mn, 17895 AS mx UNION ALL
SELECT 5366 AS mn, 18463 AS mx UNION ALL
SELECT 11363 AS mn, 16552 AS mx UNION ALL
SELECT 1 AS mn, 5211 AS mx UNION ALL
SELECT 6 AS mn, 19431 AS mx UNION ALL
SELECT 11378 AS mn, 16946 AS mx UNION ALL
SELECT 4676 AS mn, 4944 AS mx UNION ALL
SELECT 5228 AS mn, 18466 AS mx
`;
describe("Box Plot", () => {
const viewportWidth = Cypress.config("viewportWidth");
beforeEach(() => {
cy.login();
cy.createQuery({ query: SQL })
.then(({ id }) => cy.createVisualization(id, "BOXPLOT", "Boxplot (Deprecated)", {}))
.then(({ id: visualizationId, query_id: queryId }) => {
cy.visit(`queries/${queryId}/source#${visualizationId}`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click();
});
});
it("creates visualization", () => {
cy.clickThrough(`
EditVisualization
`);
cy.fillInputs({
"BoxPlot.XAxisLabel": "X Axis",
"BoxPlot.YAxisLabel": "Y Axis",
});
// Wait for proper initialization of visualization
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("VisualizationPreview").find("svg").should("exist");
cy.percySnapshot("Visualizations - Box Plot", { widths: [viewportWidth] });
});
});
================================================
FILE: client/cypress/integration/visualizations/chart_spec.js
================================================
/* global cy */
import { getWidgetTestId } from "../../support/dashboard";
import {
assertAxesAndAddLabels,
assertPlotPreview,
assertTabbedEditor,
createChartThroughUI,
createDashboardWithCharts,
} from "../../support/visualizations/chart";
const SQL = `
SELECT 'a' AS stage, 11 AS value1, 22 AS value2 UNION ALL
SELECT 'a' AS stage, 12 AS value1, 41 AS value2 UNION ALL
SELECT 'a' AS stage, 45 AS value1, 93 AS value2 UNION ALL
SELECT 'a' AS stage, 54 AS value1, 79 AS value2 UNION ALL
SELECT 'b' AS stage, 33 AS value1, 65 AS value2 UNION ALL
SELECT 'b' AS stage, 73 AS value1, 50 AS value2 UNION ALL
SELECT 'b' AS stage, 90 AS value1, 40 AS value2 UNION ALL
SELECT 'c' AS stage, 19 AS value1, 33 AS value2 UNION ALL
SELECT 'c' AS stage, 92 AS value1, 14 AS value2 UNION ALL
SELECT 'c' AS stage, 63 AS value1, 65 AS value2 UNION ALL
SELECT 'c' AS stage, 44 AS value1, 27 AS value2\
`;
describe("Chart", () => {
beforeEach(() => {
cy.login();
cy.createQuery({ name: "Chart Visualization", query: SQL }).its("id").as("queryId");
});
it("creates Bar charts", function () {
cy.visit(`queries/${this.queryId}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click();
const getBarChartAssertionFunction =
(specificBarChartAssertionFn = () => {}) =>
() => {
// checks for TabbedEditor standard tabs
assertTabbedEditor();
// standard chart should be bar
cy.getByTestId("Chart.GlobalSeriesType").contains(".ant-select-selection-item", "Bar");
// checks the plot canvas exists and is empty
assertPlotPreview("not.exist");
// creates a chart and checks it is plotted
cy.getByTestId("Chart.ColumnMapping.x").selectAntdOption("Chart.ColumnMapping.x.stage");
cy.getByTestId("Chart.ColumnMapping.y").selectAntdOption("Chart.ColumnMapping.y.value1");
cy.getByTestId("Chart.ColumnMapping.y").selectAntdOption("Chart.ColumnMapping.y.value2");
assertPlotPreview("exist");
specificBarChartAssertionFn();
};
const chartTests = [
{
name: "Basic Bar Chart",
alias: "basicBarChart",
assertionFn: () => {
assertAxesAndAddLabels("Stage", "Value");
},
},
{
name: "Horizontal Bar Chart",
alias: "horizontalBarChart",
assertionFn: () => {
cy.getByTestId("Chart.SwappedAxes").check();
cy.getByTestId("VisualizationEditor.Tabs.XAxis").should("have.text", "Y Axis");
cy.getByTestId("VisualizationEditor.Tabs.YAxis").should("have.text", "X Axis");
},
},
{
name: "Stacked Bar Chart",
alias: "stackedBarChart",
assertionFn: () => {
cy.getByTestId("Chart.Stacking").selectAntdOption("Chart.Stacking.Stack");
},
},
{
name: "Normalized Bar Chart",
alias: "normalizedBarChart",
assertionFn: () => {
cy.getByTestId("Chart.NormalizeValues").check();
},
},
];
chartTests.forEach(({ name, alias, assertionFn }) => {
createChartThroughUI(name, getBarChartAssertionFunction(assertionFn)).as(alias);
});
const chartGetters = chartTests.map(({ alias }) => alias);
const withDashboardWidgetsAssertionFn = (widgetGetters, dashboardUrl) => {
cy.visit(dashboardUrl);
widgetGetters.forEach((widgetGetter) => {
cy.get(`@${widgetGetter}`).then((widget) => {
cy.getByTestId(getWidgetTestId(widget)).within(() => {
cy.get("g.points").should("exist");
});
});
});
};
createDashboardWithCharts("Bar chart visualizations", chartGetters, withDashboardWidgetsAssertionFn);
cy.percySnapshot("Visualizations - Charts - Bar");
});
it("colors Bar charts", function () {
cy.visit(`queries/${this.queryId}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click();
cy.getByTestId("NewVisualization").click();
cy.getByTestId("Chart.ColumnMapping.x").selectAntdOption("Chart.ColumnMapping.x.stage");
cy.getByTestId("Chart.ColumnMapping.y").selectAntdOption("Chart.ColumnMapping.y.value1");
cy.getByTestId("VisualizationEditor.Tabs.Colors").click();
cy.getByTestId("ColorScheme").click();
cy.getByTestId("ColorOptionViridis").click();
cy.getByTestId("ColorScheme").click();
cy.getByTestId("ColorOptionTableau 10").click();
cy.getByTestId("ColorScheme").click();
cy.getByTestId("ColorOptionD3 Category 10").click();
});
it("colors Pie charts", function () {
cy.visit(`queries/${this.queryId}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click();
cy.getByTestId("NewVisualization").click();
cy.getByTestId("Chart.GlobalSeriesType").click();
cy.getByTestId("Chart.ChartType.pie").click();
cy.getByTestId("Chart.ColumnMapping.x").selectAntdOption("Chart.ColumnMapping.x.stage");
cy.getByTestId("Chart.ColumnMapping.y").selectAntdOption("Chart.ColumnMapping.y.value1");
cy.getByTestId("VisualizationEditor.Tabs.Colors").click();
cy.getByTestId("ColorScheme").click();
cy.getByTestId("ColorOptionViridis").click();
cy.getByTestId("ColorScheme").click();
cy.getByTestId("ColorOptionTableau 10").click();
cy.getByTestId("ColorScheme").click();
cy.getByTestId("ColorOptionD3 Category 10").click();
});
});
================================================
FILE: client/cypress/integration/visualizations/choropleth_spec.js
================================================
/* global cy */
const SQL = `
SELECT 'AR' AS "code", 'Argentina' AS "name", 37.62 AS "value" UNION ALL
SELECT 'AU' AS "code", 'Australia' AS "name", 37.62 AS "value" UNION ALL
SELECT 'AT' AS "code", 'Austria' AS "name", 42.62 AS "value" UNION ALL
SELECT 'BE' AS "code", 'Belgium' AS "name", 37.62 AS "value" UNION ALL
SELECT 'BR' AS "code", 'Brazil' AS "name", 190.10 AS "value" UNION ALL
SELECT 'CA' AS "code", 'Canada' AS "name", 303.96 AS "value" UNION ALL
SELECT 'CL' AS "code", 'Chile' AS "name", 46.62 AS "value" UNION ALL
SELECT 'CZ' AS "code", 'Czech Republic' AS "name", 90.24 AS "value" UNION ALL
SELECT 'DK' AS "code", 'Denmark' AS "name", 37.62 AS "value" UNION ALL
SELECT 'FI' AS "code", 'Finland' AS "name", 41.62 AS "value" UNION ALL
SELECT 'FR' AS "code", 'France' AS "name", 195.10 AS "value" UNION ALL
SELECT 'DE' AS "code", 'Germany' AS "name", 156.48 AS "value" UNION ALL
SELECT 'HU' AS "code", 'Hungary' AS "name", 45.62 AS "value" UNION ALL
SELECT 'IN' AS "code", 'India' AS "name", 75.26 AS "value" UNION ALL
SELECT 'IE' AS "code", 'Ireland' AS "name", 45.62 AS "value" UNION ALL
SELECT 'IT' AS "code", 'Italy' AS "name", 37.62 AS "value" UNION ALL
SELECT 'NL' AS "code", 'Netherlands' AS "name", 40.62 AS "value" UNION ALL
SELECT 'NO' AS "code", 'Norway' AS "name", 39.62 AS "value" UNION ALL
SELECT 'PL' AS "code", 'Poland' AS "name", 37.62 AS "value" UNION ALL
SELECT 'PT' AS "code", 'Portugal' AS "name", 77.24 AS "value" UNION ALL
SELECT 'ES' AS "code", 'Spain' AS "name", 37.62 AS "value" UNION ALL
SELECT 'SE' AS "code", 'Sweden' AS "name", 38.62 AS "value" UNION ALL
SELECT 'US' AS "code", 'USA' AS "name", 523.06 AS "value" UNION ALL
SELECT 'GB' AS "code", 'United Kingdom' AS "name", 112.86 AS "value"
`;
describe("Choropleth", () => {
const viewportWidth = Cypress.config("viewportWidth");
beforeEach(() => {
cy.login();
cy.createQuery({ query: SQL }).then(({ id }) => {
cy.visit(`queries/${id}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click();
});
cy.getByTestId("NewVisualization").click();
cy.getByTestId("VisualizationType").selectAntdOption("VisualizationType.CHOROPLETH");
});
it("creates visualization", () => {
cy.clickThrough(`
VisualizationEditor.Tabs.General
Choropleth.Editor.MapType
Choropleth.Editor.MapType.countries
Choropleth.Editor.KeyColumn
Choropleth.Editor.KeyColumn.name
Choropleth.Editor.TargetField
Choropleth.Editor.TargetField.name
Choropleth.Editor.ValueColumn
Choropleth.Editor.ValueColumn.value
`);
cy.clickThrough("VisualizationEditor.Tabs.Colors");
cy.clickThrough("Choropleth.Editor.Colors.Min");
cy.fillInputs({ "ColorPicker.CustomColor": "yellow{enter}" });
cy.getByTestId("ColorPicker.CustomColor").should("not.be.visible");
cy.clickThrough("Choropleth.Editor.Colors.Max");
cy.fillInputs({ "ColorPicker.CustomColor": "red{enter}" });
cy.getByTestId("ColorPicker.CustomColor").should("not.be.visible");
cy.clickThrough("Choropleth.Editor.Colors.Borders");
cy.fillInputs({ "ColorPicker.CustomColor": "black{enter}" });
cy.getByTestId("ColorPicker.CustomColor").should("not.be.visible");
cy.clickThrough(`
VisualizationEditor.Tabs.Format
Choropleth.Editor.LegendPosition
Choropleth.Editor.LegendPosition.TopRight
`);
cy.getByTestId("Choropleth.Editor.LegendTextAlignment")
.find('[data-test="TextAlignmentSelect.Left"]')
.check({ force: true });
// Wait for proper initialization of visualization
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("VisualizationPreview").find(".map-visualization-container.leaflet-container").should("exist");
cy.percySnapshot("Visualizations - Choropleth", { widths: [viewportWidth] });
});
});
================================================
FILE: client/cypress/integration/visualizations/cohort_spec.js
================================================
/* global cy, Cypress */
const SQL = `
SELECT '2019-01-01' AS "date", 21 AS "bucket", 5 AS "value", 1 AS "stage" UNION ALL
SELECT '2019-01-01' AS "date", 21 AS "bucket", 8 AS "value", 2 AS "stage" UNION ALL
SELECT '2019-01-01' AS "date", 21 AS "bucket", 2 AS "value", 3 AS "stage" UNION ALL
SELECT '2019-01-01' AS "date", 21 AS "bucket", 6 AS "value", 4 AS "stage" UNION ALL
SELECT '2019-02-01' AS "date", 10 AS "bucket", 7 AS "value", 1 AS "stage" UNION ALL
SELECT '2019-02-01' AS "date", 10 AS "bucket", 3 AS "value", 3 AS "stage" UNION ALL
SELECT '2019-03-01' AS "date", 19 AS "bucket", 4 AS "value", 1 AS "stage" UNION ALL
SELECT '2019-03-01' AS "date", 19 AS "bucket", 7 AS "value", 2 AS "stage" UNION ALL
SELECT '2019-03-01' AS "date", 19 AS "bucket", 8 AS "value", 3 AS "stage" UNION ALL
SELECT '2019-05-01' AS "date", 15 AS "bucket", 13 AS "value", 1 AS "stage" UNION ALL
SELECT '2019-05-01' AS "date", 15 AS "bucket", 2 AS "value", 4 AS "stage"
`;
describe("Cohort", () => {
const viewportWidth = Cypress.config("viewportWidth");
beforeEach(() => {
cy.login();
cy.createQuery({ query: SQL }).then(({ id }) => {
cy.visit(`queries/${id}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click();
});
cy.getByTestId("NewVisualization").click();
cy.getByTestId("VisualizationType").selectAntdOption("VisualizationType.COHORT");
});
it("creates visualization", () => {
cy.clickThrough(`
VisualizationEditor.Tabs.Options
Cohort.TimeInterval
Cohort.TimeInterval.monthly
Cohort.Mode
Cohort.Mode.simple
VisualizationEditor.Tabs.Columns
Cohort.DateColumn
Cohort.DateColumn.date
Cohort.StageColumn
Cohort.StageColumn.stage
Cohort.TotalColumn
Cohort.TotalColumn.bucket
Cohort.ValueColumn
Cohort.ValueColumn.value
`);
// Wait for proper initialization of visualization
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("VisualizationPreview").find("table").should("exist");
cy.percySnapshot("Visualizations - Cohort (simple)", { widths: [viewportWidth] });
cy.clickThrough(`
VisualizationEditor.Tabs.Options
Cohort.Mode
Cohort.Mode.diagonal
`);
// Wait for proper initialization of visualization
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("VisualizationPreview").find("table").should("exist");
cy.percySnapshot("Visualizations - Cohort (diagonal)", { widths: [viewportWidth] });
});
});
================================================
FILE: client/cypress/integration/visualizations/counter_spec.js
================================================
/* global cy, Cypress */
const SQL = `
SELECT 27182.8182846 AS a, 20000 AS b, 'lorem' AS c UNION ALL
SELECT 31415.9265359 AS a, 40000 AS b, 'ipsum' AS c
`;
describe("Counter", () => {
const viewportWidth = Cypress.config("viewportWidth");
beforeEach(() => {
cy.login();
cy.createQuery({ query: SQL }).then(({ id }) => {
cy.visit(`queries/${id}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click();
});
cy.getByTestId("NewVisualization").click();
cy.getByTestId("VisualizationType").selectAntdOption("VisualizationType.COUNTER");
});
it("creates simple Counter", () => {
cy.clickThrough(`
Counter.General.ValueColumn
Counter.General.ValueColumn.a
`);
cy.getByTestId("VisualizationPreview").find(".counter-visualization-container").should("exist");
// wait a bit before taking snapshot
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.percySnapshot("Visualizations - Counter (with defaults)", { widths: [viewportWidth] });
});
it("creates Counter with custom label", () => {
cy.clickThrough(`
Counter.General.ValueColumn
Counter.General.ValueColumn.a
`);
cy.fillInputs({
"Counter.General.Label": "Custom Label",
});
cy.getByTestId("VisualizationPreview").find(".counter-visualization-container").should("exist");
// wait a bit before taking snapshot
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.percySnapshot("Visualizations - Counter (custom label)", { widths: [viewportWidth] });
});
it("creates Counter with non-numeric value", () => {
cy.clickThrough(`
Counter.General.ValueColumn
Counter.General.ValueColumn.c
Counter.General.TargetValueColumn
Counter.General.TargetValueColumn.c
`);
cy.fillInputs({
"Counter.General.TargetValueRowNumber": "2",
});
cy.getByTestId("VisualizationPreview").find(".counter-visualization-container").should("exist");
// wait a bit before taking snapshot
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.percySnapshot("Visualizations - Counter (non-numeric value)", { widths: [viewportWidth] });
});
it("creates Counter with target value (trend positive)", () => {
cy.clickThrough(`
Counter.General.ValueColumn
Counter.General.ValueColumn.a
Counter.General.TargetValueColumn
Counter.General.TargetValueColumn.b
`);
cy.getByTestId("VisualizationPreview").find(".counter-visualization-container").should("exist");
// wait a bit before taking snapshot
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.percySnapshot("Visualizations - Counter (target value + trend positive)", { widths: [viewportWidth] });
});
it("creates Counter with custom row number (trend negative)", () => {
cy.clickThrough(`
Counter.General.ValueColumn
Counter.General.ValueColumn.a
Counter.General.TargetValueColumn
Counter.General.TargetValueColumn.b
`);
cy.fillInputs({
"Counter.General.ValueRowNumber": "2",
"Counter.General.TargetValueRowNumber": "2",
});
cy.getByTestId("VisualizationPreview").find(".counter-visualization-container").should("exist");
// wait a bit before taking snapshot
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.percySnapshot("Visualizations - Counter (row number + trend negative)", { widths: [viewportWidth] });
});
it("creates Counter with count rows", () => {
cy.clickThrough(`
Counter.General.ValueColumn
Counter.General.ValueColumn.a
Counter.General.CountRows
`);
cy.getByTestId("VisualizationPreview").find(".counter-visualization-container").should("exist");
// wait a bit before taking snapshot
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.percySnapshot("Visualizations - Counter (count rows)", { widths: [viewportWidth] });
});
it("creates Counter with formatting", () => {
cy.clickThrough(`
Counter.General.ValueColumn
Counter.General.ValueColumn.a
Counter.General.TargetValueColumn
Counter.General.TargetValueColumn.b
VisualizationEditor.Tabs.Format
`);
cy.fillInputs({
"Counter.Formatting.DecimalPlace": "4",
"Counter.Formatting.DecimalCharacter": ",",
"Counter.Formatting.ThousandsSeparator": "`",
"Counter.Formatting.StringPrefix": "$",
"Counter.Formatting.StringSuffix": "%",
});
cy.getByTestId("VisualizationPreview").find(".counter-visualization-container").should("exist");
// wait a bit before taking snapshot
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.percySnapshot("Visualizations - Counter (custom formatting)", { widths: [viewportWidth] });
});
it("creates Counter with target value formatting", () => {
cy.clickThrough(`
Counter.General.ValueColumn
Counter.General.ValueColumn.a
Counter.General.TargetValueColumn
Counter.General.TargetValueColumn.b
VisualizationEditor.Tabs.Format
Counter.Formatting.FormatTargetValue
`);
cy.fillInputs({
"Counter.Formatting.DecimalPlace": "4",
"Counter.Formatting.DecimalCharacter": ",",
"Counter.Formatting.ThousandsSeparator": "`",
"Counter.Formatting.StringPrefix": "$",
"Counter.Formatting.StringSuffix": "%",
});
cy.getByTestId("VisualizationPreview").find(".counter-visualization-container").should("exist");
// wait a bit before taking snapshot
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.percySnapshot("Visualizations - Counter (format target value)", { widths: [viewportWidth] });
});
});
================================================
FILE: client/cypress/integration/visualizations/edit_visualization_dialog_spec.js
================================================
/* global cy */
describe("Edit visualization dialog", () => {
beforeEach(() => {
cy.login();
cy.createQuery().then(({ id }) => {
cy.visit(`queries/${id}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click();
});
});
it("opens New Visualization dialog", () => {
cy.getByTestId("NewVisualization").should("exist").click();
cy.getByTestId("EditVisualizationDialog").should("exist");
// Default visualization should be selected
cy.getByTestId("VisualizationType").should("exist").should("contain", "Chart");
cy.getByTestId("VisualizationName").should("exist").should("have.value", "Chart");
});
it("opens Edit Visualization dialog", () => {
cy.getByTestId("EditVisualization").click();
cy.getByTestId("EditVisualizationDialog").should("exist");
// Default `Table` visualization should be selected
cy.getByTestId("VisualizationType").should("exist").should("contain", "Table");
cy.getByTestId("VisualizationName").should("exist").should("have.value", "Table");
});
it("creates visualization with custom name", () => {
const visualizationName = "Custom name";
cy.clickThrough(`
NewVisualization
VisualizationType
VisualizationType.TABLE
`);
cy.getByTestId("VisualizationName").clear().type(visualizationName);
cy.getByTestId("EditVisualizationDialog").contains("button", "Save").click();
cy.getByTestId("QueryPageVisualizationTabs").contains("span", visualizationName).should("exist");
});
});
================================================
FILE: client/cypress/integration/visualizations/funnel_spec.js
================================================
/* global cy, Cypress */
const SQL = `
SELECT 'a.01' AS a, 1.758831600227 AS b UNION ALL
SELECT 'a.02' AS a, 613.4456936572 AS b UNION ALL
SELECT 'a.03' AS a, 9.045647090023 AS b UNION ALL
SELECT 'a.04' AS a, 29.37836413439 AS b UNION ALL
SELECT 'a.05' AS a, 642.9434910444 AS b UNION ALL
SELECT 'a.06' AS a, 176.7634164480 AS b UNION ALL
SELECT 'a.07' AS a, 279.4880059198 AS b UNION ALL
SELECT 'a.08' AS a, 78.48128609207 AS b UNION ALL
SELECT 'a.09' AS a, 14.10443892662 AS b UNION ALL
SELECT 'a.10' AS a, 59.25097112438 AS b UNION ALL
SELECT 'a.11' AS a, 61.58610868125 AS b UNION ALL
SELECT 'a.12' AS a, 277.8473055268 AS b UNION ALL
SELECT 'a.13' AS a, 621.1535090415 AS b UNION ALL
SELECT 'a.14' AS a, 261.1409234646 AS b UNION ALL
SELECT 'a.15' AS a, 72.94883358030 AS b
`;
describe("Funnel", () => {
const viewportWidth = Cypress.config("viewportWidth");
beforeEach(() => {
cy.login();
cy.createQuery({ query: SQL }).then(({ id }) => {
cy.visit(`queries/${id}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click();
});
});
it("creates visualization", () => {
cy.clickThrough(`
NewVisualization
`);
cy.getByTestId("VisualizationType").selectAntdOption("VisualizationType.FUNNEL");
cy.clickThrough(`
VisualizationEditor.Tabs.General
Funnel.StepColumn
Funnel.StepColumn.a
Funnel.ValueColumn
Funnel.ValueColumn.b
Funnel.CustomSort
Funnel.SortColumn
Funnel.SortColumn.b
Funnel.SortDirection
Funnel.SortDirection.Ascending
`);
cy.fillInputs(
{
"Funnel.StepColumnTitle": "Column A",
"Funnel.ValueColumnTitle": "Column B",
},
{ wait: 200 }
); // inputs are debounced
// Wait for proper initialization of visualization
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("VisualizationPreview").find("table").should("exist");
cy.percySnapshot("Visualizations - Funnel (basic)", { widths: [viewportWidth] });
cy.clickThrough(`
VisualizationEditor.Tabs.Appearance
`);
cy.fillInputs(
{
"Funnel.NumberFormat": "0[.]00",
"Funnel.PercentFormat": "0[.]0000%",
"Funnel.ItemsLimit": "10",
"Funnel.PercentRangeMin": "10",
"Funnel.PercentRangeMax": "90",
},
{ wait: 200 }
); // inputs are debounced
// Wait for proper initialization of visualization
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("VisualizationPreview").find("table").should("exist");
cy.percySnapshot("Visualizations - Funnel (extra options)", { widths: [viewportWidth] });
});
});
================================================
FILE: client/cypress/integration/visualizations/map_spec.js
================================================
/* global cy */
const SQL = `
SELECT 'Israel' AS country, 32.0808800 AS lat, 34.7805700 AS lng UNION ALL
SELECT 'Israel' AS country, 31.7690400 AS lat, 35.2163300 AS lng UNION ALL
SELECT 'Israel' AS country, 32.8184100 AS lat, 34.9885000 AS lng UNION ALL
SELECT 'Ukraine' AS country, 50.4546600 AS lat, 30.5238000 AS lng UNION ALL
SELECT 'Ukraine' AS country, 49.8382600 AS lat, 24.0232400 AS lng UNION ALL
SELECT 'Ukraine' AS country, 49.9808100 AS lat, 36.2527200 AS lng UNION ALL
SELECT 'Hungary' AS country, 47.4980100 AS lat, 19.0399100 AS lng
`;
describe("Map (Markers)", () => {
const viewportWidth = Cypress.config("viewportWidth");
beforeEach(() => {
cy.login();
const mapTileUrl = "/static/images/fixtures/map-tile.png";
cy.createQuery({ query: SQL })
.then(({ id }) => cy.createVisualization(id, "MAP", "Map (Markers)", { mapTileUrl }))
.then(({ id: visualizationId, query_id: queryId }) => {
cy.visit(`queries/${queryId}/source#${visualizationId}`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click();
});
});
it("creates Map with groups", () => {
cy.clickThrough(`
EditVisualization
VisualizationEditor.Tabs.General
Map.Editor.LatitudeColumnName
Map.Editor.LatitudeColumnName.lat
Map.Editor.LongitudeColumnName
Map.Editor.LongitudeColumnName.lng
Map.Editor.GroupBy
Map.Editor.GroupBy.country
`);
cy.clickThrough("VisualizationEditor.Tabs.Groups");
cy.clickThrough("Map.Editor.Groups.Israel.Color");
cy.fillInputs({ "ColorPicker.CustomColor": "red{enter}" });
cy.getByTestId("ColorPicker.CustomColor").should("not.be.visible");
cy.clickThrough("Map.Editor.Groups.Ukraine.Color");
cy.fillInputs({ "ColorPicker.CustomColor": "green{enter}" });
cy.getByTestId("ColorPicker.CustomColor").should("not.be.visible");
cy.clickThrough("Map.Editor.Groups.Hungary.Color");
cy.fillInputs({ "ColorPicker.CustomColor": "blue{enter}" });
cy.getByTestId("ColorPicker.CustomColor").should("not.be.visible");
cy.getByTestId("VisualizationPreview").find(".leaflet-control-zoom-in").click();
// Wait for proper initialization of visualization
cy.wait(1000); // eslint-disable-line cypress/no-unnecessary-waiting
cy.percySnapshot("Visualizations - Map (Markers) with groups", { widths: [viewportWidth] });
});
it("creates Map with custom markers", () => {
cy.clickThrough(`
EditVisualization
VisualizationEditor.Tabs.General
Map.Editor.LatitudeColumnName
Map.Editor.LatitudeColumnName.lat
Map.Editor.LongitudeColumnName
Map.Editor.LongitudeColumnName.lng
`);
cy.clickThrough(`
VisualizationEditor.Tabs.Style
Map.Editor.ClusterMarkers
Map.Editor.CustomizeMarkers
`);
cy.fillInputs({ "Map.Editor.MarkerIcon": "home" }, { wait: 250 }); // this input is debounced
cy.clickThrough("Map.Editor.MarkerBackgroundColor");
cy.fillInputs({ "ColorPicker.CustomColor": "red{enter}" });
cy.getByTestId("ColorPicker.CustomColor").should("not.be.visible");
cy.clickThrough("Map.Editor.MarkerBorderColor");
cy.fillInputs({ "ColorPicker.CustomColor": "maroon{enter}" });
cy.getByTestId("ColorPicker.CustomColor").should("not.be.visible");
cy.getByTestId("VisualizationPreview").find(".leaflet-control-zoom-in").click();
// Wait for proper initialization of visualization
cy.wait(1000); // eslint-disable-line cypress/no-unnecessary-waiting
cy.percySnapshot("Visualizations - Map (Markers) with custom markers", { widths: [viewportWidth] });
});
});
================================================
FILE: client/cypress/integration/visualizations/pivot_spec.js
================================================
/* global cy */
import { getWidgetTestId } from "../../support/dashboard";
const SQL = `
SELECT 'a' AS stage1, 'a1' AS stage2, 11 AS value UNION ALL
SELECT 'a' AS stage1, 'a2' AS stage2, 12 AS value UNION ALL
SELECT 'a' AS stage1, 'a3' AS stage2, 45 AS value UNION ALL
SELECT 'a' AS stage1, 'a4' AS stage2, 54 AS value UNION ALL
SELECT 'b' AS stage1, 'b1' AS stage2, 33 AS value UNION ALL
SELECT 'b' AS stage1, 'b2' AS stage2, 73 AS value UNION ALL
SELECT 'b' AS stage1, 'b3' AS stage2, 90 AS value UNION ALL
SELECT 'c' AS stage1, 'c1' AS stage2, 19 AS value UNION ALL
SELECT 'c' AS stage1, 'c2' AS stage2, 92 AS value UNION ALL
SELECT 'c' AS stage1, 'c3' AS stage2, 63 AS value UNION ALL
SELECT 'c' AS stage1, 'c4' AS stage2, 44 AS value\
`;
function createPivotThroughUI(visualizationName, options = {}) {
cy.getByTestId("NewVisualization").click();
cy.getByTestId("VisualizationType").selectAntdOption("VisualizationType.PIVOT");
cy.getByTestId("VisualizationName").clear().type(visualizationName);
if (options.hideControls) {
cy.getByTestId("PivotEditor.HideControls").click();
cy.getByTestId("VisualizationPreview")
.find("table")
.find(".pvtAxisContainer, .pvtRenderer, .pvtVals")
.should("be.not.visible");
}
cy.getByTestId("VisualizationPreview").find("table").should("exist");
cy.getByTestId("EditVisualizationDialog").contains("button", "Save").click();
}
describe("Pivot", () => {
beforeEach(() => {
cy.login();
cy.createQuery({ name: "Pivot Visualization", query: SQL }).its("id").as("queryId");
});
it("creates Pivot with controls", function () {
cy.visit(`queries/${this.queryId}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click();
const visualizationName = "Pivot";
createPivotThroughUI(visualizationName);
cy.getByTestId("QueryPageVisualizationTabs").contains("span", visualizationName).should("exist");
});
it("creates Pivot without controls", function () {
cy.visit(`queries/${this.queryId}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click();
const visualizationName = "Pivot";
cy.server();
cy.route("POST", "**/api/visualizations").as("SaveVisualization");
createPivotThroughUI(visualizationName, { hideControls: true });
cy.wait("@SaveVisualization");
// Added visualization should also have hidden controls
cy.getByTestId("PivotTableVisualization")
.find("table")
.find(".pvtAxisContainer, .pvtRenderer, .pvtVals")
.should("be.not.visible");
});
it("updates the visualization when results change", function () {
const options = {
aggregatorName: "Count",
data: [], // force it to have a data object, although it shouldn't
controls: { enabled: false },
cols: ["stage1"],
rows: ["stage2"],
vals: ["value"],
};
cy.createVisualization(this.queryId, "PIVOT", "Pivot", options).then((visualization) => {
cy.visit(`queries/${this.queryId}/source#${visualization.id}`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click();
// assert number of rows is 11
cy.getByTestId("PivotTableVisualization").contains(".pvtGrandTotal", "11");
cy.getByTestId("QueryEditor")
.get(".ace_text-input")
.first()
.focus()
.type(" UNION ALL {enter}SELECT 'c' AS stage1, 'c5' AS stage2, 55 AS value");
// wait for the query text change to propagate (it's debounced in QuerySource.jsx)
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(200);
cy.getByTestId("SaveButton").click();
cy.getByTestId("ExecuteButton").should("be.enabled").click();
// assert number of rows is 12
cy.getByTestId("PivotTableVisualization").contains(".pvtGrandTotal", "12");
});
});
it("takes a snapshot with different configured Pivots", function () {
const options = {
aggregatorName: "Sum",
controls: { enabled: true },
cols: ["stage1"],
rows: ["stage2"],
vals: ["value"],
};
const pivotTables = [
{ name: "Pivot", options, position: { autoHeight: false, sizeY: 10, sizeX: 2 } },
{
name: "Pivot without Row Totals",
options: { ...options, rendererOptions: { table: { rowTotals: false } } },
position: { autoHeight: false, col: 2, sizeY: 10, sizeX: 2 },
},
{
name: "Pivot without Col Totals",
options: { ...options, rendererOptions: { table: { colTotals: false } } },
position: { autoHeight: false, col: 4, sizeY: 10, sizeX: 2 },
},
{
name: "Pivot with Controls",
options: { ...options, controls: { enabled: false } },
position: { autoHeight: false, row: 9, sizeY: 13 },
},
];
cy.createDashboard("Pivot Visualization")
.then((dashboard) => {
this.dashboardUrl = `/dashboards/${dashboard.id}`;
return cy.all(
pivotTables.map(
(pivot) => () =>
cy
.createVisualization(this.queryId, "PIVOT", pivot.name, pivot.options)
.then((visualization) => cy.addWidget(dashboard.id, visualization.id, { position: pivot.position }))
)
);
})
.then((widgets) => {
cy.visit(this.dashboardUrl);
widgets.forEach((widget) => {
cy.getByTestId(getWidgetTestId(widget)).within(() =>
cy.getByTestId("PivotTableVisualization").should("exist")
);
});
cy.percySnapshot("Visualizations - Pivot Table");
});
});
});
================================================
FILE: client/cypress/integration/visualizations/sankey_sunburst_spec.js
================================================
/* global cy */
import { getWidgetTestId } from "../../support/dashboard";
const SQL = `
SELECT 'a' AS s1, 'a1' AS s2, 'a2' AS s3, null AS s4, null AS s5, 11 AS value UNION ALL
SELECT 'a' AS s1, 'a2' AS s2, null AS s3, null AS s4, null AS s5, 12 AS value UNION ALL
SELECT 'a' AS s1, 'a3' AS s2, null AS s3, null AS s4, null AS s5, 45 AS value UNION ALL
SELECT 'a' AS s1, 'a4' AS s2, null AS s3, null AS s4, null AS s5, 54 AS value UNION ALL
SELECT 'b' AS s1, 'b1' AS s2, 'a2' AS s3, 'c1' AS s4, null AS s5, 33 AS value UNION ALL
SELECT 'b' AS s1, 'b2' AS s2, 'a4' AS s3, 'c3' AS s4, null AS s5, 73 AS value UNION ALL
SELECT 'b' AS s1, 'b3' AS s2, null AS s3, null AS s4, null AS s5, 90 AS value UNION ALL
SELECT 'c' AS s1, 'c1' AS s2, null AS s3, null AS s4, null AS s5, 19 AS value UNION ALL
SELECT 'c' AS s1, 'c2' AS s2, 'b2' AS s3, 'a2' AS s4, 'a3' AS s5, 92 AS value UNION ALL
SELECT 'c' AS s1, 'c3' AS s2, 'c4' AS s3, null AS s4, null AS s5, 63 AS value UNION ALL
SELECT 'c' AS s1, 'c4' AS s2, null AS s3, null AS s4, null AS s5, 44 AS value
`;
describe("Sankey and Sunburst", () => {
beforeEach(() => {
cy.login();
});
describe("Creation through UI", () => {
beforeEach(() => {
cy.createQuery({ query: SQL }).then(({ id }) => {
cy.visit(`queries/${id}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click();
cy.getByTestId("NewVisualization").click();
cy.getByTestId("VisualizationType").selectAntdOption("VisualizationType.SUNBURST_SEQUENCE");
});
});
it("creates Sunburst", () => {
const visualizationName = "Sunburst";
cy.getByTestId("VisualizationName").clear().type(visualizationName);
cy.getByTestId("VisualizationPreview").find("svg").should("exist");
cy.getByTestId("EditVisualizationDialog").contains("button", "Save").click();
cy.getByTestId("QueryPageVisualizationTabs").contains("span", visualizationName).should("exist");
});
it("creates Sankey", () => {
const visualizationName = "Sankey";
cy.getByTestId("VisualizationName").clear().type(visualizationName);
cy.getByTestId("VisualizationPreview").find("svg").should("exist");
cy.getByTestId("EditVisualizationDialog").contains("button", "Save").click();
cy.getByTestId("QueryPageVisualizationTabs").contains("span", visualizationName).should("exist");
});
});
const STAGES_WIDGETS = [
{ name: "1 stage", query: `SELECT s1,value FROM (${SQL}) q`, position: { autoHeight: false, sizeY: 10, sizeX: 2 } },
{
name: "2 stages",
query: `SELECT s1,s2,value FROM (${SQL}) q`,
position: { autoHeight: false, col: 2, sizeY: 10, sizeX: 2 },
},
{
name: "3 stages",
query: `SELECT s1,s2,s3,value FROM (${SQL}) q`,
position: { autoHeight: false, col: 4, sizeY: 10, sizeX: 2 },
},
{
name: "4 stages",
query: `SELECT s1,s2,s3,s4,value FROM (${SQL}) q`,
position: { autoHeight: false, row: 9, sizeY: 10, sizeX: 2 },
},
{
name: "5 stages",
query: `SELECT s1,s2,s3,s4,s5,value FROM (${SQL}) q`,
position: { autoHeight: false, row: 9, col: 2, sizeY: 10, sizeX: 2 },
},
];
it("takes a snapshot with Sunburst (1 - 5 stages)", function () {
cy.createDashboard("Sunburst Visualization").then((dashboard) => {
this.dashboardUrl = `/dashboards/${dashboard.id}`;
return cy
.all(
STAGES_WIDGETS.map(
(sunburst) => () =>
cy
.createQuery({ name: `Sunburst with ${sunburst.name}`, query: sunburst.query })
.then((queryData) => cy.createVisualization(queryData.id, "SUNBURST_SEQUENCE", "Sunburst", {}))
.then((visualization) => cy.addWidget(dashboard.id, visualization.id, { position: sunburst.position }))
)
)
.then((widgets) => {
cy.visit(this.dashboardUrl);
widgets.forEach((widget) => {
cy.getByTestId(getWidgetTestId(widget)).within(() => cy.get("svg").should("exist"));
});
// wait a bit before taking snapshot
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.percySnapshot("Visualizations - Sunburst");
});
});
});
it("takes a snapshot with Sankey (1 - 5 stages)", function () {
cy.createDashboard("Sankey Visualization").then((dashboard) => {
this.dashboardUrl = `/dashboards/${dashboard.id}`;
return cy
.all(
STAGES_WIDGETS.map(
(sankey) => () =>
cy
.createQuery({ name: `Sankey with ${sankey.name}`, query: sankey.query })
.then((queryData) => cy.createVisualization(queryData.id, "SANKEY", "Sankey", {}))
.then((visualization) => cy.addWidget(dashboard.id, visualization.id, { position: sankey.position }))
)
)
.then((widgets) => {
cy.visit(this.dashboardUrl);
widgets.forEach((widget) => {
cy.getByTestId(getWidgetTestId(widget)).within(() => cy.get("svg").should("exist"));
});
// wait a bit before taking snapshot
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.percySnapshot("Visualizations - Sankey");
});
});
});
});
================================================
FILE: client/cypress/integration/visualizations/table/.mocks/all-cell-types.js
================================================
export const query = `
SELECT
314159.265359 AS num,
'test' AS str,
'hello, world ' AS html,
'hello, world ' AS html2,
'Link: http://example.com' AS html3,
'1995-09-03T07:45' AS "date",
true AS bool,
'[{"a": 3.14, "b": "test", "c": [], "d": {}}, false, [null, 123], "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."]' AS json,
'ukr' AS img,
'redash' AS link
`;
export const config = {
itemsPerPage: 25,
columns: [
{
name: "num",
displayAs: "number",
numberFormat: "0.000",
},
{
name: "str",
displayAs: "string",
allowHTML: true,
highlightLinks: false,
},
{
name: "html",
displayAs: "string",
allowHTML: true,
highlightLinks: false,
},
{
name: "html2",
displayAs: "string",
allowHTML: false,
highlightLinks: false,
},
{
name: "html3",
displayAs: "string",
allowHTML: true,
highlightLinks: true,
},
{
name: "date",
displayAs: "datetime",
dateTimeFormat: "D MMMM YYYY, h:mm A",
},
{
name: "bool",
displayAs: "boolean",
booleanValues: ["No", "Yes"],
},
{
name: "json",
displayAs: "json",
},
{
name: "img",
displayAs: "image",
imageUrlTemplate: "https://raw.githubusercontent.com/linssen/country-flag-icons/master/images/png/{{ @ }}.png",
imageTitleTemplate: "ISO: {{ @ }}",
imageWidth: "30",
imageHeight: "",
},
{
name: "link",
displayAs: "link",
linkUrlTemplate: "https://www.google.com.ua/search?q={{ @ }}",
linkTextTemplate: "Search for '{{ @ }}'",
linkTitleTemplate: "Search for '{{ @ }}'",
linkOpenInNewTab: true,
},
],
};
================================================
FILE: client/cypress/integration/visualizations/table/.mocks/large-dataset.js
================================================
const loremIpsum =
"Lorem ipsum dolor sit amet consectetur adipiscing elit" +
"sed do eiusmod tempor incididunt ut labore et dolore magna aliqua";
function pseudoRandom(seed) {
const x = Math.sin(seed) * 10000;
return x - Math.floor(x);
}
function randomString(index) {
const n = pseudoRandom(index);
const offset = Math.floor(n * loremIpsum.length);
const length = Math.floor(n * 15) + 1;
return loremIpsum.substr(offset, length).trim();
}
export const query = new Array(400)
.fill(null) // we actually don't need these values, but `.map()` ignores undefined elements
.map((unused, index) => `SELECT ${index} AS a, '${randomString(index)}' as b`)
.join(" UNION ALL\n");
export const config = {
itemsPerPage: 10,
columns: [
{
name: "a",
displayAs: "number",
numberFormat: "0",
},
{
name: "b",
displayAs: "string",
},
],
};
================================================
FILE: client/cypress/integration/visualizations/table/.mocks/multi-column-sort.js
================================================
export const query = `
SELECT 3 AS a, 1 AS b, 'h' AS c UNION ALL
SELECT 1 AS a, 1 AS b, 'b' AS c UNION ALL
SELECT 2 AS a, 1 AS b, 'e' AS c UNION ALL
SELECT 1 AS a, 3 AS b, 'd' AS c UNION ALL
SELECT 2 AS a, 2 AS b, 'f' AS c UNION ALL
SELECT 1 AS a, 1 AS b, 'a' AS c UNION ALL
SELECT 3 AS a, 2 AS b, 'i' AS c UNION ALL
SELECT 2 AS a, 3 AS b, 'g' AS c UNION ALL
SELECT 1 AS a, 2 AS b, 'c' AS c UNION ALL
SELECT 3 AS a, 3 AS b, 'j' AS c
`;
export const config = {
itemsPerPage: 25,
columns: [
{
name: "a",
displayAs: "number",
numberFormat: "0",
},
{
name: "b",
displayAs: "number",
numberFormat: "0",
},
{
name: "c",
displayAs: "string",
},
],
};
================================================
FILE: client/cypress/integration/visualizations/table/.mocks/search-in-data.js
================================================
export const query = `
SELECT 'contains test' AS a, 'random string' AS b, 'another string' AS c UNION ALL
SELECT 'contains test' AS a, 'also contains Test' AS b, '' AS c UNION ALL
SELECT 'lorem ipsum' AS a, 'but TEST is here' AS b, 'none' AS c UNION ALL
SELECT 'should not appear' AS a, 'because' AS b, '"test" is here' AS c
`;
export const config = {
itemsPerPage: 25,
columns: [
{
name: "a",
displayAs: "string",
allowSearch: true,
},
{
name: "b",
displayAs: "string",
allowSearch: true,
},
{
name: "c",
allowSearch: false,
},
],
};
================================================
FILE: client/cypress/integration/visualizations/table/table_spec.js
================================================
/* global cy, Cypress */
/*
This test suite relies on Percy (does not validate rendered visualizations)
*/
import * as AllCellTypes from "./.mocks/all-cell-types";
import * as MultiColumnSort from "./.mocks/multi-column-sort";
import * as SearchInData from "./.mocks/search-in-data";
import * as LargeDataset from "./.mocks/large-dataset";
function prepareVisualization(query, type, name, options) {
return cy
.createQuery({ query })
.then(({ id }) => cy.createVisualization(id, type, name, options))
.then(({ id: visualizationId, query_id: queryId }) => {
// use data-only view because we don't need editor features, but it will
// free more space for visualizations. Also, we'll hide schema browser (via shortcut)
cy.visit(`queries/${queryId}#${visualizationId}`);
cy.getByTestId("ExecuteButton").click();
cy.get("body").type("{alt}D");
// do some pre-checks here to ensure that visualization was created and is visible
cy.getByTestId("TableVisualization").should("exist").find("table").should("exist");
return cy.then(() => ({ queryId, visualizationId }));
});
}
describe("Table", () => {
const viewportWidth = Cypress.config("viewportWidth");
beforeEach(() => {
cy.login();
});
it("renders all cell types", () => {
const { query, config } = AllCellTypes;
prepareVisualization(query, "TABLE", "All cell types", config).then(() => {
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(500); // add some waiting to avoid an async update error from .jvi-toggle
// expand JSON cell
cy.get(".jvi-item.jvi-root .jvi-toggle").click();
cy.get(".jvi-item.jvi-root .jvi-item .jvi-toggle").click({ multiple: true });
cy.percySnapshot("Visualizations - Table (All cell types)", { widths: [viewportWidth] });
});
});
describe("Sorting data", () => {
beforeEach(function () {
const { query, config } = MultiColumnSort;
prepareVisualization(query, "TABLE", "Sort data", config).then(({ queryId, visualizationId }) => {
this.queryId = queryId;
this.visualizationId = visualizationId;
});
});
it("sorts data by a single column", function () {
cy.getByTestId("TableVisualization").find("table th").contains("c").should("exist").click();
cy.percySnapshot("Visualizations - Table (Single-column sort)", { widths: [viewportWidth] });
});
it("sorts data by a multiple columns", function () {
cy.getByTestId("TableVisualization").find("table th").contains("a").should("exist").click();
cy.get("body").type("{shift}", { release: false });
cy.getByTestId("TableVisualization").find("table th").contains("b").should("exist").click();
cy.percySnapshot("Visualizations - Table (Multi-column sort)", { widths: [viewportWidth] });
});
it("sorts data in reverse order", function () {
cy.getByTestId("TableVisualization").find("table th").contains("c").should("exist").click().click();
cy.percySnapshot("Visualizations - Table (Single-column reverse sort)", { widths: [viewportWidth] });
});
});
it("searches in multiple columns", () => {
const { query, config } = SearchInData;
prepareVisualization(query, "TABLE", "Search", config).then(({ visualizationId }) => {
cy.getByTestId("TableVisualization").find("table input").should("exist").type("test");
cy.percySnapshot("Visualizations - Table (Search in data)", { widths: [viewportWidth] });
});
});
it("shows pagination and navigates to third page", () => {
const { query, config } = LargeDataset;
prepareVisualization(query, "TABLE", "With pagination", config).then(({ visualizationId }) => {
cy.get(".visualization-renderer")
.find(".ant-table-pagination")
.should("exist")
.find("li")
.contains("3")
.should("exist")
.click();
cy.percySnapshot("Visualizations - Table (Pagination)", { widths: [viewportWidth] });
});
});
});
================================================
FILE: client/cypress/integration/visualizations/word_cloud_spec.js
================================================
/* global cy, Cypress */
const { map } = Cypress._;
const SQL = `
SELECT 'Lorem ipsum dolor' AS a, 'ipsum' AS b, 2 AS c UNION ALL
SELECT 'Lorem sit amet' AS a, 'amet' AS b, 2 AS c UNION ALL
SELECT 'dolor adipiscing elit' AS a, 'elit' AS b, 4 AS c UNION ALL
SELECT 'sed do sed' AS a, 'sed' AS b, 5 AS c UNION ALL
SELECT 'sed eiusmod tempor' AS a, 'tempor' AS b, 7 AS c
`;
// Hack to fix Cypress -> Percy communication
// Word Cloud uses `font-family` defined in CSS with a lot of fallbacks, so
// it's almost impossible to know which font will be used on particular machine/browser.
// In Cypress browser it could be one font, in Percy - another.
// The issue is in how Percy takes screenshots: it takes a DOM/CSS/assets snapshot in Cypress,
// copies it to own servers and restores in own browsers. Word Cloud computes its layout
// using Cypress font, sets absolute positions for elements (in pixels), and when it is restored
// on Percy machines (with another font) - visualization gets messed up.
// Solution: explicitly provide some font that will be 100% the same on all CI machines. In this
// case, it's "Roboto" just because it's in the list of fallback fonts and we already have this
// webfont in assets folder (so browser can load it).
function injectFont(document) {
const style = document.createElement("style");
style.setAttribute("id", "percy-fix");
style.setAttribute("type", "text/css");
const fonts = [
["Roboto", "Roboto-Light-webfont", 300],
["Roboto", "Roboto-Regular-webfont", 400],
["Roboto", "Roboto-Medium-webfont", 500],
["Roboto", "Roboto-Bold-webfont", 700],
];
const basePath = "/static/fonts/roboto/";
// `insertRule` does not load font for some reason. Using text content works ¯\_(ツ)_/¯
style.appendChild(
document.createTextNode(
map(
fonts,
([fontFamily, fileName, fontWeight]) => `
@font-face {
font-family: "${fontFamily}";
font-weight: ${fontWeight};
src: url("${basePath}${fileName}.eot");
src: url("${basePath}${fileName}.eot?#iefix") format("embedded-opentype"),
url("${basePath}${fileName}.woff") format("woff"),
url("${basePath}${fileName}.ttf") format("truetype"),
url("${basePath}${fileName}.svg") format("svg");
}
`
).join("\n\n")
)
);
document.getElementsByTagName("head")[0].appendChild(style);
}
describe("Word Cloud", () => {
const viewportWidth = Cypress.config("viewportWidth");
beforeEach(() => {
cy.login();
cy.createQuery({ query: SQL }).then(({ id }) => {
cy.visit(`queries/${id}/source`);
cy.wait(1500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("ExecuteButton").click();
});
cy.document().then(injectFont);
cy.getByTestId("NewVisualization").click();
cy.getByTestId("VisualizationType").selectAntdOption("VisualizationType.WORD_CLOUD");
});
it("creates visualization with automatic word frequencies", () => {
cy.clickThrough(`
WordCloud.WordsColumn
WordCloud.WordsColumn.a
`);
// Wait for proper initialization of visualization
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("VisualizationPreview").find("svg text").should("have.length", 11);
cy.percySnapshot("Visualizations - Word Cloud (Automatic word frequencies)", { widths: [viewportWidth] });
});
it("creates visualization with word frequencies from another column", () => {
cy.clickThrough(`
WordCloud.WordsColumn
WordCloud.WordsColumn.b
WordCloud.FrequenciesColumn
WordCloud.FrequenciesColumn.c
`);
// Wait for proper initialization of visualization
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("VisualizationPreview").find("svg text").should("have.length", 5);
cy.percySnapshot("Visualizations - Word Cloud (Frequencies from another column)", { widths: [viewportWidth] });
});
it("creates visualization with word length and frequencies limits", () => {
cy.clickThrough(`
WordCloud.WordsColumn
WordCloud.WordsColumn.b
WordCloud.FrequenciesColumn
WordCloud.FrequenciesColumn.c
`);
cy.fillInputs({
"WordCloud.WordLengthLimit.Min": "4",
"WordCloud.WordLengthLimit.Max": "5",
"WordCloud.WordCountLimit.Min": "1",
"WordCloud.WordCountLimit.Max": "3",
});
// Wait for proper initialization of visualization
cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
cy.getByTestId("VisualizationPreview").find("svg text").should("have.length", 2);
cy.percySnapshot("Visualizations - Word Cloud (With filters)", { widths: [viewportWidth] });
});
});
================================================
FILE: client/cypress/seed-data.js
================================================
exports.seedData = [
{
route: "/setup",
type: "form",
data: {
name: "Example Admin",
email: "admin@redash.io",
password: "password",
org_name: "Redash",
},
},
{
route: "/login",
type: "form",
data: {
email: "admin@redash.io",
password: "password",
},
},
{
route: "/api/data_sources",
type: "json",
data: {
name: "Test PostgreSQL",
options: {
dbname: "postgres",
host: "postgres",
port: 5432,
sslmode: "prefer",
user: "postgres",
},
type: "pg",
},
},
{
route: "/api/destinations",
type: "json",
data: {
name: "Test Email Destination",
options: {
addresses: "test@example.com",
},
type: "email",
},
},
];
================================================
FILE: client/cypress/support/commands.js
================================================
/* global Cypress */
import "@percy/cypress"; // eslint-disable-line import/no-extraneous-dependencies, import/no-unresolved
import "@testing-library/cypress/add-commands";
const { each } = Cypress._;
Cypress.Commands.add("login", (email = "admin@redash.io", password = "password") => {
let csrf;
cy.visit("/login");
cy.getCookie("csrf_token")
.then(cookie => {
if (cookie) {
csrf = cookie.value;
} else {
cy.visit("/login").then(() => {
cy.get('input[name="csrf_token"]')
.invoke("val")
.then(csrf_token => {
csrf = csrf_token;
});
});
}
})
.then(() => {
cy.request({
url: "/login",
method: "POST",
form: true,
body: {
email,
password,
csrf_token: csrf,
},
});
});
});
Cypress.Commands.add("logout", () => cy.visit("/logout"));
Cypress.Commands.add("getByTestId", element => cy.get('[data-test="' + element + '"]'));
/* Clicks a series of elements. Pass in a newline-seperated string in order to click all elements by their test id,
or enclose the above string in an object with 'button' as key to click the buttons by name. For example:
cy.clickThrough(`
TestId1
TestId2
TestId3
`, { button: `
Label of button 4
Label of button 5
` }, `
TestId6
TestId7`);
*/
Cypress.Commands.add("clickThrough", (...args) => {
args.forEach(elements => {
const names = elements.button || elements;
const click = element =>
(elements.button ? cy.contains("button", element.trim()) : cy.getByTestId(element.trim())).click();
names
.trim()
.split(/\n/)
.filter(Boolean)
.forEach(click);
});
return undefined;
});
/**
* Selects ANTD selector option
*/
Cypress.Commands.add("selectAntdOption", { prevSubject: "element" }, (subject, testId) => {
cy.wrap(subject).click();
return cy.getByTestId(testId).click({ force: true });
});
Cypress.Commands.add("fillInputs", (elements, { wait = 0 } = {}) => {
each(elements, (value, testId) => {
cy.getByTestId(testId)
.filter(":visible")
.clear()
.type(value);
if (wait > 0) {
cy.wait(wait); // eslint-disable-line cypress/no-unnecessary-waiting
}
});
});
Cypress.Commands.add("dragBy", { prevSubject: true }, (subject, offsetLeft, offsetTop, force = false) => {
if (!offsetLeft) {
offsetLeft = 1;
}
if (!offsetTop) {
offsetTop = 1;
}
return cy
.wrap(subject)
.trigger("mouseover", { force })
.trigger("mousedown", "topLeft", { force })
.trigger("mousemove", 1, 1, { force }) // must have at least 2 mousemove events for react-grid-layout to trigger onLayoutChange
.trigger("mousemove", offsetLeft, offsetTop, { force })
.trigger("mouseup", { force });
});
Cypress.Commands.add("all", (...functions) => {
if (Cypress._.isEmpty(functions)) {
return [];
}
const fns = Cypress._.isArray(functions[0]) ? functions[0] : functions;
const results = [];
fns.reduce((prev, fn) => {
fn().then(result => results.push(result));
return results;
}, results);
return cy.wrap(results);
});
Cypress.Commands.overwrite("percySnapshot", (originalFn, ...args) => {
Cypress.$("*[data-test=TimeAgo]").text("just now");
return originalFn(...args);
});
================================================
FILE: client/cypress/support/dashboard/index.js
================================================
/* global cy */
const { get } = Cypress._;
const RESIZE_HANDLE_SELECTOR = ".react-resizable-handle";
export function getWidgetTestId(widget) {
return `WidgetId${widget.id}`;
}
export function createQueryAndAddWidget(dashboardId, queryData = {}, widgetOptions = {}) {
return cy
.createQuery(queryData)
.then(query => {
const visualizationId = get(query, "visualizations.0.id");
assert.isDefined(visualizationId, "Query api call returns at least one visualization with id");
return cy.addWidget(dashboardId, visualizationId, widgetOptions);
})
.then(getWidgetTestId);
}
export function editDashboard() {
cy.getByTestId("DashboardMoreButton").click();
cy.getByTestId("DashboardMoreButtonMenu")
.contains("Edit")
.click();
}
export function shareDashboard() {
cy.clickThrough(
{ button: "Publish" },
`OpenShareForm
PublicAccessEnabled`
);
return cy.getByTestId("SecretAddress").invoke("val");
}
export function resizeBy(wrapper, offsetLeft = 0, offsetTop = 0) {
return wrapper.within(() => {
cy.get(RESIZE_HANDLE_SELECTOR).dragBy(offsetLeft, offsetTop, true);
});
}
================================================
FILE: client/cypress/support/index.js
================================================
/* global Cypress */
import "@cypress/code-coverage/support";
import "./commands";
import "./redash-api/index.js";
Cypress.env("dataSourceId", 1);
Cypress.on("uncaught:exception", err => {
// Prevent ResizeObserver error from failing tests
if (err && Cypress._.includes(err.message, "ResizeObserver loop limit exceeded")) {
return false;
}
});
================================================
FILE: client/cypress/support/parameters.js
================================================
export function dragParam(paramName, offsetLeft, offsetTop) {
cy.getByTestId(`DragHandle-${paramName}`)
.trigger("mouseover")
.trigger("mousedown");
cy.get(".parameter-dragged .drag-handle")
.trigger("mousemove", offsetLeft, offsetTop, { force: true })
.trigger("mouseup", { force: true });
}
export function expectParamOrder(expectedOrder) {
cy.get(".parameter-container label").each(($label, index) => expect($label).to.have.text(expectedOrder[index]));
}
================================================
FILE: client/cypress/support/redash-api/index.js
================================================
/* global cy, Cypress */
const { extend, get, merge, find } = Cypress._;
const post = (options) =>
cy
.getCookie("csrf_token")
.then((csrf) => cy.request({ ...options, method: "POST", headers: { "X-CSRF-TOKEN": csrf.value } }));
Cypress.Commands.add("createDashboard", (name) => {
return post({ url: "api/dashboards", body: { name } }).then(({ body }) => body);
});
Cypress.Commands.add("createQuery", (data, shouldPublish = true) => {
const merged = extend(
{
name: "Test Query",
query: "select 1",
data_source_id: Cypress.env("dataSourceId"),
options: {
parameters: [],
},
schedule: null,
},
data
);
// eslint-disable-next-line cypress/no-assigning-return-values
let request = post({ url: "/api/queries", body: merged }).then(({ body }) => body);
if (shouldPublish) {
request = request.then((query) =>
post({ url: `/api/queries/${query.id}`, body: { is_draft: false } }).then(() => query)
);
}
return request;
});
Cypress.Commands.add("createVisualization", (queryId, type, name, options) => {
const data = { query_id: queryId, type, name, options };
return post({ url: "/api/visualizations", body: data }).then(({ body }) => ({
query_id: queryId,
...body,
}));
});
Cypress.Commands.add("addTextbox", (dashboardId, text = "text", options = {}) => {
const defaultOptions = {
position: { col: 0, row: 0, sizeX: 3, sizeY: 3 },
};
const data = {
width: 1,
dashboard_id: dashboardId,
visualization_id: null,
text,
options: merge(defaultOptions, options),
};
return post({ url: "api/widgets", body: data }).then(({ body }) => {
const id = get(body, "id");
assert.isDefined(id, "Widget api call returns widget id");
return body;
});
});
Cypress.Commands.add("addWidget", (dashboardId, visualizationId, options = {}) => {
const defaultOptions = {
position: { col: 0, row: 0, sizeX: 3, sizeY: 3 },
};
const data = {
width: 1,
dashboard_id: dashboardId,
visualization_id: visualizationId,
options: merge(defaultOptions, options),
};
return post({ url: "api/widgets", body: data }).then(({ body }) => {
const id = get(body, "id");
assert.isDefined(id, "Widget api call returns widget id");
return body;
});
});
Cypress.Commands.add("createAlert", (queryId, options = {}, name) => {
const defaultOptions = {
column: "?column?",
selector: "first",
op: "greater than",
rearm: 0,
value: 1,
};
const data = {
query_id: queryId,
name: name || "Alert for query " + queryId,
options: merge(defaultOptions, options),
};
return post({ url: "api/alerts", body: data }).then(({ body }) => {
const id = get(body, "id");
assert.isDefined(id, "Alert api call retu ns alert id");
return body;
});
});
Cypress.Commands.add("createUser", ({ name, email, password }) => {
return post({
url: "api/users?no_invite=yes",
body: { name, email },
failOnStatusCode: false,
}).then((xhr) => {
const { status, body } = xhr;
if (status < 200 || status > 400) {
throw new Error(xhr);
}
if (status === 400 && body.message === "Email already taken.") {
// all is good, do nothing
return;
}
const id = get(body, "id");
assert.isDefined(id, "User api call returns user id");
return post({
url: body.invite_link,
form: true,
body: { password },
});
});
});
Cypress.Commands.add("createDestination", (name, type, options = {}) => {
return post({
url: "api/destinations",
body: { name, type, options },
failOnStatusCode: false,
});
});
Cypress.Commands.add("getDestinations", () => {
return cy.request("GET", "api/destinations").then(({ body }) => body);
});
Cypress.Commands.add("addDestinationSubscription", (alertId, destinationName) => {
return cy
.getDestinations()
.then((destinations) => {
const destination = find(destinations, { name: destinationName });
if (!destination) {
throw new Error("Destination not found");
}
return post({
url: `api/alerts/${alertId}/subscriptions`,
body: {
alert_id: alertId,
destination_id: destination.id,
},
});
})
.then(({ body }) => {
const id = get(body, "id");
assert.isDefined(id, "Subscription api call returns subscription id");
return body;
});
});
Cypress.Commands.add("updateOrgSettings", (settings) => {
return post({ url: "api/settings/organization", body: settings }).then(({ body }) => body);
});
================================================
FILE: client/cypress/support/tags/index.js
================================================
export function expectTagsToContain(tags = []) {
cy.getByTestId("TagsControl").within(() => {
cy.getByTestId("TagLabel")
.should("have.length", tags.length)
.each($tag => expect(tags).to.contain($tag.text()));
});
}
export function typeInTagsSelectAndSave(text) {
cy.getByTestId("EditTagsDialog").within(() => {
cy.get(".ant-select")
.find("input")
.type(text, { force: true });
cy.get(".ant-modal-header").click(); // hide dropdown options
cy.contains("OK").click();
});
}
================================================
FILE: client/cypress/support/visualizations/chart.js
================================================
/**
* Asserts the preview canvas exists, then captures the g.points element, which should be generated by plotly and asserts whether it exists
* @param should Passed to should expression after plot points are captured
*/
export function assertPlotPreview(should = "exist") {
cy.getByTestId("VisualizationPreview").find("g.overplot").should("exist").find("g.points").should(should);
}
export function createChartThroughUI(chartName, chartSpecificAssertionFn = () => {}) {
cy.getByTestId("NewVisualization").click();
cy.getByTestId("VisualizationType").selectAntdOption("VisualizationType.CHART");
cy.getByTestId("VisualizationName").clear().type(chartName);
chartSpecificAssertionFn();
cy.server();
cy.route("POST", "**/api/visualizations").as("SaveVisualization");
cy.getByTestId("EditVisualizationDialog").contains("button", "Save").click();
cy.getByTestId("QueryPageVisualizationTabs").contains("span", chartName).should("exist");
cy.wait("@SaveVisualization").should("have.property", "status", 200);
return cy.get("@SaveVisualization").then((xhr) => {
const { id, name, options } = xhr.response.body;
return cy.wrap({ id, name, options });
});
}
export function assertTabbedEditor(chartSpecificTabbedEditorAssertionFn = () => {}) {
cy.getByTestId("Chart.GlobalSeriesType").should("exist");
cy.getByTestId("VisualizationEditor.Tabs.Series").click();
cy.getByTestId("VisualizationEditor").find("table").should("exist");
cy.getByTestId("VisualizationEditor.Tabs.Colors").click();
cy.getByTestId("VisualizationEditor").find("table").should("exist");
cy.getByTestId("VisualizationEditor.Tabs.DataLabels").click();
cy.getByTestId("VisualizationEditor").getByTestId("Chart.DataLabels.ShowDataLabels").should("exist");
chartSpecificTabbedEditorAssertionFn();
cy.getByTestId("VisualizationEditor.Tabs.General").click();
}
export function assertAxesAndAddLabels(xaxisLabel, yaxisLabel) {
cy.getByTestId("VisualizationEditor.Tabs.XAxis").click();
cy.getByTestId("Chart.XAxis.Type").contains(".ant-select-selection-item", "Auto Detect").should("exist");
cy.getByTestId("Chart.XAxis.Name").clear().type(xaxisLabel);
cy.getByTestId("VisualizationEditor.Tabs.YAxis").click();
cy.getByTestId("Chart.LeftYAxis.Type").contains(".ant-select-selection-item", "Linear").should("exist");
cy.getByTestId("Chart.LeftYAxis.Name").clear().type(yaxisLabel);
cy.getByTestId("Chart.LeftYAxis.TickFormat").clear().type("+");
cy.getByTestId("VisualizationEditor.Tabs.General").click();
}
export function createDashboardWithCharts(title, chartGetters, widgetsAssertionFn = () => {}) {
cy.createDashboard(title).then((dashboard) => {
const dashboardUrl = `/dashboards/${dashboard.id}`;
const widgetGetters = chartGetters.map((chartGetter) => `${chartGetter}Widget`);
chartGetters.forEach((chartGetter, i) => {
const position = { autoHeight: false, sizeY: 8, sizeX: 3, col: (i % 2) * 3 };
cy.get(`@${chartGetter}`)
.then((chart) => cy.addWidget(dashboard.id, chart.id, { position }))
.as(widgetGetters[i]);
});
widgetsAssertionFn(widgetGetters, dashboardUrl);
});
}
================================================
FILE: client/cypress/support/visualizations/table.js
================================================
export function expectTableToHaveLength(length) {
cy.getByTestId("TableVisualization").find("tbody tr").should("have.length", length);
}
export function expectFirstColumnToHaveMembers(values) {
cy.getByTestId("TableVisualization")
.find("tbody tr td:first-child")
.then(($cell) => Cypress.$.map($cell, (item) => Cypress.$(item).text()))
.then((firstColumnCells) => expect(firstColumnCells).to.have.members(values));
}
================================================
FILE: client/cypress/tsconfig.json
================================================
{
"extends": "../tsconfig.json",
"compilerOptions": {
"types": ["cypress", "@percy/cypress", "@testing-library/cypress"]
},
"include": ["./**/*.ts"]
}
================================================
FILE: client/prettier.config.js
================================================
module.exports = {
printWidth: 120,
jsxBracketSameLine: true,
tabWidth: 2,
trailingComma: 'es5',
};
================================================
FILE: client/tsconfig.json
================================================
{
"compilerOptions": {
// Target latest version of ECMAScript.
"target": "es2019",
// Search under node_modules for non-relative imports.
"moduleResolution": "node",
// Process & infer types from .js files.
"allowJs": true,
// Don't emit; allow Babel to transform files.
"noEmit": true,
// Enable strictest settings like strictNullChecks & noImplicitAny.
"strict": true,
// Import non-ES modules as default imports.
"esModuleInterop": true,
"jsx": "react",
"allowSyntheticDefaultImports": true,
"noUnusedLocals": true,
"lib": ["dom", "dom.iterable", "esnext"],
"forceConsistentCasingInFileNames": true,
"baseUrl": "./",
"paths": {
"@/*": ["./app/*"]
},
"skipLibCheck": true,
"typeRoots": ["../node_modules/@types"],
"types": ["jest", "node", "react", "react-dom"]
},
"include": ["app/**/*"],
"exclude": ["dist"]
}
================================================
FILE: codecov.yml
================================================
comment:
layout: " diff, flags, files"
behavior: default
require_changes: false
require_base: true
require_head: true
================================================
FILE: compose.yaml
================================================
# This configuration file is for the **development** setup.
# For a production example please refer to getredash/setup repository on GitHub.
x-redash-service: &redash-service
build:
context: .
args:
skip_frontend_build: "true" # set to empty string to build
volumes:
- .:/app
env_file:
- .env
x-redash-environment: &redash-environment
REDASH_HOST: http://localhost:5001
REDASH_LOG_LEVEL: "INFO"
REDASH_REDIS_URL: "redis://redis:6379/0"
REDASH_DATABASE_URL: "postgresql://postgres@postgres/postgres"
REDASH_RATELIMIT_ENABLED: "false"
REDASH_MAIL_DEFAULT_SENDER: "redash@example.com"
REDASH_MAIL_SERVER: "email"
REDASH_MAIL_PORT: 1025
REDASH_ENFORCE_CSRF: "true"
REDASH_GUNICORN_TIMEOUT: 60
# Set secret keys in the .env file
services:
server:
<<: *redash-service
command: dev_server
depends_on:
- postgres
- redis
ports:
- "5001:5000"
- "5678:5678"
environment:
<<: *redash-environment
PYTHONUNBUFFERED: 0
scheduler:
<<: *redash-service
command: dev_scheduler
depends_on:
- server
environment:
<<: *redash-environment
worker:
<<: *redash-service
command: dev_worker
depends_on:
- server
environment:
<<: *redash-environment
PYTHONUNBUFFERED: 0
redis:
image: redis:7-alpine
restart: unless-stopped
postgres:
image: postgres:18-alpine
ports:
- "15432:5432"
# The following turns the DB into less durable, but gains significant performance improvements for the tests run (x3
# improvement on my personal machine). We should consider moving this into a dedicated Docker Compose configuration for
# tests.
command: "postgres -c fsync=off -c full_page_writes=off -c synchronous_commit=OFF"
restart: unless-stopped
environment:
POSTGRES_HOST_AUTH_METHOD: "trust"
email:
image: maildev/maildev
ports:
- "1080:1080"
- "1025:1025"
restart: unless-stopped
================================================
FILE: cypress.config.js
================================================
const { defineConfig } = require('cypress')
module.exports = defineConfig({
e2e: {
baseUrl: 'http://localhost:5001',
defaultCommandTimeout: 20000,
downloadsFolder: 'client/cypress/downloads',
fixturesFolder: 'client/cypress/fixtures',
requestTimeout: 15000,
screenshotsFolder: 'client/cypress/screenshots',
specPattern: 'client/cypress/integration/',
supportFile: 'client/cypress/support/index.js',
video: true,
videoUploadOnPasses: false,
videosFolder: 'client/cypress/videos',
viewportHeight: 1024,
viewportWidth: 1280,
env: {
coverage: false
}
},
})
================================================
FILE: manage.py
================================================
#!/usr/bin/env python
"""
CLI to manage redash.
"""
from redash.cli import manager
if __name__ == "__main__":
manager()
================================================
FILE: migrations/0001_warning.py
================================================
from __future__ import print_function
# This is here just to print a warning for users who use the old Fabric upgrade script.
if __name__ == '__main__':
warning = "You're using an outdated upgrade script that is running migrations the wrong way. Please upgrade to " \
"newer version of the script before continuning the upgrade process."
print("*" * 20)
print(warning)
print("*" * 20)
exit(1)
================================================
FILE: migrations/README
================================================
Generic single-database configuration.
================================================
FILE: migrations/alembic.ini
================================================
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = [%(asctime)s][PID:%(process)d][%(levelname)s][%(name)s] %(message)s
================================================
FILE: migrations/env.py
================================================
from __future__ import with_statement
from alembic import context
from sqlalchemy import engine_from_config, pool
from logging.config import fileConfig
import logging
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger("alembic.env")
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
from flask import current_app
db_url_escaped = current_app.config.get("SQLALCHEMY_DATABASE_URI").replace("%", "%%")
config.set_main_option("sqlalchemy.url", db_url_escaped)
target_metadata = current_app.extensions["migrate"].db.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.readthedocs.org/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, "autogenerate", False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info("No changes in schema detected.")
engine = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
connection = engine.connect()
context.configure(
connection=connection,
target_metadata=target_metadata,
process_revision_directives=process_revision_directives,
**current_app.extensions["migrate"].configure_args
)
try:
with context.begin_transaction():
context.run_migrations()
finally:
connection.close()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
================================================
FILE: migrations/script.py.mako
================================================
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}
================================================
FILE: migrations/versions/0ec979123ba4_.py
================================================
"""empty message
Revision ID: 0ec979123ba4
Revises: e5c7a4e2df4d
Create Date: 2020-12-23 21:35:32.766354
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSON
# revision identifiers, used by Alembic.
revision = '0ec979123ba4'
down_revision = 'e5c7a4e2df4d'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('dashboards', sa.Column('options', JSON(astext_type=sa.Text()), server_default='{}', nullable=False))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('dashboards', 'options')
# ### end Alembic commands ###
================================================
FILE: migrations/versions/0f740a081d20_inline_tags.py
================================================
"""inline_tags
Revision ID: 0f740a081d20
Revises: a92d92aa678e
Create Date: 2018-05-10 15:47:56.120338
"""
import re
from funcy import flatten, compact
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import text
from redash import models
# revision identifiers, used by Alembic.
revision = "0f740a081d20"
down_revision = "a92d92aa678e"
branch_labels = None
depends_on = None
def upgrade():
tags_regex = re.compile(r"^([\w\s]+):|#([\w-]+)", re.I | re.U)
connection = op.get_bind()
dashboards = connection.execute("SELECT id, name FROM dashboards")
update_query = text("UPDATE dashboards SET tags = :tags WHERE id = :id")
for dashboard in dashboards:
tags = compact(flatten(tags_regex.findall(dashboard[1])))
if tags:
connection.execute(update_query, tags=tags, id=dashboard[0])
def downgrade():
pass
================================================
FILE: migrations/versions/1038c2174f5d_make_case_insensitive_hash_of_query_text.py
================================================
"""Make case insensitive hash of query text
Revision ID: 1038c2174f5d
Revises: fd4fc850d7ea
Create Date: 2023-07-16 23:10:12.885949
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import table
from redash.utils import gen_query_hash
# revision identifiers, used by Alembic.
revision = '1038c2174f5d'
down_revision = 'fd4fc850d7ea'
branch_labels = None
depends_on = None
def change_query_hash(conn, table, query_text_to):
for record in conn.execute(table.select()):
query_text = query_text_to(record.query)
conn.execute(
table
.update()
.where(table.c.id == record.id)
.values(query_hash=gen_query_hash(query_text)))
def upgrade():
queries = table(
'queries',
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('query', sa.Text),
sa.Column('query_hash', sa.String(length=10)))
conn = op.get_bind()
change_query_hash(conn, queries, query_text_to=str)
def downgrade():
queries = table(
'queries',
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('query', sa.Text),
sa.Column('query_hash', sa.String(length=10)))
conn = op.get_bind()
change_query_hash(conn, queries, query_text_to=str.lower)
================================================
FILE: migrations/versions/1655999df5e3_default_alert_selector.py
================================================
"""set default alert selector
Revision ID: 1655999df5e3
Revises: 9e8c841d1a30
Create Date: 2025-07-09 14:44:00
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = '1655999df5e3'
down_revision = '9e8c841d1a30'
branch_labels = None
depends_on = None
def upgrade():
op.execute("""
UPDATE alerts
SET options = jsonb_set(options, '{selector}', '"first"')
WHERE options->>'selector' IS NULL;
""")
def downgrade():
pass
================================================
FILE: migrations/versions/1daa601d3ae5_add_columns_for_disabled_users.py
================================================
"""add columns for disabled users
Revision ID: 1daa601d3ae5
Revises: 969126bd800f
Create Date: 2018-03-07 10:20:10.410159
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "1daa601d3ae5"
down_revision = "969126bd800f"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("users", sa.Column("disabled_at", sa.DateTime(True), nullable=True))
def downgrade():
op.drop_column("users", "disabled_at")
================================================
FILE: migrations/versions/5ec5c84ba61e_.py
================================================
"""Add Query.search_vector field for full text search.
Revision ID: 5ec5c84ba61e
Revises: 7671dca4e604
Create Date: 2017-10-17 18:21:00.174015
"""
from alembic import op
import sqlalchemy as sa
import sqlalchemy_utils as su
import sqlalchemy_searchable as ss
# revision identifiers, used by Alembic.
revision = "5ec5c84ba61e"
down_revision = "7671dca4e604"
branch_labels = None
depends_on = None
def upgrade():
conn = op.get_bind()
op.add_column("queries", sa.Column("search_vector", su.TSVectorType()))
op.create_index(
"ix_queries_search_vector",
"queries",
["search_vector"],
unique=False,
postgresql_using="gin",
)
ss.sync_trigger(conn, "queries", "search_vector", ["name", "description", "query"])
def downgrade():
conn = op.get_bind()
ss.drop_trigger(conn, "queries", "search_vector")
op.drop_index("ix_queries_search_vector", table_name="queries")
op.drop_column("queries", "search_vector")
================================================
FILE: migrations/versions/640888ce445d_.py
================================================
"""
Add new scheduling data.
Revision ID: 640888ce445d
Revises: 71477dadd6ef
Create Date: 2018-09-21 19:35:58.578796
"""
import json
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import table
from redash.models import MutableDict
# revision identifiers, used by Alembic.
revision = "640888ce445d"
down_revision = "71477dadd6ef"
branch_labels = None
depends_on = None
def upgrade():
# Copy "schedule" column into "old_schedule" column
op.add_column(
"queries", sa.Column("old_schedule", sa.String(length=10), nullable=True)
)
queries = table(
"queries",
sa.Column("schedule", sa.String(length=10)),
sa.Column("old_schedule", sa.String(length=10)),
)
op.execute(queries.update().values({"old_schedule": queries.c.schedule}))
# Recreate "schedule" column as a dict type
op.drop_column("queries", "schedule")
op.add_column(
"queries",
sa.Column(
"schedule",
sa.Text(),
nullable=False,
server_default=json.dumps({}),
),
)
# Move over values from old_schedule
queries = table(
"queries",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("schedule", sa.Text()),
sa.Column("old_schedule", sa.String(length=10)),
)
conn = op.get_bind()
for query in conn.execute(queries.select()):
schedule_json = {
"interval": None,
"until": None,
"day_of_week": None,
"time": None,
}
if query.old_schedule is not None:
if ":" in query.old_schedule:
schedule_json["interval"] = 86400
schedule_json["time"] = query.old_schedule
else:
schedule_json["interval"] = int(query.old_schedule)
conn.execute(
queries.update()
.where(queries.c.id == query.id)
.values(schedule=MutableDict(schedule_json))
)
op.drop_column("queries", "old_schedule")
def downgrade():
op.add_column(
"queries",
sa.Column(
"old_schedule",
sa.Text(),
nullable=False,
server_default=json.dumps({}),
),
)
queries = table(
"queries",
sa.Column("schedule", sa.Text()),
sa.Column("old_schedule", sa.Text()),
)
op.execute(queries.update().values({"old_schedule": queries.c.schedule}))
op.drop_column("queries", "schedule")
op.add_column("queries", sa.Column("schedule", sa.String(length=10), nullable=True))
queries = table(
"queries",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("schedule", sa.String(length=10)),
sa.Column("old_schedule", sa.Text()),
)
conn = op.get_bind()
for query in conn.execute(queries.select()):
scheduleValue = query.old_schedule["interval"]
if scheduleValue <= 86400:
scheduleValue = query.old_schedule["time"]
conn.execute(
queries.update()
.where(queries.c.id == query.id)
.values(schedule=scheduleValue)
)
op.drop_column("queries", "old_schedule")
================================================
FILE: migrations/versions/65fc9ede4746_add_is_draft_status_to_queries_and_.py
================================================
"""Add is_draft status to queries and dashboards
Revision ID: 65fc9ede4746
Revises:
Create Date: 2016-12-07 18:08:13.395586
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
from sqlalchemy.exc import ProgrammingError
revision = "65fc9ede4746"
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
try:
op.add_column(
"queries", sa.Column("is_draft", sa.Boolean, default=True, index=True)
)
op.add_column(
"dashboards", sa.Column("is_draft", sa.Boolean, default=True, index=True)
)
op.execute("UPDATE queries SET is_draft = (name = 'New Query')")
op.execute("UPDATE dashboards SET is_draft = false")
except ProgrammingError as e:
# The columns might exist if you ran the old migrations.
if 'column "is_draft" of relation "queries" already exists' in str(e):
print(
"Can't run this migration as you already have is_draft columns, please run:"
)
print(
"./manage.py db stamp {} # you might need to alter the command to match your environment.".format(
revision
)
)
exit()
def downgrade():
op.drop_column("queries", "is_draft")
op.drop_column("dashboards", "is_draft")
================================================
FILE: migrations/versions/6b5be7e0a0ef_.py
================================================
"""Re-index Query.search_vector with existing queries.
Revision ID: 6b5be7e0a0ef
Revises: 5ec5c84ba61e
Create Date: 2017-11-02 20:42:13.356360
"""
from alembic import op
import sqlalchemy as sa
import sqlalchemy_searchable as ss
# revision identifiers, used by Alembic.
revision = "6b5be7e0a0ef"
down_revision = "5ec5c84ba61e"
branch_labels = None
depends_on = None
def upgrade():
ss.vectorizer.clear()
conn = op.get_bind()
metadata = sa.MetaData(bind=conn)
queries = sa.Table("queries", metadata, autoload=True)
@ss.vectorizer(queries.c.id)
def integer_vectorizer(column):
return sa.func.cast(column, sa.Text)
ss.sync_trigger(
conn,
"queries",
"search_vector",
["id", "name", "description", "query"],
metadata=metadata,
)
def downgrade():
conn = op.get_bind()
ss.drop_trigger(conn, "queries", "search_vector")
op.drop_index("ix_queries_search_vector", table_name="queries")
op.create_index(
"ix_queries_search_vector",
"queries",
["search_vector"],
unique=False,
postgresql_using="gin",
)
ss.sync_trigger(conn, "queries", "search_vector", ["name", "description", "query"])
================================================
FILE: migrations/versions/71477dadd6ef_favorites_unique_constraint.py
================================================
"""favorites_unique_constraint
Revision ID: 71477dadd6ef
Revises: 0f740a081d20
Create Date: 2018-07-11 12:49:52.792123
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "71477dadd6ef"
down_revision = "0f740a081d20"
branch_labels = None
depends_on = None
def upgrade():
op.create_unique_constraint(
"unique_favorite", "favorites", ["object_type", "object_id", "user_id"]
)
def downgrade():
op.drop_constraint("unique_favorite", "favorites", type_="unique")
================================================
FILE: migrations/versions/7205816877ec_change_type_of_json_fields_from_varchar_.py
================================================
"""change type of json fields from varchar to json
Revision ID: 7205816877ec
Revises: 7ce5925f832b
Create Date: 2024-01-03 13:55:18.885021
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB, JSON
# revision identifiers, used by Alembic.
revision = '7205816877ec'
down_revision = '7ce5925f832b'
branch_labels = None
depends_on = None
def upgrade():
connection = op.get_bind()
op.alter_column('queries', 'options',
existing_type=sa.Text(),
type_=JSONB(astext_type=sa.Text()),
nullable=True,
postgresql_using='options::jsonb',
)
op.alter_column('queries', 'schedule',
existing_type=sa.Text(),
type_=JSONB(astext_type=sa.Text()),
nullable=True,
postgresql_using='schedule::jsonb',
)
op.alter_column('events', 'additional_properties',
existing_type=sa.Text(),
type_=JSONB(astext_type=sa.Text()),
nullable=True,
postgresql_using='additional_properties::jsonb',
)
op.alter_column('organizations', 'settings',
existing_type=sa.Text(),
type_=JSONB(astext_type=sa.Text()),
nullable=True,
postgresql_using='settings::jsonb',
)
op.alter_column('alerts', 'options',
existing_type=JSON(astext_type=sa.Text()),
type_=JSONB(astext_type=sa.Text()),
nullable=True,
postgresql_using='options::jsonb',
)
op.alter_column('dashboards', 'options',
existing_type=JSON(astext_type=sa.Text()),
type_=JSONB(astext_type=sa.Text()),
postgresql_using='options::jsonb',
)
op.alter_column('dashboards', 'layout',
existing_type=sa.Text(),
type_=JSONB(astext_type=sa.Text()),
postgresql_using='layout::jsonb',
)
op.alter_column('changes', 'change',
existing_type=JSON(astext_type=sa.Text()),
type_=JSONB(astext_type=sa.Text()),
postgresql_using='change::jsonb',
)
op.alter_column('visualizations', 'options',
existing_type=sa.Text(),
type_=JSONB(astext_type=sa.Text()),
postgresql_using='options::jsonb',
)
op.alter_column('widgets', 'options',
existing_type=sa.Text(),
type_=JSONB(astext_type=sa.Text()),
postgresql_using='options::jsonb',
)
def downgrade():
connection = op.get_bind()
op.alter_column('queries', 'options',
existing_type=JSONB(astext_type=sa.Text()),
type_=sa.Text(),
postgresql_using='options::text',
existing_nullable=True,
)
op.alter_column('queries', 'schedule',
existing_type=JSONB(astext_type=sa.Text()),
type_=sa.Text(),
postgresql_using='schedule::text',
existing_nullable=True,
)
op.alter_column('events', 'additional_properties',
existing_type=JSONB(astext_type=sa.Text()),
type_=sa.Text(),
postgresql_using='additional_properties::text',
existing_nullable=True,
)
op.alter_column('organizations', 'settings',
existing_type=JSONB(astext_type=sa.Text()),
type_=sa.Text(),
postgresql_using='settings::text',
existing_nullable=True,
)
op.alter_column('alerts', 'options',
existing_type=JSONB(astext_type=sa.Text()),
type_=JSON(astext_type=sa.Text()),
postgresql_using='options::json',
existing_nullable=True,
)
op.alter_column('dashboards', 'options',
existing_type=JSONB(astext_type=sa.Text()),
type_=JSON(astext_type=sa.Text()),
postgresql_using='options::json',
)
op.alter_column('dashboards', 'layout',
existing_type=JSONB(astext_type=sa.Text()),
type_=sa.Text(),
postgresql_using='layout::text',
)
op.alter_column('changes', 'change',
existing_type=JSONB(astext_type=sa.Text()),
type_=JSON(astext_type=sa.Text()),
postgresql_using='change::json',
)
op.alter_column('visualizations', 'options',
type_=sa.Text(),
existing_type=JSONB(astext_type=sa.Text()),
postgresql_using='options::text',
)
op.alter_column('widgets', 'options',
type_=sa.Text(),
existing_type=JSONB(astext_type=sa.Text()),
postgresql_using='options::text',
)
================================================
FILE: migrations/versions/73beceabb948_bring_back_null_schedule.py
================================================
"""bring_back_null_schedule
Revision ID: 73beceabb948
Revises: e7f8a917aa8e
Create Date: 2019-01-17 13:22:21.729334
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import table
from redash.models import MutableDict
# revision identifiers, used by Alembic.
revision = "73beceabb948"
down_revision = "e7f8a917aa8e"
branch_labels = None
depends_on = None
def is_empty_schedule(schedule):
if schedule is None:
return False
if schedule == {}:
return True
if (
schedule.get("interval") is None
and schedule.get("until") is None
and schedule.get("day_of_week") is None
and schedule.get("time") is None
):
return True
return False
def upgrade():
op.alter_column("queries", "schedule", nullable=True, server_default=None)
queries = table(
"queries",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("schedule", sa.Text()),
)
conn = op.get_bind()
for query in conn.execute(queries.select()):
if is_empty_schedule(query.schedule):
conn.execute(
queries.update().where(queries.c.id == query.id).values(schedule=None)
)
def downgrade():
pass
================================================
FILE: migrations/versions/7671dca4e604_.py
================================================
"""empty message
Revision ID: 7671dca4e604
Revises: d1eae8b9893e
Create Date: 2017-11-22 22:20:25.166045
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "7671dca4e604"
down_revision = "d1eae8b9893e"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"users",
sa.Column("profile_image_url", sa.String(), nullable=True, server_default=None),
)
def downgrade():
op.drop_column("users", "profile_image_url")
================================================
FILE: migrations/versions/7ce5925f832b_create_sqlalchemy_searchable_expressions.py
================================================
"""create sqlalchemy_searchable expressions
Revision ID: 7ce5925f832b
Revises: 1038c2174f5d
Create Date: 2023-09-29 16:48:29.517762
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy_searchable import sql_expressions
# revision identifiers, used by Alembic.
revision = '7ce5925f832b'
down_revision = '1038c2174f5d'
branch_labels = None
depends_on = None
def upgrade():
op.execute(sql_expressions)
def downgrade():
pass
================================================
FILE: migrations/versions/89bc7873a3e0_fix_multiple_heads.py
================================================
"""fix_multiple_heads
Revision ID: 89bc7873a3e0
Revises: 0ec979123ba4, d7d747033183
Create Date: 2021-01-21 18:11:04.312259
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '89bc7873a3e0'
down_revision = ('0ec979123ba4', 'd7d747033183')
branch_labels = None
depends_on = None
def upgrade():
pass
def downgrade():
pass
================================================
FILE: migrations/versions/969126bd800f_.py
================================================
"""Update widget's position data based on dashboard layout.
Revision ID: 969126bd800f
Revises: 6b5be7e0a0ef
Create Date: 2018-01-31 15:20:30.396533
"""
import json
from alembic import op
import sqlalchemy as sa
from redash.models import Dashboard, Widget, db
# revision identifiers, used by Alembic.
revision = "969126bd800f"
down_revision = "6b5be7e0a0ef"
branch_labels = None
depends_on = None
def upgrade():
# Update widgets position data:
column_size = 3
print("Updating dashboards position data:")
dashboard_result = db.session.execute("SELECT id, layout FROM dashboards")
for dashboard in dashboard_result:
print(" Updating dashboard: {}".format(dashboard["id"]))
layout = json.loads(dashboard["layout"])
print(" Building widgets map:")
widgets = {}
widget_result = db.session.execute(
"SELECT id, options, width FROM widgets WHERE dashboard_id=:dashboard_id",
{"dashboard_id": dashboard["id"]},
)
for w in widget_result:
print(" Widget: {}".format(w["id"]))
widgets[w["id"]] = w
widget_result.close()
print(" Iterating over layout:")
for row_index, row in enumerate(layout):
print(" Row: {} - {}".format(row_index, row))
if row is None:
continue
for column_index, widget_id in enumerate(row):
print(" Column: {} - {}".format(column_index, widget_id))
widget = widgets.get(widget_id)
if widget is None:
continue
options = json.loads(widget["options"]) or {}
options["position"] = {
"row": row_index,
"col": column_index * column_size,
"sizeX": column_size * widget.width,
}
db.session.execute(
"UPDATE widgets SET options=:options WHERE id=:id",
{"options": json.dumps(options), "id": widget_id},
)
dashboard_result.close()
db.session.commit()
# Remove legacy columns no longer in use.
op.drop_column("widgets", "type")
op.drop_column("widgets", "query_id")
def downgrade():
op.add_column(
"widgets",
sa.Column("query_id", sa.INTEGER(), autoincrement=False, nullable=True),
)
op.add_column(
"widgets",
sa.Column("type", sa.VARCHAR(length=100), autoincrement=False, nullable=True),
)
================================================
FILE: migrations/versions/98af61feea92_add_encrypted_options_to_data_sources.py
================================================
"""add_encrypted_options_to_data_sources
Revision ID: 98af61feea92
Revises: 73beceabb948
Create Date: 2019-01-31 09:21:31.517265
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import BYTEA
from sqlalchemy.sql import table
from sqlalchemy_utils.types.encrypted.encrypted_type import FernetEngine
from redash import settings
from redash.utils.configuration import ConfigurationContainer
from redash.models.types import (
EncryptedConfiguration,
Configuration,
MutableDict,
MutableList,
)
# revision identifiers, used by Alembic.
revision = "98af61feea92"
down_revision = "73beceabb948"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"data_sources",
sa.Column("encrypted_options", BYTEA(), nullable=True),
)
# copy values
data_sources = table(
"data_sources",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column(
"encrypted_options",
ConfigurationContainer.as_mutable(
EncryptedConfiguration(
sa.Text, settings.DATASOURCE_SECRET_KEY, FernetEngine
)
),
),
sa.Column("options", ConfigurationContainer.as_mutable(Configuration)),
)
conn = op.get_bind()
for ds in conn.execute(data_sources.select()):
conn.execute(
data_sources.update()
.where(data_sources.c.id == ds.id)
.values(encrypted_options=ds.options)
)
op.drop_column("data_sources", "options")
op.alter_column("data_sources", "encrypted_options", nullable=False)
def downgrade():
pass
================================================
FILE: migrations/versions/9e8c841d1a30_fix_hash.py
================================================
"""fix_hash
Revision ID: 9e8c841d1a30
Revises: 7205816877ec
Create Date: 2024-10-05 18:55:35.730573
"""
import logging
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import table
from sqlalchemy import select
from redash.query_runner import BaseQueryRunner, get_query_runner
# revision identifiers, used by Alembic.
revision = '9e8c841d1a30'
down_revision = '7205816877ec'
branch_labels = None
depends_on = None
def update_query_hash(record):
should_apply_auto_limit = record['options'].get("apply_auto_limit", False) if record['options'] else False
query_runner = get_query_runner(record['type'], {}) if record['type'] else BaseQueryRunner({})
query_text = record['query']
parameters_dict = {p["name"]: p.get("value") for p in record['options'].get('parameters', [])} if record.options else {}
if any(parameters_dict):
print(f"Query {record['query_id']} has parameters. Hash might be incorrect.")
return query_runner.gen_query_hash(query_text, should_apply_auto_limit)
def upgrade():
conn = op.get_bind()
metadata = sa.MetaData(bind=conn)
queries = sa.Table("queries", metadata, autoload=True)
data_sources = sa.Table("data_sources", metadata, autoload=True)
joined_table = queries.outerjoin(data_sources, queries.c.data_source_id == data_sources.c.id)
query = select([
queries.c.id.label("query_id"),
queries.c.query,
queries.c.query_hash,
queries.c.options,
data_sources.c.id.label("data_source_id"),
data_sources.c.type
]).select_from(joined_table)
for record in conn.execute(query):
new_hash = update_query_hash(record)
print(f"Updating hash for query {record['query_id']} from {record['query_hash']} to {new_hash}")
conn.execute(
queries.update()
.where(queries.c.id == record['query_id'])
.values(query_hash=new_hash))
def downgrade():
pass
================================================
FILE: migrations/versions/a92d92aa678e_inline_tags.py
================================================
"""inline_tags
Revision ID: a92d92aa678e
Revises: e7004224f284
Create Date: 2018-05-10 15:41:28.053237
"""
import re
from funcy import flatten, compact
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import ARRAY
from redash import models
# revision identifiers, used by Alembic.
revision = "a92d92aa678e"
down_revision = "e7004224f284"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"dashboards", sa.Column("tags", ARRAY(sa.Unicode()), nullable=True)
)
op.add_column(
"queries", sa.Column("tags", ARRAY(sa.Unicode()), nullable=True)
)
def downgrade():
op.drop_column("queries", "tags")
op.drop_column("dashboards", "tags")
================================================
FILE: migrations/versions/d1eae8b9893e_.py
================================================
"""add Query.schedule_failures
Revision ID: d1eae8b9893e
Revises: 65fc9ede4746
Create Date: 2017-02-03 01:45:02.954923
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "d1eae8b9893e"
down_revision = "65fc9ede4746"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"queries",
sa.Column(
"schedule_failures", sa.Integer(), nullable=False, server_default="0"
),
)
def downgrade():
op.drop_column("queries", "schedule_failures")
================================================
FILE: migrations/versions/d4c798575877_create_favorites.py
================================================
"""empty message
Revision ID: d4c798575877
Revises: 1daa601d3ae5
Create Date: 2018-05-09 10:28:22.931442
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "d4c798575877"
down_revision = "1daa601d3ae5"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"favorites",
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("object_type", sa.Unicode(length=255), nullable=False),
sa.Column("object_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
sa.PrimaryKeyConstraint("id"),
)
def downgrade():
op.drop_table("favorites")
================================================
FILE: migrations/versions/d7d747033183_encrypt_alert_destinations.py
================================================
"""encrypt alert destinations
Revision ID: d7d747033183
Revises: e5c7a4e2df4d
Create Date: 2020-12-14 21:42:48.661684
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import BYTEA
from sqlalchemy.sql import table
from sqlalchemy_utils.types.encrypted.encrypted_type import FernetEngine
from redash import settings
from redash.utils.configuration import ConfigurationContainer
from redash.models.base import key_type
from redash.models.types import (
EncryptedConfiguration,
Configuration,
)
# revision identifiers, used by Alembic.
revision = 'd7d747033183'
down_revision = 'e5c7a4e2df4d'
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"notification_destinations",
sa.Column("encrypted_options", BYTEA(), nullable=True)
)
# copy values
notification_destinations = table(
"notification_destinations",
sa.Column("id", key_type("NotificationDestination"), primary_key=True),
sa.Column(
"encrypted_options",
ConfigurationContainer.as_mutable(
EncryptedConfiguration(
sa.Text, settings.DATASOURCE_SECRET_KEY, FernetEngine
)
),
),
sa.Column("options", ConfigurationContainer.as_mutable(Configuration)),
)
conn = op.get_bind()
for dest in conn.execute(notification_destinations.select()):
conn.execute(
notification_destinations.update()
.where(notification_destinations.c.id == dest.id)
.values(encrypted_options=dest.options)
)
op.drop_column("notification_destinations", "options")
op.alter_column("notification_destinations", "encrypted_options", nullable=False)
def downgrade():
pass
================================================
FILE: migrations/versions/db0aca1ebd32_12_column_dashboard_layout.py
================================================
"""12-column dashboard layout
Revision ID: db0aca1ebd32
Revises: 1655999df5e3
Create Date: 2025-03-31 13:45:43.160893
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'db0aca1ebd32'
down_revision = '1655999df5e3'
branch_labels = None
depends_on = None
def upgrade():
op.execute("""
UPDATE widgets
SET options = jsonb_set(options, '{position,col}', to_json((options->'position'->>'col')::int * 2)::jsonb);
UPDATE widgets
SET options = jsonb_set(options, '{position,sizeX}', to_json((options->'position'->>'sizeX')::int * 2)::jsonb);
""")
def downgrade():
op.execute("""
UPDATE widgets
SET options = jsonb_set(options, '{position,col}', to_json((options->'position'->>'col')::int / 2)::jsonb);
UPDATE widgets
SET options = jsonb_set(options, '{position,sizeX}', to_json((options->'position'->>'sizeX')::int / 2)::jsonb);
""")
================================================
FILE: migrations/versions/e5c7a4e2df4d_remove_query_tracker_keys.py
================================================
"""remove_query_tracker_keys
Revision ID: e5c7a4e2df4d
Revises: 98af61feea92
Create Date: 2019-02-27 11:30:15.375318
"""
from alembic import op
import sqlalchemy as sa
from redash import redis_connection
# revision identifiers, used by Alembic.
revision = "e5c7a4e2df4d"
down_revision = "98af61feea92"
branch_labels = None
depends_on = None
DONE_LIST = "query_task_trackers:done"
WAITING_LIST = "query_task_trackers:waiting"
IN_PROGRESS_LIST = "query_task_trackers:in_progress"
def prune(list_name, keep_count, max_keys=100):
count = redis_connection.zcard(list_name)
if count <= keep_count:
return 0
remove_count = min(max_keys, count - keep_count)
keys = redis_connection.zrange(list_name, 0, remove_count - 1)
redis_connection.delete(*keys)
redis_connection.zremrangebyrank(list_name, 0, remove_count - 1)
return remove_count
def prune_all(list_name):
removed = 1000
while removed > 0:
removed = prune(list_name, 0)
def upgrade():
prune_all(DONE_LIST)
prune_all(WAITING_LIST)
prune_all(IN_PROGRESS_LIST)
def downgrade():
pass
================================================
FILE: migrations/versions/e7004224f284_add_org_id_to_favorites.py
================================================
"""add_org_id_to_favorites
Revision ID: e7004224f284
Revises: d4c798575877
Create Date: 2018-05-10 09:46:31.169938
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "e7004224f284"
down_revision = "d4c798575877"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("favorites", sa.Column("org_id", sa.Integer(), nullable=False))
op.create_foreign_key(None, "favorites", "organizations", ["org_id"], ["id"])
def downgrade():
op.drop_constraint(None, "favorites", type_="foreignkey")
op.drop_column("favorites", "org_id")
================================================
FILE: migrations/versions/e7f8a917aa8e_add_user_details_json_column.py
================================================
"""Add user details JSON column.
Revision ID: e7f8a917aa8e
Revises: 71477dadd6ef
Create Date: 2018-11-08 16:12:17.023569
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSON
# revision identifiers, used by Alembic.
revision = "e7f8a917aa8e"
down_revision = "640888ce445d"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"users",
sa.Column(
"details",
JSON(astext_type=sa.Text()),
server_default="{}",
nullable=True,
),
)
def downgrade():
op.drop_column("users", "details")
================================================
FILE: migrations/versions/fd4fc850d7ea_.py
================================================
"""Convert user details to jsonb and move user profile image url into details column
Revision ID: fd4fc850d7ea
Revises: 89bc7873a3e0
Create Date: 2022-01-31 15:24:16.507888
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSON, JSONB
from redash.models import db
# revision identifiers, used by Alembic.
revision = 'fd4fc850d7ea'
down_revision = '89bc7873a3e0'
branch_labels = None
depends_on = None
def upgrade():
connection = op.get_bind()
### commands auto generated by Alembic - please adjust! ###
op.alter_column('users', 'details',
existing_type=JSON(astext_type=sa.Text()),
type_=JSONB(astext_type=sa.Text()),
existing_nullable=True,
existing_server_default=sa.text("'{}'::jsonb"))
### end Alembic commands ###
update_query = """
update users
set details = details::jsonb || ('{"profile_image_url": "' || profile_image_url || '"}')::jsonb
where 1=1
"""
connection.execute(update_query)
op.drop_column("users", "profile_image_url")
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
connection = op.get_bind()
op.add_column("users", sa.Column("profile_image_url", db.String(320), nullable=True))
update_query = """
update users set
profile_image_url = details->>'profile_image_url',
details = details - 'profile_image_url' ;
"""
connection.execute(update_query)
db.session.commit()
op.alter_column('users', 'details',
existing_type=JSONB(astext_type=sa.Text()),
type_=JSON(astext_type=sa.Text()),
existing_nullable=True,
existing_server_default=sa.text("'{}'::json"))
# ### end Alembic commands ###
================================================
FILE: netlify.toml
================================================
[build]
base = "client"
publish = "client/dist"
command = "cd ../ && pnpm install --frozen-lockfile && pnpm run build && cd ./client"
[build.environment]
NODE_VERSION = "24"
CYPRESS_INSTALL_BINARY = "0"
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD = "1"
[[redirects]]
from = "/api/*"
to = "http://preview-backend.redashapp.com/api/:splat"
status = 200
[[redirects]]
from = "/login"
to = "http://preview-login.redashapp.com/login"
status = 200
[[redirects]]
from = "/logout"
to = "http://preview-backend.redashapp.com/logout"
status = 200
[[redirects]]
from = "/status.json"
to = "http://preview-backend.redashapp.com/status.json"
status = 200
[[redirects]]
from = "/static/server*"
to = "http://preview-backend.redashapp.com/static/server:splat"
status = 200
[[redirects]]
from = "/static/*"
to = "/:splat"
status = 200
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
================================================
FILE: package.json
================================================
{
"name": "redash-client",
"version": "26.03.0-dev",
"description": "The frontend part of Redash.",
"main": "index.js",
"scripts": {
"start": "npm-run-all --parallel watch:viz webpack-dev-server",
"clean": "rm -rf ./client/dist/",
"build:viz": "pnpm --filter @redash/viz build:babel",
"build": "pnpm run clean && pnpm run build:viz && NODE_ENV=production webpack",
"watch:app": "webpack watch --progress",
"watch:viz": "pnpm --filter @redash/viz watch:babel",
"watch": "npm-run-all --parallel watch:*",
"webpack-dev-server": "webpack-dev-server",
"analyze": "pnpm run clean && BUNDLE_ANALYZER=on webpack",
"analyze:build": "pnpm run clean && NODE_ENV=production BUNDLE_ANALYZER=on webpack",
"lint": "pnpm run lint:base --ext .js --ext .jsx --ext .ts --ext .tsx ./client",
"lint:fix": "pnpm run lint:base --fix --ext .js --ext .jsx --ext .ts --ext .tsx ./client",
"lint:base": "eslint --config ./client/.eslintrc.js --ignore-path ./client/.eslintignore",
"lint:ci": "pnpm run lint --max-warnings 0 --format junit --output-file /tmp/test-results/eslint/results.xml",
"prettier": "prettier --write 'client/app/**/*.{js,jsx,ts,tsx}' 'client/cypress/**/*.{js,jsx,ts,tsx}'",
"type-check": "tsc --noEmit --project client/tsconfig.json",
"type-check:watch": "pnpm run type-check --watch",
"jest": "TZ=Africa/Khartoum jest",
"test": "run-s type-check jest",
"test:watch": "jest --watch",
"cypress": "node client/cypress/cypress.js",
"postinstall": "pnpm run build:viz"
},
"repository": {
"type": "git",
"url": "git+https://github.com/getredash/redash.git"
},
"packageManager": "pnpm@10.30.3",
"engines": {
"node": ">=18.0.0 <26.0.0"
},
"author": "Redash Contributors",
"license": "BSD-2-Clause",
"bugs": {
"url": "https://github.com/getredash/redash/issues"
},
"homepage": "https://redash.io/",
"dependencies": {
"@ant-design/icons": "^4.2.1",
"@redash/viz": "workspace:*",
"ace-builds": "^1.43.3",
"antd": "4.4.3",
"axios": "0.27.2",
"axios-auth-refresh": "3.3.6",
"bootstrap": "^3.4.1",
"classnames": "^2.2.6",
"d3": "^3.5.17",
"debug": "^3.2.7",
"dompurify": "^2.0.17",
"elliptic": "^6.6.0",
"font-awesome": "^4.7.0",
"history": "^4.10.1",
"hoist-non-react-statics": "^3.3.0",
"markdown": "0.5.0",
"material-design-iconic-font": "^2.2.0",
"mousetrap": "^1.6.1",
"mustache": "^2.3.0",
"numeral": "^2.0.6",
"path-to-regexp": "^3.3.0",
"prop-types": "^15.6.1",
"query-string": "^6.9.0",
"react": "16.14.0",
"react-ace": "^14.0.1",
"react-dom": "^16.14.0",
"react-grid-layout": "^0.18.2",
"react-resizable": "^1.10.1",
"react-virtualized": "^9.21.2",
"sql-formatter": "git+https://github.com/getredash/sql-formatter.git",
"universal-router": "^8.3.0",
"use-debounce": "^3.1.0",
"use-media": "^1.4.0"
},
"devDependencies": {
"@babel/cli": "^7.28.6",
"@babel/core": "^7.29.0",
"@babel/plugin-transform-class-properties": "^7.28.5",
"@babel/plugin-transform-object-assign": "^7.27.1",
"@babel/preset-env": "^7.29.0",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@cypress/code-coverage": "^3.11.0",
"@percy/agent": "^0.28.7",
"@percy/cypress": "^3.1.2",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
"@testing-library/cypress": "^8.0.7",
"@types/classnames": "^2.2.10",
"@types/hoist-non-react-statics": "^3.3.1",
"@types/lodash": "^4.14.157",
"@types/prop-types": "^15.7.3",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"@types/sql-formatter": "^2.3.0",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"assert": "^2.1.0",
"atob": "^2.1.2",
"babel-jest": "^30.2.0",
"babel-loader": "^10.0.0",
"babel-plugin-istanbul": "^6.1.1",
"babel-plugin-transform-builtin-extend": "^1.1.2",
"copy-webpack-plugin": "^13.0.1",
"css-loader": "^7.1.4",
"cypress": "^11.2.0",
"dayjs": "^1.11.9",
"enzyme": "^3.8.0",
"enzyme-adapter-react-16": "^1.7.1",
"enzyme-to-json": "^3.3.5",
"eslint": "^8.57.1",
"eslint-config-prettier": "^8.10.0",
"eslint-config-react-app": "^7.0.1",
"eslint-plugin-chai-friendly": "^1.1.0",
"eslint-plugin-compat": "^4.2.0",
"eslint-plugin-cypress": "^2.15.2",
"eslint-plugin-flowtype": "^8.0.3",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jest": "^27.9.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-no-only-tests": "^3.3.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-webpack-plugin": "^4.2.0",
"html-webpack-plugin": "^5.6.6",
"identity-obj-proxy": "^3.0.0",
"jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0",
"less": "^3.13.1",
"less-loader": "^11.1.4",
"less-plugin-autoprefix": "^2.0.0",
"lodash": "^4.17.21",
"mini-css-extract-plugin": "^2.10.0",
"mockdate": "^2.0.2",
"npm-run-all": "^4.1.5",
"prettier": "3.3.2",
"process": "^0.11.10",
"react-refresh": "^0.14.0",
"react-test-renderer": "^16.14.0",
"request-cookies": "^1.1.0",
"source-map-loader": "^5.0.0",
"stream-browserify": "^3.0.0",
"style-loader": "^4.0.0",
"typescript": "^5.9.3",
"url": "^0.11.4",
"webpack": "^5.105.3",
"webpack-build-notifier": "^3.1.0",
"webpack-bundle-analyzer": "^5.2.0",
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.3",
"webpack-manifest-plugin": "^6.0.1"
},
"optionalDependencies": {
"fsevents": "^2.3.2"
},
"jest": {
"testEnvironment": "jsdom",
"rootDir": "./client",
"setupFiles": [
"./app/__tests__/enzyme_setup.js",
"./app/__tests__/mocks.js"
],
"snapshotSerializers": [
"enzyme-to-json/serializer"
],
"moduleNameMapper": {
"^@/(.*)": "/app/$1",
"\\.(css|less)$": "identity-obj-proxy"
},
"testPathIgnorePatterns": [
"/app/__tests__/"
]
},
"nyc": {
"include": [
"client/app/**",
"viz-lib/**"
]
},
"browser": {
"fs": false,
"path": false
},
"//": "browserslist set to 'Async functions' compatibility",
"browserslist": [
"Edge >= 15",
"Firefox >= 52",
"Chrome >= 55",
"Safari >= 10.1",
"iOS >= 10.3",
"Opera >= 42",
"op_mob >= 46",
"android >= 67",
"and_chr >= 71",
"and_ff >= 64",
"and_uc >= 11.8",
"samsung >= 6.2"
],
"pnpm": {
"overrides": {
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"cheerio": "1.0.0-rc.12"
}
}
}
================================================
FILE: pnpm-workspace.yaml
================================================
packages:
- "viz-lib"
onlyBuiltDependencies:
- "sql-formatter"
- "core-js"
- "core-js-pure"
- "cypress"
- "es5-ext"
- "less"
- "puppeteer"
- "unrs-resolver"
================================================
FILE: pyproject.toml
================================================
[project]
name = "redash"
version = "26.03.0-dev"
requires-python = ">=3.13"
description = "Make Your Company Data Driven. Connect to any data source, easily visualize, dashboard and share your data."
authors = [
{ name = "Arik Fraimovich", email = "" }
]
# to be added to/removed from the mailing list, please reach out to Arik via the above email or Discord
maintainers = [
{ name = "Redash maintainers and contributors", email = "" }
]
readme = "README.md"
dependencies = []
[tool.black]
target-version = ['py38']
line-length = 119
force-exclude = '''
/(
migrations
)/
'''
[tool.poetry.dependencies]
python = ">=3.13,<3.14"
advocate = "1.0.0"
aniso8601 = "8.0.0"
authlib = "0.15.5"
backoff = "2.2.1"
blinker = "1.6.2"
click = "8.1.3"
cryptography = "43.0.1"
disposable-email-domains = ">=0.0.52"
flask = "2.3.2"
flask-limiter = "3.3.1"
flask-login = "0.6.0"
flask-mail = "0.9.1"
flask-migrate = "2.5.2"
flask-restful = "0.3.10"
flask-sqlalchemy = "2.5.1"
flask-talisman = "0.7.0"
flask-wtf = "1.1.1"
funcy = "1.13"
gevent = "25.9.1"
greenlet = "3.3.2"
gunicorn = "22.0.0"
httplib2 = "0.19.0"
itsdangerous = "2.1.2"
jinja2 = "3.1.5"
jsonschema = "3.1.1"
markupsafe = "2.1.1"
maxminddb-geolite2 = "2018.703"
parsedatetime = "2.6"
passlib = "1.7.3"
psycopg2-binary = "2.9.11"
pyjwt = "2.4.0"
pyopenssl = "24.2.1"
pypd = "1.1.0"
pysaml2 = "7.3.1"
pystache = "0.6.0"
python-dateutil = "2.9.0.post0"
python-dotenv = "0.19.2"
pytz = ">=2019.3"
pyyaml = "6.0.1"
redis = "4.6.0"
regex = "2023.8.8"
requests = "2.32.3"
restrictedpython = "8.1"
rq = "1.16.1"
pyasynchat = "1.0.5"
rq-scheduler = "0.13.1"
semver = "2.8.1"
sentry-sdk = "1.45.1"
sqlalchemy = "1.3.24"
sqlalchemy-searchable = "1.2.0"
sqlalchemy-utils = "0.38.3"
sqlparse = "0.5.0"
sshtunnel = "0.1.5"
statsd = "3.3.0"
supervisor = "4.1.0"
supervisor-checks = "0.8.1"
ua-parser = "0.18.0"
urllib3 = "1.26.19"
user-agents = "2.0"
werkzeug = "2.3.8"
wtforms = "2.2.1"
xlsxwriter = "3.2.9"
tzlocal = "4.3.1"
pyodbc = "5.3.0"
debugpy = "^1.8.9"
paramiko = "3.4.1"
oracledb = "2.5.1"
ibm-db = { version = "^3.2.7", markers = "platform_machine == 'x86_64' or platform_machine == 'AMD64'" }
[tool.poetry.group.all_ds]
optional = true
[tool.poetry.group.all_ds.dependencies]
atsd-client = "3.0.5"
azure-kusto-data = "5.0.1"
boto3 = "1.28.8"
botocore = "1.31.8"
cassandra-driver = "3.29.3"
certifi = ">=2019.9.11"
cmem-cmempy = "21.2.3"
databend-py = "0.4.6"
databend-sqlalchemy = "0.2.4"
duckdb = "1.3.2"
google-api-python-client = "2.190.0"
gspread = "5.11.2"
impyla = "0.22.0"
influxdb = "5.2.3"
influxdb-client = "1.38.0"
memsql = "3.2.0"
mysqlclient = "2.1.1"
numpy = "2.4.2"
nzalchemy = "^11.0.2"
nzpy = ">=1.15"
oauth2client = "4.1.3"
openpyxl = "3.1.5"
pandas = "2.3.3"
phoenixdb = "1.2.2"
pinotdb = ">=0.4.5"
protobuf = "6.33.5"
pyathena = "2.25.2"
pydgraph = "25.1.0"
pydruid = "0.5.7"
pyexasol = "0.12.0"
pyhive = "0.6.1"
pyignite = "0.6.1"
pymongo = { version = "4.6.3", extras = ["srv", "tls"] }
pymssql = "^2.3.1"
pyodbc = "5.3.0"
python-arango = "6.1.0"
python-rapidjson = "1.20"
requests-aws-sign = "0.1.5"
sasl = ">=0.4a1"
simple-salesforce = "0.74.3"
snowflake-connector-python = "3.12.3"
td-client = "1.5.0"
thrift = ">=0.8.0"
thrift-sasl = ">=0.1.0"
trino = ">=0.305,<1.0"
vertica-python = "1.1.1"
xlrd = "2.0.1"
e6data-python-connector = "2.2.0"
[tool.poetry.group.ldap3]
optional = true
[tool.poetry.group.ldap3.dependencies]
ldap3 = "2.9.1"
[tool.poetry.group.dev]
optional = true
[tool.poetry.group.dev.dependencies]
pytest = "7.4.0"
coverage = "7.2.7"
freezegun = "1.5.5"
jwcrypto = "1.5.6"
mock = "5.0.2"
pre-commit = "3.3.3"
ptpython = "3.0.23"
pytest-cov = "4.1.0"
watchdog = "3.0.0"
ruff = "0.0.289"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.ruff]
exclude = [".git", "viz-lib", "node_modules", "migrations"]
ignore = ["E501"]
select = ["C9", "E", "F", "W", "I001", "UP004"]
[tool.ruff.mccabe]
max-complexity = 15
[tool.ruff.per-file-ignores]
"__init__.py" = ["F401"]
================================================
FILE: pytest.ini
================================================
[pytest]
norecursedirs = *.egg .eggs dist build docs .tox
filterwarnings =
once::DeprecationWarning
once::PendingDeprecationWarning
================================================
FILE: redash/__init__.py
================================================
import logging
import os
import sys
import redis
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from flask_mail import Mail
from flask_migrate import Migrate
from statsd import StatsClient
from redash import settings
from redash.app import create_app # noqa
from redash.destinations import import_destinations
from redash.query_runner import import_query_runners
__version__ = "26.03.0-dev"
if os.environ.get("REMOTE_DEBUG"):
import debugpy
debugpy.listen(("0.0.0.0", 5678))
debugpy.wait_for_client()
def setup_logging():
handler = logging.StreamHandler(sys.stdout if settings.LOG_STDOUT else sys.stderr)
formatter = logging.Formatter(settings.LOG_FORMAT)
handler.setFormatter(formatter)
logging.getLogger().addHandler(handler)
logging.getLogger().setLevel(settings.LOG_LEVEL)
# Make noisy libraries less noisy
if settings.LOG_LEVEL != "DEBUG":
for name in [
"passlib",
"requests.packages.urllib3",
"snowflake.connector",
"apiclient",
]:
logging.getLogger(name).setLevel("ERROR")
setup_logging()
redis_connection = redis.from_url(settings.REDIS_URL)
rq_redis_connection = redis.from_url(settings.RQ_REDIS_URL)
mail = Mail()
migrate = Migrate(compare_type=True)
statsd_client = StatsClient(host=settings.STATSD_HOST, port=settings.STATSD_PORT, prefix=settings.STATSD_PREFIX)
limiter = Limiter(key_func=get_remote_address, storage_uri=settings.LIMITER_STORAGE)
import_query_runners(settings.QUERY_RUNNERS)
import_destinations(settings.DESTINATIONS)
================================================
FILE: redash/app.py
================================================
from flask import Flask
from werkzeug.middleware.proxy_fix import ProxyFix
from redash import settings
class Redash(Flask):
"""A custom Flask app for Redash"""
def __init__(self, *args, **kwargs):
kwargs.update(
{
"template_folder": settings.FLASK_TEMPLATE_PATH,
"static_folder": settings.STATIC_ASSETS_PATH,
"static_url_path": "/static",
}
)
super(Redash, self).__init__(__name__, *args, **kwargs)
# Make sure we get the right referral address even behind proxies like nginx.
self.wsgi_app = ProxyFix(self.wsgi_app, x_for=settings.PROXIES_COUNT, x_host=1)
# Configure Redash using our settings
self.config.from_object("redash.settings")
def create_app():
from . import (
authentication,
handlers,
limiter,
mail,
migrate,
security,
tasks,
)
from .handlers.webpack import configure_webpack
from .metrics import request as request_metrics
from .models import db, users
from .utils import sentry
from .version_check import reset_new_version_status
sentry.init()
app = Redash()
# Check and update the cached version for use by the client
reset_new_version_status()
security.init_app(app)
request_metrics.init_app(app)
db.init_app(app)
migrate.init_app(app, db)
mail.init_app(app)
authentication.init_app(app)
limiter.init_app(app)
handlers.init_app(app)
configure_webpack(app)
users.init_app(app)
tasks.init_app(app)
return app
================================================
FILE: redash/authentication/__init__.py
================================================
import hashlib
import hmac
import logging
import time
from datetime import timedelta
from urllib.parse import urlsplit, urlunsplit
from flask import jsonify, redirect, request, session, url_for
from flask_login import LoginManager, login_user, logout_user, user_logged_in
from sqlalchemy.orm.exc import NoResultFound
from werkzeug.exceptions import Unauthorized
from redash import models, settings
from redash.authentication import jwt_auth
from redash.authentication.org_resolving import current_org
from redash.settings.organization import settings as org_settings
from redash.tasks import record_event
login_manager = LoginManager()
logger = logging.getLogger("authentication")
def get_login_url(external=False, next="/"):
if settings.MULTI_ORG and current_org == None: # noqa: E711
login_url = "/"
elif settings.MULTI_ORG:
login_url = url_for("redash.login", org_slug=current_org.slug, next=next, _external=external)
else:
login_url = url_for("redash.login", next=next, _external=external)
return login_url
def sign(key, path, expires):
if not key:
return None
h = hmac.new(key.encode(), msg=path.encode(), digestmod=hashlib.sha1)
h.update(str(expires).encode())
return h.hexdigest()
@login_manager.user_loader
def load_user(user_id_with_identity):
user = api_key_load_user_from_request(request)
if user:
return user
org = current_org._get_current_object()
try:
user_id, _ = user_id_with_identity.split("-")
user = models.User.get_by_id_and_org(user_id, org)
if user.is_disabled or user.get_id() != user_id_with_identity:
return None
return user
except (models.NoResultFound, ValueError, AttributeError):
return None
def request_loader(request):
user = None
if settings.AUTH_TYPE == "hmac":
user = hmac_load_user_from_request(request)
elif settings.AUTH_TYPE == "api_key":
user = api_key_load_user_from_request(request)
else:
logger.warning("Unknown authentication type ({}). Using default (HMAC).".format(settings.AUTH_TYPE))
user = hmac_load_user_from_request(request)
if org_settings["auth_jwt_login_enabled"] and user is None:
user = jwt_token_load_user_from_request(request)
return user
def hmac_load_user_from_request(request):
signature = request.args.get("signature")
expires = float(request.args.get("expires") or 0)
query_id = request.view_args.get("query_id", None)
user_id = request.args.get("user_id", None)
# TODO: 3600 should be a setting
if signature and time.time() < expires <= time.time() + 3600:
if user_id:
user = models.User.query.get(user_id)
calculated_signature = sign(user.api_key, request.path, expires)
if user.api_key and signature == calculated_signature:
return user
if query_id:
query = models.Query.query.filter(models.Query.id == query_id).one()
calculated_signature = sign(query.api_key, request.path, expires)
if query.api_key and signature == calculated_signature:
return models.ApiUser(
query.api_key,
query.org,
list(query.groups.keys()),
name="ApiKey: Query {}".format(query.id),
)
return None
def get_user_from_api_key(api_key, query_id):
if not api_key:
return None
user = None
# TODO: once we switch all api key storage into the ApiKey model, this code will be much simplified
org = current_org._get_current_object()
try:
user = models.User.get_by_api_key_and_org(api_key, org)
if user.is_disabled:
user = None
except models.NoResultFound:
try:
api_key = models.ApiKey.get_by_api_key(api_key)
user = models.ApiUser(api_key, api_key.org, [])
except models.NoResultFound:
if query_id:
query = models.Query.get_by_id_and_org(query_id, org)
if query and query.api_key == api_key:
user = models.ApiUser(
api_key,
query.org,
list(query.groups.keys()),
name="ApiKey: Query {}".format(query.id),
)
return user
def get_api_key_from_request(request):
api_key = request.args.get("api_key", None)
if api_key is not None:
return api_key
if request.headers.get("Authorization"):
auth_header = request.headers.get("Authorization")
api_key = auth_header.replace("Key ", "", 1)
elif request.view_args is not None and request.view_args.get("token"):
api_key = request.view_args["token"]
return api_key
def api_key_load_user_from_request(request):
api_key = get_api_key_from_request(request)
if request.view_args is not None:
query_id = request.view_args.get("query_id", None)
user = get_user_from_api_key(api_key, query_id)
else:
user = None
return user
def jwt_token_load_user_from_request(request):
org = current_org._get_current_object()
payload = None
if org_settings["auth_jwt_auth_cookie_name"]:
jwt_token = request.cookies.get(org_settings["auth_jwt_auth_cookie_name"], None)
elif org_settings["auth_jwt_auth_header_name"]:
jwt_token = request.headers.get(org_settings["auth_jwt_auth_header_name"], None)
else:
return None
if jwt_token:
payload, token_is_valid = jwt_auth.verify_jwt_token(
jwt_token,
expected_issuer=org_settings["auth_jwt_auth_issuer"],
expected_audience=org_settings["auth_jwt_auth_audience"],
algorithms=org_settings["auth_jwt_auth_algorithms"],
public_certs_url=org_settings["auth_jwt_auth_public_certs_url"],
)
if not token_is_valid:
raise Unauthorized("Invalid JWT token")
if not payload:
return
if "email" not in payload:
logger.info("No email field in token, refusing to login")
return
try:
user = models.User.get_by_email_and_org(payload["email"], org)
except models.NoResultFound:
user = create_and_login_user(current_org, payload["email"], payload["email"])
return user
def log_user_logged_in(app, user):
event = {
"org_id": user.org_id,
"user_id": user.id,
"action": "login",
"object_type": "redash",
"timestamp": int(time.time()),
"user_agent": request.user_agent.string,
"ip": request.remote_addr,
}
record_event.delay(event)
@login_manager.unauthorized_handler
def redirect_to_login():
is_xhr = request.headers.get("X-Requested-With") == "XMLHttpRequest"
if is_xhr or "/api/" in request.path:
return {"message": "Couldn't find resource. Please login and try again."}, 404
login_url = get_login_url(next=request.url, external=False)
return redirect(login_url)
def logout_and_redirect_to_index():
logout_user()
if settings.MULTI_ORG and current_org == None: # noqa: E711
index_url = "/"
elif settings.MULTI_ORG:
index_url = url_for("redash.index", org_slug=current_org.slug, _external=False)
else:
index_url = url_for("redash.index", _external=False)
return redirect(index_url)
def init_app(app):
from redash.authentication import ldap_auth, remote_user_auth, saml_auth
from redash.authentication.google_oauth import (
create_google_oauth_blueprint,
)
login_manager.init_app(app)
login_manager.anonymous_user = models.AnonymousUser
login_manager.REMEMBER_COOKIE_DURATION = settings.REMEMBER_COOKIE_DURATION
@app.before_request
def extend_session():
session.permanent = True
app.permanent_session_lifetime = timedelta(seconds=settings.SESSION_EXPIRY_TIME)
from redash.security import csrf
# Authlib's flask oauth client requires a Flask app to initialize
for blueprint in [
create_google_oauth_blueprint(app),
saml_auth.blueprint,
remote_user_auth.blueprint,
ldap_auth.blueprint,
]:
csrf.exempt(blueprint)
app.register_blueprint(blueprint)
user_logged_in.connect(log_user_logged_in)
login_manager.request_loader(request_loader)
def create_and_login_user(org, name, email, picture=None):
try:
user_object = models.User.get_by_email_and_org(email, org)
if user_object.is_disabled:
return None
if user_object.is_invitation_pending:
user_object.is_invitation_pending = False
models.db.session.commit()
if user_object.name != name:
logger.debug("Updating user name (%r -> %r)", user_object.name, name)
user_object.name = name
models.db.session.commit()
except NoResultFound:
logger.debug("Creating user object (%r)", name)
user_object = models.User(
org=org,
name=name,
email=email,
is_invitation_pending=False,
_profile_image_url=picture,
group_ids=[org.default_group.id],
)
models.db.session.add(user_object)
models.db.session.commit()
login_user(user_object, remember=True)
return user_object
def get_next_path(unsafe_next_path):
if not unsafe_next_path:
return ""
# Preventing open redirection attacks
parts = list(urlsplit(unsafe_next_path))
parts[0] = "" # clear scheme
parts[1] = "" # clear netloc
safe_next_path = urlunsplit(parts)
# If the original path was a URL, we might end up with an empty
# safe url, which will redirect to the login page. Changing to
# relative root to redirect to the app root after login.
if not safe_next_path:
safe_next_path = "./"
return safe_next_path
================================================
FILE: redash/authentication/account.py
================================================
import logging
from flask import render_template
from itsdangerous import URLSafeTimedSerializer
from redash import settings
from redash.tasks import send_mail
from redash.utils import base_url
logger = logging.getLogger(__name__)
serializer = URLSafeTimedSerializer(settings.SECRET_KEY)
def invite_token(user):
return serializer.dumps(str(user.id))
def verify_link_for_user(user):
token = invite_token(user)
verify_url = "{}/verify/{}".format(base_url(user.org), token)
return verify_url
def invite_link_for_user(user):
token = invite_token(user)
invite_url = "{}/invite/{}".format(base_url(user.org), token)
return invite_url
def reset_link_for_user(user):
token = invite_token(user)
invite_url = "{}/reset/{}".format(base_url(user.org), token)
return invite_url
def validate_token(token):
max_token_age = settings.INVITATION_TOKEN_MAX_AGE
return serializer.loads(token, max_age=max_token_age)
def send_verify_email(user, org):
context = {"user": user, "verify_url": verify_link_for_user(user)}
html_content = render_template("emails/verify.html", **context)
text_content = render_template("emails/verify.txt", **context)
subject = "{}, please verify your email address".format(user.name)
send_mail.delay([user.email], subject, html_content, text_content)
def send_invite_email(inviter, invited, invite_url, org):
context = dict(inviter=inviter, invited=invited, org=org, invite_url=invite_url)
html_content = render_template("emails/invite.html", **context)
text_content = render_template("emails/invite.txt", **context)
subject = "{} invited you to join Redash".format(inviter.name)
send_mail.delay([invited.email], subject, html_content, text_content)
def send_password_reset_email(user):
reset_link = reset_link_for_user(user)
context = dict(user=user, reset_link=reset_link)
html_content = render_template("emails/reset.html", **context)
text_content = render_template("emails/reset.txt", **context)
subject = "Reset your password"
send_mail.delay([user.email], subject, html_content, text_content)
return reset_link
def send_user_disabled_email(user):
html_content = render_template("emails/reset_disabled.html", user=user)
text_content = render_template("emails/reset_disabled.txt", user=user)
subject = "Your Redash account is disabled"
send_mail.delay([user.email], subject, html_content, text_content)
================================================
FILE: redash/authentication/google_oauth.py
================================================
import logging
import requests
from authlib.integrations.flask_client import OAuth
from flask import Blueprint, flash, redirect, request, session, url_for
from redash import models, settings
from redash.authentication import (
create_and_login_user,
get_next_path,
logout_and_redirect_to_index,
)
from redash.authentication.org_resolving import current_org
def verify_profile(org, profile):
if org.is_public:
return True
email = profile["email"]
domain = email.split("@")[-1]
if domain in org.google_apps_domains:
return True
if org.has_user(email) == 1:
return True
return False
def get_user_profile(access_token, logger):
headers = {"Authorization": f"OAuth {access_token}"}
response = requests.get("https://www.googleapis.com/oauth2/v1/userinfo", headers=headers)
if response.status_code == 401:
logger.warning("Failed getting user profile (response code 401).")
return None
return response.json()
def build_redirect_uri():
scheme = settings.GOOGLE_OAUTH_SCHEME_OVERRIDE or None
return url_for(".callback", _external=True, _scheme=scheme)
def build_next_path(org_slug=None):
next_path = request.args.get("next")
if not next_path:
if org_slug is None:
org_slug = session.get("org_slug")
scheme = None
if settings.GOOGLE_OAUTH_SCHEME_OVERRIDE:
scheme = settings.GOOGLE_OAUTH_SCHEME_OVERRIDE
next_path = url_for(
"redash.index",
org_slug=org_slug,
_external=True,
_scheme=scheme,
)
return next_path
def create_google_oauth_blueprint(app):
oauth = OAuth(app)
logger = logging.getLogger("google_oauth")
blueprint = Blueprint("google_oauth", __name__)
CONF_URL = "https://accounts.google.com/.well-known/openid-configuration"
oauth.register(
name="google",
server_metadata_url=CONF_URL,
client_kwargs={"scope": "openid email profile"},
)
@blueprint.route("//oauth/google", endpoint="authorize_org")
def org_login(org_slug):
session["org_slug"] = current_org.slug
return redirect(url_for(".authorize", next=request.args.get("next", None)))
@blueprint.route("/oauth/google", endpoint="authorize")
def login():
redirect_uri = build_redirect_uri()
next_path = build_next_path()
logger.debug("Callback url: %s", redirect_uri)
logger.debug("Next is: %s", next_path)
session["next_url"] = next_path
return oauth.google.authorize_redirect(redirect_uri)
@blueprint.route("/oauth/google_callback", endpoint="callback")
def authorized():
logger.debug("Authorized user inbound")
resp = oauth.google.authorize_access_token()
user = resp.get("userinfo")
if user:
session["user"] = user
access_token = resp["access_token"]
if access_token is None:
logger.warning("Access token missing in call back request.")
flash("Validation error. Please retry.")
return redirect(url_for("redash.login"))
profile = get_user_profile(access_token, logger)
if profile is None:
flash("Validation error. Please retry.")
return redirect(url_for("redash.login"))
if "org_slug" in session:
org = models.Organization.get_by_slug(session.pop("org_slug"))
else:
org = current_org
if not verify_profile(org, profile):
logger.warning(
"User tried to login with unauthorized domain name: %s (org: %s)",
profile["email"],
org,
)
flash("Your Google Apps account ({}) isn't allowed.".format(profile["email"]))
return redirect(url_for("redash.login", org_slug=org.slug))
picture_url = "%s?sz=40" % profile["picture"]
user = create_and_login_user(org, profile["name"], profile["email"], picture_url)
if user is None:
return logout_and_redirect_to_index()
unsafe_next_path = session.get("next_url")
if not unsafe_next_path:
unsafe_next_path = build_next_path(org.slug)
next_path = get_next_path(unsafe_next_path)
return redirect(next_path)
return blueprint
================================================
FILE: redash/authentication/jwt_auth.py
================================================
import json
import logging
import jwt
import requests
logger = logging.getLogger("jwt_auth")
FILE_SCHEME_PREFIX = "file://"
def get_public_key_from_file(url):
file_path = url[len(FILE_SCHEME_PREFIX) :]
with open(file_path) as key_file:
key_str = key_file.read()
get_public_keys.key_cache[url] = [key_str]
return key_str
def get_public_key_from_net(url):
r = requests.get(url)
r.raise_for_status()
data = r.json()
if "keys" in data:
public_keys = []
for key_dict in data["keys"]:
public_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(key_dict))
public_keys.append(public_key)
get_public_keys.key_cache[url] = public_keys
return public_keys
else:
get_public_keys.key_cache[url] = data
return data
def get_public_keys(url):
"""
Returns:
List of RSA public keys usable by PyJWT.
"""
key_cache = get_public_keys.key_cache
keys = {}
if url in key_cache:
keys = key_cache[url]
else:
if url.startswith(FILE_SCHEME_PREFIX):
keys = [get_public_key_from_file(url)]
else:
keys = get_public_key_from_net(url)
return keys
get_public_keys.key_cache = {}
def verify_jwt_token(jwt_token, expected_issuer, expected_audience, algorithms, public_certs_url):
# https://developers.cloudflare.com/access/setting-up-access/validate-jwt-tokens/
# https://cloud.google.com/iap/docs/signed-headers-howto
# Loop through the keys since we can't pass the key set to the decoder
keys = get_public_keys(public_certs_url)
key_id = jwt.get_unverified_header(jwt_token).get("kid", "")
if key_id and isinstance(keys, dict):
keys = [keys.get(key_id)]
valid_token = False
payload = None
for key in keys:
try:
# decode returns the claims which has the email if you need it
payload = jwt.decode(jwt_token, key=key, audience=expected_audience, algorithms=algorithms)
issuer = payload["iss"]
if issuer != expected_issuer:
raise Exception("Wrong issuer: {}".format(issuer))
valid_token = True
break
except Exception as e:
logging.exception(e)
return payload, valid_token
================================================
FILE: redash/authentication/ldap_auth.py
================================================
import logging
import sys
from flask import Blueprint, flash, redirect, render_template, request, url_for
from flask_login import current_user
from redash import settings
try:
from ldap3 import Connection, Server
from ldap3.utils.conv import escape_filter_chars
except ImportError:
if settings.LDAP_LOGIN_ENABLED:
sys.exit(
"The ldap3 library was not found. This is required to use LDAP authentication. Rebuild the Docker image installing the `ldap3` poetry dependency group."
)
from redash.authentication import (
create_and_login_user,
get_next_path,
logout_and_redirect_to_index,
)
from redash.authentication.org_resolving import current_org
from redash.handlers.base import org_scoped_rule
logger = logging.getLogger("ldap_auth")
blueprint = Blueprint("ldap_auth", __name__)
@blueprint.route(org_scoped_rule("/ldap/login"), methods=["GET", "POST"])
def login(org_slug=None):
index_url = url_for("redash.index", org_slug=org_slug)
unsafe_next_path = request.args.get("next", index_url)
next_path = get_next_path(unsafe_next_path)
if not settings.LDAP_LOGIN_ENABLED:
logger.error("Cannot use LDAP for login without being enabled in settings")
return redirect(url_for("redash.index", next=next_path))
if current_user.is_authenticated:
return redirect(next_path)
if request.method == "POST":
ldap_user = auth_ldap_user(request.form["email"], request.form["password"])
if ldap_user is not None:
user = create_and_login_user(
current_org,
ldap_user[settings.LDAP_DISPLAY_NAME_KEY][0],
ldap_user[settings.LDAP_EMAIL_KEY][0],
)
if user is None:
return logout_and_redirect_to_index()
return redirect(next_path or url_for("redash.index"))
else:
flash("Incorrect credentials.")
return render_template(
"login.html",
org_slug=org_slug,
next=next_path,
email=request.form.get("email", ""),
show_password_login=True,
username_prompt=settings.LDAP_CUSTOM_USERNAME_PROMPT,
hide_forgot_password=True,
)
def auth_ldap_user(username, password):
clean_username = escape_filter_chars(username)
server = Server(settings.LDAP_HOST_URL, use_ssl=settings.LDAP_SSL)
if settings.LDAP_BIND_DN is not None:
conn = Connection(
server,
settings.LDAP_BIND_DN,
password=settings.LDAP_BIND_DN_PASSWORD,
authentication=settings.LDAP_AUTH_METHOD,
auto_bind=True,
)
else:
conn = Connection(server, auto_bind=True)
conn.search(
settings.LDAP_SEARCH_DN,
settings.LDAP_SEARCH_TEMPLATE % {"username": clean_username},
attributes=[settings.LDAP_DISPLAY_NAME_KEY, settings.LDAP_EMAIL_KEY],
)
if len(conn.entries) == 0:
return None
user = conn.entries[0]
if not conn.rebind(user=user.entry_dn, password=password):
return None
return user
================================================
FILE: redash/authentication/org_resolving.py
================================================
import logging
from flask import g, request
from werkzeug.local import LocalProxy
from redash.models import Organization
def _get_current_org():
if "org" in g:
return g.org
if request.view_args is None:
slug = g.get("org_slug", "default")
else:
slug = request.view_args.get("org_slug", g.get("org_slug", "default"))
g.org = Organization.get_by_slug(slug)
logging.debug("Current organization: %s (slug: %s)", g.org, slug)
return g.org
# TODO: move to authentication
current_org = LocalProxy(_get_current_org)
================================================
FILE: redash/authentication/remote_user_auth.py
================================================
import logging
from flask import Blueprint, redirect, request, url_for
from redash import settings
from redash.authentication import (
create_and_login_user,
get_next_path,
logout_and_redirect_to_index,
)
from redash.authentication.org_resolving import current_org
from redash.handlers.base import org_scoped_rule
logger = logging.getLogger("remote_user_auth")
blueprint = Blueprint("remote_user_auth", __name__)
@blueprint.route(org_scoped_rule("/remote_user/login"))
def login(org_slug=None):
unsafe_next_path = request.args.get("next")
next_path = get_next_path(unsafe_next_path)
if not settings.REMOTE_USER_LOGIN_ENABLED:
logger.error("Cannot use remote user for login without being enabled in settings")
return redirect(url_for("redash.index", next=next_path, org_slug=org_slug))
email = request.headers.get(settings.REMOTE_USER_HEADER)
# Some Apache auth configurations will, stupidly, set (null) instead of a
# falsey value. Special case that here so it Just Works for more installs.
# '(null)' should never really be a value that anyone wants to legitimately
# use as a redash user email.
if email == "(null)":
email = None
if not email:
logger.error(
"Cannot use remote user for login when it's not provided in the request (looked in headers['"
+ settings.REMOTE_USER_HEADER
+ "'])"
)
return redirect(url_for("redash.index", next=next_path, org_slug=org_slug))
logger.info("Logging in " + email + " via remote user")
user = create_and_login_user(current_org, email, email)
if user is None:
return logout_and_redirect_to_index()
return redirect(next_path or url_for("redash.index", org_slug=org_slug), code=302)
================================================
FILE: redash/authentication/saml_auth.py
================================================
import logging
from flask import Blueprint, flash, redirect, request, url_for
from saml2 import BINDING_HTTP_POST, BINDING_HTTP_REDIRECT, entity
from saml2.client import Saml2Client
from saml2.config import Config as Saml2Config
from saml2.saml import NAMEID_FORMAT_TRANSIENT
from saml2.sigver import get_xmlsec_binary
from redash import settings
from redash.authentication import (
create_and_login_user,
logout_and_redirect_to_index,
)
from redash.authentication.org_resolving import current_org
from redash.handlers.base import org_scoped_rule
from redash.utils import mustache_render
logger = logging.getLogger("saml_auth")
blueprint = Blueprint("saml_auth", __name__)
inline_metadata_template = """{{x509_cert}} """
def get_saml_client(org):
"""
Return SAML configuration.
The configuration is a hash for use by saml2.config.Config
"""
saml_type = org.get_setting("auth_saml_type")
entity_id = org.get_setting("auth_saml_entity_id")
sso_url = org.get_setting("auth_saml_sso_url")
x509_cert = org.get_setting("auth_saml_x509_cert")
metadata_url = org.get_setting("auth_saml_metadata_url")
sp_settings = org.get_setting("auth_saml_sp_settings")
if settings.SAML_SCHEME_OVERRIDE:
acs_url = url_for(
"saml_auth.idp_initiated",
org_slug=org.slug,
_external=True,
_scheme=settings.SAML_SCHEME_OVERRIDE,
)
else:
acs_url = url_for("saml_auth.idp_initiated", org_slug=org.slug, _external=True)
saml_settings = {
"metadata": {"remote": [{"url": metadata_url}]},
"service": {
"sp": {
"endpoints": {
"assertion_consumer_service": [
(acs_url, BINDING_HTTP_REDIRECT),
(acs_url, BINDING_HTTP_POST),
]
},
# Don't verify that the incoming requests originate from us via
# the built-in cache for authn request ids in pysaml2
"allow_unsolicited": True,
# Don't sign authn requests, since signed requests only make
# sense in a situation where you control both the SP and IdP
"authn_requests_signed": False,
"logout_requests_signed": True,
"want_assertions_signed": True,
"want_response_signed": False,
}
},
}
if settings.SAML_ENCRYPTION_ENABLED:
encryption_dict = {
"xmlsec_binary": get_xmlsec_binary(),
"encryption_keypairs": [
{
"key_file": settings.SAML_ENCRYPTION_PEM_PATH,
"cert_file": settings.SAML_ENCRYPTION_CERT_PATH,
}
],
}
saml_settings.update(encryption_dict)
if saml_type is not None and saml_type == "static":
metadata_inline = mustache_render(
inline_metadata_template,
entity_id=entity_id,
x509_cert=x509_cert,
sso_url=sso_url,
)
saml_settings["metadata"] = {"inline": [metadata_inline]}
if entity_id is not None and entity_id != "":
saml_settings["entityid"] = entity_id
if sp_settings:
import json
saml_settings["service"]["sp"].update(json.loads(sp_settings))
sp_config = Saml2Config()
sp_config.load(saml_settings)
sp_config.allow_unknown_attributes = True
saml_client = Saml2Client(config=sp_config)
return saml_client
@blueprint.route(org_scoped_rule("/saml/callback"), methods=["POST"])
def idp_initiated(org_slug=None):
if not current_org.get_setting("auth_saml_enabled"):
logger.error("SAML Login is not enabled")
return redirect(url_for("redash.index", org_slug=org_slug))
saml_client = get_saml_client(current_org)
try:
authn_response = saml_client.parse_authn_request_response(
request.form["SAMLResponse"], entity.BINDING_HTTP_POST
)
except Exception:
logger.error("Failed to parse SAML response", exc_info=True)
flash("SAML login failed. Please try again later.")
return redirect(url_for("redash.login", org_slug=org_slug))
authn_response.get_identity()
user_info = authn_response.get_subject()
email = user_info.text
name = "%s %s" % (
authn_response.ava["FirstName"][0],
authn_response.ava["LastName"][0],
)
# This is what as known as "Just In Time (JIT) provisioning".
# What that means is that, if a user in a SAML assertion
# isn't in the user store, we create that user first, then log them in
user = create_and_login_user(current_org, name, email)
if user is None:
return logout_and_redirect_to_index()
if "RedashGroups" in authn_response.ava:
group_names = authn_response.ava.get("RedashGroups")
user.update_group_assignments(group_names)
url = url_for("redash.index", org_slug=org_slug)
return redirect(url)
@blueprint.route(org_scoped_rule("/saml/login"))
def sp_initiated(org_slug=None):
if not current_org.get_setting("auth_saml_enabled"):
logger.error("SAML Login is not enabled")
return redirect(url_for("redash.index", org_slug=org_slug))
saml_client = get_saml_client(current_org)
nameid_format = current_org.get_setting("auth_saml_nameid_format")
if nameid_format is None or nameid_format == "":
nameid_format = NAMEID_FORMAT_TRANSIENT
_, info = saml_client.prepare_for_authenticate(nameid_format=nameid_format)
redirect_url = None
# Select the IdP URL to send the AuthN request to
for key, value in info["headers"]:
if key == "Location":
redirect_url = value
response = redirect(redirect_url, code=302)
# NOTE:
# I realize I _technically_ don't need to set Cache-Control or Pragma:
# https://stackoverflow.com/a/5494469
# However, Section 3.2.3.2 of the SAML spec suggests they are set:
# http://docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf
# We set those headers here as a "belt and suspenders" approach,
# since enterprise environments don't always conform to RFCs
response.headers["Cache-Control"] = "no-cache, no-store"
response.headers["Pragma"] = "no-cache"
return response
================================================
FILE: redash/cli/__init__.py
================================================
import json
import click
from flask import current_app
from flask.cli import FlaskGroup, run_command, with_appcontext
from rq import Connection
from redash import __version__, create_app, rq_redis_connection, settings
from redash.cli import (
data_sources,
database,
groups,
organization,
queries,
rq,
users,
)
from redash.monitor import get_status
def create():
app = current_app or create_app()
@app.shell_context_processor
def shell_context():
from redash import models, settings
return {"models": models, "settings": settings}
return app
@click.group(cls=FlaskGroup, create_app=create)
def manager():
"""Management script for Redash"""
manager.add_command(database.manager, "database")
manager.add_command(users.manager, "users")
manager.add_command(groups.manager, "groups")
manager.add_command(data_sources.manager, "ds")
manager.add_command(organization.manager, "org")
manager.add_command(queries.manager, "queries")
manager.add_command(rq.manager, "rq")
manager.add_command(run_command, "runserver")
@manager.command()
def version():
"""Displays Redash version."""
print(__version__)
@manager.command()
def status():
with Connection(rq_redis_connection):
print(json.dumps(get_status(), indent=2))
@manager.command()
def check_settings():
"""Show the settings as Redash sees them (useful for debugging)."""
for name, item in current_app.config.items():
print("{} = {}".format(name, item))
@manager.command()
@click.argument("email", default=settings.MAIL_DEFAULT_SENDER, required=False)
def send_test_mail(email=None):
"""
Send test message to EMAIL (default: the address you defined in MAIL_DEFAULT_SENDER)
"""
from flask_mail import Message
from redash import mail
if email is None:
email = settings.MAIL_DEFAULT_SENDER
mail.send(Message(subject="Test Message from Redash", recipients=[email], body="Test message."))
@manager.command("shell")
@with_appcontext
def shell():
import sys
from flask.globals import _app_ctx_stack
from ptpython import repl
app = _app_ctx_stack.top.app
repl.embed(globals=app.make_shell_context())
================================================
FILE: redash/cli/data_sources.py
================================================
from sys import exit
import click
from click.types import convert_type
from flask.cli import AppGroup
from sqlalchemy.orm.exc import NoResultFound
from redash import models
from redash.query_runner import (
get_configuration_schema_for_query_runner_type,
query_runners,
)
from redash.utils import json_loads
from redash.utils.configuration import ConfigurationContainer
manager = AppGroup(help="Data sources management commands.")
@manager.command(name="list")
@click.option(
"--org",
"organization",
default=None,
help="The organization the user belongs to (leave blank for " "all organizations).",
)
def list_command(organization=None):
"""List currently configured data sources."""
if organization:
org = models.Organization.get_by_slug(organization)
data_sources = models.DataSource.query.filter(models.DataSource.org == org)
else:
data_sources = models.DataSource.query
for i, ds in enumerate(data_sources.order_by(models.DataSource.name)):
if i > 0:
print("-" * 20)
print("Id: {}\nName: {}\nType: {}\nOptions: {}".format(ds.id, ds.name, ds.type, ds.options.to_json()))
@manager.command(name="list_types")
def list_types():
print("Enabled Query Runners:")
types = sorted(query_runners.keys())
for query_runner_type in types:
print(query_runner_type)
print("Total of {}.".format(len(types)))
def validate_data_source_type(type):
if type not in query_runners.keys():
print(
'Error: the type "{}" is not supported (supported types: {}).'.format(
type, ", ".join(query_runners.keys())
)
)
print("OJNK")
exit(1)
@manager.command()
@click.argument("name")
@click.option(
"--org",
"organization",
default="default",
help="The organization the user belongs to " "(leave blank for 'default').",
)
def test(name, organization="default"):
"""Test connection to data source by issuing a trivial query."""
try:
org = models.Organization.get_by_slug(organization)
data_source = models.DataSource.query.filter(
models.DataSource.name == name, models.DataSource.org == org
).one()
print("Testing connection to data source: {} (id={})".format(name, data_source.id))
try:
data_source.query_runner.test_connection()
except Exception as e:
print("Failure: {}".format(e))
exit(1)
else:
print("Success")
except NoResultFound:
print("Couldn't find data source named: {}".format(name))
exit(1)
@manager.command()
@click.argument("name", default=None, required=False)
@click.option("--type", default=None, help="new type for the data source")
@click.option("--options", default=None, help="updated options for the data source")
@click.option(
"--org",
"organization",
default="default",
help="The organization the user belongs to (leave blank for " "'default').",
)
def new(name=None, type=None, options=None, organization="default"):
"""Create new data source."""
if name is None:
name = click.prompt("Name")
if type is None:
print("Select type:")
for i, query_runner_name in enumerate(query_runners.keys()):
print("{}. {}".format(i + 1, query_runner_name))
idx = 0
while idx < 1 or idx > len(list(query_runners.keys())):
idx = click.prompt("[{}-{}]".format(1, len(query_runners.keys())), type=int)
type = list(query_runners.keys())[idx - 1]
else:
validate_data_source_type(type)
query_runner = query_runners[type]
schema = query_runner.configuration_schema()
if options is None:
types = {"string": str, "number": int, "boolean": bool}
options_obj = {}
for k, prop in schema["properties"].items():
required = k in schema.get("required", [])
default_value = "<>"
if required:
default_value = None
prompt = prop.get("title", k.capitalize())
if required:
prompt = "{} (required)".format(prompt)
else:
prompt = "{} (optional)".format(prompt)
_type = types[prop["type"]]
def value_proc(value):
if value == default_value:
return default_value
return convert_type(_type, default_value)(value)
value = click.prompt(
prompt,
default=default_value,
type=_type,
show_default=False,
value_proc=value_proc,
)
if value != default_value:
options_obj[k] = value
options = ConfigurationContainer(options_obj, schema)
else:
options = ConfigurationContainer(json_loads(options), schema)
if not options.is_valid():
print("Error: invalid configuration.")
exit(1)
print("Creating {} data source ({}) with options:\n{}".format(type, name, options.to_json()))
data_source = models.DataSource.create_with_group(
name=name,
type=type,
options=options,
org=models.Organization.get_by_slug(organization),
)
models.db.session.commit()
print("Id: {}".format(data_source.id))
@manager.command()
@click.argument("name")
@click.option(
"--org",
"organization",
default="default",
help="The organization the user belongs to (leave blank for " "'default').",
)
def delete(name, organization="default"):
"""Delete data source by name."""
try:
org = models.Organization.get_by_slug(organization)
data_source = models.DataSource.query.filter(
models.DataSource.name == name, models.DataSource.org == org
).one()
print("Deleting data source: {} (id={})".format(name, data_source.id))
models.db.session.delete(data_source)
models.db.session.commit()
except NoResultFound:
print("Couldn't find data source named: {}".format(name))
exit(1)
def update_attr(obj, attr, new_value):
if new_value is not None:
old_value = getattr(obj, attr)
print("Updating {}: {} -> {}".format(attr, old_value, new_value))
setattr(obj, attr, new_value)
@manager.command()
@click.argument("name")
@click.option("--name", "new_name", default=None, help="new name for the data source")
@click.option("--options", default=None, help="updated options for the data source")
@click.option("--type", default=None, help="new type for the data source")
@click.option(
"--org",
"organization",
default="default",
help="The organization the user belongs to (leave blank for " "'default').",
)
def edit(name, new_name=None, options=None, type=None, organization="default"):
"""Edit data source settings (name, options, type)."""
try:
if type is not None:
validate_data_source_type(type)
org = models.Organization.get_by_slug(organization)
data_source = models.DataSource.query.filter(
models.DataSource.name == name, models.DataSource.org == org
).one()
update_attr(data_source, "name", new_name)
update_attr(data_source, "type", type)
if options is not None:
schema = get_configuration_schema_for_query_runner_type(data_source.type)
options = json_loads(options)
data_source.options.set_schema(schema)
data_source.options.update(options)
models.db.session.add(data_source)
models.db.session.commit()
except NoResultFound:
print("Couldn't find data source named: {}".format(name))
================================================
FILE: redash/cli/database.py
================================================
import logging
import time
import sqlalchemy
from click import argument, option
from cryptography.fernet import InvalidToken
from flask.cli import AppGroup
from flask_migrate import stamp
from sqlalchemy.exc import DatabaseError
from sqlalchemy.sql import select
from sqlalchemy_utils.types.encrypted.encrypted_type import FernetEngine
from redash import settings
from redash.models.base import Column, key_type
from redash.models.types import EncryptedConfiguration
from redash.utils.configuration import ConfigurationContainer
manager = AppGroup(help="Manage the database (create/drop tables. reencrypt data.).")
def _wait_for_db_connection(db):
retried = False
while not retried:
try:
db.engine.execute("SELECT 1;")
return
except DatabaseError:
time.sleep(30)
retried = True
def is_db_empty():
from redash.models import db
table_names = sqlalchemy.inspect(db.get_engine()).get_table_names()
return len(table_names) == 0
def load_extensions(db):
with db.engine.connect() as connection:
for extension in settings.dynamic_settings.database_extensions:
connection.execute(f'CREATE EXTENSION IF NOT EXISTS "{extension}";')
@manager.command(name="create_tables")
def create_tables():
"""Create the database tables."""
from redash.models import db
_wait_for_db_connection(db)
# We need to make sure we run this only if the DB is empty, because otherwise calling
# stamp() will stamp it with the latest migration value and migrations won't run.
if is_db_empty():
load_extensions(db)
# To create triggers for searchable models, we need to call configure_mappers().
sqlalchemy.orm.configure_mappers()
db.create_all()
# Need to mark current DB as up to date
stamp()
@manager.command(name="drop_tables")
def drop_tables():
"""Drop the database tables."""
from redash.models import db
_wait_for_db_connection(db)
db.drop_all()
@manager.command()
@argument("old_secret")
@argument("new_secret")
@option("--show-sql/--no-show-sql", default=False, help="show sql for debug")
def reencrypt(old_secret, new_secret, show_sql):
"""Reencrypt data encrypted by OLD_SECRET with NEW_SECRET."""
from redash.models import db
_wait_for_db_connection(db)
if show_sql:
logging.basicConfig()
logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO)
def _reencrypt_for_table(table_name, orm_name):
table_for_select = sqlalchemy.Table(
table_name,
sqlalchemy.MetaData(),
Column("id", key_type(orm_name), primary_key=True),
Column(
"encrypted_options",
ConfigurationContainer.as_mutable(EncryptedConfiguration(db.Text, old_secret, FernetEngine)),
),
)
table_for_update = sqlalchemy.Table(
table_name,
sqlalchemy.MetaData(),
Column("id", key_type(orm_name), primary_key=True),
Column(
"encrypted_options",
ConfigurationContainer.as_mutable(EncryptedConfiguration(db.Text, new_secret, FernetEngine)),
),
)
update = table_for_update.update()
selected_items = db.session.execute(select([table_for_select]))
for item in selected_items:
try:
stmt = update.where(table_for_update.c.id == item["id"]).values(
encrypted_options=item["encrypted_options"]
)
except InvalidToken:
logging.error(f'Invalid Decryption Key for id {item["id"]} in table {table_for_select}')
else:
db.session.execute(stmt)
selected_items.close()
db.session.commit()
_reencrypt_for_table("data_sources", "DataSource")
_reencrypt_for_table("notification_destinations", "NotificationDestination")
================================================
FILE: redash/cli/groups.py
================================================
from sys import exit
from click import argument, option
from flask.cli import AppGroup
from sqlalchemy.orm.exc import NoResultFound
from redash import models
manager = AppGroup(help="Groups management commands.")
@manager.command()
@argument("name")
@option(
"--org",
"organization",
default="default",
help="The organization the user belongs to (leave blank for " "'default').",
)
@option(
"--permissions",
default=None,
help="Comma separated list of permissions ('create_dashboard',"
" 'create_query', 'edit_dashboard', 'edit_query', "
"'view_query', 'view_source', 'execute_query', 'list_users',"
" 'schedule_query', 'list_dashboards', 'list_alerts',"
" 'list_data_sources') (leave blank for default).",
)
def create(name, permissions=None, organization="default"):
print("Creating group (%s)..." % (name))
org = models.Organization.get_by_slug(organization)
permissions = extract_permissions_string(permissions)
print("permissions: [%s]" % ",".join(permissions))
try:
models.db.session.add(models.Group(name=name, org=org, permissions=permissions))
models.db.session.commit()
except Exception as e:
print("Failed create group: %s" % e)
exit(1)
@manager.command(name="change_permissions")
@argument("group_id")
@option(
"--permissions",
default=None,
help="Comma separated list of permissions ('create_dashboard',"
" 'create_query', 'edit_dashboard', 'edit_query',"
" 'view_query', 'view_source', 'execute_query', 'list_users',"
" 'schedule_query', 'list_dashboards', 'list_alerts',"
" 'list_data_sources') (leave blank for default).",
)
def change_permissions(group_id, permissions=None):
print("Change permissions of group %s ..." % group_id)
try:
group = models.Group.query.get(group_id)
except NoResultFound:
print("Group [%s] not found." % group_id)
exit(1)
permissions = extract_permissions_string(permissions)
print("current permissions [%s] will be modify to [%s]" % (",".join(group.permissions), ",".join(permissions)))
group.permissions = permissions
try:
models.db.session.add(group)
models.db.session.commit()
except Exception as e:
print("Failed change permission: %s" % e)
exit(1)
def extract_permissions_string(permissions):
if permissions is None:
permissions = models.Group.DEFAULT_PERMISSIONS
else:
permissions = permissions.split(",")
permissions = [p.strip() for p in permissions]
return permissions
@manager.command(name="list")
@option(
"--org",
"organization",
default=None,
help="The organization to limit to (leave blank for all).",
)
def list_command(organization=None):
"""List all groups"""
if organization:
org = models.Organization.get_by_slug(organization)
groups = models.Group.query.filter(models.Group.org == org)
else:
groups = models.Group.query
for i, group in enumerate(groups.order_by(models.Group.name)):
if i > 0:
print("-" * 20)
print(
"Id: {}\nName: {}\nType: {}\nOrganization: {}\nPermissions: [{}]".format(
group.id,
group.name,
group.type,
group.org.slug,
",".join(group.permissions),
)
)
members = models.Group.members(group.id)
user_names = [m.name for m in members]
if user_names:
print("Users: {}".format(", ".join(user_names)))
else:
print("Users:")
================================================
FILE: redash/cli/organization.py
================================================
from click import argument, option
from flask.cli import AppGroup
from redash import models
manager = AppGroup(help="Organization management commands.")
@manager.command(name="set_google_apps_domains")
@argument("domains")
def set_google_apps_domains(domains):
"""
Sets the allowable domains to the comma separated list DOMAINS.
"""
organization = models.Organization.query.first()
k = models.Organization.SETTING_GOOGLE_APPS_DOMAINS
organization.settings[k] = domains.split(",")
models.db.session.add(organization)
models.db.session.commit()
print("Updated list of allowed domains to: {}".format(organization.google_apps_domains))
@manager.command(name="show_google_apps_domains")
def show_google_apps_domains():
organization = models.Organization.query.first()
print("Current list of Google Apps domains: {}".format(", ".join(organization.google_apps_domains)))
@manager.command(name="create")
@argument("name")
@option(
"--slug",
"slug",
default="default",
help="The slug the organization belongs to (leave blank for " "'default').",
)
def create(name, slug="default"):
print("Creating organization (%s)..." % (name))
try:
models.db.session.add(models.Organization(name=name, slug=slug, settings={}))
models.db.session.commit()
except Exception as e:
print("Failed create organization: %s" % e)
exit(1)
@manager.command(name="list")
def list_command():
"""List all organizations"""
orgs = models.Organization.query
for i, org in enumerate(orgs.order_by(models.Organization.name)):
if i > 0:
print("-" * 20)
print("Id: {}\nName: {}\nSlug: {}".format(org.id, org.name, org.slug))
================================================
FILE: redash/cli/queries.py
================================================
from click import argument
from flask.cli import AppGroup
from sqlalchemy.orm.exc import NoResultFound
manager = AppGroup(help="Queries management commands.")
@manager.command(name="rehash")
def rehash():
from redash import models
for q in models.Query.query.all():
old_hash = q.query_hash
q.update_query_hash()
new_hash = q.query_hash
if old_hash != new_hash:
print(f"Query {q.id} has changed hash from {old_hash} to {new_hash}")
models.db.session.add(q)
models.db.session.commit()
@manager.command(name="add_tag")
@argument("query_id")
@argument("tag")
def add_tag(query_id, tag):
from redash import models
query_id = int(query_id)
try:
q = models.Query.get_by_id(query_id)
except NoResultFound:
print("Query not found.")
exit(1)
tags = q.tags
if tags is None:
tags = []
tags.append(tag)
q.tags = list(set(tags))
models.db.session.add(q)
models.db.session.commit()
print("Tag added.")
@manager.command(name="remove_tag")
@argument("query_id")
@argument("tag")
def remove_tag(query_id, tag):
from redash import models
query_id = int(query_id)
try:
q = models.Query.get_by_id(query_id)
except NoResultFound:
print("Query not found.")
exit(1)
tags = q.tags
if tags is None:
print("Tag is empty.")
exit(1)
try:
tags.remove(tag)
except ValueError:
print("Tag not found.")
exit(1)
q.tags = list(set(tags))
models.db.session.add(q)
models.db.session.commit()
print("Tag removed.")
================================================
FILE: redash/cli/rq.py
================================================
import datetime
import socket
from itertools import chain
from click import argument
from flask.cli import AppGroup
from rq import Connection
from rq.worker import WorkerStatus
from sqlalchemy.orm import configure_mappers
from supervisor_checks import check_runner
from supervisor_checks.check_modules import base
from redash import rq_redis_connection
from redash.tasks import (
periodic_job_definitions,
rq_scheduler,
schedule_periodic_jobs,
)
from redash.tasks.worker import Worker
from redash.worker import default_queues
manager = AppGroup(help="RQ management commands.")
@manager.command()
def scheduler():
jobs = periodic_job_definitions()
schedule_periodic_jobs(jobs)
rq_scheduler.run()
@manager.command()
@argument("queues", nargs=-1)
def worker(queues):
# Configure any SQLAlchemy mappers loaded until now so that the mapping configuration
# will already be available to the forked work horses and they won't need
# to spend valuable time re-doing that on every fork.
configure_mappers()
if not queues:
queues = default_queues
else:
queues = chain(*[queue.split(",") for queue in queues])
with Connection(rq_redis_connection):
w = Worker(queues, log_job_description=False, job_monitoring_interval=5)
w.work()
class WorkerHealthcheck(base.BaseCheck):
NAME = "RQ Worker Healthcheck"
def __call__(self, process_spec):
pid = process_spec["pid"]
all_workers = Worker.all(connection=rq_redis_connection)
workers = [w for w in all_workers if w.hostname == socket.gethostname() and w.pid == pid]
if not workers:
self._log(f"Cannot find worker for hostname {socket.gethostname()} and pid {pid}. ==> Is healthy? False")
return False
worker = workers.pop()
is_busy = worker.get_state() == WorkerStatus.BUSY
time_since_seen = datetime.datetime.utcnow() - worker.last_heartbeat
seen_lately = time_since_seen.seconds < 60
total_jobs_in_watched_queues = sum([len(q.jobs) for q in worker.queues])
has_nothing_to_do = total_jobs_in_watched_queues == 0
is_healthy = is_busy or seen_lately or has_nothing_to_do
self._log(
"Worker %s healthcheck: Is busy? %s. "
"Seen lately? %s (%d seconds ago). "
"Has nothing to do? %s (%d jobs in watched queues). "
"==> Is healthy? %s",
worker.key,
is_busy,
seen_lately,
time_since_seen.seconds,
has_nothing_to_do,
total_jobs_in_watched_queues,
is_healthy,
)
return is_healthy
@manager.command()
def healthcheck():
return check_runner.CheckRunner("worker_healthcheck", "worker", None, [(WorkerHealthcheck, {})]).run()
================================================
FILE: redash/cli/users.py
================================================
import json
from sys import exit
from click import BOOL, argument, option, prompt
from flask.cli import AppGroup
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm.exc import NoResultFound
from redash import models
from redash.handlers.users import invite_user
manager = AppGroup(help="Users management commands.")
def build_groups(org, groups, is_admin):
if isinstance(groups, str):
groups = groups.split(",")
groups.remove("") # in case it was empty string
groups = [int(g) for g in groups]
if groups is None:
groups = [org.default_group.id]
if is_admin:
groups += [org.admin_group.id]
return groups
@manager.command(name="grant_admin")
@argument("email")
@option(
"--org",
"organization",
default="default",
help="the organization the user belongs to, (leave blank for " "'default').",
)
def grant_admin(email, organization="default"):
"""
Grant admin access to user EMAIL.
"""
try:
org = models.Organization.get_by_slug(organization)
admin_group = org.admin_group
user = models.User.get_by_email_and_org(email, org)
if admin_group.id in user.group_ids:
print("User is already an admin.")
else:
user.group_ids = user.group_ids + [org.admin_group.id]
models.db.session.add(user)
models.db.session.commit()
print("User updated.")
except NoResultFound:
print("User [%s] not found." % email)
@manager.command()
@argument("email")
@argument("name")
@option(
"--org",
"organization",
default="default",
help="The organization the user belongs to (leave blank for " "'default').",
)
@option("--admin", "is_admin", is_flag=True, default=False, help="set user as admin")
@option(
"--google",
"google_auth",
is_flag=True,
default=False,
help="user uses Google Auth to login",
)
@option(
"--password",
"password",
default=None,
help="Password for users who don't use Google Auth " "(leave blank for prompt).",
)
@option(
"--groups",
"groups",
default=None,
help="Comma separated list of groups (leave blank for " "default).",
)
def create(
email,
name,
groups,
is_admin=False,
google_auth=False,
password=None,
organization="default",
):
"""
Create user EMAIL with display name NAME.
"""
print("Creating user (%s, %s) in organization %s..." % (email, name, organization))
print("Admin: %r" % is_admin)
print("Login with Google Auth: %r\n" % google_auth)
org = models.Organization.get_by_slug(organization)
groups = build_groups(org, groups, is_admin)
user = models.User(org=org, email=email, name=name, group_ids=groups)
if not password and not google_auth:
password = prompt("Password", hide_input=True, confirmation_prompt=True)
if not google_auth:
user.hash_password(password)
try:
models.db.session.add(user)
models.db.session.commit()
except Exception as e:
print("Failed creating user: %s" % e)
exit(1)
@manager.command(name="create_root")
@argument("email")
@argument("name")
@option(
"--org",
"organization",
default="default",
help="The organization the root user belongs to (leave blank for 'default').",
)
@option(
"--google",
"google_auth",
is_flag=True,
default=False,
help="user uses Google Auth to login",
)
@option(
"--password",
"password",
default=None,
help="Password for root user who don't use Google Auth (leave blank for prompt).",
)
def create_root(email, name, google_auth=False, password=None, organization="default"):
"""
Create root user.
"""
print("Creating root user (%s, %s) in organization %s..." % (email, name, organization))
print("Login with Google Auth: %r\n" % google_auth)
user = models.User.query.filter(models.User.email == email).first()
if user is not None:
print("User [%s] is already exists." % email)
exit(1)
org_slug = organization
org = models.Organization.query.filter(models.Organization.slug == org_slug).first()
if org is None:
org = models.Organization(name=org_slug, slug=org_slug, settings={})
admin_group = models.Group(
name="admin",
permissions=models.Group.ADMIN_PERMISSIONS,
org=org,
type=models.Group.BUILTIN_GROUP,
)
default_group = models.Group(
name="default",
permissions=models.Group.DEFAULT_PERMISSIONS,
org=org,
type=models.Group.BUILTIN_GROUP,
)
models.db.session.add_all([org, admin_group, default_group])
models.db.session.commit()
user = models.User(
org=org,
email=email,
name=name,
group_ids=[admin_group.id, default_group.id],
)
if not google_auth:
user.hash_password(password)
try:
models.db.session.add(user)
models.db.session.commit()
except Exception as e:
print("Failed creating root user: %s" % e)
exit(1)
@manager.command()
@argument("email")
@option(
"--org",
"organization",
default=None,
help="The organization the user belongs to (leave blank for all" " organizations).",
)
def delete(email, organization=None):
"""
Delete user EMAIL.
"""
if organization:
org = models.Organization.get_by_slug(organization)
deleted_count = models.User.query.filter(models.User.email == email, models.User.org == org.id).delete()
else:
deleted_count = models.User.query.filter(models.User.email == email).delete(synchronize_session=False)
models.db.session.commit()
print("Deleted %d users." % deleted_count)
@manager.command()
@argument("email")
@argument("password")
@option(
"--org",
"organization",
default=None,
help="The organization the user belongs to (leave blank for all " "organizations).",
)
def password(email, password, organization=None):
"""
Resets password for EMAIL to PASSWORD.
"""
if organization:
org = models.Organization.get_by_slug(organization)
user = models.User.query.filter(models.User.email == email, models.User.org == org).first()
else:
user = models.User.query.filter(models.User.email == email).first()
if user is not None:
user.hash_password(password)
models.db.session.add(user)
models.db.session.commit()
print("User updated.")
else:
print("User [%s] not found." % email)
exit(1)
@manager.command()
@argument("email")
@argument("name")
@argument("inviter_email")
@option(
"--org",
"organization",
default="default",
help="The organization the user belongs to (leave blank for 'default')",
)
@option("--admin", "is_admin", type=BOOL, default=False, help="set user as admin")
@option(
"--groups",
"groups",
default=None,
help="Comma separated list of groups (leave blank for default).",
)
def invite(email, name, inviter_email, groups, is_admin=False, organization="default"):
"""
Sends an invitation to the given NAME and EMAIL from INVITER_EMAIL.
"""
org = models.Organization.get_by_slug(organization)
groups = build_groups(org, groups, is_admin)
try:
user_from = models.User.get_by_email_and_org(inviter_email, org)
user = models.User(org=org, name=name, email=email, group_ids=groups)
models.db.session.add(user)
try:
models.db.session.commit()
invite_user(org, user_from, user)
print("An invitation was sent to [%s] at [%s]." % (name, email))
except IntegrityError as e:
if "email" in str(e):
print("Cannot invite. User already exists [%s]" % email)
else:
print(e)
except NoResultFound:
print("The inviter [%s] was not found." % inviter_email)
@manager.command(name="list")
@option(
"--org",
"organization",
default=None,
help="The organization the user belongs to (leave blank for all" " organizations)",
)
@option("--json", "as_json", is_flag=True, default=False, help="Output as JSON")
def list_command(organization=None, as_json=False):
"""List all users"""
if organization:
org = models.Organization.get_by_slug(organization)
users = models.User.query.filter(models.User.org == org)
else:
users = models.User.query
if as_json:
result = []
for user in users.order_by(models.User.name):
result.append(
{
"id": user.id,
"name": user.name,
"email": user.email,
"org": {
"slug": user.org.slug,
"name": user.org.name,
},
"active": not user.is_disabled,
}
)
print(json.dumps(result, indent=2))
return
for i, user in enumerate(users.order_by(models.User.name)):
if i > 0:
print("-" * 20)
print(
"Id: {}\nName: {}\nEmail: {}\nOrganization: {}\nActive: {}".format(
user.id, user.name, user.email, user.org.name, not (user.is_disabled)
)
)
groups = models.Group.query.filter(models.Group.id.in_(user.group_ids)).all()
group_names = [group.name for group in groups]
print("Groups: {}".format(", ".join(group_names)))
================================================
FILE: redash/destinations/__init__.py
================================================
import logging
logger = logging.getLogger(__name__)
__all__ = ["BaseDestination", "register", "get_destination", "import_destinations"]
class BaseDestination:
deprecated = False
def __init__(self, configuration):
self.configuration = configuration
@classmethod
def name(cls):
return cls.__name__
@classmethod
def type(cls):
return cls.__name__.lower()
@classmethod
def icon(cls):
return "fa-bullseye"
@classmethod
def enabled(cls):
return True
@classmethod
def configuration_schema(cls):
return {}
def notify(self, alert, query, user, new_state, app, host, metadata, options):
raise NotImplementedError()
@classmethod
def to_dict(cls):
return {
"name": cls.name(),
"type": cls.type(),
"icon": cls.icon(),
"configuration_schema": cls.configuration_schema(),
**({"deprecated": True} if cls.deprecated else {}),
}
destinations = {}
def register(destination_class):
global destinations
if destination_class.enabled():
logger.debug(
"Registering %s (%s) destinations.",
destination_class.name(),
destination_class.type(),
)
destinations[destination_class.type()] = destination_class
else:
logger.warning(
"%s destination enabled but not supported, not registering. Either disable or install missing dependencies.",
destination_class.name(),
)
def get_destination(destination_type, configuration):
destination_class = destinations.get(destination_type, None)
if destination_class is None:
return None
return destination_class(configuration)
def get_configuration_schema_for_destination_type(destination_type):
destination_class = destinations.get(destination_type, None)
if destination_class is None:
return None
return destination_class.configuration_schema()
def import_destinations(destination_imports):
for destination_import in destination_imports:
__import__(destination_import)
================================================
FILE: redash/destinations/asana.py
================================================
import logging
import textwrap
import requests
from redash.destinations import BaseDestination, register
from redash.models import Alert
class Asana(BaseDestination):
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"pat": {"type": "string", "title": "Asana Personal Access Token"},
"project_id": {"type": "string", "title": "Asana Project ID"},
},
"secret": ["pat"],
"required": ["pat", "project_id"],
}
@classmethod
def icon(cls):
return "fa-asana"
@property
def api_base_url(self):
return "https://app.asana.com/api/1.0/tasks"
def notify(self, alert, query, user, new_state, app, host, metadata, options):
# Documentation: https://developers.asana.com/docs/tasks
state = "TRIGGERED" if new_state == Alert.TRIGGERED_STATE else "RECOVERED"
notes = textwrap.dedent(
f"""
{alert.name} has {state}.
Query: {host}/queries/{query.id}
Alert: {host}/alerts/{alert.id}
"""
).strip()
data = {
"name": f"[Redash Alert] {state}: {alert.name}",
"notes": notes,
"projects": [options["project_id"]],
}
try:
resp = requests.post(
self.api_base_url,
data=data,
timeout=5.0,
headers={"Authorization": f"Bearer {options['pat']}"},
)
logging.warning(resp.text)
if resp.status_code != 201:
logging.error("Asana send ERROR. status_code => {status}".format(status=resp.status_code))
except Exception as e:
logging.exception("Asana send ERROR. {exception}".format(exception=e))
register(Asana)
================================================
FILE: redash/destinations/chatwork.py
================================================
import logging
import requests
from redash.destinations import BaseDestination, register
class ChatWork(BaseDestination):
ALERTS_DEFAULT_MESSAGE_TEMPLATE = "{alert_name} changed state to {new_state}.\\n{alert_url}\\n{query_url}"
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"api_token": {"type": "string", "title": "API Token"},
"room_id": {"type": "string", "title": "Room ID"},
"message_template": {
"type": "string",
"default": ChatWork.ALERTS_DEFAULT_MESSAGE_TEMPLATE,
"title": "Message Template",
},
},
"secret": ["api_token"],
"required": ["message_template", "api_token", "room_id"],
}
@classmethod
def icon(cls):
return "fa-comment"
def notify(self, alert, query, user, new_state, app, host, metadata, options):
try:
# Documentation: http://developer.chatwork.com/ja/endpoint_rooms.html#POST-rooms-room_id-messages
url = "https://api.chatwork.com/v2/rooms/{room_id}/messages".format(room_id=options.get("room_id"))
message = ""
if alert.custom_subject:
message = alert.custom_subject + "\n"
if alert.custom_body:
message += alert.custom_body
else:
alert_url = "{host}/alerts/{alert_id}".format(host=host, alert_id=alert.id)
query_url = "{host}/queries/{query_id}".format(host=host, query_id=query.id)
message_template = options.get("message_template", ChatWork.ALERTS_DEFAULT_MESSAGE_TEMPLATE)
message += message_template.replace("\\n", "\n").format(
alert_name=alert.name,
new_state=new_state.upper(),
alert_url=alert_url,
query_url=query_url,
)
headers = {"X-ChatWorkToken": options.get("api_token")}
payload = {"body": message}
resp = requests.post(url, headers=headers, data=payload, timeout=5.0)
logging.warning(resp.text)
if resp.status_code != 200:
logging.error("ChatWork send ERROR. status_code => {status}".format(status=resp.status_code))
except Exception:
logging.exception("ChatWork send ERROR.")
register(ChatWork)
================================================
FILE: redash/destinations/datadog.py
================================================
import logging
import os
import requests
from redash.destinations import BaseDestination, register
from redash.utils import json_dumps
class Datadog(BaseDestination):
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"api_key": {"type": "string", "title": "API Key"},
"tags": {"type": "string", "title": "Tags"},
"priority": {"type": "string", "default": "normal", "title": "Priority"},
# https://docs.datadoghq.com/integrations/faq/list-of-api-source-attribute-value/
"source_type_name": {"type": "string", "default": "my_apps", "title": "Source Type Name"},
},
"secret": ["api_key"],
"required": ["api_key"],
}
@classmethod
def icon(cls):
return "fa-datadog"
def notify(self, alert, query, user, new_state, app, host, metadata, options):
# Documentation: https://docs.datadoghq.com/api/latest/events/#post-an-event
if new_state == "triggered":
alert_type = "error"
if alert.custom_subject:
title = alert.custom_subject
else:
title = f"{alert.name} just triggered"
else:
alert_type = "success"
if alert.custom_subject:
title = alert.custom_subject
else:
title = f"{alert.name} went back to normal"
if alert.custom_body:
text = alert.custom_body
else:
text = f"{alert.name} changed state to {new_state}."
query_url = f"{host}/queries/{query.id}"
alert_url = f"{host}/alerts/{alert.id}"
text += f"\nQuery: {query_url}\nAlert: {alert_url}"
headers = {
"Accept": "application/json",
"Content-Type": "application/json",
"DD-API-KEY": options.get("api_key"),
}
body = {
"title": title,
"text": text,
"alert_type": alert_type,
"priority": options.get("priority"),
"source_type_name": options.get("source_type_name"),
"aggregation_key": f"redash:{alert_url}",
"tags": [],
}
tags = options.get("tags")
if tags:
body["tags"] = tags.split(",")
body["tags"].extend(
[
"redash",
f"query_id:{query.id}",
f"alert_id:{alert.id}",
]
)
dd_host = os.getenv("DATADOG_HOST", "api.datadoghq.com")
url = f"https://{dd_host}/api/v1/events"
try:
resp = requests.post(url, headers=headers, data=json_dumps(body), timeout=5.0)
logging.warning(resp.text)
if resp.status_code != 202:
logging.error(f"Datadog send ERROR. status_code => {resp.status_code}")
except Exception as e:
logging.exception("Datadog send ERROR: %s", e)
register(Datadog)
================================================
FILE: redash/destinations/discord.py
================================================
import logging
import requests
from redash.destinations import BaseDestination, register
from redash.models import Alert
from redash.utils import json_dumps
colors = {
# Colors are in a Decimal format as Discord requires them to be Decimals for embeds
Alert.OK_STATE: "2600544", # Green Decimal Code
Alert.TRIGGERED_STATE: "12597547", # Red Decimal Code
Alert.UNKNOWN_STATE: "16776960", # Yellow Decimal Code
}
class Discord(BaseDestination):
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {"url": {"type": "string", "title": "Discord Webhook URL"}},
"secret": ["url"],
"required": ["url"],
}
@classmethod
def icon(cls):
return "fa-discord"
def notify(self, alert, query, user, new_state, app, host, metadata, options):
# Documentation: https://birdie0.github.io/discord-webhooks-guide/discord_webhook.html
fields = [
{
"name": "Query",
"value": f"{host}/queries/{query.id}",
"inline": True,
},
{
"name": "Alert",
"value": f"{host}/alerts/{alert.id}",
"inline": True,
},
]
if alert.custom_body:
fields.append({"name": "Description", "value": alert.custom_body})
if new_state == Alert.TRIGGERED_STATE:
if alert.options.get("custom_subject"):
text = alert.options["custom_subject"]
else:
text = f"{alert.name} just triggered"
else:
text = f"{alert.name} went back to normal"
color = colors.get(new_state)
payload = {"content": text, "embeds": [{"color": color, "fields": fields}]}
headers = {"Content-Type": "application/json"}
try:
resp = requests.post(
options.get("url"),
data=json_dumps(payload),
headers=headers,
timeout=5.0,
)
if resp.status_code != 200 and resp.status_code != 204:
logging.error(f"Discord send ERROR. status_code => {resp.status_code}")
except Exception as e:
logging.exception("Discord send ERROR: %s", e)
register(Discord)
================================================
FILE: redash/destinations/email.py
================================================
import logging
from flask_mail import Message
from redash import mail, settings
from redash.destinations import BaseDestination, register
class Email(BaseDestination):
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"addresses": {"type": "string"},
"subject_template": {
"type": "string",
"default": settings.ALERTS_DEFAULT_MAIL_SUBJECT_TEMPLATE,
"title": "Subject Template",
},
},
"required": ["addresses"],
"extra_options": ["subject_template"],
}
@classmethod
def icon(cls):
return "fa-envelope"
def notify(self, alert, query, user, new_state, app, host, metadata, options):
recipients = [email for email in options.get("addresses", "").split(",") if email]
if not recipients:
logging.warning("No emails given. Skipping send.")
if alert.custom_body:
html = alert.custom_body
else:
with open(settings.REDASH_ALERTS_DEFAULT_MAIL_BODY_TEMPLATE_FILE, "r") as f:
html = alert.render_template(f.read())
logging.debug("Notifying: %s", recipients)
try:
state = new_state.upper()
if alert.custom_subject:
subject = alert.custom_subject
else:
subject_template = options.get("subject_template", settings.ALERTS_DEFAULT_MAIL_SUBJECT_TEMPLATE)
subject = subject_template.format(alert_name=alert.name, state=state)
message = Message(recipients=recipients, subject=subject, html=html)
mail.send(message)
except Exception:
logging.exception("Mail send error.")
register(Email)
================================================
FILE: redash/destinations/hangoutschat.py
================================================
import logging
import requests
from redash.destinations import BaseDestination, register
from redash.utils import json_dumps
class HangoutsChat(BaseDestination):
@classmethod
def name(cls):
return "Google Hangouts Chat"
@classmethod
def type(cls):
return "hangouts_chat"
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"url": {
"type": "string",
"title": "Webhook URL (get it from the room settings)",
},
"icon_url": {
"type": "string",
"title": "Icon URL (32x32 or multiple, png format)",
},
},
"secret": ["url"],
"required": ["url"],
}
@classmethod
def icon(cls):
return "fa-bolt"
def notify(self, alert, query, user, new_state, app, host, metadata, options):
try:
if new_state == "triggered":
message = 'Triggered '
elif new_state == "ok":
message = 'Went back to normal '
else:
message = "Unable to determine status. Check Query and Alert configuration."
if alert.custom_subject:
title = alert.custom_subject
else:
title = alert.name
data = {
"cards": [
{
"header": {"title": title},
"sections": [{"widgets": [{"textParagraph": {"text": message}}]}],
}
]
}
if alert.custom_body:
data["cards"][0]["sections"].append({"widgets": [{"textParagraph": {"text": alert.custom_body}}]})
if options.get("icon_url"):
data["cards"][0]["header"]["imageUrl"] = options.get("icon_url")
# Hangouts Chat will create a blank card if an invalid URL (no hostname) is posted.
if host:
data["cards"][0]["sections"][0]["widgets"].append(
{
"buttons": [
{
"textButton": {
"text": "OPEN QUERY",
"onClick": {
"openLink": {
"url": "{host}/queries/{query_id}".format(host=host, query_id=query.id)
}
},
}
}
]
}
)
headers = {"Content-Type": "application/json; charset=UTF-8"}
resp = requests.post(options.get("url"), data=json_dumps(data), headers=headers, timeout=5.0)
if resp.status_code != 200:
logging.error("webhook send ERROR. status_code => {status}".format(status=resp.status_code))
except Exception:
logging.exception("webhook send ERROR.")
register(HangoutsChat)
================================================
FILE: redash/destinations/mattermost.py
================================================
import logging
import requests
from redash.destinations import BaseDestination, register
from redash.utils import json_dumps
class Mattermost(BaseDestination):
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"url": {"type": "string", "title": "Mattermost Webhook URL"},
"username": {"type": "string", "title": "Username"},
"icon_url": {"type": "string", "title": "Icon (URL)"},
"channel": {"type": "string", "title": "Channel"},
},
"secret": "url",
}
@classmethod
def icon(cls):
return "fa-bolt"
def notify(self, alert, query, user, new_state, app, host, metadata, options):
if alert.custom_subject:
text = alert.custom_subject
elif new_state == "triggered":
text = "#### " + alert.name + " just triggered"
else:
text = "#### " + alert.name + " went back to normal"
payload = {"text": text}
if alert.custom_body:
payload["attachments"] = [{"fields": [{"title": "Description", "value": alert.custom_body}]}]
if options.get("username"):
payload["username"] = options.get("username")
if options.get("icon_url"):
payload["icon_url"] = options.get("icon_url")
if options.get("channel"):
payload["channel"] = options.get("channel")
try:
resp = requests.post(options.get("url"), data=json_dumps(payload), timeout=5.0)
logging.warning(resp.text)
if resp.status_code != 200:
logging.error("Mattermost webhook send ERROR. status_code => {status}".format(status=resp.status_code))
except Exception:
logging.exception("Mattermost webhook send ERROR.")
register(Mattermost)
================================================
FILE: redash/destinations/microsoft_teams_webhook.py
================================================
import logging
from string import Template
import requests
from redash.destinations import BaseDestination, register
from redash.utils import json_dumps
def json_string_substitute(j, substitutions):
"""
Alternative to string.format when the string has braces.
:param j: json string that will have substitutions
:type j: str
:param substitutions: dictionary of values to be replaced
:type substitutions: dict
"""
if substitutions:
substitution_candidate = j.replace("{", "${")
string_template = Template(substitution_candidate)
substituted = string_template.safe_substitute(substitutions)
out_str = substituted.replace("${", "{")
return out_str
else:
return j
class MicrosoftTeamsWebhook(BaseDestination):
ALERTS_DEFAULT_MESSAGE_TEMPLATE = json_dumps(
{
"@type": "MessageCard",
"@context": "http://schema.org/extensions",
"themeColor": "0076D7",
"summary": "A Redash Alert was Triggered",
"sections": [
{
"activityTitle": "A Redash Alert was Triggered",
"facts": [
{"name": "Alert Name", "value": "{alert_name}"},
{"name": "Alert URL", "value": "{alert_url}"},
{"name": "Query", "value": "{query_text}"},
{"name": "Query URL", "value": "{query_url}"},
],
"markdown": True,
}
],
}
)
@classmethod
def name(cls):
return "Microsoft Teams Webhook"
@classmethod
def type(cls):
return "microsoft_teams_webhook"
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"url": {"type": "string", "title": "Microsoft Teams Webhook URL"},
"message_template": {
"type": "string",
"default": MicrosoftTeamsWebhook.ALERTS_DEFAULT_MESSAGE_TEMPLATE,
"title": "Message Template",
},
},
"required": ["url"],
}
@classmethod
def icon(cls):
return "fa-bolt"
def notify(self, alert, query, user, new_state, app, host, metadata, options):
"""
:type app: redash.Redash
"""
try:
alert_url = "{host}/alerts/{alert_id}".format(host=host, alert_id=alert.id)
query_url = "{host}/queries/{query_id}".format(host=host, query_id=query.id)
message_template = options.get("message_template", MicrosoftTeamsWebhook.ALERTS_DEFAULT_MESSAGE_TEMPLATE)
# Doing a string Template substitution here because the template contains braces, which
# result in keyerrors when attempting string.format
payload = json_string_substitute(
message_template,
{
"alert_name": alert.name,
"alert_url": alert_url,
"query_text": query.query_text,
"query_url": query_url,
},
)
headers = {"Content-Type": "application/json"}
resp = requests.post(
options.get("url"),
data=payload,
headers=headers,
timeout=5.0,
)
if resp.status_code != 200:
logging.error("MS Teams Webhook send ERROR. status_code => {status}".format(status=resp.status_code))
except Exception:
logging.exception("MS Teams Webhook send ERROR.")
register(MicrosoftTeamsWebhook)
================================================
FILE: redash/destinations/pagerduty.py
================================================
import logging
from redash.destinations import BaseDestination, register
enabled = True
try:
import pypd
except ImportError:
enabled = False
class PagerDuty(BaseDestination):
KEY_STRING = "{alert_id}_{query_id}"
DESCRIPTION_STR = "Alert: {alert_name}"
@classmethod
def enabled(cls):
return enabled
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"integration_key": {
"type": "string",
"title": "PagerDuty Service Integration Key",
},
"description": {
"type": "string",
"title": "Description for the event, defaults to alert name",
},
},
"secret": ["integration_key"],
"required": ["integration_key"],
}
@classmethod
def icon(cls):
return "creative-commons-pd-alt"
def notify(self, alert, query, user, new_state, app, host, metadata, options):
if alert.custom_subject:
default_desc = alert.custom_subject
elif options.get("description"):
default_desc = options.get("description")
else:
default_desc = self.DESCRIPTION_STR.format(alert_name=alert.name)
incident_key = self.KEY_STRING.format(alert_id=alert.id, query_id=query.id)
data = {
"routing_key": options.get("integration_key"),
"incident_key": incident_key,
"dedup_key": incident_key,
"payload": {
"summary": default_desc,
"severity": "error",
"source": "redash",
},
}
if alert.custom_body:
data["payload"]["custom_details"] = alert.custom_body
if new_state == "triggered":
data["event_action"] = "trigger"
elif new_state == "unknown":
logging.info("Unknown state, doing nothing")
return
else:
data["event_action"] = "resolve"
try:
ev = pypd.EventV2.create(data=data)
logging.warning(ev)
except Exception:
logging.exception("PagerDuty trigger failed!")
register(PagerDuty)
================================================
FILE: redash/destinations/slack.py
================================================
import logging
import requests
from redash.destinations import BaseDestination, register
from redash.utils import json_dumps
class Slack(BaseDestination):
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"url": {"type": "string", "title": "Slack Webhook URL"},
},
"secret": ["url"],
}
@classmethod
def icon(cls):
return "fa-slack"
def notify(self, alert, query, user, new_state, app, host, metadata, options):
# Documentation: https://api.slack.com/docs/attachments
fields = [
{
"title": "Query",
"type": "mrkdwn",
"value": "{host}/queries/{query_id}".format(host=host, query_id=query.id),
},
{
"title": "Alert",
"type": "mrkdwn",
"value": "{host}/alerts/{alert_id}".format(host=host, alert_id=alert.id),
},
]
if alert.custom_body:
fields.append({"title": "Description", "value": alert.custom_body})
if new_state == "triggered":
if alert.custom_subject:
text = alert.custom_subject
else:
text = alert.name + " just triggered"
color = "#c0392b"
else:
text = alert.name + " went back to normal"
color = "#27ae60"
payload = {"attachments": [{"text": text, "color": color, "fields": fields}]}
try:
resp = requests.post(options.get("url"), data=json_dumps(payload).encode("utf-8"), timeout=5.0)
logging.warning(resp.text)
if resp.status_code != 200:
logging.error("Slack send ERROR. status_code => {status}".format(status=resp.status_code))
except Exception:
logging.exception("Slack send ERROR.")
register(Slack)
================================================
FILE: redash/destinations/webex.py
================================================
import html
import json
import logging
from copy import deepcopy
import requests
from redash.destinations import BaseDestination, register
from redash.models import Alert
class Webex(BaseDestination):
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"webex_bot_token": {"type": "string", "title": "Webex Bot Token"},
"to_person_emails": {
"type": "string",
"title": "People (comma-separated)",
},
"to_room_ids": {
"type": "string",
"title": "Rooms (comma-separated)",
},
},
"secret": ["webex_bot_token"],
"required": ["webex_bot_token"],
}
@classmethod
def icon(cls):
return "fa-webex"
@property
def api_base_url(self):
return "https://webexapis.com/v1/messages"
@staticmethod
def formatted_attachments_template(subject, description, query_link, alert_link):
# Attempt to parse the description to find a 2D array
try:
# Extract the part of the description that looks like a JSON array
start_index = description.find("[")
end_index = description.rfind("]") + 1
json_array_str = description[start_index:end_index]
# Decode HTML entities
json_array_str = html.unescape(json_array_str)
# Replace single quotes with double quotes for valid JSON
json_array_str = json_array_str.replace("'", '"')
# Load the JSON array
data_array = json.loads(json_array_str)
# Check if it's a 2D array
if isinstance(data_array, list) and all(isinstance(i, list) for i in data_array):
# Create a table for the Adaptive Card
table_rows = []
for row in data_array:
table_rows.append(
{
"type": "ColumnSet",
"columns": [
{"type": "Column", "items": [{"type": "TextBlock", "text": str(item), "wrap": True}]}
for item in row
],
}
)
# Create the body of the card with the table
body = (
[
{
"type": "TextBlock",
"text": f"{subject}",
"weight": "bolder",
"size": "medium",
"wrap": True,
},
{
"type": "TextBlock",
"text": f"{description[:start_index]}",
"isSubtle": True,
"wrap": True,
},
]
+ table_rows
+ [
{
"type": "TextBlock",
"text": f"Click [here]({query_link}) to check your query!",
"wrap": True,
"isSubtle": True,
},
{
"type": "TextBlock",
"text": f"Click [here]({alert_link}) to check your alert!",
"wrap": True,
"isSubtle": True,
},
]
)
else:
# Fallback to the original description if no valid 2D array is found
body = [
{
"type": "TextBlock",
"text": f"{subject}",
"weight": "bolder",
"size": "medium",
"wrap": True,
},
{
"type": "TextBlock",
"text": f"{description}",
"isSubtle": True,
"wrap": True,
},
{
"type": "TextBlock",
"text": f"Click [here]({query_link}) to check your query!",
"wrap": True,
"isSubtle": True,
},
{
"type": "TextBlock",
"text": f"Click [here]({alert_link}) to check your alert!",
"wrap": True,
"isSubtle": True,
},
]
except json.JSONDecodeError:
# If parsing fails, fallback to the original description
body = [
{
"type": "TextBlock",
"text": f"{subject}",
"weight": "bolder",
"size": "medium",
"wrap": True,
},
{
"type": "TextBlock",
"text": f"{description}",
"isSubtle": True,
"wrap": True,
},
{
"type": "TextBlock",
"text": f"Click [here]({query_link}) to check your query!",
"wrap": True,
"isSubtle": True,
},
{
"type": "TextBlock",
"text": f"Click [here]({alert_link}) to check your alert!",
"wrap": True,
"isSubtle": True,
},
]
return [
{
"contentType": "application/vnd.microsoft.card.adaptive",
"content": {
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.0",
"body": body,
},
}
]
def notify(self, alert, query, user, new_state, app, host, metadata, options):
# Documentation: https://developer.webex.com/docs/api/guides/cards
query_link = f"{host}/queries/{query.id}"
alert_link = f"{host}/alerts/{alert.id}"
if new_state == Alert.TRIGGERED_STATE:
subject = alert.custom_subject or f"{alert.name} just triggered"
else:
subject = f"{alert.name} went back to normal"
attachments = self.formatted_attachments_template(
subject=subject, description=alert.custom_body, query_link=query_link, alert_link=alert_link
)
template_payload = {"markdown": subject + "\n" + alert.custom_body, "attachments": attachments}
headers = {"Authorization": f"Bearer {options['webex_bot_token']}"}
api_destinations = {
"toPersonEmail": options.get("to_person_emails"),
"roomId": options.get("to_room_ids"),
}
for payload_tag, destinations in api_destinations.items():
if destinations is None:
continue
# destinations is guaranteed to be a comma-separated string
for destination_id in destinations.split(","):
destination_id = destination_id.strip() # Remove any leading or trailing whitespace
if not destination_id: # Check if the destination_id is empty or blank
continue # Skip to the next iteration if it's empty or blank
payload = deepcopy(template_payload)
payload[payload_tag] = destination_id
self.post_message(payload, headers)
def post_message(self, payload, headers):
try:
resp = requests.post(
self.api_base_url,
json=payload,
headers=headers,
timeout=5.0,
)
logging.warning(resp.text)
if resp.status_code != 200:
logging.error("Webex send ERROR. status_code => {status}".format(status=resp.status_code))
except Exception as e:
logging.exception(f"Webex send ERROR: {e}")
register(Webex)
================================================
FILE: redash/destinations/webhook.py
================================================
import logging
import requests
from requests.auth import HTTPBasicAuth
from redash.destinations import BaseDestination, register
from redash.serializers import serialize_alert
from redash.utils import json_dumps
class Webhook(BaseDestination):
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"url": {"type": "string"},
"username": {"type": "string"},
"password": {"type": "string"},
},
"required": ["url"],
"secret": ["password", "url"],
}
@classmethod
def icon(cls):
return "fa-bolt"
def notify(self, alert, query, user, new_state, app, host, metadata, options):
try:
data = {
"event": "alert_state_change",
"alert": serialize_alert(alert, full=False),
"url_base": host,
"metadata": metadata,
}
data["alert"]["description"] = alert.custom_body
data["alert"]["title"] = alert.custom_subject
headers = {"Content-Type": "application/json"}
auth = HTTPBasicAuth(options.get("username"), options.get("password")) if options.get("username") else None
resp = requests.post(
options.get("url"),
data=json_dumps(data).encode("utf-8"),
auth=auth,
headers=headers,
timeout=5.0,
)
if resp.status_code != 200:
logging.error("webhook send ERROR. status_code => {status}".format(status=resp.status_code))
except Exception:
logging.exception("webhook send ERROR.")
register(Webhook)
================================================
FILE: redash/handlers/__init__.py
================================================
from flask import jsonify
from flask_login import login_required
from redash.handlers.api import api
from redash.handlers.base import routes
from redash.monitor import get_status
from redash.permissions import require_super_admin
from redash.security import talisman
@routes.route("/ping", methods=["GET"])
@talisman(force_https=False)
def ping():
return "PONG."
@routes.route("/status.json")
@login_required
@require_super_admin
def status_api():
status = get_status()
return jsonify(status)
def init_app(app):
from redash.handlers import (
admin,
authentication,
embed,
organization,
queries,
setup,
static,
)
app.register_blueprint(routes)
api.init_app(app)
================================================
FILE: redash/handlers/admin.py
================================================
from flask_login import current_user, login_required
from redash import models, redis_connection
from redash.authentication import current_org
from redash.handlers import routes
from redash.handlers.base import json_response, record_event
from redash.monitor import rq_status
from redash.permissions import require_super_admin
from redash.serializers import QuerySerializer
from redash.utils import json_loads
@routes.route("/api/admin/queries/outdated", methods=["GET"])
@require_super_admin
@login_required
def outdated_queries():
manager_status = redis_connection.hgetall("redash:status")
query_ids = json_loads(manager_status.get("query_ids", "[]"))
if query_ids:
outdated_queries = (
models.Query.query.outerjoin(models.QueryResult)
.filter(models.Query.id.in_(query_ids))
.order_by(models.Query.created_at.desc())
)
else:
outdated_queries = []
record_event(
current_org,
current_user._get_current_object(),
{
"action": "list",
"object_type": "outdated_queries",
},
)
response = {
"queries": QuerySerializer(outdated_queries, with_stats=True, with_last_modified_by=False).serialize(),
"updated_at": manager_status["last_refresh_at"],
}
return json_response(response)
@routes.route("/api/admin/queries/rq_status", methods=["GET"])
@require_super_admin
@login_required
def queries_rq_status():
record_event(
current_org,
current_user._get_current_object(),
{"action": "list", "object_type": "rq_status"},
)
return json_response(rq_status())
================================================
FILE: redash/handlers/alerts.py
================================================
from flask import request
from funcy import project
from redash import models, utils
from redash.handlers.base import (
BaseResource,
get_object_or_404,
require_fields,
)
from redash.permissions import (
require_access,
require_admin_or_owner,
require_permission,
view_only,
)
from redash.serializers import serialize_alert
from redash.tasks.alerts import (
notify_subscriptions,
should_notify,
)
class AlertResource(BaseResource):
def get(self, alert_id):
alert = get_object_or_404(models.Alert.get_by_id_and_org, alert_id, self.current_org)
require_access(alert, self.current_user, view_only)
self.record_event({"action": "view", "object_id": alert.id, "object_type": "alert"})
return serialize_alert(alert)
def post(self, alert_id):
req = request.get_json(True)
params = project(req, ("options", "name", "query_id", "rearm"))
alert = get_object_or_404(models.Alert.get_by_id_and_org, alert_id, self.current_org)
require_admin_or_owner(alert.user.id)
self.update_model(alert, params)
models.db.session.commit()
self.record_event({"action": "edit", "object_id": alert.id, "object_type": "alert"})
return serialize_alert(alert)
def delete(self, alert_id):
alert = get_object_or_404(models.Alert.get_by_id_and_org, alert_id, self.current_org)
require_admin_or_owner(alert.user_id)
models.db.session.delete(alert)
models.db.session.commit()
class AlertEvaluateResource(BaseResource):
def post(self, alert_id):
alert = get_object_or_404(models.Alert.get_by_id_and_org, alert_id, self.current_org)
require_admin_or_owner(alert.user.id)
new_state = alert.evaluate()
if should_notify(alert, new_state):
alert.state = new_state
alert.last_triggered_at = utils.utcnow()
models.db.session.commit()
notify_subscriptions(alert, new_state, {})
self.record_event({"action": "evaluate", "object_id": alert.id, "object_type": "alert"})
class AlertMuteResource(BaseResource):
def post(self, alert_id):
alert = get_object_or_404(models.Alert.get_by_id_and_org, alert_id, self.current_org)
require_admin_or_owner(alert.user.id)
alert.options["muted"] = True
models.db.session.commit()
self.record_event({"action": "mute", "object_id": alert.id, "object_type": "alert"})
def delete(self, alert_id):
alert = get_object_or_404(models.Alert.get_by_id_and_org, alert_id, self.current_org)
require_admin_or_owner(alert.user.id)
alert.options["muted"] = False
models.db.session.commit()
self.record_event({"action": "unmute", "object_id": alert.id, "object_type": "alert"})
class AlertListResource(BaseResource):
def post(self):
req = request.get_json(True)
require_fields(req, ("options", "name", "query_id"))
query = models.Query.get_by_id_and_org(req["query_id"], self.current_org)
require_access(query, self.current_user, view_only)
alert = models.Alert(
name=req["name"],
query_rel=query,
user=self.current_user,
rearm=req.get("rearm"),
options=req["options"],
)
models.db.session.add(alert)
models.db.session.flush()
models.db.session.commit()
self.record_event({"action": "create", "object_id": alert.id, "object_type": "alert"})
return serialize_alert(alert)
@require_permission("list_alerts")
def get(self):
self.record_event({"action": "list", "object_type": "alert"})
return [serialize_alert(alert) for alert in models.Alert.all(group_ids=self.current_user.group_ids)]
class AlertSubscriptionListResource(BaseResource):
def post(self, alert_id):
req = request.get_json(True)
alert = models.Alert.get_by_id_and_org(alert_id, self.current_org)
require_access(alert, self.current_user, view_only)
kwargs = {"alert": alert, "user": self.current_user}
if "destination_id" in req:
destination = models.NotificationDestination.get_by_id_and_org(req["destination_id"], self.current_org)
kwargs["destination"] = destination
subscription = models.AlertSubscription(**kwargs)
models.db.session.add(subscription)
models.db.session.commit()
self.record_event(
{
"action": "subscribe",
"object_id": alert_id,
"object_type": "alert",
"destination": req.get("destination_id"),
}
)
d = subscription.to_dict()
return d
def get(self, alert_id):
alert = models.Alert.get_by_id_and_org(alert_id, self.current_org)
require_access(alert, self.current_user, view_only)
subscriptions = models.AlertSubscription.all(alert_id)
return [s.to_dict() for s in subscriptions]
class AlertSubscriptionResource(BaseResource):
def delete(self, alert_id, subscriber_id):
subscription = models.AlertSubscription.query.get_or_404(subscriber_id)
require_admin_or_owner(subscription.user.id)
models.db.session.delete(subscription)
models.db.session.commit()
self.record_event({"action": "unsubscribe", "object_id": alert_id, "object_type": "alert"})
================================================
FILE: redash/handlers/api.py
================================================
from flask import make_response
from flask_restful import Api
from werkzeug.wrappers import Response
from redash.handlers.alerts import (
AlertEvaluateResource,
AlertListResource,
AlertMuteResource,
AlertResource,
AlertSubscriptionListResource,
AlertSubscriptionResource,
)
from redash.handlers.base import org_scoped_rule
from redash.handlers.dashboards import (
DashboardFavoriteListResource,
DashboardForkResource,
DashboardListResource,
DashboardResource,
DashboardShareResource,
DashboardTagsResource,
MyDashboardsResource,
PublicDashboardResource,
)
from redash.handlers.data_sources import (
DataSourceListResource,
DataSourcePauseResource,
DataSourceResource,
DataSourceSchemaResource,
DataSourceTestResource,
DataSourceTypeListResource,
)
from redash.handlers.databricks import (
DatabricksDatabaseListResource,
DatabricksSchemaResource,
DatabricksTableColumnListResource,
)
from redash.handlers.destinations import (
DestinationListResource,
DestinationResource,
DestinationTypeListResource,
)
from redash.handlers.events import EventsResource
from redash.handlers.favorites import (
DashboardFavoriteResource,
QueryFavoriteResource,
)
from redash.handlers.groups import (
GroupDataSourceListResource,
GroupDataSourceResource,
GroupListResource,
GroupMemberListResource,
GroupMemberResource,
GroupResource,
)
from redash.handlers.permissions import (
CheckPermissionResource,
ObjectPermissionsListResource,
)
from redash.handlers.queries import (
MyQueriesResource,
QueryArchiveResource,
QueryFavoriteListResource,
QueryForkResource,
QueryListResource,
QueryRecentResource,
QueryRefreshResource,
QueryRegenerateApiKeyResource,
QueryResource,
QuerySearchResource,
QueryTagsResource,
)
from redash.handlers.query_results import (
JobResource,
QueryDropdownsResource,
QueryResultDropdownResource,
QueryResultListResource,
QueryResultResource,
)
from redash.handlers.query_snippets import (
QuerySnippetListResource,
QuerySnippetResource,
)
from redash.handlers.settings import OrganizationSettings
from redash.handlers.users import (
UserDisableResource,
UserInviteResource,
UserListResource,
UserRegenerateApiKeyResource,
UserResetPasswordResource,
UserResource,
)
from redash.handlers.visualizations import (
VisualizationListResource,
VisualizationResource,
)
from redash.handlers.widgets import WidgetListResource, WidgetResource
from redash.utils import json_dumps
class ApiExt(Api):
def add_org_resource(self, resource, *urls, **kwargs):
urls = [org_scoped_rule(url) for url in urls]
return self.add_resource(resource, *urls, **kwargs)
api = ApiExt()
@api.representation("application/json")
def json_representation(data, code, headers=None):
# Flask-Restful checks only for flask.Response but flask-login uses werkzeug.wrappers.Response
if isinstance(data, Response):
return data
resp = make_response(json_dumps(data), code)
resp.headers.extend(headers or {})
return resp
api.add_org_resource(AlertResource, "/api/alerts/", endpoint="alert")
api.add_org_resource(AlertMuteResource, "/api/alerts//mute", endpoint="alert_mute")
api.add_org_resource(AlertEvaluateResource, "/api/alerts//eval", endpoint="alert_eval")
api.add_org_resource(
AlertSubscriptionListResource,
"/api/alerts//subscriptions",
endpoint="alert_subscriptions",
)
api.add_org_resource(
AlertSubscriptionResource,
"/api/alerts//subscriptions/",
endpoint="alert_subscription",
)
api.add_org_resource(AlertListResource, "/api/alerts", endpoint="alerts")
api.add_org_resource(DashboardListResource, "/api/dashboards", endpoint="dashboards")
api.add_org_resource(DashboardResource, "/api/dashboards/", endpoint="dashboard")
api.add_org_resource(
PublicDashboardResource,
"/api/dashboards/public/",
endpoint="public_dashboard",
)
api.add_org_resource(
DashboardShareResource,
"/api/dashboards//share",
endpoint="dashboard_share",
)
api.add_org_resource(DataSourceTypeListResource, "/api/data_sources/types", endpoint="data_source_types")
api.add_org_resource(DataSourceListResource, "/api/data_sources", endpoint="data_sources")
api.add_org_resource(DataSourceSchemaResource, "/api/data_sources//schema")
api.add_org_resource(DatabricksDatabaseListResource, "/api/databricks/databases/")
api.add_org_resource(
DatabricksSchemaResource,
"/api/databricks/databases///tables",
)
api.add_org_resource(
DatabricksTableColumnListResource,
"/api/databricks/databases///columns/",
)
api.add_org_resource(DataSourcePauseResource, "/api/data_sources//pause")
api.add_org_resource(DataSourceTestResource, "/api/data_sources//test")
api.add_org_resource(DataSourceResource, "/api/data_sources/", endpoint="data_source")
api.add_org_resource(GroupListResource, "/api/groups", endpoint="groups")
api.add_org_resource(GroupResource, "/api/groups/", endpoint="group")
api.add_org_resource(GroupMemberListResource, "/api/groups//members", endpoint="group_members")
api.add_org_resource(
GroupMemberResource,
"/api/groups//members/",
endpoint="group_member",
)
api.add_org_resource(
GroupDataSourceListResource,
"/api/groups//data_sources",
endpoint="group_data_sources",
)
api.add_org_resource(
GroupDataSourceResource,
"/api/groups//data_sources/",
endpoint="group_data_source",
)
api.add_org_resource(EventsResource, "/api/events", endpoint="events")
api.add_org_resource(QueryFavoriteListResource, "/api/queries/favorites", endpoint="query_favorites")
api.add_org_resource(QueryFavoriteResource, "/api/queries//favorite", endpoint="query_favorite")
api.add_org_resource(
DashboardFavoriteListResource,
"/api/dashboards/favorites",
endpoint="dashboard_favorites",
)
api.add_org_resource(
DashboardFavoriteResource,
"/api/dashboards//favorite",
endpoint="dashboard_favorite",
)
api.add_org_resource(DashboardForkResource, "/api/dashboards//fork", endpoint="dashboard_fork")
api.add_org_resource(MyDashboardsResource, "/api/dashboards/my", endpoint="my_dashboards")
api.add_org_resource(QueryTagsResource, "/api/queries/tags", endpoint="query_tags")
api.add_org_resource(DashboardTagsResource, "/api/dashboards/tags", endpoint="dashboard_tags")
api.add_org_resource(QuerySearchResource, "/api/queries/search", endpoint="queries_search")
api.add_org_resource(QueryRecentResource, "/api/queries/recent", endpoint="recent_queries")
api.add_org_resource(QueryArchiveResource, "/api/queries/archive", endpoint="queries_archive")
api.add_org_resource(QueryListResource, "/api/queries", endpoint="queries")
api.add_org_resource(MyQueriesResource, "/api/queries/my", endpoint="my_queries")
api.add_org_resource(QueryRefreshResource, "/api/queries//refresh", endpoint="query_refresh")
api.add_org_resource(QueryResource, "/api/queries/", endpoint="query")
api.add_org_resource(QueryForkResource, "/api/queries//fork", endpoint="query_fork")
api.add_org_resource(
QueryRegenerateApiKeyResource,
"/api/queries//regenerate_api_key",
endpoint="query_regenerate_api_key",
)
api.add_org_resource(
ObjectPermissionsListResource,
"/api///acl",
endpoint="object_permissions",
)
api.add_org_resource(
CheckPermissionResource,
"/api///acl/",
endpoint="check_permissions",
)
api.add_org_resource(QueryResultListResource, "/api/query_results", endpoint="query_results")
api.add_org_resource(
QueryResultDropdownResource,
"/api/queries//dropdown",
endpoint="query_result_dropdown",
)
api.add_org_resource(
QueryDropdownsResource,
"/api/queries//dropdowns/",
endpoint="query_result_dropdowns",
)
api.add_org_resource(
QueryResultResource,
"/api/query_results/.",
"/api/query_results/",
"/api/queries//results",
"/api/queries//results.",
"/api/queries//results/.",
endpoint="query_result",
)
api.add_org_resource(
JobResource,
"/api/jobs/",
"/api/queries//jobs/",
endpoint="job",
)
api.add_org_resource(UserListResource, "/api/users", endpoint="users")
api.add_org_resource(UserResource, "/api/users/", endpoint="user")
api.add_org_resource(UserInviteResource, "/api/users//invite", endpoint="user_invite")
api.add_org_resource(
UserResetPasswordResource,
"/api/users//reset_password",
endpoint="user_reset_password",
)
api.add_org_resource(
UserRegenerateApiKeyResource,
"/api/users//regenerate_api_key",
endpoint="user_regenerate_api_key",
)
api.add_org_resource(UserDisableResource, "/api/users//disable", endpoint="user_disable")
api.add_org_resource(VisualizationListResource, "/api/visualizations", endpoint="visualizations")
api.add_org_resource(
VisualizationResource,
"/api/visualizations/",
endpoint="visualization",
)
api.add_org_resource(WidgetListResource, "/api/widgets", endpoint="widgets")
api.add_org_resource(WidgetResource, "/api/widgets/", endpoint="widget")
api.add_org_resource(DestinationTypeListResource, "/api/destinations/types", endpoint="destination_types")
api.add_org_resource(DestinationResource, "/api/destinations/", endpoint="destination")
api.add_org_resource(DestinationListResource, "/api/destinations", endpoint="destinations")
api.add_org_resource(QuerySnippetResource, "/api/query_snippets/", endpoint="query_snippet")
api.add_org_resource(QuerySnippetListResource, "/api/query_snippets", endpoint="query_snippets")
api.add_org_resource(OrganizationSettings, "/api/settings/organization", endpoint="organization_settings")
================================================
FILE: redash/handlers/authentication.py
================================================
import logging
from flask import abort, flash, redirect, render_template, request, url_for
from flask_login import current_user, login_required, login_user, logout_user
from itsdangerous import BadSignature, SignatureExpired
from sqlalchemy.orm.exc import NoResultFound
from redash import __version__, limiter, models, settings
from redash.authentication import current_org, get_login_url, get_next_path
from redash.authentication.account import (
send_password_reset_email,
send_user_disabled_email,
send_verify_email,
validate_token,
)
from redash.handlers import routes
from redash.handlers.base import json_response, org_scoped_rule
from redash.version_check import get_latest_version
logger = logging.getLogger(__name__)
def get_google_auth_url(next_path):
if settings.MULTI_ORG:
google_auth_url = url_for("google_oauth.authorize_org", next=next_path, org_slug=current_org.slug)
else:
google_auth_url = url_for("google_oauth.authorize", next=next_path)
return google_auth_url
def render_token_login_page(template, org_slug, token, invite):
error_message = None
try:
user_id = validate_token(token)
org = current_org._get_current_object()
user = models.User.get_by_id_and_org(user_id, org)
except NoResultFound:
logger.exception(
"Bad user id in token. Token=%s , User id= %s, Org=%s",
token,
user_id,
org_slug,
)
error_message = "Your invite link is invalid. Bad user id in token. Please ask for a new one."
except SignatureExpired:
logger.exception("Token signature has expired. Token: %s, org=%s", token, org_slug)
error_message = "Your invite link has expired. Please ask for a new one."
except BadSignature:
logger.exception("Bad signature for the token: %s, org=%s", token, org_slug)
error_message = "Your invite link is invalid. Bad signature. Please double-check the token."
if error_message:
return (
render_template(
"error.html",
error_message=error_message,
),
400,
)
if invite and user.details.get("is_invitation_pending") is False:
return (
render_template(
"error.html",
error_message=(
"This invitation has already been accepted. Please try resetting your password instead."
),
),
400,
)
status_code = 200
if request.method == "POST":
if "password" not in request.form:
flash("Bad Request")
status_code = 400
elif not request.form["password"]:
flash("Cannot use empty password.")
status_code = 400
elif len(request.form["password"]) < 6:
flash("Password length is too short (<6).")
status_code = 400
else:
if invite or user.is_invitation_pending:
user.is_invitation_pending = False
user.hash_password(request.form["password"])
models.db.session.add(user)
login_user(user)
models.db.session.commit()
return redirect(url_for("redash.index", org_slug=org_slug))
google_auth_url = get_google_auth_url(url_for("redash.index", org_slug=org_slug))
return (
render_template(
template,
show_google_openid=settings.GOOGLE_OAUTH_ENABLED,
google_auth_url=google_auth_url,
show_saml_login=current_org.get_setting("auth_saml_enabled"),
show_remote_user_login=settings.REMOTE_USER_LOGIN_ENABLED,
show_ldap_login=settings.LDAP_LOGIN_ENABLED,
org_slug=org_slug,
user=user,
),
status_code,
)
@routes.route(org_scoped_rule("/invite/"), methods=["GET", "POST"])
def invite(token, org_slug=None):
return render_token_login_page("invite.html", org_slug, token, True)
@routes.route(org_scoped_rule("/reset/"), methods=["GET", "POST"])
def reset(token, org_slug=None):
return render_token_login_page("reset.html", org_slug, token, False)
@routes.route(org_scoped_rule("/verify/"), methods=["GET"])
def verify(token, org_slug=None):
try:
user_id = validate_token(token)
org = current_org._get_current_object()
user = models.User.get_by_id_and_org(user_id, org)
except (BadSignature, NoResultFound):
logger.exception("Failed to verify email verification token: %s, org=%s", token, org_slug)
return (
render_template(
"error.html",
error_message="Your verification link is invalid. Please ask for a new one.",
),
400,
)
user.is_email_verified = True
models.db.session.add(user)
models.db.session.commit()
template_context = {"org_slug": org_slug} if settings.MULTI_ORG else {}
next_url = url_for("redash.index", **template_context)
return render_template("verify.html", next_url=next_url)
@routes.route(org_scoped_rule("/forgot"), methods=["GET", "POST"])
@limiter.limit(settings.THROTTLE_PASS_RESET_PATTERN)
def forgot_password(org_slug=None):
if not current_org.get_setting("auth_password_login_enabled"):
abort(404)
submitted = False
if request.method == "POST" and request.form["email"]:
submitted = True
email = request.form["email"]
try:
org = current_org._get_current_object()
user = models.User.get_by_email_and_org(email, org)
if user.is_disabled:
send_user_disabled_email(user)
else:
send_password_reset_email(user)
except NoResultFound:
logging.error("No user found for forgot password: %s", email)
return render_template("forgot.html", submitted=submitted)
@routes.route(org_scoped_rule("/verification_email/"), methods=["POST"])
def verification_email(org_slug=None):
if not current_user.is_email_verified:
send_verify_email(current_user, current_org)
return json_response({"message": "Please check your email inbox in order to verify your email address."})
@routes.route(org_scoped_rule("/login"), methods=["GET", "POST"])
@limiter.limit(settings.THROTTLE_LOGIN_PATTERN)
def login(org_slug=None):
# We intentionally use == as otherwise it won't actually use the proxy. So weird :O
# noinspection PyComparisonWithNone
if current_org == None and not settings.MULTI_ORG: # noqa: E711
return redirect("/setup")
elif current_org == None: # noqa: E711
return redirect("/")
index_url = url_for("redash.index", org_slug=org_slug)
unsafe_next_path = request.args.get("next", index_url)
next_path = get_next_path(unsafe_next_path)
if current_user.is_authenticated:
return redirect(next_path)
if request.method == "POST" and current_org.get_setting("auth_password_login_enabled"):
try:
org = current_org._get_current_object()
user = models.User.get_by_email_and_org(request.form["email"], org)
if user and not user.is_disabled and user.verify_password(request.form["password"]):
remember = "remember" in request.form
login_user(user, remember=remember)
return redirect(next_path)
else:
flash("Wrong email or password.")
except NoResultFound:
flash("Wrong email or password.")
elif request.method == "POST" and not current_org.get_setting("auth_password_login_enabled"):
flash("Password login is not enabled for your organization.")
google_auth_url = get_google_auth_url(next_path)
return render_template(
"login.html",
org_slug=org_slug,
next=next_path,
email=request.form.get("email", ""),
show_google_openid=settings.GOOGLE_OAUTH_ENABLED,
google_auth_url=google_auth_url,
show_password_login=current_org.get_setting("auth_password_login_enabled"),
show_saml_login=current_org.get_setting("auth_saml_enabled"),
show_remote_user_login=settings.REMOTE_USER_LOGIN_ENABLED,
show_ldap_login=settings.LDAP_LOGIN_ENABLED,
)
@routes.route(org_scoped_rule("/logout"))
def logout(org_slug=None):
logout_user()
return redirect(get_login_url(next=None))
def base_href():
if settings.MULTI_ORG:
base_href = url_for("redash.index", _external=True, org_slug=current_org.slug)
else:
base_href = url_for("redash.index", _external=True)
return base_href
def date_time_format_config():
date_format = current_org.get_setting("date_format")
date_format_list = set(["DD/MM/YY", "MM/DD/YY", "YYYY-MM-DD", settings.DATE_FORMAT])
time_format = current_org.get_setting("time_format")
time_format_list = set(["HH:mm", "HH:mm:ss", "HH:mm:ss.SSS", settings.TIME_FORMAT])
return {
"dateFormat": date_format,
"dateFormatList": list(date_format_list),
"timeFormatList": list(time_format_list),
"dateTimeFormat": "{0} {1}".format(date_format, time_format),
}
def number_format_config():
return {
"integerFormat": current_org.get_setting("integer_format"),
"floatFormat": current_org.get_setting("float_format"),
}
def null_value_config():
return {
"nullValue": current_org.get_setting("null_value"),
}
def client_config():
if not current_user.is_api_user() and current_user.is_authenticated:
client_config = {
"newVersionAvailable": bool(get_latest_version()),
"version": __version__,
}
else:
client_config = {}
if current_user.has_permission("admin") and current_org.get_setting("beacon_consent") is None:
client_config["showBeaconConsentMessage"] = True
defaults = {
"allowScriptsInUserInput": settings.ALLOW_SCRIPTS_IN_USER_INPUT,
"showPermissionsControl": current_org.get_setting("feature_show_permissions_control"),
"hidePlotlyModeBar": current_org.get_setting("hide_plotly_mode_bar"),
"disablePublicUrls": current_org.get_setting("disable_public_urls"),
"multiByteSearchEnabled": current_org.get_setting("multi_byte_search_enabled"),
"allowCustomJSVisualizations": settings.FEATURE_ALLOW_CUSTOM_JS_VISUALIZATIONS,
"autoPublishNamedQueries": settings.FEATURE_AUTO_PUBLISH_NAMED_QUERIES,
"extendedAlertOptions": settings.FEATURE_EXTENDED_ALERT_OPTIONS,
"mailSettingsMissing": not settings.email_server_is_configured(),
"dashboardRefreshIntervals": settings.DASHBOARD_REFRESH_INTERVALS,
"queryRefreshIntervals": settings.QUERY_REFRESH_INTERVALS,
"googleLoginEnabled": settings.GOOGLE_OAUTH_ENABLED,
"ldapLoginEnabled": settings.LDAP_LOGIN_ENABLED,
"pageSize": settings.PAGE_SIZE,
"pageSizeOptions": settings.PAGE_SIZE_OPTIONS,
"tableCellMaxJSONSize": settings.TABLE_CELL_MAX_JSON_SIZE,
}
client_config.update(defaults)
client_config.update({"basePath": base_href()})
client_config.update(date_time_format_config())
client_config.update(number_format_config())
client_config.update(null_value_config())
return client_config
def messages():
messages = []
if not current_user.is_email_verified:
messages.append("email-not-verified")
if settings.ALLOW_PARAMETERS_IN_EMBEDS:
messages.append("using-deprecated-embed-feature")
return messages
@routes.route("/api/config", methods=["GET"])
def config(org_slug=None):
return json_response({"org_slug": current_org.slug, "client_config": client_config()})
@routes.route(org_scoped_rule("/api/session"), methods=["GET"])
@login_required
def session(org_slug=None):
if current_user.is_api_user():
user = {"permissions": [], "apiKey": current_user.id}
else:
user = {
"profile_image_url": current_user.profile_image_url,
"id": current_user.id,
"name": current_user.name,
"email": current_user.email,
"groups": current_user.group_ids,
"permissions": current_user.permissions,
}
return json_response(
{
"user": user,
"messages": messages(),
"org_slug": current_org.slug,
"client_config": client_config(),
}
)
================================================
FILE: redash/handlers/base.py
================================================
import time
from inspect import isclass
from flask import Blueprint, current_app, request
from flask_login import current_user, login_required
from flask_restful import Resource, abort
from sqlalchemy import cast
from sqlalchemy.dialects.postgresql import ARRAY
from sqlalchemy.orm.exc import NoResultFound
from redash import settings
from redash.authentication import current_org
from redash.models import db
from redash.tasks import record_event as record_event_task
from redash.utils import json_dumps
from redash.utils.query_order import sort_query
routes = Blueprint("redash", __name__, template_folder=settings.fix_assets_path("templates"))
class BaseResource(Resource):
decorators = [login_required]
def __init__(self, *args, **kwargs):
super(BaseResource, self).__init__(*args, **kwargs)
self._user = None
def dispatch_request(self, *args, **kwargs):
kwargs.pop("org_slug", None)
return super(BaseResource, self).dispatch_request(*args, **kwargs)
@property
def current_user(self):
return current_user._get_current_object()
@property
def current_org(self):
return current_org._get_current_object()
def record_event(self, options):
record_event(self.current_org, self.current_user, options)
# TODO: this should probably be somewhere else
def update_model(self, model, updates):
for k, v in updates.items():
setattr(model, k, v)
def record_event(org, user, options):
if user.is_api_user():
options.update({"api_key": user.name, "org_id": org.id})
else:
options.update({"user_id": user.id, "user_name": user.name, "org_id": org.id})
options.update({"user_agent": request.user_agent.string, "ip": request.remote_addr})
if "timestamp" not in options:
options["timestamp"] = int(time.time())
record_event_task.delay(options)
def require_fields(req, fields):
for f in fields:
if f not in req:
abort(400)
def get_object_or_404(fn, *args, **kwargs):
try:
rv = fn(*args, **kwargs)
if rv is None:
abort(404)
except NoResultFound:
abort(404)
return rv
def paginate(query_set, page, page_size, serializer, **kwargs):
count = query_set.count()
if page < 1:
abort(400, message="Page must be positive integer.")
if (page - 1) * page_size + 1 > count > 0:
abort(400, message="Page is out of range.")
if page_size > 250 or page_size < 1:
abort(400, message="Page size is out of range (1-250).")
results = query_set.paginate(page, page_size)
# support for old function based serializers
if isclass(serializer):
items = serializer(results.items, **kwargs).serialize()
else:
items = [serializer(result) for result in results.items]
return {"count": count, "page": page, "page_size": page_size, "results": items}
def org_scoped_rule(rule):
if settings.MULTI_ORG:
return "/{}".format(rule)
return rule
def json_response(response):
return current_app.response_class(json_dumps(response), mimetype="application/json")
def filter_by_tags(result_set, column):
if request.args.getlist("tags"):
tags = request.args.getlist("tags")
result_set = result_set.filter(cast(column, ARRAY(db.Text)).contains(tags))
return result_set
def order_results(results, default_order, allowed_orders, fallback=True):
"""
Orders the given results with the sort order as requested in the
"order" request query parameter or the given default order.
"""
# See if a particular order has been requested
requested_order = request.args.get("order", "").strip()
# and if not (and no fallback is wanted) return results as is
if not requested_order and not fallback:
return results
# and if it matches a long-form for related fields, falling
# back to the default order
selected_order = allowed_orders.get(requested_order, None)
if selected_order is None and fallback:
selected_order = default_order
# The query may already have an ORDER BY statement attached
# so we clear it here and apply the selected order
return sort_query(results.order_by(None), selected_order)
================================================
FILE: redash/handlers/dashboards.py
================================================
from flask import request, url_for
from flask_restful import abort
from funcy import partial, project
from sqlalchemy.orm.exc import StaleDataError
from redash import models
from redash.handlers.base import (
BaseResource,
filter_by_tags,
get_object_or_404,
paginate,
)
from redash.handlers.base import order_results as _order_results
from redash.permissions import (
can_modify,
require_admin_or_owner,
require_object_modify_permission,
require_permission,
)
from redash.security import csp_allows_embeding
from redash.serializers import DashboardSerializer, public_dashboard
# Ordering map for relationships
order_map = {
"name": "lowercase_name",
"-name": "-lowercase_name",
"created_at": "created_at",
"-created_at": "-created_at",
"starred_at": "favorites-created_at",
"-starred_at": "-favorites-created_at",
}
order_results = partial(_order_results, default_order="-created_at", allowed_orders=order_map)
class DashboardListResource(BaseResource):
@require_permission("list_dashboards")
def get(self):
"""
Lists all accessible dashboards.
:qparam number page_size: Number of queries to return per page
:qparam number page: Page number to retrieve
:qparam number order: Name of column to order by
:qparam number q: Full text search term
Responds with an array of :ref:`dashboard `
objects.
"""
search_term = request.args.get("q")
if search_term:
results = models.Dashboard.search(
self.current_org,
self.current_user.group_ids,
self.current_user.id,
search_term,
)
else:
results = models.Dashboard.all(self.current_org, self.current_user.group_ids, self.current_user.id)
results = filter_by_tags(results, models.Dashboard.tags)
# order results according to passed order parameter,
# special-casing search queries where the database
# provides an order by search rank
ordered_results = order_results(results, fallback=not bool(search_term))
page = request.args.get("page", 1, type=int)
page_size = request.args.get("page_size", 25, type=int)
response = paginate(
ordered_results,
page=page,
page_size=page_size,
serializer=DashboardSerializer,
)
if search_term:
self.record_event({"action": "search", "object_type": "dashboard", "term": search_term})
else:
self.record_event({"action": "list", "object_type": "dashboard"})
return response
@require_permission("create_dashboard")
def post(self):
"""
Creates a new dashboard.
:`.
"""
dashboard_properties = request.get_json(force=True)
dashboard = models.Dashboard(
name=dashboard_properties["name"],
org=self.current_org,
user=self.current_user,
is_draft=True,
layout=[],
)
models.db.session.add(dashboard)
models.db.session.commit()
return DashboardSerializer(dashboard).serialize()
class MyDashboardsResource(BaseResource):
@require_permission("list_dashboards")
def get(self):
"""
Retrieve a list of dashboards created by the current user.
:qparam number page_size: Number of dashboards to return per page
:qparam number page: Page number to retrieve
:qparam number order: Name of column to order by
:qparam number search: Full text search term
Responds with an array of :ref:`dashboard `
objects.
"""
search_term = request.args.get("q", "")
if search_term:
results = models.Dashboard.search_by_user(search_term, self.current_user)
else:
results = models.Dashboard.by_user(self.current_user)
results = filter_by_tags(results, models.Dashboard.tags)
# order results according to passed order parameter,
# special-casing search queries where the database
# provides an order by search rank
ordered_results = order_results(results, fallback=not bool(search_term))
page = request.args.get("page", 1, type=int)
page_size = request.args.get("page_size", 25, type=int)
return paginate(ordered_results, page, page_size, DashboardSerializer)
class DashboardResource(BaseResource):
@require_permission("list_dashboards")
def get(self, dashboard_id=None):
"""
Retrieves a dashboard.
:qparam number id: Id of dashboard to retrieve.
.. _dashboard-response-label:
:>json number id: Dashboard ID
:>json string name:
:>json string slug:
:>json number user_id: ID of the dashboard creator
:>json string created_at: ISO format timestamp for dashboard creation
:>json string updated_at: ISO format timestamp for last dashboard modification
:>json number version: Revision number of dashboard
:>json boolean dashboard_filters_enabled: Whether filters are enabled or not
:>json boolean is_archived: Whether this dashboard has been removed from the index or not
:>json boolean is_draft: Whether this dashboard is a draft or not.
:>json array layout: Array of arrays containing widget IDs, corresponding to the rows and columns the widgets are displayed in
:>json array widgets: Array of arrays containing :ref:`widget ` data
:>json object options: Dashboard options
.. _widget-response-label:
Widget structure:
:>json number widget.id: Widget ID
:>json number widget.width: Widget size
:>json object widget.options: Widget options
:>json number widget.dashboard_id: ID of dashboard containing this widget
:>json string widget.text: Widget contents, if this is a text-box widget
:>json object widget.visualization: Widget contents, if this is a visualization widget
:>json string widget.created_at: ISO format timestamp for widget creation
:>json string widget.updated_at: ISO format timestamp for last widget modification
"""
if request.args.get("legacy") is not None:
fn = models.Dashboard.get_by_slug_and_org
else:
fn = models.Dashboard.get_by_id_and_org
dashboard = get_object_or_404(fn, dashboard_id, self.current_org)
response = DashboardSerializer(dashboard, with_widgets=True, user=self.current_user).serialize()
api_key = models.ApiKey.get_by_object(dashboard)
if api_key:
response["public_url"] = url_for(
"redash.public_dashboard",
token=api_key.api_key,
org_slug=self.current_org.slug,
_external=True,
)
response["api_key"] = api_key.api_key
response["can_edit"] = can_modify(dashboard, self.current_user)
self.record_event({"action": "view", "object_id": dashboard.id, "object_type": "dashboard"})
return response
@require_permission("edit_dashboard")
def post(self, dashboard_id):
"""
Modifies a dashboard.
:qparam number id: Id of dashboard to retrieve.
Responds with the updated :ref:`dashboard `.
:status 200: success
:status 409: Version conflict -- dashboard modified since last read
"""
dashboard_properties = request.get_json(force=True)
# TODO: either convert all requests to use slugs or ids
dashboard = models.Dashboard.get_by_id_and_org(dashboard_id, self.current_org)
require_object_modify_permission(dashboard, self.current_user)
updates = project(
dashboard_properties,
(
"name",
"layout",
"version",
"tags",
"is_draft",
"is_archived",
"dashboard_filters_enabled",
"options",
),
)
# SQLAlchemy handles the case where a concurrent transaction beats us
# to the update. But we still have to make sure that we're not starting
# out behind.
if "version" in updates and updates["version"] != dashboard.version:
abort(409)
updates["changed_by"] = self.current_user
self.update_model(dashboard, updates)
models.db.session.add(dashboard)
try:
models.db.session.commit()
except StaleDataError:
abort(409)
result = DashboardSerializer(dashboard, with_widgets=True, user=self.current_user).serialize()
self.record_event({"action": "edit", "object_id": dashboard.id, "object_type": "dashboard"})
return result
@require_permission("edit_dashboard")
def delete(self, dashboard_id):
"""
Archives a dashboard.
:qparam number id: Id of dashboard to retrieve.
Responds with the archived :ref:`dashboard `.
"""
dashboard = models.Dashboard.get_by_id_and_org(dashboard_id, self.current_org)
dashboard.is_archived = True
dashboard.record_changes(changed_by=self.current_user)
models.db.session.add(dashboard)
d = DashboardSerializer(dashboard, with_widgets=True, user=self.current_user).serialize()
models.db.session.commit()
self.record_event({"action": "archive", "object_id": dashboard.id, "object_type": "dashboard"})
return d
class PublicDashboardResource(BaseResource):
decorators = BaseResource.decorators + [csp_allows_embeding]
def get(self, token):
"""
Retrieve a public dashboard.
:param token: An API key for a public dashboard.
:>json array widgets: An array of arrays of :ref:`public widgets `, corresponding to the rows and columns the widgets are displayed in
"""
if self.current_org.get_setting("disable_public_urls"):
abort(400, message="Public URLs are disabled.")
if not isinstance(self.current_user, models.ApiUser):
api_key = get_object_or_404(models.ApiKey.get_by_api_key, token)
dashboard = api_key.object
else:
dashboard = self.current_user.object
return public_dashboard(dashboard)
class DashboardShareResource(BaseResource):
def post(self, dashboard_id):
"""
Allow anonymous access to a dashboard.
:param dashboard_id: The numeric ID of the dashboard to share.
:>json string public_url: The URL for anonymous access to the dashboard.
:>json api_key: The API key to use when accessing it.
"""
dashboard = models.Dashboard.get_by_id_and_org(dashboard_id, self.current_org)
require_admin_or_owner(dashboard.user_id)
api_key = models.ApiKey.create_for_object(dashboard, self.current_user)
models.db.session.flush()
models.db.session.commit()
public_url = url_for(
"redash.public_dashboard",
token=api_key.api_key,
org_slug=self.current_org.slug,
_external=True,
)
self.record_event(
{
"action": "activate_api_key",
"object_id": dashboard.id,
"object_type": "dashboard",
}
)
return {"public_url": public_url, "api_key": api_key.api_key}
def delete(self, dashboard_id):
"""
Disable anonymous access to a dashboard.
:param dashboard_id: The numeric ID of the dashboard to unshare.
"""
dashboard = models.Dashboard.get_by_id_and_org(dashboard_id, self.current_org)
require_admin_or_owner(dashboard.user_id)
api_key = models.ApiKey.get_by_object(dashboard)
if api_key:
api_key.active = False
models.db.session.add(api_key)
models.db.session.commit()
self.record_event(
{
"action": "deactivate_api_key",
"object_id": dashboard.id,
"object_type": "dashboard",
}
)
class DashboardTagsResource(BaseResource):
@require_permission("list_dashboards")
def get(self):
"""
Lists all accessible dashboards.
"""
tags = models.Dashboard.all_tags(self.current_org, self.current_user)
return {"tags": [{"name": name, "count": count} for name, count in tags]}
class DashboardFavoriteListResource(BaseResource):
def get(self):
search_term = request.args.get("q")
if search_term:
base_query = models.Dashboard.search(
self.current_org,
self.current_user.group_ids,
self.current_user.id,
search_term,
)
favorites = models.Dashboard.favorites(self.current_user, base_query=base_query)
else:
favorites = models.Dashboard.favorites(self.current_user)
favorites = filter_by_tags(favorites, models.Dashboard.tags)
# order results according to passed order parameter,
# special-casing search queries where the database
# provides an order by search rank
favorites = order_results(favorites, fallback=not bool(search_term))
page = request.args.get("page", 1, type=int)
page_size = request.args.get("page_size", 25, type=int)
# TODO: we don't need to check for favorite status here
response = paginate(favorites, page, page_size, DashboardSerializer)
self.record_event(
{
"action": "load_favorites",
"object_type": "dashboard",
"params": {
"q": search_term,
"tags": request.args.getlist("tags"),
"page": page,
},
}
)
return response
class DashboardForkResource(BaseResource):
@require_permission("edit_dashboard")
def post(self, dashboard_id):
dashboard = models.Dashboard.get_by_id_and_org(dashboard_id, self.current_org)
fork_dashboard = dashboard.fork(self.current_user)
models.db.session.commit()
self.record_event({"action": "fork", "object_id": dashboard_id, "object_type": "dashboard"})
return DashboardSerializer(fork_dashboard, with_widgets=True).serialize()
================================================
FILE: redash/handlers/data_sources.py
================================================
import logging
import time
from flask import make_response, request
from flask_restful import abort
from funcy import project
from sqlalchemy.exc import IntegrityError
from redash import models
from redash.handlers.base import (
BaseResource,
get_object_or_404,
require_fields,
)
from redash.permissions import (
require_access,
require_admin,
require_permission,
view_only,
)
from redash.query_runner import (
get_configuration_schema_for_query_runner_type,
query_runners,
)
from redash.serializers import serialize_job
from redash.tasks.general import get_schema, test_connection
from redash.utils import filter_none
from redash.utils.configuration import ConfigurationContainer, ValidationError
class DataSourceTypeListResource(BaseResource):
@require_admin
def get(self):
return [q.to_dict() for q in sorted(query_runners.values(), key=lambda q: q.name().lower())]
class DataSourceResource(BaseResource):
def get(self, data_source_id):
data_source = get_object_or_404(models.DataSource.get_by_id_and_org, data_source_id, self.current_org)
require_access(data_source, self.current_user, view_only)
ds = {}
if self.current_user.has_permission("list_data_sources"):
# if it's a non-admin, limit the information
ds = data_source.to_dict(all=self.current_user.has_permission("admin"))
# add view_only info, required for frontend permissions
ds["view_only"] = all(project(data_source.groups, self.current_user.group_ids).values())
self.record_event({"action": "view", "object_id": data_source_id, "object_type": "datasource"})
return ds
@require_admin
def post(self, data_source_id):
data_source = models.DataSource.get_by_id_and_org(data_source_id, self.current_org)
req = request.get_json(True)
schema = get_configuration_schema_for_query_runner_type(req["type"])
if schema is None:
abort(400)
try:
data_source.options.set_schema(schema)
data_source.options.update(filter_none(req["options"]))
except ValidationError:
abort(400)
data_source.type = req["type"]
data_source.name = req["name"]
models.db.session.add(data_source)
try:
models.db.session.commit()
except IntegrityError as e:
if req["name"] in str(e):
abort(
400,
message="Data source with the name {} already exists.".format(req["name"]),
)
abort(400)
self.record_event({"action": "edit", "object_id": data_source.id, "object_type": "datasource"})
return data_source.to_dict(all=True)
@require_admin
def delete(self, data_source_id):
data_source = models.DataSource.get_by_id_and_org(data_source_id, self.current_org)
data_source.delete()
self.record_event(
{
"action": "delete",
"object_id": data_source_id,
"object_type": "datasource",
}
)
return make_response("", 204)
class DataSourceListResource(BaseResource):
@require_permission("list_data_sources")
def get(self):
if self.current_user.has_permission("admin"):
data_sources = models.DataSource.all(self.current_org)
else:
data_sources = models.DataSource.all(self.current_org, group_ids=self.current_user.group_ids)
response = {}
for ds in data_sources:
if ds.id in response:
continue
try:
d = ds.to_dict()
d["view_only"] = all(project(ds.groups, self.current_user.group_ids).values())
response[ds.id] = d
except AttributeError:
logging.exception("Error with DataSource#to_dict (data source id: %d)", ds.id)
self.record_event(
{
"action": "list",
"object_id": "admin/data_sources",
"object_type": "datasource",
}
)
return sorted(list(response.values()), key=lambda d: d["name"].lower())
@require_admin
def post(self):
req = request.get_json(True)
require_fields(req, ("options", "name", "type"))
schema = get_configuration_schema_for_query_runner_type(req["type"])
if schema is None:
abort(400)
config = ConfigurationContainer(filter_none(req["options"]), schema)
if not config.is_valid():
abort(400)
try:
datasource = models.DataSource.create_with_group(
org=self.current_org, name=req["name"], type=req["type"], options=config
)
models.db.session.commit()
except IntegrityError as e:
if req["name"] in str(e):
abort(
400,
message="Data source with the name {} already exists.".format(req["name"]),
)
abort(400)
self.record_event(
{
"action": "create",
"object_id": datasource.id,
"object_type": "datasource",
}
)
return datasource.to_dict(all=True)
class DataSourceSchemaResource(BaseResource):
def get(self, data_source_id):
data_source = get_object_or_404(models.DataSource.get_by_id_and_org, data_source_id, self.current_org)
require_access(data_source, self.current_user, view_only)
refresh = request.args.get("refresh") is not None
if not refresh:
cached_schema = data_source.get_cached_schema()
if cached_schema is not None:
return {"schema": cached_schema}
job = get_schema.delay(data_source.id, refresh)
return serialize_job(job)
class DataSourcePauseResource(BaseResource):
@require_admin
def post(self, data_source_id):
data_source = get_object_or_404(models.DataSource.get_by_id_and_org, data_source_id, self.current_org)
data = request.get_json(force=True, silent=True)
if data:
reason = data.get("reason")
else:
reason = request.args.get("reason")
data_source.pause(reason)
self.record_event(
{
"action": "pause",
"object_id": data_source.id,
"object_type": "datasource",
}
)
return data_source.to_dict()
@require_admin
def delete(self, data_source_id):
data_source = get_object_or_404(models.DataSource.get_by_id_and_org, data_source_id, self.current_org)
data_source.resume()
self.record_event(
{
"action": "resume",
"object_id": data_source.id,
"object_type": "datasource",
}
)
return data_source.to_dict()
class DataSourceTestResource(BaseResource):
@require_admin
def post(self, data_source_id):
data_source = get_object_or_404(models.DataSource.get_by_id_and_org, data_source_id, self.current_org)
response = {}
job = test_connection.delay(data_source.id)
while not (job.is_finished or job.is_failed):
time.sleep(1)
job.refresh()
if isinstance(job.result, Exception):
response = {"message": str(job.result), "ok": False}
else:
response = {"message": "success", "ok": True}
self.record_event(
{
"action": "test",
"object_id": data_source_id,
"object_type": "datasource",
"result": response,
}
)
return response
================================================
FILE: redash/handlers/databricks.py
================================================
from flask import request
from flask_restful import abort
from redash import models, redis_connection
from redash.handlers.base import BaseResource, get_object_or_404
from redash.permissions import require_access, view_only
from redash.serializers import serialize_job
from redash.tasks.databricks import (
get_database_tables_with_columns,
get_databricks_databases,
get_databricks_table_columns,
get_databricks_tables,
)
from redash.utils import json_loads
def _get_databricks_data_source(data_source_id, user, org):
data_source = get_object_or_404(models.DataSource.get_by_id_and_org, data_source_id, org)
require_access(data_source, user, view_only)
if not data_source.type == "databricks":
abort(400, message="Resource only available for the Databricks query runner.")
return data_source
def _databases_key(data_source_id):
return "databricks:databases:{}".format(data_source_id)
def _tables_key(data_source_id, database_name):
return "databricks:database_tables:{}:{}".format(data_source_id, database_name)
def _get_databases_from_cache(data_source_id):
cache = redis_connection.get(_databases_key(data_source_id))
return json_loads(cache) if cache else None
def _get_tables_from_cache(data_source_id, database_name):
cache = redis_connection.get(_tables_key(data_source_id, database_name))
return json_loads(cache) if cache else None
class DatabricksDatabaseListResource(BaseResource):
def get(self, data_source_id):
data_source = _get_databricks_data_source(data_source_id, user=self.current_user, org=self.current_org)
refresh = request.args.get("refresh") is not None
if not refresh:
cached_databases = _get_databases_from_cache(data_source_id)
if cached_databases is not None:
return cached_databases
job = get_databricks_databases.delay(data_source.id, redis_key=_databases_key(data_source_id))
return serialize_job(job)
class DatabricksSchemaResource(BaseResource):
def get(self, data_source_id, database_name):
data_source = _get_databricks_data_source(data_source_id, user=self.current_user, org=self.current_org)
refresh = request.args.get("refresh") is not None
if not refresh:
cached_tables = _get_tables_from_cache(data_source_id, database_name)
if cached_tables is not None:
return {"schema": cached_tables, "has_columns": True}
job = get_databricks_tables.delay(data_source.id, database_name)
return serialize_job(job)
job = get_database_tables_with_columns.delay(
data_source.id, database_name, redis_key=_tables_key(data_source_id, database_name)
)
return serialize_job(job)
class DatabricksTableColumnListResource(BaseResource):
def get(self, data_source_id, database_name, table_name):
data_source = _get_databricks_data_source(data_source_id, user=self.current_user, org=self.current_org)
job = get_databricks_table_columns.delay(data_source.id, database_name, table_name)
return serialize_job(job)
================================================
FILE: redash/handlers/destinations.py
================================================
from flask import make_response, request
from flask_restful import abort
from sqlalchemy.exc import IntegrityError
from redash import models
from redash.destinations import (
destinations,
get_configuration_schema_for_destination_type,
)
from redash.handlers.base import BaseResource, require_fields
from redash.permissions import require_admin
from redash.utils.configuration import ConfigurationContainer, ValidationError
class DestinationTypeListResource(BaseResource):
@require_admin
def get(self):
return [q.to_dict() for q in destinations.values()]
class DestinationResource(BaseResource):
@require_admin
def get(self, destination_id):
destination = models.NotificationDestination.get_by_id_and_org(destination_id, self.current_org)
d = destination.to_dict(all=True)
self.record_event(
{
"action": "view",
"object_id": destination_id,
"object_type": "destination",
}
)
return d
@require_admin
def post(self, destination_id):
destination = models.NotificationDestination.get_by_id_and_org(destination_id, self.current_org)
req = request.get_json(True)
schema = get_configuration_schema_for_destination_type(req["type"])
if schema is None:
abort(400)
try:
destination.type = req["type"]
destination.name = req["name"]
destination.options.set_schema(schema)
destination.options.update(req["options"])
models.db.session.add(destination)
models.db.session.commit()
except ValidationError:
abort(400)
except IntegrityError as e:
if "name" in str(e):
abort(
400,
message="Alert Destination with the name {} already exists.".format(req["name"]),
)
abort(500)
return destination.to_dict(all=True)
@require_admin
def delete(self, destination_id):
destination = models.NotificationDestination.get_by_id_and_org(destination_id, self.current_org)
models.db.session.delete(destination)
models.db.session.commit()
self.record_event(
{
"action": "delete",
"object_id": destination_id,
"object_type": "destination",
}
)
return make_response("", 204)
class DestinationListResource(BaseResource):
def get(self):
destinations = models.NotificationDestination.all(self.current_org)
response = {}
for ds in destinations:
if ds.id in response:
continue
d = ds.to_dict()
response[ds.id] = d
self.record_event(
{
"action": "list",
"object_id": "admin/destinations",
"object_type": "destination",
}
)
return list(response.values())
@require_admin
def post(self):
req = request.get_json(True)
require_fields(req, ("options", "name", "type"))
schema = get_configuration_schema_for_destination_type(req["type"])
if schema is None:
abort(400)
config = ConfigurationContainer(req["options"], schema)
if not config.is_valid():
abort(400)
destination = models.NotificationDestination(
org=self.current_org,
name=req["name"],
type=req["type"],
options=config,
user=self.current_user,
)
try:
models.db.session.add(destination)
models.db.session.commit()
except IntegrityError as e:
if "name" in str(e):
abort(
400,
message="Alert Destination with the name {} already exists.".format(req["name"]),
)
abort(500)
return destination.to_dict(all=True)
================================================
FILE: redash/handlers/embed.py
================================================
from flask import request
from flask_login import current_user, login_required
from redash import models
from redash.handlers import routes
from redash.handlers.base import (
get_object_or_404,
org_scoped_rule,
record_event,
)
from redash.handlers.static import render_index
from redash.security import csp_allows_embeding
from .authentication import current_org
@routes.route(
org_scoped_rule("/embed/query//visualization/"),
methods=["GET"],
)
@login_required
@csp_allows_embeding
def embed(query_id, visualization_id, org_slug=None):
record_event(
current_org,
current_user._get_current_object(),
{
"action": "view",
"object_id": visualization_id,
"object_type": "visualization",
"query_id": query_id,
"embed": True,
"referer": request.headers.get("Referer"),
},
)
return render_index()
@routes.route(org_scoped_rule("/public/dashboards/"), methods=["GET"])
@login_required
@csp_allows_embeding
def public_dashboard(token, org_slug=None):
if current_user.is_api_user():
dashboard = current_user.object
else:
api_key = get_object_or_404(models.ApiKey.get_by_api_key, token)
dashboard = api_key.object
record_event(
current_org,
current_user,
{
"action": "view",
"object_id": dashboard.id,
"object_type": "dashboard",
"public": True,
"headless": "embed" in request.args,
"referer": request.headers.get("Referer"),
},
)
return render_index()
================================================
FILE: redash/handlers/events.py
================================================
import geolite2
import maxminddb
from flask import request
from user_agents import parse as parse_ua
from redash.handlers.base import BaseResource, paginate
from redash.permissions import require_admin
def get_location(ip):
if ip is None:
return "Unknown"
with maxminddb.open_database(geolite2.geolite2_database()) as reader:
try:
match = reader.get(ip)
return match["country"]["names"]["en"]
except Exception:
return "Unknown"
def event_details(event):
details = {}
if event.object_type == "data_source" and event.action == "execute_query":
details["query"] = event.additional_properties["query"]
details["data_source"] = event.object_id
elif event.object_type == "page" and event.action == "view":
details["page"] = event.object_id
else:
details["object_id"] = event.object_id
details["object_type"] = event.object_type
return details
def serialize_event(event):
d = {
"org_id": event.org_id,
"user_id": event.user_id,
"action": event.action,
"object_type": event.object_type,
"object_id": event.object_id,
"created_at": event.created_at,
}
if event.user_id:
d["user_name"] = event.additional_properties.get("user_name", "User {}".format(event.user_id))
if not event.user_id:
d["user_name"] = event.additional_properties.get("api_key", "Unknown")
d["browser"] = str(parse_ua(event.additional_properties.get("user_agent", "")))
d["location"] = get_location(event.additional_properties.get("ip"))
d["details"] = event_details(event)
return d
class EventsResource(BaseResource):
def post(self):
events_list = request.get_json(force=True)
for event in events_list:
self.record_event(event)
@require_admin
def get(self):
page = request.args.get("page", 1, type=int)
page_size = request.args.get("page_size", 25, type=int)
return paginate(self.current_org.events, page, page_size, serialize_event)
================================================
FILE: redash/handlers/favorites.py
================================================
from sqlalchemy.exc import IntegrityError
from redash import models
from redash.handlers.base import BaseResource, get_object_or_404
from redash.permissions import require_access, view_only
class QueryFavoriteResource(BaseResource):
def post(self, query_id):
query = get_object_or_404(models.Query.get_by_id_and_org, query_id, self.current_org)
require_access(query, self.current_user, view_only)
fav = models.Favorite(org_id=self.current_org.id, object=query, user=self.current_user)
models.db.session.add(fav)
try:
models.db.session.commit()
except IntegrityError as e:
if "unique_favorite" in str(e):
models.db.session.rollback()
else:
raise e
self.record_event({"action": "favorite", "object_id": query.id, "object_type": "query"})
def delete(self, query_id):
query = get_object_or_404(models.Query.get_by_id_and_org, query_id, self.current_org)
require_access(query, self.current_user, view_only)
models.Favorite.query.filter(
models.Favorite.object_id == query_id,
models.Favorite.object_type == "Query",
models.Favorite.user == self.current_user,
).delete()
models.db.session.commit()
self.record_event({"action": "favorite", "object_id": query.id, "object_type": "query"})
class DashboardFavoriteResource(BaseResource):
def post(self, object_id):
dashboard = get_object_or_404(models.Dashboard.get_by_id_and_org, object_id, self.current_org)
fav = models.Favorite(org_id=self.current_org.id, object=dashboard, user=self.current_user)
models.db.session.add(fav)
try:
models.db.session.commit()
except IntegrityError as e:
if "unique_favorite" in str(e):
models.db.session.rollback()
else:
raise e
self.record_event(
{
"action": "favorite",
"object_id": dashboard.id,
"object_type": "dashboard",
}
)
def delete(self, object_id):
dashboard = get_object_or_404(models.Dashboard.get_by_id_and_org, object_id, self.current_org)
models.Favorite.query.filter(
models.Favorite.object == dashboard,
models.Favorite.user == self.current_user,
).delete()
models.db.session.commit()
self.record_event(
{
"action": "unfavorite",
"object_id": dashboard.id,
"object_type": "dashboard",
}
)
================================================
FILE: redash/handlers/groups.py
================================================
from flask import request
from flask_restful import abort
from redash import models
from redash.handlers.base import BaseResource, get_object_or_404
from redash.permissions import require_admin, require_permission
class GroupListResource(BaseResource):
@require_admin
def post(self):
name = request.json["name"]
group = models.Group(name=name, org=self.current_org)
models.db.session.add(group)
models.db.session.commit()
self.record_event({"action": "create", "object_id": group.id, "object_type": "group"})
return group.to_dict()
def get(self):
if self.current_user.has_permission("admin"):
groups = models.Group.all(self.current_org)
else:
groups = models.Group.query.filter(models.Group.id.in_(self.current_user.group_ids))
self.record_event({"action": "list", "object_id": "groups", "object_type": "group"})
return [g.to_dict() for g in groups]
class GroupResource(BaseResource):
@require_admin
def post(self, group_id):
group = models.Group.get_by_id_and_org(group_id, self.current_org)
if group.type == models.Group.BUILTIN_GROUP:
abort(400, message="Can't modify built-in groups.")
group.name = request.json["name"]
models.db.session.commit()
self.record_event({"action": "edit", "object_id": group.id, "object_type": "group"})
return group.to_dict()
def get(self, group_id):
if not (self.current_user.has_permission("admin") or int(group_id) in self.current_user.group_ids):
abort(403)
group = models.Group.get_by_id_and_org(group_id, self.current_org)
self.record_event({"action": "view", "object_id": group_id, "object_type": "group"})
return group.to_dict()
@require_admin
def delete(self, group_id):
group = models.Group.get_by_id_and_org(group_id, self.current_org)
if group.type == models.Group.BUILTIN_GROUP:
abort(400, message="Can't delete built-in groups.")
members = models.Group.members(group_id)
for member in members:
member.group_ids.remove(int(group_id))
models.db.session.add(member)
models.db.session.delete(group)
models.db.session.commit()
class GroupMemberListResource(BaseResource):
@require_admin
def post(self, group_id):
user_id = request.json["user_id"]
user = models.User.get_by_id_and_org(user_id, self.current_org)
group = models.Group.get_by_id_and_org(group_id, self.current_org)
user.group_ids.append(group.id)
models.db.session.commit()
self.record_event(
{
"action": "add_member",
"object_id": group.id,
"object_type": "group",
"member_id": user.id,
}
)
return user.to_dict()
@require_permission("list_users")
def get(self, group_id):
if not (self.current_user.has_permission("admin") or int(group_id) in self.current_user.group_ids):
abort(403)
members = models.Group.members(group_id)
return [m.to_dict() for m in members]
class GroupMemberResource(BaseResource):
@require_admin
def delete(self, group_id, user_id):
user = models.User.get_by_id_and_org(user_id, self.current_org)
user.group_ids.remove(int(group_id))
models.db.session.commit()
self.record_event(
{
"action": "remove_member",
"object_id": group_id,
"object_type": "group",
"member_id": user.id,
}
)
def serialize_data_source_with_group(data_source, data_source_group):
d = data_source.to_dict()
d["view_only"] = data_source_group.view_only
return d
class GroupDataSourceListResource(BaseResource):
@require_admin
def post(self, group_id):
data_source_id = request.json["data_source_id"]
data_source = models.DataSource.get_by_id_and_org(data_source_id, self.current_org)
group = models.Group.get_by_id_and_org(group_id, self.current_org)
data_source_group = data_source.add_group(group)
models.db.session.commit()
self.record_event(
{
"action": "add_data_source",
"object_id": group_id,
"object_type": "group",
"member_id": data_source.id,
}
)
return serialize_data_source_with_group(data_source, data_source_group)
@require_admin
def get(self, group_id):
group = get_object_or_404(models.Group.get_by_id_and_org, group_id, self.current_org)
# TOOD: move to models
data_sources = models.DataSource.query.join(models.DataSourceGroup).filter(
models.DataSourceGroup.group == group
)
self.record_event({"action": "list", "object_id": group_id, "object_type": "group"})
return [ds.to_dict(with_permissions_for=group) for ds in data_sources]
class GroupDataSourceResource(BaseResource):
@require_admin
def post(self, group_id, data_source_id):
data_source = models.DataSource.get_by_id_and_org(data_source_id, self.current_org)
group = models.Group.get_by_id_and_org(group_id, self.current_org)
view_only = request.json["view_only"]
data_source_group = data_source.update_group_permission(group, view_only)
models.db.session.commit()
self.record_event(
{
"action": "change_data_source_permission",
"object_id": group_id,
"object_type": "group",
"member_id": data_source.id,
"view_only": view_only,
}
)
return serialize_data_source_with_group(data_source, data_source_group)
@require_admin
def delete(self, group_id, data_source_id):
data_source = models.DataSource.get_by_id_and_org(data_source_id, self.current_org)
group = models.Group.get_by_id_and_org(group_id, self.current_org)
data_source.remove_group(group)
models.db.session.commit()
self.record_event(
{
"action": "remove_data_source",
"object_id": group_id,
"object_type": "group",
"member_id": data_source.id,
}
)
================================================
FILE: redash/handlers/organization.py
================================================
from flask_login import current_user, login_required
from redash import models
from redash.authentication import current_org
from redash.handlers import routes
from redash.handlers.base import json_response, org_scoped_rule
@routes.route(org_scoped_rule("/api/organization/status"), methods=["GET"])
@login_required
def organization_status(org_slug=None):
counters = {
"users": models.User.all(current_org).count(),
"alerts": models.Alert.all(group_ids=current_user.group_ids).count(),
"data_sources": models.DataSource.all(current_org, group_ids=current_user.group_ids).count(),
"queries": models.Query.all_queries(current_user.group_ids, current_user.id, include_drafts=True).count(),
"dashboards": models.Dashboard.query.filter(
models.Dashboard.org == current_org, models.Dashboard.is_archived.is_(False)
).count(),
}
return json_response(dict(object_counters=counters))
================================================
FILE: redash/handlers/permissions.py
================================================
from collections import defaultdict
from flask import request
from flask_restful import abort
from sqlalchemy.orm.exc import NoResultFound
from redash.handlers.base import BaseResource, get_object_or_404
from redash.models import AccessPermission, Dashboard, Query, User, db
from redash.permissions import ACCESS_TYPES, require_admin_or_owner
model_to_types = {"queries": Query, "dashboards": Dashboard}
def get_model_from_type(type):
model = model_to_types.get(type)
if model is None:
abort(404)
return model
class ObjectPermissionsListResource(BaseResource):
def get(self, object_type, object_id):
model = get_model_from_type(object_type)
obj = get_object_or_404(model.get_by_id_and_org, object_id, self.current_org)
# TODO: include grantees in search to avoid N+1 queries
permissions = AccessPermission.find(obj)
result = defaultdict(list)
for perm in permissions:
result[perm.access_type].append(perm.grantee.to_dict())
return result
def post(self, object_type, object_id):
model = get_model_from_type(object_type)
obj = get_object_or_404(model.get_by_id_and_org, object_id, self.current_org)
require_admin_or_owner(obj.user_id)
req = request.get_json(True)
access_type = req["access_type"]
if access_type not in ACCESS_TYPES:
abort(400, message="Unknown access type.")
try:
grantee = User.get_by_id_and_org(req["user_id"], self.current_org)
except NoResultFound:
abort(400, message="User not found.")
permission = AccessPermission.grant(obj, access_type, grantee, self.current_user)
db.session.commit()
self.record_event(
{
"action": "grant_permission",
"object_id": object_id,
"object_type": object_type,
"grantee": grantee.id,
"access_type": access_type,
}
)
return permission.to_dict()
def delete(self, object_type, object_id):
model = get_model_from_type(object_type)
obj = get_object_or_404(model.get_by_id_and_org, object_id, self.current_org)
require_admin_or_owner(obj.user_id)
req = request.get_json(True)
grantee_id = req["user_id"]
access_type = req["access_type"]
grantee = User.query.get(req["user_id"])
if grantee is None:
abort(400, message="User not found.")
AccessPermission.revoke(obj, grantee, access_type)
db.session.commit()
self.record_event(
{
"action": "revoke_permission",
"object_id": object_id,
"object_type": object_type,
"access_type": access_type,
"grantee_id": grantee_id,
}
)
class CheckPermissionResource(BaseResource):
def get(self, object_type, object_id, access_type):
model = get_model_from_type(object_type)
obj = get_object_or_404(model.get_by_id_and_org, object_id, self.current_org)
has_access = AccessPermission.exists(obj, access_type, self.current_user)
return {"response": has_access}
================================================
FILE: redash/handlers/queries.py
================================================
import sqlparse
from flask import jsonify, request, url_for
from flask_login import login_required
from flask_restful import abort
from funcy import partial
from sqlalchemy.orm.exc import StaleDataError
from redash import models, settings
from redash.authentication.org_resolving import current_org
from redash.handlers.base import (
BaseResource,
filter_by_tags,
get_object_or_404,
org_scoped_rule,
paginate,
routes,
)
from redash.handlers.base import order_results as _order_results
from redash.handlers.query_results import run_query
from redash.models.parameterized_query import ParameterizedQuery
from redash.permissions import (
can_modify,
not_view_only,
require_access,
require_admin_or_owner,
require_object_modify_permission,
require_permission,
view_only,
)
from redash.serializers import QuerySerializer
from redash.utils import collect_parameters_from_request
# Ordering map for relationships
order_map = {
"name": "lowercase_name",
"-name": "-lowercase_name",
"created_at": "created_at",
"-created_at": "-created_at",
"schedule": "interval",
"-schedule": "-interval",
"runtime": "query_results-runtime",
"-runtime": "-query_results-runtime",
"executed_at": "query_results-retrieved_at",
"-executed_at": "-query_results-retrieved_at",
"created_by": "users-name",
"-created_by": "-users-name",
"starred_at": "favorites-created_at",
"-starred_at": "-favorites-created_at",
}
order_results = partial(_order_results, default_order="-created_at", allowed_orders=order_map)
@routes.route(org_scoped_rule("/api/queries/format"), methods=["POST"])
@login_required
def format_sql_query(org_slug=None):
"""
Formats an SQL query using the Python ``sqlparse`` formatter.
:json string query: Formatted SQL text
"""
arguments = request.get_json(force=True)
query = arguments.get("query", "")
return jsonify({"query": sqlparse.format(query, **settings.SQLPARSE_FORMAT_OPTIONS)})
class QuerySearchResource(BaseResource):
@require_permission("view_query")
def get(self):
"""
Search query text, names, and descriptions.
:qparam string q: Search term
:qparam number include_drafts: Whether to include draft in results
Responds with a list of :ref:`query ` objects.
"""
term = request.args.get("q", "")
if not term:
return []
include_drafts = request.args.get("include_drafts") is not None
self.record_event({"action": "search", "object_type": "query", "term": term})
# this redirects to the new query list API that is aware of search
new_location = url_for(
"queries",
q=term,
org_slug=current_org.slug,
drafts="true" if include_drafts else "false",
)
return {}, 301, {"Location": new_location}
class QueryRecentResource(BaseResource):
@require_permission("view_query")
def get(self):
"""
Retrieve up to 10 queries recently modified by the user.
Responds with a list of :ref:`query ` objects.
"""
results = models.Query.by_user(self.current_user).order_by(models.Query.updated_at.desc()).limit(10)
return QuerySerializer(results, with_last_modified_by=False, with_user=False).serialize()
class BaseQueryListResource(BaseResource):
def get_queries(self, search_term):
if search_term:
results = models.Query.search(
search_term,
self.current_user.group_ids,
self.current_user.id,
include_drafts=True,
multi_byte_search=current_org.get_setting("multi_byte_search_enabled"),
)
else:
results = models.Query.all_queries(self.current_user.group_ids, self.current_user.id, include_drafts=True)
return filter_by_tags(results, models.Query.tags)
@require_permission("view_query")
def get(self):
"""
Retrieve a list of queries.
:qparam number page_size: Number of queries to return per page
:qparam number page: Page number to retrieve
:qparam number order: Name of column to order by
:qparam number q: Full text search term
Responds with an array of :ref:`query ` objects.
"""
# See if we want to do full-text search or just regular queries
search_term = request.args.get("q", "")
queries = self.get_queries(search_term)
results = filter_by_tags(queries, models.Query.tags)
# order results according to passed order parameter,
# special-casing search queries where the database
# provides an order by search rank
ordered_results = order_results(results, fallback=not bool(search_term))
page = request.args.get("page", 1, type=int)
page_size = request.args.get("page_size", 25, type=int)
response = paginate(
ordered_results,
page=page,
page_size=page_size,
serializer=QuerySerializer,
with_stats=True,
with_last_modified_by=False,
)
if search_term:
self.record_event({"action": "search", "object_type": "query", "term": search_term})
else:
self.record_event({"action": "list", "object_type": "query"})
return response
def require_access_to_dropdown_queries(user, query_def):
parameters = query_def.get("options", {}).get("parameters", [])
dropdown_query_ids = set([str(p["queryId"]) for p in parameters if p["type"] == "query"])
if dropdown_query_ids:
groups = models.Query.all_groups_for_query_ids(dropdown_query_ids)
if len(groups) < len(dropdown_query_ids):
abort(
400,
message="You are trying to associate a dropdown query that does not have a matching group. "
"Please verify the dropdown query id you are trying to associate with this query.",
)
require_access(dict(groups), user, view_only)
class QueryListResource(BaseQueryListResource):
@require_permission("create_query")
def post(self):
"""
Create a new query.
:json number id: Query ID
:>json number latest_query_data_id: ID for latest output data from this query
:>json string name:
:>json string description:
:>json string query: Query text
:>json string query_hash: Hash of query text
:>json string schedule: Schedule interval, in seconds, for repeated execution of this query
:>json string api_key: Key for public access to this query's results.
:>json boolean is_archived: Whether this query is displayed in indexes and search results or not.
:>json boolean is_draft: Whether this query is a draft or not
:>json string updated_at: Time of last modification, in ISO format
:>json string created_at: Time of creation, in ISO format
:>json number data_source_id: ID of the data source this query will run on
:>json object options: Query options
:>json number version: Revision version (for update conflict avoidance)
:>json number user_id: ID of query creator
:>json number last_modified_by_id: ID of user who last modified this query
:>json string retrieved_at: Time when query results were last retrieved, in ISO format (may be null)
:>json number runtime: Runtime of last query execution, in seconds (may be null)
"""
query_def = request.get_json(force=True)
data_source = models.DataSource.get_by_id_and_org(query_def.pop("data_source_id"), self.current_org)
require_access(data_source, self.current_user, not_view_only)
require_access_to_dropdown_queries(self.current_user, query_def)
for field in [
"id",
"created_at",
"api_key",
"visualizations",
"latest_query_data",
"last_modified_by",
]:
query_def.pop(field, None)
query_def["query_text"] = query_def.pop("query")
query_def["user"] = self.current_user
query_def["data_source"] = data_source
query_def["org"] = self.current_org
query_def["is_draft"] = True
query = models.Query.create(**query_def)
models.db.session.add(query)
models.db.session.commit()
query.update_latest_result_by_query_hash()
models.db.session.commit()
self.record_event({"action": "create", "object_id": query.id, "object_type": "query"})
return QuerySerializer(query, with_visualizations=True).serialize()
class QueryArchiveResource(BaseQueryListResource):
def get_queries(self, search_term):
if search_term:
return models.Query.search(
search_term,
self.current_user.group_ids,
self.current_user.id,
include_drafts=False,
include_archived=True,
multi_byte_search=current_org.get_setting("multi_byte_search_enabled"),
)
else:
return models.Query.all_queries(
self.current_user.group_ids,
self.current_user.id,
include_drafts=False,
include_archived=True,
)
class MyQueriesResource(BaseResource):
@require_permission("view_query")
def get(self):
"""
Retrieve a list of queries created by the current user.
:qparam number page_size: Number of queries to return per page
:qparam number page: Page number to retrieve
:qparam number order: Name of column to order by
:qparam number search: Full text search term
Responds with an array of :ref:`query ` objects.
"""
search_term = request.args.get("q", "")
if search_term:
results = models.Query.search_by_user(
search_term,
self.current_user,
multi_byte_search=current_org.get_setting("multi_byte_search_enabled"),
)
else:
results = models.Query.by_user(self.current_user)
results = filter_by_tags(results, models.Query.tags)
# order results according to passed order parameter,
# special-casing search queries where the database
# provides an order by search rank
ordered_results = order_results(results, fallback=not bool(search_term))
page = request.args.get("page", 1, type=int)
page_size = request.args.get("page_size", 25, type=int)
return paginate(
ordered_results,
page,
page_size,
QuerySerializer,
with_stats=True,
with_last_modified_by=False,
)
class QueryResource(BaseResource):
@require_permission("edit_query")
def post(self, query_id):
"""
Modify a query.
:param query_id: ID of query to update
:` object.
"""
query = get_object_or_404(models.Query.get_by_id_and_org, query_id, self.current_org)
query_def = request.get_json(force=True)
require_object_modify_permission(query, self.current_user)
require_access_to_dropdown_queries(self.current_user, query_def)
for field in [
"id",
"created_at",
"api_key",
"visualizations",
"latest_query_data",
"user",
"last_modified_by",
"org",
]:
query_def.pop(field, None)
if "query" in query_def:
query_def["query_text"] = query_def.pop("query")
if "tags" in query_def:
query_def["tags"] = [tag for tag in query_def["tags"] if tag]
if "data_source_id" in query_def:
data_source = models.DataSource.get_by_id_and_org(query_def["data_source_id"], self.current_org)
require_access(data_source, self.current_user, not_view_only)
query_def["last_modified_by"] = self.current_user
query_def["changed_by"] = self.current_user
# SQLAlchemy handles the case where a concurrent transaction beats us
# to the update. But we still have to make sure that we're not starting
# out behind.
if "version" in query_def and query_def["version"] != query.version:
abort(409)
try:
self.update_model(query, query_def)
models.db.session.commit()
query.update_latest_result_by_query_hash()
models.db.session.commit()
except StaleDataError:
abort(409)
return QuerySerializer(query, with_visualizations=True).serialize()
@require_permission("view_query")
def get(self, query_id):
"""
Retrieve a query.
:param query_id: ID of query to fetch
Responds with the :ref:`query ` contents.
"""
q = get_object_or_404(models.Query.get_by_id_and_org, query_id, self.current_org)
require_access(q, self.current_user, view_only)
result = QuerySerializer(q, with_visualizations=True).serialize()
result["can_edit"] = can_modify(q, self.current_user)
self.record_event({"action": "view", "object_id": query_id, "object_type": "query"})
return result
# TODO: move to resource of its own? (POST /queries/{id}/archive)
def delete(self, query_id):
"""
Archives a query.
:param query_id: ID of query to archive
"""
query = get_object_or_404(models.Query.get_by_id_and_org, query_id, self.current_org)
require_admin_or_owner(query.user_id)
query.archive(self.current_user)
models.db.session.commit()
class QueryRegenerateApiKeyResource(BaseResource):
@require_permission("edit_query")
def post(self, query_id):
query = get_object_or_404(models.Query.get_by_id_and_org, query_id, self.current_org)
require_admin_or_owner(query.user_id)
query.regenerate_api_key()
models.db.session.commit()
self.record_event(
{
"action": "regnerate_api_key",
"object_id": query_id,
"object_type": "query",
}
)
result = QuerySerializer(query).serialize()
return result
class QueryForkResource(BaseResource):
@require_permission("edit_query")
def post(self, query_id):
"""
Creates a new query, copying the query text from an existing one.
:param query_id: ID of query to fork
Responds with created :ref:`query ` object.
"""
query = get_object_or_404(models.Query.get_by_id_and_org, query_id, self.current_org)
require_access(query.data_source, self.current_user, not_view_only)
forked_query = query.fork(self.current_user)
models.db.session.commit()
self.record_event({"action": "fork", "object_id": query_id, "object_type": "query"})
return QuerySerializer(forked_query, with_visualizations=True).serialize()
class QueryRefreshResource(BaseResource):
def post(self, query_id):
"""
Execute a query, updating the query object with the results.
:param query_id: ID of query to execute
Responds with query task details.
"""
# TODO: this should actually check for permissions, but because currently you can only
# get here either with a user API key or a query one, we can just check whether it's
# an api key (meaning this is a query API key, which only grants read access).
if self.current_user.is_api_user():
abort(403, message="Please use a user API key.")
query = get_object_or_404(models.Query.get_by_id_and_org, query_id, self.current_org)
require_access(query, self.current_user, not_view_only)
parameter_values = collect_parameters_from_request(request.args)
parameterized_query = ParameterizedQuery(query.query_text, org=self.current_org)
should_apply_auto_limit = query.options.get("apply_auto_limit", False)
return run_query(parameterized_query, parameter_values, query.data_source, query.id, should_apply_auto_limit)
class QueryTagsResource(BaseResource):
def get(self):
"""
Returns all query tags including those for drafts.
"""
tags = models.Query.all_tags(self.current_user, include_drafts=True)
return {"tags": [{"name": name, "count": count} for name, count in tags]}
class QueryFavoriteListResource(BaseResource):
def get(self):
search_term = request.args.get("q")
if search_term:
base_query = models.Query.search(
search_term,
self.current_user.group_ids,
include_drafts=True,
limit=None,
multi_byte_search=current_org.get_setting("multi_byte_search_enabled"),
)
favorites = models.Query.favorites(self.current_user, base_query=base_query)
else:
favorites = models.Query.favorites(self.current_user)
favorites = filter_by_tags(favorites, models.Query.tags)
# order results according to passed order parameter,
# special-casing search queries where the database
# provides an order by search rank
ordered_favorites = order_results(favorites, fallback=not bool(search_term))
page = request.args.get("page", 1, type=int)
page_size = request.args.get("page_size", 25, type=int)
response = paginate(
ordered_favorites,
page,
page_size,
QuerySerializer,
with_stats=True,
with_last_modified_by=False,
)
self.record_event(
{
"action": "load_favorites",
"object_type": "query",
"params": {
"q": search_term,
"tags": request.args.getlist("tags"),
"page": page,
},
}
)
return response
================================================
FILE: redash/handlers/query_results.py
================================================
import unicodedata
from urllib.parse import quote
import regex
from flask import make_response, request
from flask_login import current_user
from flask_restful import abort
from redash import models, settings
from redash.handlers.base import BaseResource, get_object_or_404, record_event
from redash.models.parameterized_query import (
InvalidParameterError,
ParameterizedQuery,
QueryDetachedFromDataSourceError,
dropdown_values,
)
from redash.permissions import (
has_access,
not_view_only,
require_access,
require_any_of_permission,
require_permission,
view_only,
)
from redash.serializers import (
serialize_job,
serialize_query_result,
serialize_query_result_to_dsv,
serialize_query_result_to_xlsx,
)
from redash.tasks import Job
from redash.tasks.queries import enqueue_query
from redash.utils import (
collect_parameters_from_request,
json_dumps,
to_filename,
)
def error_response(message, http_status=400):
return {"job": {"status": 4, "error": message}}, http_status
error_messages = {
"unsafe_when_shared": error_response(
"This query contains potentially unsafe parameters and cannot be executed on a shared dashboard or an embedded visualization.",
403,
),
"unsafe_on_view_only": error_response(
"This query contains potentially unsafe parameters and cannot be executed with read-only access to this data source.",
403,
),
"no_permission": error_response("You do not have permission to run queries with this data source.", 403),
"select_data_source": error_response("Please select data source to run this query.", 401),
"no_data_source": error_response("Target data source not available.", 401),
}
def run_query(query, parameters, data_source, query_id, should_apply_auto_limit, max_age=0):
if not data_source:
return error_messages["no_data_source"]
if data_source.paused:
if data_source.pause_reason:
message = "{} is paused ({}). Please try later.".format(data_source.name, data_source.pause_reason)
else:
message = "{} is paused. Please try later.".format(data_source.name)
return error_response(message)
try:
query.apply(parameters)
except (InvalidParameterError, QueryDetachedFromDataSourceError) as e:
abort(400, message=str(e))
query_text = data_source.query_runner.apply_auto_limit(query.text, should_apply_auto_limit)
if query.missing_params:
return error_response("Missing parameter value for: {}".format(", ".join(query.missing_params)))
if max_age == 0:
query_result = None
else:
query_result = models.QueryResult.get_latest(data_source, query_text, max_age)
record_event(
current_user.org,
current_user,
{
"action": "execute_query",
"cache": "hit" if query_result else "miss",
"object_id": data_source.id,
"object_type": "data_source",
"query": query_text,
"query_id": query_id,
"parameters": parameters,
},
)
if query_result:
return {"query_result": serialize_query_result(query_result, current_user.is_api_user())}
else:
job = enqueue_query(
query_text,
data_source,
current_user.id,
current_user.is_api_user(),
metadata={
"Username": current_user.get_actual_user(),
"query_id": query_id,
},
)
return serialize_job(job)
def get_download_filename(query_result, query, filetype):
retrieved_at = query_result.retrieved_at.strftime("%Y_%m_%d")
if query:
query_name = regex.sub(r"\p{C}", "", query.name)
filename = to_filename(query_name) if query_name != "" else str(query.id)
else:
filename = str(query_result.id)
return "{}_{}.{}".format(filename, retrieved_at, filetype)
def content_disposition_filenames(attachment_filename):
if not isinstance(attachment_filename, str):
attachment_filename = attachment_filename.decode("utf-8")
try:
attachment_filename = attachment_filename.encode("ascii")
except UnicodeEncodeError:
filenames = {
"filename": unicodedata.normalize("NFKD", attachment_filename).encode("ascii", "ignore"),
"filename*": "UTF-8''%s" % quote(attachment_filename, safe=b""),
}
else:
filenames = {"filename": attachment_filename}
return filenames
class QueryResultListResource(BaseResource):
@require_permission("execute_query")
def post(self):
"""
Execute a query (or retrieve recent results).
:qparam string query: The query text to execute
:qparam number query_id: The query object to update with the result (optional)
:qparam number max_age: If query results less than `max_age` seconds old are available,
return them, otherwise execute the query; if omitted or -1, returns
any cached result, or executes if not available. Set to zero to
always execute.
:qparam number data_source_id: ID of data source to query
:qparam object parameters: A set of parameter values to apply to the query.
"""
params = request.get_json(force=True)
query = params["query"]
max_age = params.get("max_age", -1)
# max_age might have the value of None, in which case calling int(None) will fail
if max_age is None:
max_age = -1
max_age = int(max_age)
query_id = params.get("query_id", "adhoc")
parameters = params.get("parameters", collect_parameters_from_request(request.args))
parameterized_query = ParameterizedQuery(query, org=self.current_org)
should_apply_auto_limit = params.get("apply_auto_limit", False)
data_source_id = params.get("data_source_id")
if data_source_id:
data_source = models.DataSource.get_by_id_and_org(params.get("data_source_id"), self.current_org)
else:
return error_messages["select_data_source"]
if not has_access(data_source, self.current_user, not_view_only):
return error_messages["no_permission"]
return run_query(
parameterized_query,
parameters,
data_source,
query_id,
should_apply_auto_limit,
max_age,
)
ONE_YEAR = 60 * 60 * 24 * 365.25
class QueryResultDropdownResource(BaseResource):
def get(self, query_id):
query = get_object_or_404(models.Query.get_by_id_and_org, query_id, self.current_org)
require_access(query.data_source, current_user, view_only)
try:
return dropdown_values(query_id, self.current_org)
except QueryDetachedFromDataSourceError as e:
abort(400, message=str(e))
class QueryDropdownsResource(BaseResource):
def get(self, query_id, dropdown_query_id):
query = get_object_or_404(models.Query.get_by_id_and_org, query_id, self.current_org)
require_access(query, current_user, view_only)
related_queries_ids = [p["queryId"] for p in query.parameters if p["type"] == "query"]
if int(dropdown_query_id) not in related_queries_ids:
dropdown_query = get_object_or_404(models.Query.get_by_id_and_org, dropdown_query_id, self.current_org)
require_access(dropdown_query.data_source, current_user, view_only)
return dropdown_values(dropdown_query_id, self.current_org)
class QueryResultResource(BaseResource):
@staticmethod
def add_cors_headers(headers):
if "Origin" in request.headers:
origin = request.headers["Origin"]
if set(["*", origin]) & settings.ACCESS_CONTROL_ALLOW_ORIGIN:
headers["Access-Control-Allow-Origin"] = origin
headers["Access-Control-Allow-Credentials"] = str(settings.ACCESS_CONTROL_ALLOW_CREDENTIALS).lower()
@require_any_of_permission(("view_query", "execute_query"))
def options(self, query_id=None, query_result_id=None, filetype="json"):
headers = {}
self.add_cors_headers(headers)
if settings.ACCESS_CONTROL_REQUEST_METHOD:
headers["Access-Control-Request-Method"] = settings.ACCESS_CONTROL_REQUEST_METHOD
if settings.ACCESS_CONTROL_ALLOW_HEADERS:
headers["Access-Control-Allow-Headers"] = settings.ACCESS_CONTROL_ALLOW_HEADERS
return make_response("", 200, headers)
@require_any_of_permission(("view_query", "execute_query"))
def post(self, query_id):
"""
Execute a saved query.
:param number query_id: The ID of the query whose results should be fetched.
:param object parameters: The parameter values to apply to the query.
:qparam number max_age: If query results less than `max_age` seconds old are available,
return them, otherwise execute the query; if omitted or -1, returns
any cached result, or executes if not available. Set to zero to
always execute.
"""
params = request.get_json(force=True, silent=True) or {}
parameter_values = params.get("parameters", {})
max_age = params.get("max_age", -1)
# max_age might have the value of None, in which case calling int(None) will fail
if max_age is None:
max_age = -1
max_age = int(max_age)
query = get_object_or_404(models.Query.get_by_id_and_org, query_id, self.current_org)
allow_executing_with_view_only_permissions = query.parameterized.is_safe
if "apply_auto_limit" in params:
should_apply_auto_limit = params.get("apply_auto_limit", False)
else:
should_apply_auto_limit = query.options.get("apply_auto_limit", False)
if has_access(query, self.current_user, allow_executing_with_view_only_permissions):
return run_query(
query.parameterized,
parameter_values,
query.data_source,
query_id,
should_apply_auto_limit,
max_age,
)
else:
if not query.parameterized.is_safe:
if current_user.is_api_user():
return error_messages["unsafe_when_shared"]
else:
return error_messages["unsafe_on_view_only"]
else:
return error_messages["no_permission"]
@require_any_of_permission(("view_query", "execute_query"))
def get(self, query_id=None, query_result_id=None, filetype="json"):
"""
Retrieve query results.
:param number query_id: The ID of the query whose results should be fetched
:param number query_result_id: the ID of the query result to fetch
:param string filetype: Format to return. One of 'json', 'xlsx', or 'csv'. Defaults to 'json'.
: 0:
self.add_cors_headers(response.headers)
if should_cache:
response.headers.add_header("Cache-Control", "private,max-age=%d" % ONE_YEAR)
filename = get_download_filename(query_result, query, filetype)
filenames = content_disposition_filenames(filename)
response.headers.add("Content-Disposition", "attachment", **filenames)
return response
else:
abort(404, message="No cached result found for this query.")
@staticmethod
def make_json_response(query_result):
data = json_dumps({"query_result": query_result.to_dict()})
headers = {"Content-Type": "application/json"}
return make_response(data, 200, headers)
@staticmethod
def make_csv_response(query_result):
headers = {"Content-Type": "text/csv; charset=UTF-8"}
return make_response(serialize_query_result_to_dsv(query_result, ","), 200, headers)
@staticmethod
def make_tsv_response(query_result):
headers = {"Content-Type": "text/tab-separated-values; charset=UTF-8"}
return make_response(serialize_query_result_to_dsv(query_result, "\t"), 200, headers)
@staticmethod
def make_excel_response(query_result):
headers = {"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"}
return make_response(serialize_query_result_to_xlsx(query_result), 200, headers)
class JobResource(BaseResource):
def get(self, job_id, query_id=None):
"""
Retrieve info about a running query job.
"""
job = Job.fetch(job_id)
return serialize_job(job)
def delete(self, job_id):
"""
Cancel a query job in progress.
"""
job = Job.fetch(job_id)
job.cancel()
================================================
FILE: redash/handlers/query_snippets.py
================================================
from flask import request
from funcy import project
from redash import models
from redash.handlers.base import (
BaseResource,
get_object_or_404,
require_fields,
)
from redash.permissions import require_admin_or_owner
class QuerySnippetResource(BaseResource):
def get(self, snippet_id):
snippet = get_object_or_404(models.QuerySnippet.get_by_id_and_org, snippet_id, self.current_org)
self.record_event({"action": "view", "object_id": snippet_id, "object_type": "query_snippet"})
return snippet.to_dict()
def post(self, snippet_id):
req = request.get_json(True)
params = project(req, ("trigger", "description", "snippet"))
snippet = get_object_or_404(models.QuerySnippet.get_by_id_and_org, snippet_id, self.current_org)
require_admin_or_owner(snippet.user.id)
self.update_model(snippet, params)
models.db.session.commit()
self.record_event({"action": "edit", "object_id": snippet.id, "object_type": "query_snippet"})
return snippet.to_dict()
def delete(self, snippet_id):
snippet = get_object_or_404(models.QuerySnippet.get_by_id_and_org, snippet_id, self.current_org)
require_admin_or_owner(snippet.user.id)
models.db.session.delete(snippet)
models.db.session.commit()
self.record_event(
{
"action": "delete",
"object_id": snippet.id,
"object_type": "query_snippet",
}
)
class QuerySnippetListResource(BaseResource):
def post(self):
req = request.get_json(True)
require_fields(req, ("trigger", "description", "snippet"))
snippet = models.QuerySnippet(
trigger=req["trigger"],
description=req["description"],
snippet=req["snippet"],
user=self.current_user,
org=self.current_org,
)
models.db.session.add(snippet)
models.db.session.commit()
self.record_event(
{
"action": "create",
"object_id": snippet.id,
"object_type": "query_snippet",
}
)
return snippet.to_dict()
def get(self):
self.record_event({"action": "list", "object_type": "query_snippet"})
return [snippet.to_dict() for snippet in models.QuerySnippet.all(org=self.current_org)]
================================================
FILE: redash/handlers/settings.py
================================================
from flask import request
from redash.handlers.base import BaseResource
from redash.models import Organization, db
from redash.permissions import require_admin
from redash.settings.organization import settings as org_settings
def get_settings_with_defaults(defaults, org):
values = org.settings.get("settings", {})
settings = {}
for setting, default_value in defaults.items():
current_value = values.get(setting)
if current_value is None and default_value is None:
continue
if current_value is None:
settings[setting] = default_value
else:
settings[setting] = current_value
settings["auth_google_apps_domains"] = org.google_apps_domains
return settings
class OrganizationSettings(BaseResource):
@require_admin
def get(self):
settings = get_settings_with_defaults(org_settings, self.current_org)
return {"settings": settings}
@require_admin
def post(self):
new_values = request.json
if self.current_org.settings.get("settings") is None:
self.current_org.settings["settings"] = {}
previous_values = {}
for k, v in new_values.items():
if k == "auth_google_apps_domains":
previous_values[k] = self.current_org.google_apps_domains
self.current_org.settings[Organization.SETTING_GOOGLE_APPS_DOMAINS] = v
else:
previous_values[k] = self.current_org.get_setting(k, raise_on_missing=False)
self.current_org.set_setting(k, v)
db.session.add(self.current_org)
db.session.commit()
self.record_event(
{
"action": "edit",
"object_id": self.current_org.id,
"object_type": "settings",
"new_values": new_values,
"previous_values": previous_values,
}
)
settings = get_settings_with_defaults(org_settings, self.current_org)
return {"settings": settings}
================================================
FILE: redash/handlers/setup.py
================================================
from flask import g, redirect, render_template, request, url_for
from flask_login import login_user
from wtforms import BooleanField, Form, PasswordField, StringField, validators
from wtforms.fields.html5 import EmailField
from redash import settings
from redash.authentication.org_resolving import current_org
from redash.handlers.base import routes
from redash.models import Group, Organization, User, db
from redash.tasks.general import subscribe
class SetupForm(Form):
name = StringField("Name", validators=[validators.InputRequired()])
email = EmailField("Email Address", validators=[validators.Email()])
password = PasswordField("Password", validators=[validators.Length(6)])
org_name = StringField("Organization Name", validators=[validators.InputRequired()])
security_notifications = BooleanField()
newsletter = BooleanField()
def create_org(org_name, user_name, email, password):
default_org = Organization(name=org_name, slug="default", settings={})
admin_group = Group(
name="admin",
permissions=Group.ADMIN_PERMISSIONS,
org=default_org,
type=Group.BUILTIN_GROUP,
)
default_group = Group(
name="default",
permissions=Group.DEFAULT_PERMISSIONS,
org=default_org,
type=Group.BUILTIN_GROUP,
)
db.session.add_all([default_org, admin_group, default_group])
db.session.commit()
user = User(
org=default_org,
name=user_name,
email=email,
group_ids=[admin_group.id, default_group.id],
)
user.hash_password(password)
db.session.add(user)
db.session.commit()
return default_org, user
@routes.route("/setup", methods=["GET", "POST"])
def setup():
if current_org != None or settings.MULTI_ORG: # noqa: E711
return redirect("/")
form = SetupForm(request.form)
form.newsletter.data = True
form.security_notifications.data = True
if request.method == "POST" and form.validate():
default_org, user = create_org(form.org_name.data, form.name.data, form.email.data, form.password.data)
g.org = default_org
login_user(user)
# signup to newsletter if needed
if form.newsletter.data or form.security_notifications:
subscribe.delay(form.data)
return redirect(url_for("redash.index", org_slug=None))
return render_template("setup.html", form=form)
================================================
FILE: redash/handlers/static.py
================================================
from flask import render_template, send_file
from flask_login import login_required
from werkzeug.utils import safe_join
from redash import settings
from redash.handlers import routes
from redash.handlers.authentication import base_href
from redash.handlers.base import org_scoped_rule
from redash.security import csp_allows_embeding
def render_index():
if settings.MULTI_ORG:
response = render_template("multi_org.html", base_href=base_href())
else:
full_path = safe_join(settings.STATIC_ASSETS_PATH, "index.html")
response = send_file(full_path, **dict(max_age=0, conditional=True))
return response
@routes.route(org_scoped_rule("/dashboard/"), methods=["GET"])
@login_required
@csp_allows_embeding
def dashboard(slug, org_slug=None):
return render_index()
@routes.route(org_scoped_rule("/"))
@routes.route(org_scoped_rule("/"))
@login_required
def index(**kwargs):
return render_index()
================================================
FILE: redash/handlers/users.py
================================================
from disposable_email_domains import blacklist
from flask import request
from flask_login import current_user, login_user
from flask_restful import abort
from funcy import partial, project
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm.exc import NoResultFound
from redash import limiter, models, settings
from redash.authentication.account import (
invite_link_for_user,
send_invite_email,
send_password_reset_email,
send_verify_email,
)
from redash.handlers.base import (
BaseResource,
get_object_or_404,
paginate,
require_fields,
)
from redash.handlers.base import order_results as _order_results
from redash.permissions import (
is_admin_or_owner,
require_admin,
require_admin_or_owner,
require_permission,
require_permission_or_owner,
)
from redash.settings import parse_boolean
# Ordering map for relationships
order_map = {
"name": "name",
"-name": "-name",
"active_at": "active_at",
"-active_at": "-active_at",
"created_at": "created_at",
"-created_at": "-created_at",
"groups": "group_ids",
"-groups": "-group_ids",
}
order_results = partial(_order_results, default_order="-created_at", allowed_orders=order_map)
def invite_user(org, inviter, user, send_email=True):
d = user.to_dict()
invite_url = invite_link_for_user(user)
if settings.email_server_is_configured() and send_email:
send_invite_email(inviter, user, invite_url, org)
else:
d["invite_link"] = invite_url
return d
def require_allowed_email(email):
# `example.com` and `example.com.` are equal - last dot stands for DNS root but usually is omitted
_, domain = email.lower().rstrip(".").split("@", 1)
if domain in blacklist or domain in settings.BLOCKED_DOMAINS:
abort(400, message="Bad email address.")
class UserListResource(BaseResource):
decorators = BaseResource.decorators + [limiter.limit("200/day;50/hour", methods=["POST"])]
def get_users(self, disabled, pending, search_term):
if disabled:
users = models.User.all_disabled(self.current_org)
else:
users = models.User.all(self.current_org)
if pending is not None:
users = models.User.pending(users, pending)
if search_term:
users = models.User.search(users, search_term)
self.record_event(
{
"action": "search",
"object_type": "user",
"term": search_term,
"pending": pending,
}
)
else:
self.record_event({"action": "list", "object_type": "user", "pending": pending})
# order results according to passed order parameter,
# special-casing search queries where the database
# provides an order by search rank
return order_results(users, fallback=not bool(search_term))
@require_permission("list_users")
def get(self):
page = request.args.get("page", 1, type=int)
page_size = request.args.get("page_size", 25, type=int)
groups = {group.id: group for group in models.Group.all(self.current_org)}
def serialize_user(user):
d = user.to_dict()
user_groups = []
for group_id in set(d["groups"]):
group = groups.get(group_id)
if group:
user_groups.append({"id": group.id, "name": group.name})
d["groups"] = user_groups
return d
search_term = request.args.get("q", "")
disabled = request.args.get("disabled", "false") # get enabled users by default
disabled = parse_boolean(disabled)
pending = request.args.get("pending", None) # get both active and pending by default
if pending is not None:
pending = parse_boolean(pending)
users = self.get_users(disabled, pending, search_term)
return paginate(users, page, page_size, serialize_user)
@require_admin
def post(self):
req = request.get_json(force=True)
require_fields(req, ("name", "email"))
if "@" not in req["email"]:
abort(400, message="Bad email address.")
require_allowed_email(req["email"])
user = models.User(
org=self.current_org,
name=req["name"],
email=req["email"],
is_invitation_pending=True,
group_ids=[self.current_org.default_group.id],
)
try:
models.db.session.add(user)
models.db.session.commit()
except IntegrityError as e:
if "email" in str(e):
abort(400, message="Email already taken.")
abort(500)
self.record_event({"action": "create", "object_id": user.id, "object_type": "user"})
should_send_invitation = "no_invite" not in request.args
return invite_user(self.current_org, self.current_user, user, send_email=should_send_invitation)
class UserInviteResource(BaseResource):
@require_admin
def post(self, user_id):
user = models.User.get_by_id_and_org(user_id, self.current_org)
return invite_user(self.current_org, self.current_user, user)
class UserResetPasswordResource(BaseResource):
@require_admin
def post(self, user_id):
user = models.User.get_by_id_and_org(user_id, self.current_org)
if user.is_disabled:
abort(404, message="Not found")
reset_link = send_password_reset_email(user)
return {"reset_link": reset_link}
class UserRegenerateApiKeyResource(BaseResource):
def post(self, user_id):
user = models.User.get_by_id_and_org(user_id, self.current_org)
if user.is_disabled:
abort(404, message="Not found")
if not is_admin_or_owner(user_id):
abort(403)
user.regenerate_api_key()
models.db.session.commit()
self.record_event({"action": "regnerate_api_key", "object_id": user.id, "object_type": "user"})
return user.to_dict(with_api_key=True)
class UserResource(BaseResource):
decorators = BaseResource.decorators + [limiter.limit("50/hour", methods=["POST"])]
def get(self, user_id):
require_permission_or_owner("list_users", user_id)
user = get_object_or_404(models.User.get_by_id_and_org, user_id, self.current_org)
self.record_event({"action": "view", "object_id": user_id, "object_type": "user"})
return user.to_dict(with_api_key=is_admin_or_owner(user_id))
def post(self, user_id): # noqa: C901
require_admin_or_owner(user_id)
user = models.User.get_by_id_and_org(user_id, self.current_org)
req = request.get_json(True)
params = project(req, ("email", "name", "password", "old_password", "group_ids"))
if "password" in params and "old_password" not in params:
abort(403, message="Must provide current password to update password.")
if "old_password" in params and not user.verify_password(params["old_password"]):
abort(403, message="Incorrect current password.")
if "password" in params:
user.hash_password(params.pop("password"))
params.pop("old_password")
if "group_ids" in params:
if not self.current_user.has_permission("admin"):
abort(403, message="Must be admin to change groups membership.")
for group_id in params["group_ids"]:
try:
models.Group.get_by_id_and_org(group_id, self.current_org)
except NoResultFound:
abort(400, message="Group id {} is invalid.".format(group_id))
if len(params["group_ids"]) == 0:
params.pop("group_ids")
if "email" in params:
require_allowed_email(params["email"])
email_address_changed = "email" in params and params["email"] != user.email
needs_to_verify_email = email_address_changed and settings.email_server_is_configured()
if needs_to_verify_email:
user.is_email_verified = False
try:
self.update_model(user, params)
models.db.session.commit()
if needs_to_verify_email:
send_verify_email(user, self.current_org)
# The user has updated their email or password. This should invalidate all _other_ sessions,
# forcing them to log in again. Since we don't want to force _this_ session to have to go
# through login again, we call `login_user` in order to update the session with the new identity details.
if current_user.id == user.id:
login_user(user, remember=True)
except IntegrityError as e:
if "email" in str(e):
message = "Email already taken."
else:
message = "Error updating record"
abort(400, message=message)
self.record_event(
{
"action": "edit",
"object_id": user.id,
"object_type": "user",
"updated_fields": list(params.keys()),
}
)
return user.to_dict(with_api_key=is_admin_or_owner(user_id))
@require_admin
def delete(self, user_id):
user = models.User.get_by_id_and_org(user_id, self.current_org)
# admin cannot delete self; current user is an admin (`@require_admin`)
# so just check user id
if user.id == current_user.id:
abort(
403,
message="You cannot delete your own account. "
"Please ask another admin to do this for you.", # fmt: skip
)
elif not user.is_invitation_pending:
abort(
403,
message="You cannot delete activated users. "
"Please disable the user instead.", # fmt: skip
)
models.db.session.delete(user)
models.db.session.commit()
return user.to_dict(with_api_key=is_admin_or_owner(user_id))
class UserDisableResource(BaseResource):
@require_admin
def post(self, user_id):
user = models.User.get_by_id_and_org(user_id, self.current_org)
# admin cannot disable self; current user is an admin (`@require_admin`)
# so just check user id
if user.id == current_user.id:
abort(
403,
message="You cannot disable your own account. "
"Please ask another admin to do this for you.", # fmt: skip
)
user.disable()
models.db.session.commit()
return user.to_dict(with_api_key=is_admin_or_owner(user_id))
@require_admin
def delete(self, user_id):
user = models.User.get_by_id_and_org(user_id, self.current_org)
user.enable()
models.db.session.commit()
return user.to_dict(with_api_key=is_admin_or_owner(user_id))
================================================
FILE: redash/handlers/visualizations.py
================================================
from flask import request
from redash import models
from redash.handlers.base import BaseResource, get_object_or_404
from redash.permissions import (
require_object_modify_permission,
require_permission,
)
from redash.serializers import serialize_visualization
class VisualizationListResource(BaseResource):
@require_permission("edit_query")
def post(self):
kwargs = request.get_json(force=True)
query = get_object_or_404(models.Query.get_by_id_and_org, kwargs.pop("query_id"), self.current_org)
require_object_modify_permission(query, self.current_user)
kwargs["query_rel"] = query
vis = models.Visualization(**kwargs)
models.db.session.add(vis)
models.db.session.commit()
return serialize_visualization(vis, with_query=False)
class VisualizationResource(BaseResource):
@require_permission("edit_query")
def post(self, visualization_id):
vis = get_object_or_404(models.Visualization.get_by_id_and_org, visualization_id, self.current_org)
require_object_modify_permission(vis.query_rel, self.current_user)
kwargs = request.get_json(force=True)
kwargs.pop("id", None)
kwargs.pop("query_id", None)
self.update_model(vis, kwargs)
d = serialize_visualization(vis, with_query=False)
models.db.session.commit()
return d
@require_permission("edit_query")
def delete(self, visualization_id):
vis = get_object_or_404(models.Visualization.get_by_id_and_org, visualization_id, self.current_org)
require_object_modify_permission(vis.query_rel, self.current_user)
self.record_event(
{
"action": "delete",
"object_id": visualization_id,
"object_type": "Visualization",
}
)
models.db.session.delete(vis)
models.db.session.commit()
================================================
FILE: redash/handlers/webpack.py
================================================
import json
import os
from flask import url_for
WEBPACK_MANIFEST_PATH = os.path.join(os.path.dirname(__file__), "../../client/dist/", "asset-manifest.json")
def configure_webpack(app):
app.extensions["webpack"] = {"assets": None}
def get_asset(path):
assets = app.extensions["webpack"]["assets"]
# in debug we read in this file each request
if assets is None or app.debug:
try:
with open(WEBPACK_MANIFEST_PATH) as fp:
assets = json.load(fp)
except IOError:
app.logger.exception("Unable to load webpack manifest")
assets = {}
app.extensions["webpack"]["assets"] = assets
return url_for("static", filename=assets.get(path, path))
@app.context_processor
def webpack_assets():
return {"asset_url": get_asset}
================================================
FILE: redash/handlers/widgets.py
================================================
from flask import request
from redash import models
from redash.handlers.base import BaseResource
from redash.permissions import (
require_access,
require_object_modify_permission,
require_permission,
view_only,
)
from redash.serializers import serialize_widget
class WidgetListResource(BaseResource):
@require_permission("edit_dashboard")
def post(self):
"""
Add a widget to a dashboard.
:json object widget: The created widget
"""
widget_properties = request.get_json(force=True)
dashboard = models.Dashboard.get_by_id_and_org(widget_properties.get("dashboard_id"), self.current_org)
require_object_modify_permission(dashboard, self.current_user)
widget_properties.pop("id", None)
visualization_id = widget_properties.pop("visualization_id")
if visualization_id:
visualization = models.Visualization.get_by_id_and_org(visualization_id, self.current_org)
require_access(visualization.query_rel, self.current_user, view_only)
else:
visualization = None
widget_properties["visualization"] = visualization
widget = models.Widget(**widget_properties)
models.db.session.add(widget)
models.db.session.commit()
return serialize_widget(widget)
class WidgetResource(BaseResource):
@require_permission("edit_dashboard")
def post(self, widget_id):
"""
Updates a widget in a dashboard.
This method currently handles Text Box widgets only.
:param number widget_id: The ID of the widget to modify
:= db.func.timezone("utc", db.func.now())
),
)
return query.order_by(cls.retrieved_at.desc()).first()
@classmethod
def store_result(cls, org, data_source, query_hash, query, data, run_time, retrieved_at):
query_result = cls(
org_id=org,
query_hash=query_hash,
query_text=query,
runtime=run_time,
data_source=data_source,
retrieved_at=retrieved_at,
data=data,
)
db.session.add(query_result)
logging.info("Inserted query (%s) data; id=%s", query_hash, query_result.id)
return query_result
@property
def groups(self):
return self.data_source.groups
def should_schedule_next(previous_iteration, now, interval, time=None, day_of_week=None, failures=0):
# if previous_iteration is None, it means the query has never been run before
# so we should schedule it immediately
if previous_iteration is None:
return True
# if time exists then interval > 23 hours (82800s)
# if day_of_week exists then interval > 6 days (518400s)
if time is None:
ttl = int(interval)
next_iteration = previous_iteration + datetime.timedelta(seconds=ttl)
else:
hour, minute = time.split(":")
hour, minute = int(hour), int(minute)
# The following logic is needed for cases like the following:
# - The query scheduled to run at 23:59.
# - The scheduler wakes up at 00:01.
# - Using naive implementation of comparing timestamps, it will skip the execution.
normalized_previous_iteration = previous_iteration.replace(hour=hour, minute=minute)
if normalized_previous_iteration > previous_iteration:
previous_iteration = normalized_previous_iteration - datetime.timedelta(days=1)
days_delay = int(interval) / 60 / 60 / 24
days_to_add = 0
if day_of_week is not None:
days_to_add = list(calendar.day_name).index(day_of_week) - normalized_previous_iteration.weekday()
next_iteration = (
previous_iteration + datetime.timedelta(days=days_delay) + datetime.timedelta(days=days_to_add)
).replace(hour=hour, minute=minute)
if failures:
try:
next_iteration += datetime.timedelta(minutes=2**failures)
except OverflowError:
return False
return now > next_iteration
@gfk_type
@generic_repr(
"id",
"name",
"query_hash",
"version",
"user_id",
"org_id",
"data_source_id",
"query_hash",
"last_modified_by_id",
"is_archived",
"is_draft",
"schedule",
"schedule_failures",
)
class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
id = primary_key("Query")
version = Column(db.Integer, default=1)
org_id = Column(key_type("Organization"), db.ForeignKey("organizations.id"))
org = db.relationship(Organization, backref="queries")
data_source_id = Column(key_type("DataSource"), db.ForeignKey("data_sources.id"), nullable=True)
data_source = db.relationship(DataSource, backref="queries")
latest_query_data_id = Column(key_type("QueryResult"), db.ForeignKey("query_results.id"), nullable=True)
latest_query_data = db.relationship(QueryResult)
name = Column(db.String(255))
description = Column(db.String(4096), nullable=True)
query_text = Column("query", db.Text)
query_hash = Column(db.String(32))
api_key = Column(db.String(40), default=lambda: generate_token(40))
user_id = Column(key_type("User"), db.ForeignKey("users.id"))
user = db.relationship(User, foreign_keys=[user_id])
last_modified_by_id = Column(key_type("User"), db.ForeignKey("users.id"), nullable=True)
last_modified_by = db.relationship(User, backref="modified_queries", foreign_keys=[last_modified_by_id])
is_archived = Column(db.Boolean, default=False, index=True)
is_draft = Column(db.Boolean, default=True, index=True)
schedule = Column(MutableDict.as_mutable(JSONB), nullable=True)
interval = json_cast_property(db.Integer, "schedule", "interval", default=0)
schedule_failures = Column(db.Integer, default=0)
visualizations = db.relationship("Visualization", cascade="all, delete-orphan")
options = Column(MutableDict.as_mutable(JSONB), default={})
search_vector = Column(
TSVectorType(
"id",
"name",
"description",
"query",
weights={"name": "A", "id": "B", "description": "C", "query": "D"},
),
nullable=True,
)
tags = Column("tags", MutableList.as_mutable(ARRAY(db.Unicode)), nullable=True)
query_class = SearchBaseQuery
__tablename__ = "queries"
__mapper_args__ = {"version_id_col": version, "version_id_generator": False}
def __str__(self):
return str(self.id)
def archive(self, user=None):
db.session.add(self)
self.is_archived = True
self.schedule = None
for vis in self.visualizations:
for w in vis.widgets:
db.session.delete(w)
for a in self.alerts:
db.session.delete(a)
if user:
self.record_changes(user)
def regenerate_api_key(self):
self.api_key = generate_token(40)
@classmethod
def create(cls, **kwargs):
query = cls(**kwargs)
db.session.add(
Visualization(
query_rel=query,
name="Table",
description="",
type="TABLE",
options={},
)
)
return query
@classmethod
def all_queries(cls, group_ids, user_id=None, include_drafts=False, include_archived=False):
query_ids = (
db.session.query(distinct(cls.id))
.join(DataSourceGroup, Query.data_source_id == DataSourceGroup.data_source_id)
.filter(Query.is_archived.is_(include_archived))
.filter(DataSourceGroup.group_id.in_(group_ids))
)
queries = (
cls.query.options(
joinedload(Query.user),
joinedload(Query.latest_query_data).load_only("runtime", "retrieved_at"),
)
.filter(cls.id.in_(query_ids))
# Adding outer joins to be able to order by relationship
.outerjoin(User, User.id == Query.user_id)
.outerjoin(QueryResult, QueryResult.id == Query.latest_query_data_id)
.options(contains_eager(Query.user), contains_eager(Query.latest_query_data))
)
if not include_drafts:
queries = queries.filter(or_(Query.is_draft.is_(False), Query.user_id == user_id))
return queries
@classmethod
def favorites(cls, user, base_query=None):
if base_query is None:
base_query = cls.all_queries(user.group_ids, user.id, include_drafts=True)
return base_query.join(
(
Favorite,
and_(Favorite.object_type == "Query", Favorite.object_id == Query.id),
)
).filter(Favorite.user_id == user.id)
@classmethod
def all_tags(cls, user, include_drafts=False):
queries = cls.all_queries(group_ids=user.group_ids, user_id=user.id, include_drafts=include_drafts)
tag_column = func.unnest(cls.tags).label("tag")
usage_count = func.count(1).label("usage_count")
query = (
db.session.query(tag_column, usage_count)
.group_by(tag_column)
.filter(Query.id.in_(queries.options(load_only("id"))))
.order_by(tag_column)
)
return query
@classmethod
def by_user(cls, user):
return cls.all_queries(user.group_ids, user.id).filter(Query.user == user)
@classmethod
def by_api_key(cls, api_key):
return cls.query.filter(cls.api_key == api_key).one()
@classmethod
def past_scheduled_queries(cls):
now = utils.utcnow()
queries = Query.query.filter(func.jsonb_typeof(Query.schedule) != "null").order_by(Query.id)
return [
query
for query in queries
if "until" in query.schedule
and query.schedule["until"] is not None
and pytz.utc.localize(datetime.datetime.strptime(query.schedule["until"], "%Y-%m-%d")) <= now
]
@classmethod
def outdated_queries(cls):
queries = (
Query.query.options(joinedload(Query.latest_query_data).load_only("retrieved_at"))
.filter(func.jsonb_typeof(Query.schedule) != "null")
.order_by(Query.id)
.all()
)
now = utils.utcnow()
outdated_queries = {}
scheduled_queries_executions.refresh()
for query in queries:
try:
if query.schedule.get("disabled"):
continue
# Skip queries that have None for all schedule values. It's unclear whether this
# something that can happen in practice, but we have a test case for it.
if all(value is None for value in query.schedule.values()):
continue
if query.schedule["until"]:
schedule_until = pytz.utc.localize(datetime.datetime.strptime(query.schedule["until"], "%Y-%m-%d"))
if schedule_until <= now:
continue
retrieved_at = scheduled_queries_executions.get(query.id) or (
query.latest_query_data and query.latest_query_data.retrieved_at
)
if should_schedule_next(
retrieved_at,
now,
query.schedule["interval"],
query.schedule["time"],
query.schedule["day_of_week"],
query.schedule_failures,
):
key = "{}:{}".format(query.query_hash, query.data_source_id)
outdated_queries[key] = query
except Exception as e:
query.schedule["disabled"] = True
db.session.commit()
message = (
"Could not determine if query %d is outdated due to %s. The schedule for this query has been disabled."
% (query.id, repr(e))
)
logging.info(message)
sentry.capture_exception(type(e)(message).with_traceback(e.__traceback__))
return list(outdated_queries.values())
@classmethod
def _do_multi_byte_search(cls, all_queries, term, limit=None):
# term examples:
# - word
# - name:word
# - query:word
# - "multiple words"
# - name:"multiple words"
# - word1 word2 word3
# - word1 "multiple word" query:"select foo"
tokens = re.findall(r'(?:([^:\s]+):)?(?:"([^"]+)"|(\S+))', term)
conditions = []
for token in tokens:
key = None
if token[0]:
key = token[0]
if token[1]:
value = token[1]
else:
value = token[2]
pattern = f"%{value}%"
if key == "id" and value.isdigit():
conditions.append(cls.id.equal(int(value)))
elif key == "name":
conditions.append(cls.name.ilike(pattern))
elif key == "query":
conditions.append(cls.query_text.ilike(pattern))
elif key == "description":
conditions.append(cls.description.ilike(pattern))
else:
conditions.append(or_(cls.name.ilike(pattern), cls.description.ilike(pattern)))
return all_queries.filter(and_(*conditions)).order_by(Query.id).limit(limit)
@classmethod
def search(
cls,
term,
group_ids,
user_id=None,
include_drafts=False,
limit=None,
include_archived=False,
multi_byte_search=False,
):
all_queries = cls.all_queries(
group_ids,
user_id=user_id,
include_drafts=include_drafts,
include_archived=include_archived,
)
if multi_byte_search:
# Since tsvector doesn't work well with CJK languages, use `ilike` too
return cls._do_multi_byte_search(all_queries, term, limit)
# sort the result using the weight as defined in the search vector column
return all_queries.search(term, sort=True).limit(limit)
@classmethod
def search_by_user(cls, term, user, limit=None, multi_byte_search=False):
if multi_byte_search:
# Since tsvector doesn't work well with CJK languages, use `ilike` too
return cls._do_multi_byte_search(cls.by_user(user), term, limit)
return cls.by_user(user).search(term, sort=True).limit(limit)
@classmethod
def recent(cls, group_ids, user_id=None, limit=20):
query = (
cls.query.filter(Event.created_at > (db.func.current_date() - 7))
.join(Event, Query.id == Event.object_id.cast(db.Integer))
.join(DataSourceGroup, Query.data_source_id == DataSourceGroup.data_source_id)
.filter(
Event.action.in_(["edit", "execute", "edit_name", "edit_description", "view_source"]),
Event.object_id is not None,
Event.object_type == "query",
DataSourceGroup.group_id.in_(group_ids),
or_(Query.is_draft.is_(False), Query.user_id is user_id),
Query.is_archived.is_(False),
)
.group_by(Event.object_id, Query.id)
.order_by(db.desc(db.func.count(0)))
)
if user_id:
query = query.filter(Event.user_id == user_id)
query = query.limit(limit)
return query
@classmethod
def get_by_id(cls, _id):
return cls.query.filter(cls.id == _id).one()
@classmethod
def all_groups_for_query_ids(cls, query_ids):
query = """SELECT group_id, view_only
FROM queries
JOIN data_source_groups ON queries.data_source_id = data_source_groups.data_source_id
WHERE queries.id in :ids"""
return db.session.execute(query, {"ids": tuple(query_ids)}).fetchall()
def update_latest_result_by_query_hash(self):
query_hash = self.query_hash
data_source_id = self.data_source_id
query_result = (
QueryResult.query.options(load_only("id"))
.filter(
QueryResult.query_hash == query_hash,
QueryResult.data_source_id == data_source_id,
)
.order_by(QueryResult.retrieved_at.desc())
.first()
)
if query_result:
latest_query_data_id = query_result.id
self.latest_query_data_id = latest_query_data_id
db.session.add(self)
@classmethod
def update_latest_result(cls, query_result):
# TODO: Investigate how big an impact this select-before-update makes.
queries = Query.query.filter(
Query.query_hash == query_result.query_hash,
Query.data_source == query_result.data_source,
Query.is_archived.is_(False),
)
for q in queries:
q.latest_query_data = query_result
# don't auto-update the updated_at timestamp
q.skip_updated_at = True
db.session.add(q)
query_ids = [q.id for q in queries]
logging.info(
"Updated %s queries with result (%s).",
len(query_ids),
query_result.query_hash,
)
return query_ids
def fork(self, user):
forked_list = [
"org",
"data_source",
"latest_query_data",
"description",
"query_text",
"query_hash",
"options",
"tags",
]
kwargs = {a: getattr(self, a) for a in forked_list}
# Query.create will add default TABLE visualization, so use constructor to create bare copy of query
forked_query = Query(name="Copy of (#{}) {}".format(self.id, self.name), user=user, **kwargs)
for v in sorted(self.visualizations, key=lambda v: v.id):
forked_v = v.copy()
forked_v["query_rel"] = forked_query
fv = Visualization(**forked_v) # it will magically add it to `forked_query.visualizations`
db.session.add(fv)
db.session.add(forked_query)
return forked_query
@property
def runtime(self):
return self.latest_query_data.runtime
@property
def retrieved_at(self):
return self.latest_query_data.retrieved_at
@property
def groups(self):
if self.data_source is None:
return {}
return self.data_source.groups
@hybrid_property
def lowercase_name(self):
"Optional property useful for sorting purposes."
return self.name.lower()
@lowercase_name.expression
def lowercase_name(cls):
"The SQLAlchemy expression for the property above."
return func.lower(cls.name)
@property
def parameters(self):
return self.options.get("parameters", [])
@property
def parameterized(self):
return ParameterizedQuery(self.query_text, self.parameters, self.org)
@property
def dashboard_api_keys(self):
query = """SELECT api_keys.api_key
FROM api_keys
JOIN dashboards ON object_id = dashboards.id
JOIN widgets ON dashboards.id = widgets.dashboard_id
JOIN visualizations ON widgets.visualization_id = visualizations.id
WHERE object_type='dashboards'
AND active=true
AND visualizations.query_id = :id"""
api_keys = db.session.execute(query, {"id": self.id}).fetchall()
return [api_key[0] for api_key in api_keys]
def update_query_hash(self):
should_apply_auto_limit = self.options.get("apply_auto_limit", False) if self.options else False
query_runner = self.data_source.query_runner if self.data_source else BaseQueryRunner({})
query_text = self.query_text
parameters_dict = {p["name"]: p.get("value") for p in self.parameters} if self.options else {}
if any(parameters_dict):
try:
query_text = self.parameterized.apply(parameters_dict).query
except InvalidParameterError as e:
logging.info(f"Unable to update hash for query {self.id} because of invalid parameters: {str(e)}")
except QueryDetachedFromDataSourceError as e:
logging.info(
f"Unable to update hash for query {self.id} because of dropdown query {e.query_id} is unattached from datasource"
)
self.query_hash = query_runner.gen_query_hash(query_text, should_apply_auto_limit)
@listens_for(Query, "before_insert")
@listens_for(Query, "before_update")
def receive_before_insert_update(mapper, connection, target):
target.update_query_hash()
@listens_for(Query.user_id, "set")
def query_last_modified_by(target, val, oldval, initiator):
target.last_modified_by_id = val
@generic_repr("id", "object_type", "object_id", "user_id", "org_id")
class Favorite(TimestampMixin, db.Model):
id = primary_key("Favorite")
org_id = Column(key_type("Organization"), db.ForeignKey("organizations.id"))
object_type = Column(db.Unicode(255))
object_id = Column(key_type("Favorite"))
object = generic_relationship(object_type, object_id)
user_id = Column(key_type("User"), db.ForeignKey("users.id"))
user = db.relationship(User, backref="favorites")
__tablename__ = "favorites"
__table_args__ = (UniqueConstraint("object_type", "object_id", "user_id", name="unique_favorite"),)
@classmethod
def is_favorite(cls, user, object):
return cls.query.filter(cls.object == object, cls.user_id == user).count() > 0
@classmethod
def are_favorites(cls, user, objects):
objects = list(objects)
if not objects:
return []
object_type = str(objects[0].__class__.__name__)
return [
fav.object_id
for fav in cls.query.filter(
cls.object_id.in_([o.id for o in objects]),
cls.object_type == object_type,
cls.user_id == user,
)
]
OPERATORS = {
">": lambda v, t: v > t,
">=": lambda v, t: v >= t,
"<": lambda v, t: v < t,
"<=": lambda v, t: v <= t,
"==": lambda v, t: v == t,
"!=": lambda v, t: v != t,
# backward compatibility
"greater than": lambda v, t: v > t,
"less than": lambda v, t: v < t,
"equals": lambda v, t: v == t,
}
def next_state(op, value, threshold):
if isinstance(value, bool):
# If it's a boolean cast to string and lower case, because upper cased
# boolean value is Python specific and most likely will be confusing to
# users.
value = str(value).lower()
value_is_number = False
else:
try:
value = float(value)
value_is_number = True
except ValueError:
value_is_number = isinstance(value, numbers.Number)
if value_is_number:
try:
threshold = float(threshold)
except ValueError:
return Alert.UNKNOWN_STATE
else:
value = str(value)
if op(value, threshold):
new_state = Alert.TRIGGERED_STATE
elif not value_is_number and op not in [OPERATORS.get("!="), OPERATORS.get("=="), OPERATORS.get("equals")]:
new_state = Alert.UNKNOWN_STATE
else:
new_state = Alert.OK_STATE
return new_state
@generic_repr("id", "name", "query_id", "user_id", "state", "last_triggered_at", "rearm")
class Alert(TimestampMixin, BelongsToOrgMixin, db.Model):
UNKNOWN_STATE = "unknown"
OK_STATE = "ok"
TRIGGERED_STATE = "triggered"
TEST_STATE = "test"
id = primary_key("Alert")
name = Column(db.String(255))
query_id = Column(key_type("Query"), db.ForeignKey("queries.id"))
query_rel = db.relationship(Query, backref=backref("alerts", cascade="all"))
user_id = Column(key_type("User"), db.ForeignKey("users.id"))
user = db.relationship(User, backref="alerts")
options = Column(MutableDict.as_mutable(JSONB), nullable=True)
state = Column(db.String(255), default=UNKNOWN_STATE)
subscriptions = db.relationship("AlertSubscription", cascade="all, delete-orphan")
last_triggered_at = Column(db.DateTime(True), nullable=True)
rearm = Column(db.Integer, nullable=True)
__tablename__ = "alerts"
@classmethod
def all(cls, group_ids):
return (
cls.query.options(joinedload(Alert.user), joinedload(Alert.query_rel))
.join(Query)
.join(DataSourceGroup, DataSourceGroup.data_source_id == Query.data_source_id)
.filter(DataSourceGroup.group_id.in_(group_ids))
)
@classmethod
def get_by_id_and_org(cls, object_id, org):
return super(Alert, cls).get_by_id_and_org(object_id, org, Query)
def evaluate(self):
data = self.query_rel.latest_query_data.data if self.query_rel.latest_query_data else None
new_state = self.UNKNOWN_STATE
if data and data["rows"] and self.options["column"] in data["rows"][0]:
op = OPERATORS.get(self.options["op"], lambda v, t: False)
if "selector" not in self.options:
selector = "first"
else:
selector = self.options["selector"]
try:
if selector == "max":
max_val = float("-inf")
for i in range(len(data["rows"])):
max_val = max(max_val, float(data["rows"][i][self.options["column"]]))
value = max_val
elif selector == "min":
min_val = float("inf")
for i in range(len(data["rows"])):
min_val = min(min_val, float(data["rows"][i][self.options["column"]]))
value = min_val
else:
value = data["rows"][0][self.options["column"]]
except ValueError:
return self.UNKNOWN_STATE
threshold = self.options["value"]
if value is not None:
new_state = next_state(op, value, threshold)
return new_state
def subscribers(self):
return User.query.join(AlertSubscription).filter(AlertSubscription.alert == self)
def render_template(self, template):
if template is None:
return ""
data = self.query_rel.latest_query_data.data
host = base_url(self.query_rel.org)
col_name = self.options["column"]
if data["rows"] and col_name in data["rows"][0]:
result_value = data["rows"][0][col_name]
else:
result_value = None
result_table = [] # A two-dimensional array which can rendered as a table in Mustache
for row in data["rows"]:
result_table.append([row[col["name"]] for col in data["columns"]])
context = {
"ALERT_NAME": self.name,
"ALERT_URL": "{host}/alerts/{alert_id}".format(host=host, alert_id=self.id),
"ALERT_STATUS": self.state.upper(),
"ALERT_SELECTOR": self.options["selector"],
"ALERT_CONDITION": self.options["op"],
"ALERT_THRESHOLD": self.options["value"],
"QUERY_NAME": self.query_rel.name,
"QUERY_URL": "{host}/queries/{query_id}".format(host=host, query_id=self.query_rel.id),
"QUERY_RESULT_VALUE": result_value,
"QUERY_RESULT_ROWS": data["rows"],
"QUERY_RESULT_COLS": data["columns"],
"QUERY_RESULT_TABLE": result_table,
}
return mustache_render_escape(template, context)
@property
def custom_body(self):
template = self.options.get("custom_body", self.options.get("template"))
return self.render_template(template)
@property
def custom_subject(self):
template = self.options.get("custom_subject")
return self.render_template(template)
@property
def groups(self):
return self.query_rel.groups
@property
def muted(self):
return self.options.get("muted", False)
def generate_slug(ctx):
slug = utils.slugify(ctx.current_parameters["name"])
tries = 1
while Dashboard.query.filter(Dashboard.slug == slug).first() is not None:
slug = utils.slugify(ctx.current_parameters["name"]) + "_" + str(tries)
tries += 1
return slug
@gfk_type
@generic_repr("id", "name", "slug", "user_id", "org_id", "version", "is_archived", "is_draft")
class Dashboard(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
id = primary_key("Dashboard")
version = Column(db.Integer)
org_id = Column(key_type("Organization"), db.ForeignKey("organizations.id"))
org = db.relationship(Organization, backref="dashboards")
slug = Column(db.String(140), index=True, default=generate_slug)
name = Column(db.String(100))
user_id = Column(key_type("User"), db.ForeignKey("users.id"))
user = db.relationship(User)
# layout is no longer used, but kept so we know how to render old dashboards.
layout = Column(MutableList.as_mutable(JSONB), default=[])
dashboard_filters_enabled = Column(db.Boolean, default=False)
is_archived = Column(db.Boolean, default=False, index=True)
is_draft = Column(db.Boolean, default=True, index=True)
widgets = db.relationship("Widget", backref="dashboard", lazy="dynamic")
tags = Column("tags", MutableList.as_mutable(ARRAY(db.Unicode)), nullable=True)
options = Column(MutableDict.as_mutable(JSONB), default={})
__tablename__ = "dashboards"
__mapper_args__ = {"version_id_col": version}
def __str__(self):
return "%s=%s" % (self.id, self.name)
@property
def name_as_slug(self):
return utils.slugify(self.name)
@classmethod
def all(cls, org, group_ids, user_id):
query = (
Dashboard.query.options(joinedload(Dashboard.user).load_only("id", "name", "details", "email"))
.distinct(cls.lowercase_name, Dashboard.created_at, Dashboard.slug)
.outerjoin(Widget)
.outerjoin(Visualization)
.outerjoin(Query)
.outerjoin(DataSourceGroup, Query.data_source_id == DataSourceGroup.data_source_id)
.filter(
Dashboard.is_archived.is_(False),
(DataSourceGroup.group_id.in_(group_ids) | (Dashboard.user_id == user_id)),
Dashboard.org == org,
)
)
query = query.filter(or_(Dashboard.user_id == user_id, Dashboard.is_draft.is_(False)))
return query
@classmethod
def search(cls, org, groups_ids, user_id, search_term):
# TODO: switch to FTS
return cls.all(org, groups_ids, user_id).filter(cls.name.ilike("%{}%".format(search_term)))
@classmethod
def search_by_user(cls, term, user, limit=None):
return cls.by_user(user).filter(cls.name.ilike("%{}%".format(term))).limit(limit)
@classmethod
def all_tags(cls, org, user):
dashboards = cls.all(org, user.group_ids, user.id)
tag_column = func.unnest(cls.tags).label("tag")
usage_count = func.count(1).label("usage_count")
query = (
db.session.query(tag_column, usage_count)
.group_by(tag_column)
.filter(Dashboard.id.in_(dashboards.options(load_only("id"))))
.order_by(tag_column)
)
return query
@classmethod
def favorites(cls, user, base_query=None):
if base_query is None:
base_query = cls.all(user.org, user.group_ids, user.id)
return (
base_query.distinct(cls.lowercase_name, Dashboard.created_at, Dashboard.slug, Favorite.created_at)
.join(
(
Favorite,
and_(
Favorite.object_type == "Dashboard",
Favorite.object_id == Dashboard.id,
),
)
)
.filter(Favorite.user_id == user.id)
)
@classmethod
def by_user(cls, user):
return cls.all(user.org, user.group_ids, user.id).filter(Dashboard.user == user)
@classmethod
def get_by_slug_and_org(cls, slug, org):
return cls.query.filter(cls.slug == slug, cls.org == org).one()
def fork(self, user):
forked_list = ["org", "layout", "dashboard_filters_enabled", "tags"]
kwargs = {a: getattr(self, a) for a in forked_list}
forked_dashboard = Dashboard(name="Copy of (#{}) {}".format(self.id, self.name), user=user, **kwargs)
for w in self.widgets:
forked_w = w.copy(forked_dashboard.id)
fw = Widget(**forked_w)
db.session.add(fw)
forked_dashboard.slug = forked_dashboard.id
db.session.add(forked_dashboard)
return forked_dashboard
@hybrid_property
def lowercase_name(self):
"Optional property useful for sorting purposes."
return self.name.lower()
@lowercase_name.expression
def lowercase_name(cls):
"The SQLAlchemy expression for the property above."
return func.lower(cls.name)
@generic_repr("id", "name", "type", "query_id")
class Visualization(TimestampMixin, BelongsToOrgMixin, db.Model):
id = primary_key("Visualization")
type = Column(db.String(100))
query_id = Column(key_type("Query"), db.ForeignKey("queries.id"))
# query_rel and not query, because db.Model already has query defined.
query_rel = db.relationship(Query, back_populates="visualizations")
name = Column(db.String(255))
description = Column(db.String(4096), nullable=True)
options = Column(MutableDict.as_mutable(JSONB), nullable=True)
__tablename__ = "visualizations"
def __str__(self):
return "%s %s" % (self.id, self.type)
@classmethod
def get_by_id_and_org(cls, object_id, org):
return super(Visualization, cls).get_by_id_and_org(object_id, org, Query)
def copy(self):
return {
"type": self.type,
"name": self.name,
"description": self.description,
"options": self.options,
}
@generic_repr("id", "visualization_id", "dashboard_id")
class Widget(TimestampMixin, BelongsToOrgMixin, db.Model):
id = primary_key("Widget")
visualization_id = Column(key_type("Visualization"), db.ForeignKey("visualizations.id"), nullable=True)
visualization = db.relationship(Visualization, backref=backref("widgets", cascade="delete"))
text = Column(db.Text, nullable=True)
width = Column(db.Integer)
options = Column(MutableDict.as_mutable(JSONB), default={})
dashboard_id = Column(key_type("Dashboard"), db.ForeignKey("dashboards.id"), index=True)
__tablename__ = "widgets"
def __str__(self):
return "%s" % self.id
@classmethod
def get_by_id_and_org(cls, object_id, org):
return super(Widget, cls).get_by_id_and_org(object_id, org, Dashboard)
def copy(self, dashboard_id):
return {
"options": self.options,
"width": self.width,
"text": self.text,
"visualization_id": self.visualization_id,
"dashboard_id": dashboard_id,
}
@generic_repr("id", "object_type", "object_id", "action", "user_id", "org_id", "created_at")
class Event(db.Model):
id = primary_key("Event")
org_id = Column(key_type("Organization"), db.ForeignKey("organizations.id"))
org = db.relationship(Organization, back_populates="events")
user_id = Column(key_type("User"), db.ForeignKey("users.id"), nullable=True)
user = db.relationship(User, backref="events")
action = Column(db.String(255))
object_type = Column(db.String(255))
object_id = Column(db.String(255), nullable=True)
additional_properties = Column(MutableDict.as_mutable(JSONB), nullable=True, default={})
created_at = Column(db.DateTime(True), default=db.func.now())
__tablename__ = "events"
def __str__(self):
return "%s,%s,%s,%s" % (
self.user_id,
self.action,
self.object_type,
self.object_id,
)
def to_dict(self):
return {
"org_id": self.org_id,
"user_id": self.user_id,
"action": self.action,
"object_type": self.object_type,
"object_id": self.object_id,
"additional_properties": self.additional_properties,
"created_at": self.created_at.isoformat(),
}
@classmethod
def record(cls, event):
org_id = event.pop("org_id")
user_id = event.pop("user_id", None)
action = event.pop("action")
object_type = event.pop("object_type")
object_id = event.pop("object_id", None)
created_at = datetime.datetime.utcfromtimestamp(event.pop("timestamp"))
event = cls(
org_id=org_id,
user_id=user_id,
action=action,
object_type=object_type,
object_id=object_id,
additional_properties=event,
created_at=created_at,
)
db.session.add(event)
return event
@generic_repr("id", "created_by_id", "org_id", "active")
class ApiKey(TimestampMixin, GFKBase, db.Model):
id = primary_key("ApiKey")
org_id = Column(key_type("Organization"), db.ForeignKey("organizations.id"))
org = db.relationship(Organization)
api_key = Column(db.String(255), index=True, default=lambda: generate_token(40))
active = Column(db.Boolean, default=True)
# 'object' provided by GFKBase
object_id = Column(key_type("ApiKey"))
created_by_id = Column(key_type("User"), db.ForeignKey("users.id"), nullable=True)
created_by = db.relationship(User)
__tablename__ = "api_keys"
__table_args__ = (db.Index("api_keys_object_type_object_id", "object_type", "object_id"),)
@classmethod
def get_by_api_key(cls, api_key):
return cls.query.filter(cls.api_key == api_key, cls.active.is_(True)).one()
@classmethod
def get_by_object(cls, object):
return cls.query.filter(
cls.object_type == object.__class__.__tablename__,
cls.object_id == object.id,
cls.active.is_(True),
).first()
@classmethod
def create_for_object(cls, object, user):
k = cls(org=user.org, object=object, created_by=user)
db.session.add(k)
return k
@generic_repr("id", "name", "type", "user_id", "org_id", "created_at")
class NotificationDestination(BelongsToOrgMixin, db.Model):
id = primary_key("NotificationDestination")
org_id = Column(key_type("Organization"), db.ForeignKey("organizations.id"))
org = db.relationship(Organization, backref="notification_destinations")
user_id = Column(key_type("User"), db.ForeignKey("users.id"))
user = db.relationship(User, backref="notification_destinations")
name = Column(db.String(255))
type = Column(db.String(255))
options = Column(
"encrypted_options",
ConfigurationContainer.as_mutable(
EncryptedConfiguration(db.Text, settings.DATASOURCE_SECRET_KEY, FernetEngine)
),
)
created_at = Column(db.DateTime(True), default=db.func.now())
__tablename__ = "notification_destinations"
__table_args__ = (db.Index("notification_destinations_org_id_name", "org_id", "name", unique=True),)
def __str__(self):
return str(self.name)
def to_dict(self, all=False):
d = {
"id": self.id,
"name": self.name,
"type": self.type,
"icon": self.destination.icon(),
}
if all:
schema = get_configuration_schema_for_destination_type(self.type)
self.options.set_schema(schema)
d["options"] = self.options.to_dict(mask_secrets=True)
return d
@property
def destination(self):
return get_destination(self.type, self.options)
@classmethod
def all(cls, org):
notification_destinations = cls.query.filter(cls.org == org).order_by(cls.id.asc())
return notification_destinations
def notify(self, alert, query, user, new_state, app, host, metadata):
schema = get_configuration_schema_for_destination_type(self.type)
self.options.set_schema(schema)
return self.destination.notify(alert, query, user, new_state, app, host, metadata, self.options)
@generic_repr("id", "user_id", "destination_id", "alert_id")
class AlertSubscription(TimestampMixin, db.Model):
id = primary_key("AlertSubscription")
user_id = Column(key_type("User"), db.ForeignKey("users.id"))
user = db.relationship(User)
destination_id = Column(
key_type("NotificationDestination"), db.ForeignKey("notification_destinations.id"), nullable=True
)
destination = db.relationship(NotificationDestination)
alert_id = Column(key_type("Alert"), db.ForeignKey("alerts.id"))
alert = db.relationship(Alert, back_populates="subscriptions")
__tablename__ = "alert_subscriptions"
__table_args__ = (
db.Index(
"alert_subscriptions_destination_id_alert_id",
"destination_id",
"alert_id",
unique=True,
),
)
def to_dict(self):
d = {"id": self.id, "user": self.user.to_dict(), "alert_id": self.alert_id}
if self.destination:
d["destination"] = self.destination.to_dict()
return d
@classmethod
def all(cls, alert_id):
return AlertSubscription.query.join(User).filter(AlertSubscription.alert_id == alert_id)
def notify(self, alert, query, user, new_state, app, host, metadata):
if self.destination:
return self.destination.notify(alert, query, user, new_state, app, host, metadata)
else:
# User email subscription, so create an email destination object
config = {"addresses": self.user.email}
schema = get_configuration_schema_for_destination_type("email")
options = ConfigurationContainer(config, schema)
destination = get_destination("email", options)
return destination.notify(alert, query, user, new_state, app, host, metadata, options)
@generic_repr("id", "trigger", "user_id", "org_id")
class QuerySnippet(TimestampMixin, db.Model, BelongsToOrgMixin):
id = primary_key("QuerySnippet")
org_id = Column(key_type("Organization"), db.ForeignKey("organizations.id"))
org = db.relationship(Organization, backref="query_snippets")
trigger = Column(db.String(255), unique=True)
description = Column(db.Text)
user_id = Column(key_type("User"), db.ForeignKey("users.id"))
user = db.relationship(User, backref="query_snippets")
snippet = Column(db.Text)
__tablename__ = "query_snippets"
@classmethod
def all(cls, org):
return cls.query.filter(cls.org == org)
def to_dict(self):
d = {
"id": self.id,
"trigger": self.trigger,
"description": self.description,
"snippet": self.snippet,
"user": self.user.to_dict(),
"updated_at": self.updated_at,
"created_at": self.created_at,
}
return d
def init_db():
default_org = Organization(name="Default", slug="default", settings={})
admin_group = Group(
name="admin",
permissions=Group.ADMIN_PERMISSIONS,
org=default_org,
type=Group.BUILTIN_GROUP,
)
default_group = Group(
name="default",
permissions=Group.DEFAULT_PERMISSIONS,
org=default_org,
type=Group.BUILTIN_GROUP,
)
db.session.add_all([default_org, admin_group, default_group])
# XXX remove after fixing User.group_ids
db.session.commit()
return default_org, admin_group, default_group
================================================
FILE: redash/models/base.py
================================================
import functools
from flask_sqlalchemy import BaseQuery, SQLAlchemy
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import object_session
from sqlalchemy.pool import NullPool
from sqlalchemy_searchable import SearchQueryMixin, make_searchable, vectorizer
from redash import settings
from redash.utils import json_dumps, json_loads
class RedashSQLAlchemy(SQLAlchemy):
def apply_driver_hacks(self, app, info, options):
options.update(json_serializer=json_dumps)
if settings.SQLALCHEMY_ENABLE_POOL_PRE_PING:
options.update(pool_pre_ping=True)
return super(RedashSQLAlchemy, self).apply_driver_hacks(app, info, options)
def apply_pool_defaults(self, app, options):
super(RedashSQLAlchemy, self).apply_pool_defaults(app, options)
if settings.SQLALCHEMY_ENABLE_POOL_PRE_PING:
options["pool_pre_ping"] = True
if settings.SQLALCHEMY_DISABLE_POOL:
options["poolclass"] = NullPool
# Remove options NullPool does not support:
options.pop("max_overflow", None)
return options
db = RedashSQLAlchemy(
session_options={"expire_on_commit": False},
engine_options={"json_serializer": json_dumps, "json_deserializer": json_loads},
)
# Make sure the SQLAlchemy mappers are all properly configured first.
# This is required by SQLAlchemy-Searchable as it adds DDL listeners
# on the configuration phase of models.
db.configure_mappers()
# listen to a few database events to set up functions, trigger updates
# and indexes for the full text search
make_searchable(db.metadata, options={"regconfig": "pg_catalog.simple"})
class SearchBaseQuery(BaseQuery, SearchQueryMixin):
"""
The SQA query class to use when full text search is wanted.
"""
@vectorizer(db.Integer)
def integer_vectorizer(column):
return db.func.cast(column, db.Text)
@vectorizer(UUID)
def uuid_vectorizer(column):
return db.func.cast(column, db.Text)
Column = functools.partial(db.Column, nullable=False)
# AccessPermission and Change use a 'generic foreign key' approach to refer to
# either queries or dashboards.
# TODO replace this with association tables.
_gfk_types = {}
def gfk_type(cls):
_gfk_types[cls.__tablename__] = cls
return cls
class GFKBase:
"""
Compatibility with 'generic foreign key' approach Peewee used.
"""
object_type = Column(db.String(255))
object_id = Column(db.Integer)
_object = None
@property
def object(self):
session = object_session(self)
if self._object or not session:
return self._object
else:
object_class = _gfk_types[self.object_type]
self._object = session.query(object_class).filter(object_class.id == self.object_id).first()
return self._object
@object.setter
def object(self, value):
self._object = value
self.object_type = value.__class__.__tablename__
self.object_id = value.id
key_definitions = settings.dynamic_settings.database_key_definitions((db.Integer, {}))
def key_type(name):
return key_definitions[name][0]
def primary_key(name):
key_type, kwargs = key_definitions[name]
return Column(key_type, primary_key=True, **kwargs)
================================================
FILE: redash/models/changes.py
================================================
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.inspection import inspect
from sqlalchemy_utils.models import generic_repr
from .base import Column, GFKBase, db, key_type, primary_key
@generic_repr("id", "object_type", "object_id", "created_at")
class Change(GFKBase, db.Model):
id = primary_key("Change")
# 'object' defined in GFKBase
object_id = Column(key_type("Change"))
object_version = Column(db.Integer, default=0)
user_id = Column(key_type("User"), db.ForeignKey("users.id"))
user = db.relationship("User", backref="changes")
change = Column(JSONB)
created_at = Column(db.DateTime(True), default=db.func.now())
__tablename__ = "changes"
def to_dict(self, full=True):
d = {
"id": self.id,
"object_id": self.object_id,
"object_type": self.object_type,
"change_type": self.change_type,
"object_version": self.object_version,
"change": self.change,
"created_at": self.created_at,
}
if full:
d["user"] = self.user.to_dict()
else:
d["user_id"] = self.user_id
return d
@classmethod
def last_change(cls, obj):
return (
cls.query.filter(cls.object_id == obj.id, cls.object_type == obj.__class__.__tablename__)
.order_by(cls.object_version.desc())
.first()
)
class ChangeTrackingMixin:
skipped_fields = ("id", "created_at", "updated_at", "version")
_clean_values = None
def __init__(self, *a, **kw):
super(ChangeTrackingMixin, self).__init__(*a, **kw)
self.record_changes(self.user)
def prep_cleanvalues(self):
self.__dict__["_clean_values"] = {}
for attr in inspect(self.__class__).column_attrs:
(col,) = attr.columns
# 'query' is col name but not attr name
self._clean_values[col.name] = None
def __setattr__(self, key, value):
if self._clean_values is None:
self.prep_cleanvalues()
for attr in inspect(self.__class__).column_attrs:
(col,) = attr.columns
previous = getattr(self, attr.key, None)
self._clean_values[col.name] = previous
super(ChangeTrackingMixin, self).__setattr__(key, value)
def record_changes(self, changed_by):
db.session.add(self)
db.session.flush()
changes = {}
for attr in inspect(self.__class__).column_attrs:
(col,) = attr.columns
if attr.key not in self.skipped_fields:
changes[col.name] = {
"previous": self._clean_values[col.name],
"current": getattr(self, attr.key),
}
db.session.add(
Change(
object=self,
object_version=self.version,
user=changed_by,
change=changes,
)
)
================================================
FILE: redash/models/mixins.py
================================================
from sqlalchemy.event import listens_for
from .base import Column, db
class TimestampMixin:
updated_at = Column(db.DateTime(True), default=db.func.now(), nullable=False)
created_at = Column(db.DateTime(True), default=db.func.now(), nullable=False)
@listens_for(TimestampMixin, "before_update", propagate=True)
def timestamp_before_update(mapper, connection, target):
# Check if we really want to update the updated_at value
if hasattr(target, "skip_updated_at"):
return
target.updated_at = db.func.now()
class BelongsToOrgMixin:
@classmethod
def get_by_id_and_org(cls, object_id, org, org_cls=None):
query = cls.query.filter(cls.id == object_id)
if org_cls is None:
query = query.filter(cls.org == org)
else:
query = query.join(org_cls).filter(org_cls.org == org)
return query.one()
================================================
FILE: redash/models/organizations.py
================================================
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm.attributes import flag_modified
from sqlalchemy_utils.models import generic_repr
from redash.settings.organization import settings as org_settings
from .base import Column, db, primary_key
from .mixins import TimestampMixin
from .types import MutableDict
from .users import Group, User
@generic_repr("id", "name", "slug")
class Organization(TimestampMixin, db.Model):
SETTING_GOOGLE_APPS_DOMAINS = "google_apps_domains"
SETTING_IS_PUBLIC = "is_public"
id = primary_key("Organization")
name = Column(db.String(255))
slug = Column(db.String(255), unique=True)
settings = Column(MutableDict.as_mutable(JSONB), default={})
groups = db.relationship("Group", lazy="dynamic")
events = db.relationship("Event", lazy="dynamic", order_by="desc(Event.created_at)")
__tablename__ = "organizations"
def __str__(self):
return "%s (%s)" % (self.name, self.id)
@classmethod
def get_by_slug(cls, slug):
return cls.query.filter(cls.slug == slug).first()
@classmethod
def get_by_id(cls, _id):
return cls.query.filter(cls.id == _id).one()
@property
def default_group(self):
return self.groups.filter(Group.name == "default", Group.type == Group.BUILTIN_GROUP).first()
@property
def google_apps_domains(self):
return self.settings.get(self.SETTING_GOOGLE_APPS_DOMAINS, [])
@property
def is_public(self):
return self.settings.get(self.SETTING_IS_PUBLIC, False)
@property
def is_disabled(self):
return self.settings.get("is_disabled", False)
def disable(self):
self.settings["is_disabled"] = True
def enable(self):
self.settings["is_disabled"] = False
def set_setting(self, key, value):
if key not in org_settings:
raise KeyError(key)
self.settings.setdefault("settings", {})
self.settings["settings"][key] = value
flag_modified(self, "settings")
def get_setting(self, key, raise_on_missing=True):
if key in self.settings.get("settings", {}):
return self.settings["settings"][key]
if key in org_settings:
return org_settings[key]
if raise_on_missing:
raise KeyError(key)
return None
@property
def admin_group(self):
return self.groups.filter(Group.name == "admin", Group.type == Group.BUILTIN_GROUP).first()
def has_user(self, email):
return self.users.filter(User.email == email).count() == 1
================================================
FILE: redash/models/parameterized_query.py
================================================
import re
from functools import partial
from numbers import Number
import pystache
from dateutil.parser import parse
from funcy import distinct
from redash.utils import mustache_render
def _pluck_name_and_value(default_column, row):
row = {k.lower(): v for k, v in row.items()}
name_column = "name" if "name" in row.keys() else default_column.lower()
value_column = "value" if "value" in row.keys() else default_column.lower()
return {"name": row[name_column], "value": str(row[value_column])}
def _load_result(query_id, org):
from redash import models
query = models.Query.get_by_id_and_org(query_id, org)
if query.data_source:
query_result = models.QueryResult.get_by_id_and_org(query.latest_query_data_id, org)
return query_result.data
else:
raise QueryDetachedFromDataSourceError(query_id)
def dropdown_values(query_id, org):
data = _load_result(query_id, org)
first_column = data["columns"][0]["name"]
pluck = partial(_pluck_name_and_value, first_column)
return list(map(pluck, data["rows"]))
def join_parameter_list_values(parameters, schema):
updated_parameters = {}
for key, value in parameters.items():
if isinstance(value, list):
definition = next((definition for definition in schema if definition["name"] == key), {})
multi_values_options = definition.get("multiValuesOptions", {})
separator = str(multi_values_options.get("separator", ","))
prefix = str(multi_values_options.get("prefix", ""))
suffix = str(multi_values_options.get("suffix", ""))
updated_parameters[key] = separator.join([prefix + v + suffix for v in value])
else:
updated_parameters[key] = value
return updated_parameters
def _collect_key_names(nodes):
keys = []
for node in nodes._parse_tree:
if isinstance(node, pystache.parser._EscapeNode):
keys.append(node.key)
elif isinstance(node, pystache.parser._SectionNode):
keys.append(node.key)
keys.extend(_collect_key_names(node.parsed))
return distinct(keys)
def _collect_query_parameters(query):
nodes = pystache.parse(query)
keys = _collect_key_names(nodes)
return keys
def _parameter_names(parameter_values):
names = []
for key, value in parameter_values.items():
if isinstance(value, dict):
for inner_key in value.keys():
names.append("{}.{}".format(key, inner_key))
else:
names.append(key)
return names
def _is_number(string):
if isinstance(string, Number):
return True
else:
float(string)
return True
def _is_regex_pattern(value, regex):
try:
if re.compile(regex).fullmatch(value):
return True
else:
return False
except re.error:
return False
def _is_date(string):
parse(string)
return True
def _is_date_range(obj):
return _is_date(obj["start"]) and _is_date(obj["end"])
def _is_value_within_options(value, dropdown_options, allow_list=False):
if isinstance(value, list):
return allow_list and set(map(str, value)).issubset(set(dropdown_options))
return str(value) in dropdown_options
class ParameterizedQuery:
def __init__(self, template, schema=None, org=None):
self.schema = schema or []
self.org = org
self.template = template
self.query = template
self.parameters = {}
def apply(self, parameters):
invalid_parameter_names = [key for (key, value) in parameters.items() if not self._valid(key, value)]
if invalid_parameter_names:
raise InvalidParameterError(invalid_parameter_names)
else:
self.parameters.update(parameters)
self.query = mustache_render(self.template, join_parameter_list_values(parameters, self.schema))
return self
def _valid(self, name, value):
if not self.schema:
return True
definition = next(
(definition for definition in self.schema if definition["name"] == name),
None,
)
if not definition:
return False
enum_options = definition.get("enumOptions")
query_id = definition.get("queryId")
regex = definition.get("regex")
allow_multiple_values = isinstance(definition.get("multiValuesOptions"), dict)
if isinstance(enum_options, str):
enum_options = enum_options.split("\n")
validators = {
"text": lambda value: isinstance(value, str),
"text-pattern": lambda value: _is_regex_pattern(value, regex),
"number": _is_number,
"enum": lambda value: _is_value_within_options(value, enum_options, allow_multiple_values),
"query": lambda value: _is_value_within_options(
value,
[v["value"] for v in dropdown_values(query_id, self.org)],
allow_multiple_values,
),
"date": _is_date,
"datetime-local": _is_date,
"datetime-with-seconds": _is_date,
"date-range": _is_date_range,
"datetime-range": _is_date_range,
"datetime-range-with-seconds": _is_date_range,
}
validate = validators.get(definition["type"], lambda x: False)
try:
# multiple error types can be raised here; but we want to convert
# all except QueryDetached to InvalidParameterError in `apply`
return validate(value)
except QueryDetachedFromDataSourceError:
raise
except Exception:
return False
@property
def is_safe(self):
text_parameters = [param for param in self.schema if param["type"] == "text"]
return not any(text_parameters)
@property
def missing_params(self):
query_parameters = set(_collect_query_parameters(self.template))
return set(query_parameters) - set(_parameter_names(self.parameters))
@property
def text(self):
return self.query
class InvalidParameterError(Exception):
def __init__(self, parameters):
parameter_names = ", ".join(parameters)
message = "The following parameter values are incompatible with their definitions: {}".format(parameter_names)
super(InvalidParameterError, self).__init__(message)
class QueryDetachedFromDataSourceError(Exception):
def __init__(self, query_id):
self.query_id = query_id
super(QueryDetachedFromDataSourceError, self).__init__(
"This query is detached from any data source. Please select a different query."
)
================================================
FILE: redash/models/types.py
================================================
from sqlalchemy.ext.indexable import index_property
from sqlalchemy.ext.mutable import Mutable
from sqlalchemy.types import TypeDecorator
from sqlalchemy_utils import EncryptedType
from redash.utils import json_dumps, json_loads
from redash.utils.configuration import ConfigurationContainer
from .base import db
class Configuration(TypeDecorator):
impl = db.Text
def process_bind_param(self, value, dialect):
return value.to_json()
def process_result_value(self, value, dialect):
return ConfigurationContainer.from_json(value)
class EncryptedConfiguration(EncryptedType):
def process_bind_param(self, value, dialect):
return super(EncryptedConfiguration, self).process_bind_param(value.to_json(), dialect)
def process_result_value(self, value, dialect):
return ConfigurationContainer.from_json(
super(EncryptedConfiguration, self).process_result_value(value, dialect)
)
# Utilized for cases when JSON size is bigger than JSONB (255MB) or JSON (10MB) limit
class JSONText(TypeDecorator):
impl = db.Text
def process_bind_param(self, value, dialect):
if value is None:
return value
return json_dumps(value)
def process_result_value(self, value, dialect):
if not value:
return value
return json_loads(value)
class MutableDict(Mutable, dict):
@classmethod
def coerce(cls, key, value):
"Convert plain dictionaries to MutableDict."
if not isinstance(value, MutableDict):
if isinstance(value, dict):
return MutableDict(value)
# this call will raise ValueError
return Mutable.coerce(key, value)
else:
return value
def __setitem__(self, key, value):
"Detect dictionary set events and emit change events."
dict.__setitem__(self, key, value)
self.changed()
def __delitem__(self, key):
"Detect dictionary del events and emit change events."
dict.__delitem__(self, key)
self.changed()
class MutableList(Mutable, list):
def append(self, value):
list.append(self, value)
self.changed()
def remove(self, value):
list.remove(self, value)
self.changed()
@classmethod
def coerce(cls, key, value):
if not isinstance(value, MutableList):
if isinstance(value, list):
return MutableList(value)
return Mutable.coerce(key, value)
else:
return value
class json_cast_property(index_property):
"""
A SQLAlchemy index property that is able to cast the
entity attribute as the specified cast type. Useful
for JSON and JSONB colums for easier querying/filtering.
"""
def __init__(self, cast_type, *args, **kwargs):
super(json_cast_property, self).__init__(*args, **kwargs)
self.cast_type = cast_type
def expr(self, model):
expr = super(json_cast_property, self).expr(model)
return expr.astext.cast(self.cast_type)
================================================
FILE: redash/models/users.py
================================================
import hashlib
import itertools
import logging
import time
from functools import reduce
from operator import or_
from flask import current_app, request_started, url_for
from flask_login import AnonymousUserMixin, UserMixin, current_user
from passlib.apps import custom_app_context as pwd_context
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
from sqlalchemy_utils import EmailType
from sqlalchemy_utils.models import generic_repr
from redash import redis_connection
from redash.utils import dt_from_timestamp, generate_token
from .base import Column, GFKBase, db, key_type, primary_key
from .mixins import BelongsToOrgMixin, TimestampMixin
from .types import MutableDict, MutableList, json_cast_property
logger = logging.getLogger(__name__)
LAST_ACTIVE_KEY = "users:last_active_at"
def sync_last_active_at():
"""
Update User model with the active_at timestamp from Redis. We first fetch
all the user_ids to update, and then fetch the timestamp to minimize the
time between fetching the value and updating the DB. This is because there
might be a more recent update we skip otherwise.
"""
user_ids = redis_connection.hkeys(LAST_ACTIVE_KEY)
for user_id in user_ids:
timestamp = redis_connection.hget(LAST_ACTIVE_KEY, user_id)
active_at = dt_from_timestamp(timestamp)
user = User.query.filter(User.id == user_id).first()
if user:
user.active_at = active_at
redis_connection.hdel(LAST_ACTIVE_KEY, user_id)
db.session.commit()
def update_user_active_at(sender, *args, **kwargs):
"""
Used as a Flask request_started signal callback that adds
the current user's details to Redis
"""
if current_user.is_authenticated and not current_user.is_api_user():
redis_connection.hset(LAST_ACTIVE_KEY, current_user.id, int(time.time()))
def init_app(app):
"""
A Flask extension to keep user details updates in Redis and
sync it periodically to the database (User.details).
"""
request_started.connect(update_user_active_at, app)
class PermissionsCheckMixin:
def has_permission(self, permission):
return self.has_permissions((permission,))
def has_permissions(self, permissions):
has_permissions = reduce(
lambda a, b: a and b,
[permission in self.permissions for permission in permissions],
True,
)
return has_permissions
@generic_repr("id", "name", "email")
class User(TimestampMixin, db.Model, BelongsToOrgMixin, UserMixin, PermissionsCheckMixin):
id = primary_key("User")
org_id = Column(key_type("Organization"), db.ForeignKey("organizations.id"))
org = db.relationship("Organization", backref=db.backref("users", lazy="dynamic"))
name = Column(db.String(320))
email = Column(EmailType)
password_hash = Column(db.String(128), nullable=True)
group_ids = Column(
"groups",
MutableList.as_mutable(ARRAY(key_type("Group"))),
nullable=True,
)
api_key = Column(db.String(40), default=lambda: generate_token(40), unique=True)
disabled_at = Column(db.DateTime(True), default=None, nullable=True)
details = Column(
MutableDict.as_mutable(JSONB),
nullable=True,
server_default="{}",
default={},
)
active_at = json_cast_property(db.DateTime(True), "details", "active_at", default=None)
_profile_image_url = json_cast_property(db.Text(), "details", "profile_image_url", default=None)
is_invitation_pending = json_cast_property(db.Boolean(True), "details", "is_invitation_pending", default=False)
is_email_verified = json_cast_property(db.Boolean(True), "details", "is_email_verified", default=True)
__tablename__ = "users"
__table_args__ = (db.Index("users_org_id_email", "org_id", "email", unique=True),)
def __str__(self):
return "%s (%s)" % (self.name, self.email)
def __init__(self, *args, **kwargs):
if kwargs.get("email") is not None:
kwargs["email"] = kwargs["email"].lower()
super(User, self).__init__(*args, **kwargs)
@property
def is_disabled(self):
return self.disabled_at is not None
def disable(self):
self.disabled_at = db.func.now()
def enable(self):
self.disabled_at = None
def regenerate_api_key(self):
self.api_key = generate_token(40)
def to_dict(self, with_api_key=False):
profile_image_url = self.profile_image_url
if self.is_disabled:
assets = current_app.extensions["webpack"]["assets"] or {}
path = "images/avatar.svg"
profile_image_url = url_for("static", filename=assets.get(path, path))
d = {
"id": self.id,
"name": self.name,
"email": self.email,
"profile_image_url": profile_image_url,
"groups": self.group_ids,
"updated_at": self.updated_at,
"created_at": self.created_at,
"disabled_at": self.disabled_at,
"is_disabled": self.is_disabled,
"active_at": self.active_at,
"is_invitation_pending": self.is_invitation_pending,
"is_email_verified": self.is_email_verified,
}
if self.password_hash is None:
d["auth_type"] = "external"
else:
d["auth_type"] = "password"
if with_api_key:
d["api_key"] = self.api_key
return d
@staticmethod
def is_api_user():
return False
@property
def profile_image_url(self):
if self._profile_image_url:
return self._profile_image_url
email_md5 = hashlib.md5(self.email.lower().encode(), usedforsecurity=False).hexdigest()
return "https://www.gravatar.com/avatar/{}?s=40&d=identicon".format(email_md5)
@property
def permissions(self):
# TODO: this should be cached.
return list(itertools.chain(*[g.permissions for g in Group.query.filter(Group.id.in_(self.group_ids))]))
@classmethod
def get_by_org(cls, org):
return cls.query.filter(cls.org == org)
@classmethod
def get_by_id(cls, _id):
return cls.query.filter(cls.id == _id).one()
@classmethod
def get_by_email_and_org(cls, email, org):
return cls.get_by_org(org).filter(cls.email == email).one()
@classmethod
def get_by_api_key_and_org(cls, api_key, org):
return cls.get_by_org(org).filter(cls.api_key == api_key).one()
@classmethod
def all(cls, org):
return cls.get_by_org(org).filter(cls.disabled_at.is_(None))
@classmethod
def all_disabled(cls, org):
return cls.get_by_org(org).filter(cls.disabled_at.isnot(None))
@classmethod
def search(cls, base_query, term):
term = "%{}%".format(term)
search_filter = or_(cls.name.ilike(term), cls.email.like(term))
return base_query.filter(search_filter)
@classmethod
def pending(cls, base_query, pending):
if pending:
return base_query.filter(cls.is_invitation_pending.is_(True))
else:
return base_query.filter(cls.is_invitation_pending.isnot(True)) # check for both `false`/`null`
@classmethod
def find_by_email(cls, email):
return cls.query.filter(cls.email == email)
def hash_password(self, password):
self.password_hash = pwd_context.hash(password)
def verify_password(self, password):
return self.password_hash and pwd_context.verify(password, self.password_hash)
def update_group_assignments(self, group_names):
groups = Group.find_by_name(self.org, group_names)
groups.append(self.org.default_group)
self.group_ids = [g.id for g in groups]
db.session.add(self)
db.session.commit()
def has_access(self, obj, access_type):
return AccessPermission.exists(obj, access_type, grantee=self)
def get_id(self):
identity = hashlib.md5(
"{},{}".format(self.email, self.password_hash).encode(), usedforsecurity=False
).hexdigest()
return "{0}-{1}".format(self.id, identity)
def get_actual_user(self):
return repr(self) if self.is_api_user() else self.email
@generic_repr("id", "name", "type", "org_id")
class Group(db.Model, BelongsToOrgMixin):
DEFAULT_PERMISSIONS = [
"create_dashboard",
"create_query",
"edit_dashboard",
"edit_query",
"view_query",
"view_source",
"execute_query",
"list_users",
"schedule_query",
"list_dashboards",
"list_alerts",
"list_data_sources",
]
ADMIN_PERMISSIONS = ["admin", "super_admin"]
BUILTIN_GROUP = "builtin"
REGULAR_GROUP = "regular"
id = primary_key("Group")
data_sources = db.relationship("DataSourceGroup", back_populates="group", cascade="all")
org_id = Column(key_type("Organization"), db.ForeignKey("organizations.id"))
org = db.relationship("Organization", back_populates="groups")
type = Column(db.String(255), default=REGULAR_GROUP)
name = Column(db.String(100))
permissions = Column(ARRAY(db.String(255)), default=DEFAULT_PERMISSIONS)
created_at = Column(db.DateTime(True), default=db.func.now())
__tablename__ = "groups"
def __str__(self):
return str(self.id)
def to_dict(self):
return {
"id": self.id,
"name": self.name,
"permissions": self.permissions,
"type": self.type,
"created_at": self.created_at,
}
@classmethod
def all(cls, org):
return cls.query.filter(cls.org == org)
@classmethod
def members(cls, group_id):
return User.query.filter(User.group_ids.any(group_id))
@classmethod
def find_by_name(cls, org, group_names):
result = cls.query.filter(cls.org == org, cls.name.in_(group_names))
return list(result)
@generic_repr("id", "object_type", "object_id", "access_type", "grantor_id", "grantee_id")
class AccessPermission(GFKBase, db.Model):
id = primary_key("AccessPermission")
# 'object' defined in GFKBase
access_type = Column(db.String(255))
grantor_id = Column(key_type("User"), db.ForeignKey("users.id"))
grantor = db.relationship(User, backref="grantor", foreign_keys=[grantor_id])
grantee_id = Column(key_type("User"), db.ForeignKey("users.id"))
grantee = db.relationship(User, backref="grantee", foreign_keys=[grantee_id])
__tablename__ = "access_permissions"
@classmethod
def grant(cls, obj, access_type, grantee, grantor):
grant = cls.query.filter(
cls.object_type == obj.__tablename__,
cls.object_id == obj.id,
cls.access_type == access_type,
cls.grantee == grantee,
cls.grantor == grantor,
).one_or_none()
if not grant:
grant = cls(
object_type=obj.__tablename__,
object_id=obj.id,
access_type=access_type,
grantee=grantee,
grantor=grantor,
)
db.session.add(grant)
return grant
@classmethod
def revoke(cls, obj, grantee, access_type=None):
permissions = cls._query(obj, access_type, grantee)
return permissions.delete()
@classmethod
def find(cls, obj, access_type=None, grantee=None, grantor=None):
return cls._query(obj, access_type, grantee, grantor)
@classmethod
def exists(cls, obj, access_type, grantee):
return cls.find(obj, access_type, grantee).count() > 0
@classmethod
def _query(cls, obj, access_type=None, grantee=None, grantor=None):
q = cls.query.filter(cls.object_id == obj.id, cls.object_type == obj.__tablename__)
if access_type:
q = q.filter(AccessPermission.access_type == access_type)
if grantee:
q = q.filter(AccessPermission.grantee == grantee)
if grantor:
q = q.filter(AccessPermission.grantor == grantor)
return q
def to_dict(self):
d = {
"id": self.id,
"object_id": self.object_id,
"object_type": self.object_type,
"access_type": self.access_type,
"grantor": self.grantor_id,
"grantee": self.grantee_id,
}
return d
class AnonymousUser(AnonymousUserMixin, PermissionsCheckMixin):
@property
def permissions(self):
return []
@staticmethod
def is_api_user():
return False
class ApiUser(UserMixin, PermissionsCheckMixin):
def __init__(self, api_key, org, groups, name=None):
self.object = None
if isinstance(api_key, str):
self.id = api_key
self.name = name
else:
self.id = api_key.api_key
self.name = "ApiKey: {}".format(api_key.id)
self.object = api_key.object
self.group_ids = groups
self.org = org
def __repr__(self):
return "<{}>".format(self.name)
@staticmethod
def is_api_user():
return True
@property
def org_id(self):
if not self.org:
return None
return self.org.id
@property
def permissions(self):
return ["view_query"]
@staticmethod
def has_access(obj, access_type):
return False
def get_actual_user(self):
return repr(self)
================================================
FILE: redash/monitor.py
================================================
from funcy import flatten
from rq import Queue, Worker
from rq.job import Job
from rq.registry import StartedJobRegistry
from redash import __version__, redis_connection, rq_redis_connection, settings
from redash.models import Dashboard, Query, QueryResult, Widget, db
def get_redis_status():
info = redis_connection.info()
return {
"redis_used_memory": info["used_memory"],
"redis_used_memory_human": info["used_memory_human"],
}
def get_object_counts():
status = {}
status["queries_count"] = Query.query.count()
if settings.FEATURE_SHOW_QUERY_RESULTS_COUNT:
status["query_results_count"] = QueryResult.query.count()
status["unused_query_results_count"] = QueryResult.unused(settings.QUERY_RESULTS_CLEANUP_MAX_AGE).count()
status["dashboards_count"] = Dashboard.query.count()
status["widgets_count"] = Widget.query.count()
return status
def get_queues_status():
return {queue.name: {"size": len(queue)} for queue in Queue.all(connection=rq_redis_connection)}
def get_db_sizes():
database_metrics = []
queries = [
[
"Query Results Size",
"select pg_total_relation_size('query_results') as size from (select 1) as a",
],
["Redash DB Size", "select pg_database_size(current_database()) as size"],
]
for query_name, query in queries:
result = db.session.execute(query).first()
database_metrics.append([query_name, result[0]])
return database_metrics
def get_status():
status = {"version": __version__, "workers": []}
status.update(get_redis_status())
status.update(get_object_counts())
status["manager"] = redis_connection.hgetall("redash:status")
status["manager"]["queues"] = get_queues_status()
status["database_metrics"] = {}
status["database_metrics"]["metrics"] = get_db_sizes()
return status
def rq_job_ids():
queues = Queue.all(connection=rq_redis_connection)
started_jobs = [StartedJobRegistry(queue=q).get_job_ids() for q in queues]
queued_jobs = [q.job_ids for q in queues]
return flatten(started_jobs + queued_jobs)
def fetch_jobs(job_ids):
return [
{
"id": job.id,
"name": job.func_name,
"origin": job.origin,
"enqueued_at": job.enqueued_at,
"started_at": job.started_at,
"meta": job.meta,
}
for job in Job.fetch_many(job_ids, connection=rq_redis_connection)
if job is not None
]
def rq_queues():
return {
q.name: {
"name": q.name,
"started": fetch_jobs(StartedJobRegistry(queue=q).get_job_ids()),
"queued": len(q.job_ids),
}
for q in sorted(Queue.all(), key=lambda q: q.name)
}
def describe_job(job):
return "{} ({})".format(job.id, job.func_name.split(".").pop()) if job else None
def rq_workers():
return [
{
"name": w.name,
"hostname": w.hostname,
"pid": w.pid,
"queues": ", ".join([q.name for q in w.queues]),
"state": w.state,
"last_heartbeat": w.last_heartbeat,
"birth_date": w.birth_date,
"current_job": describe_job(w.get_current_job()),
"successful_jobs": w.successful_job_count,
"failed_jobs": w.failed_job_count,
"total_working_time": w.total_working_time,
}
for w in Worker.all()
]
def rq_status():
return {"queues": rq_queues(), "workers": rq_workers()}
================================================
FILE: redash/permissions.py
================================================
import functools
from flask_login import current_user
from flask_restful import abort
from funcy import flatten
view_only = True
not_view_only = False
ACCESS_TYPE_VIEW = "view"
ACCESS_TYPE_MODIFY = "modify"
ACCESS_TYPE_DELETE = "delete"
ACCESS_TYPES = (ACCESS_TYPE_VIEW, ACCESS_TYPE_MODIFY, ACCESS_TYPE_DELETE)
def has_access(obj, user, need_view_only):
if hasattr(obj, "api_key") and user.is_api_user():
return has_access_to_object(obj, user.id, need_view_only)
else:
return has_access_to_groups(obj, user, need_view_only)
def has_access_to_object(obj, api_key, need_view_only):
if obj.api_key == api_key:
return need_view_only
elif hasattr(obj, "dashboard_api_keys"):
# check if api_key belongs to a dashboard containing this query
return api_key in obj.dashboard_api_keys and need_view_only
else:
return False
def has_access_to_groups(obj, user, need_view_only):
groups = obj.groups if hasattr(obj, "groups") else obj
if "admin" in user.permissions:
return True
matching_groups = set(groups.keys()).intersection(user.group_ids)
if not matching_groups:
return False
required_level = 1 if need_view_only else 2
group_level = 1 if all(flatten([groups[group] for group in matching_groups])) else 2
return required_level <= group_level
def require_access(obj, user, need_view_only):
if not has_access(obj, user, need_view_only):
abort(403)
class require_permissions:
def __init__(self, permissions, allow_one=False):
self.permissions = permissions
self.allow_one = allow_one
def __call__(self, fn):
@functools.wraps(fn)
def decorated(*args, **kwargs):
if self.allow_one:
has_permissions = any([current_user.has_permission(permission) for permission in self.permissions])
else:
has_permissions = current_user.has_permissions(self.permissions)
if has_permissions:
return fn(*args, **kwargs)
else:
abort(403)
return decorated
def require_permission(permission):
return require_permissions((permission,))
def require_any_of_permission(permissions):
return require_permissions(permissions, True)
def require_admin(fn):
return require_permission("admin")(fn)
def require_super_admin(fn):
return require_permission("super_admin")(fn)
def has_permission_or_owner(permission, object_owner_id):
return int(object_owner_id) == current_user.id or current_user.has_permission(permission)
def is_admin_or_owner(object_owner_id):
return has_permission_or_owner("admin", object_owner_id)
def require_permission_or_owner(permission, object_owner_id):
if not has_permission_or_owner(permission, object_owner_id):
abort(403)
def require_admin_or_owner(object_owner_id):
if not is_admin_or_owner(object_owner_id):
abort(403, message="You don't have permission to edit this resource.")
def can_modify(obj, user):
return is_admin_or_owner(obj.user_id) or user.has_access(obj, ACCESS_TYPE_MODIFY)
def require_object_modify_permission(obj, user):
if not can_modify(obj, user):
abort(403)
================================================
FILE: redash/query_runner/__init__.py
================================================
import logging
from collections import defaultdict
from contextlib import ExitStack
from functools import wraps
import sqlparse
from dateutil import parser
from rq.timeouts import JobTimeoutException
from sshtunnel import open_tunnel
from redash import settings, utils
from redash.utils.requests_session import (
UnacceptableAddressException,
requests_or_advocate,
requests_session,
)
logger = logging.getLogger(__name__)
__all__ = [
"BaseQueryRunner",
"BaseHTTPQueryRunner",
"InterruptException",
"JobTimeoutException",
"BaseSQLQueryRunner",
"TYPE_DATETIME",
"TYPE_BOOLEAN",
"TYPE_INTEGER",
"TYPE_STRING",
"TYPE_DATE",
"TYPE_FLOAT",
"SUPPORTED_COLUMN_TYPES",
"register",
"get_query_runner",
"import_query_runners",
"guess_type",
]
# Valid types of columns returned in results:
TYPE_INTEGER = "integer"
TYPE_FLOAT = "float"
TYPE_BOOLEAN = "boolean"
TYPE_STRING = "string"
TYPE_DATETIME = "datetime"
TYPE_DATE = "date"
SUPPORTED_COLUMN_TYPES = set([TYPE_INTEGER, TYPE_FLOAT, TYPE_BOOLEAN, TYPE_STRING, TYPE_DATETIME, TYPE_DATE])
def split_sql_statements(query):
def strip_trailing_comments(stmt):
idx = len(stmt.tokens) - 1
while idx >= 0:
tok = stmt.tokens[idx]
if tok.is_whitespace or sqlparse.utils.imt(tok, i=sqlparse.sql.Comment, t=sqlparse.tokens.Comment):
stmt.tokens[idx] = sqlparse.sql.Token(sqlparse.tokens.Whitespace, " ")
else:
break
idx -= 1
return stmt
def strip_trailing_semicolon(stmt):
idx = len(stmt.tokens) - 1
while idx >= 0:
tok = stmt.tokens[idx]
# we expect that trailing comments already are removed
if not tok.is_whitespace:
if sqlparse.utils.imt(tok, t=sqlparse.tokens.Punctuation) and tok.value == ";":
stmt.tokens[idx] = sqlparse.sql.Token(sqlparse.tokens.Whitespace, " ")
break
idx -= 1
return stmt
def is_empty_statement(stmt):
# copy statement object. `copy.deepcopy` fails to do this, so just re-parse it
st = sqlparse.engine.FilterStack()
st.stmtprocess.append(sqlparse.filters.StripCommentsFilter())
stmt = next(st.run(str(stmt)), None)
if stmt is None:
return True
return str(stmt).strip() == ""
stack = sqlparse.engine.FilterStack()
result = [stmt for stmt in stack.run(query)]
result = [strip_trailing_comments(stmt) for stmt in result]
result = [strip_trailing_semicolon(stmt) for stmt in result]
result = [str(stmt).strip() for stmt in result if not is_empty_statement(stmt)]
if len(result) > 0:
return result
return [""] # if all statements were empty - return a single empty statement
def combine_sql_statements(queries):
return ";\n".join(queries)
def find_last_keyword_idx(parsed_query):
for i in reversed(range(len(parsed_query.tokens))):
if parsed_query.tokens[i].ttype in sqlparse.tokens.Keyword:
return i
return -1
class InterruptException(Exception):
pass
class NotSupported(Exception):
pass
class BaseQueryRunner:
deprecated = False
should_annotate_query = True
noop_query = None
limit_query = " LIMIT 1000"
limit_keywords = ["LIMIT", "OFFSET"]
limit_after_select = False
def __init__(self, configuration):
self.syntax = "sql"
self.configuration = configuration
@classmethod
def name(cls):
return cls.__name__
@classmethod
def type(cls):
return cls.__name__.lower()
@classmethod
def enabled(cls):
return True
@property
def host(self):
"""Returns this query runner's configured host.
This is used primarily for temporarily swapping endpoints when using SSH tunnels to connect to a data source.
`BaseQueryRunner`'s naïve implementation supports query runner implementations that store endpoints using `host` and `port`
configuration values. If your query runner uses a different schema (e.g. a web address), you should override this function.
"""
if "host" in self.configuration:
return self.configuration["host"]
else:
raise NotImplementedError()
@host.setter
def host(self, host):
"""Sets this query runner's configured host.
This is used primarily for temporarily swapping endpoints when using SSH tunnels to connect to a data source.
`BaseQueryRunner`'s naïve implementation supports query runner implementations that store endpoints using `host` and `port`
configuration values. If your query runner uses a different schema (e.g. a web address), you should override this function.
"""
if "host" in self.configuration:
self.configuration["host"] = host
else:
raise NotImplementedError()
@property
def port(self):
"""Returns this query runner's configured port.
This is used primarily for temporarily swapping endpoints when using SSH tunnels to connect to a data source.
`BaseQueryRunner`'s naïve implementation supports query runner implementations that store endpoints using `host` and `port`
configuration values. If your query runner uses a different schema (e.g. a web address), you should override this function.
"""
if "port" in self.configuration:
return self.configuration["port"]
else:
raise NotImplementedError()
@port.setter
def port(self, port):
"""Sets this query runner's configured port.
This is used primarily for temporarily swapping endpoints when using SSH tunnels to connect to a data source.
`BaseQueryRunner`'s naïve implementation supports query runner implementations that store endpoints using `host` and `port`
configuration values. If your query runner uses a different schema (e.g. a web address), you should override this function.
"""
if "port" in self.configuration:
self.configuration["port"] = port
else:
raise NotImplementedError()
@classmethod
def configuration_schema(cls):
return {}
def annotate_query(self, query, metadata):
if not self.should_annotate_query:
return query
annotation = ", ".join(["{}: {}".format(k, v) for k, v in metadata.items()])
annotated_query = "/* {} */ {}".format(annotation, query)
return annotated_query
def test_connection(self):
if self.noop_query is None:
raise NotImplementedError()
data, error = self.run_query(self.noop_query, None)
if error is not None:
raise Exception(error)
def run_query(self, query, user):
raise NotImplementedError()
def fetch_columns(self, columns):
column_names = set()
duplicates_counters = defaultdict(int)
new_columns = []
for col in columns:
column_name = col[0]
while column_name in column_names:
duplicates_counters[col[0]] += 1
column_name = "{}{}".format(col[0], duplicates_counters[col[0]])
column_names.add(column_name)
new_columns.append({"name": column_name, "friendly_name": column_name, "type": col[1]})
return new_columns
def get_schema(self, get_stats=False):
raise NotSupported()
def _handle_run_query_error(self, error):
if error is None:
return
logger.error(error)
raise Exception(f"Error during query execution. Reason: {error}")
def _run_query_internal(self, query):
results, error = self.run_query(query, None)
if error is not None:
raise Exception("Failed running query [%s]." % query)
return results["rows"]
@classmethod
def to_dict(cls):
return {
"name": cls.name(),
"type": cls.type(),
"configuration_schema": cls.configuration_schema(),
**({"deprecated": True} if cls.deprecated else {}),
}
@property
def supports_auto_limit(self):
return False
def apply_auto_limit(self, query_text, should_apply_auto_limit):
return query_text
def gen_query_hash(self, query_text, set_auto_limit=False):
query_text = self.apply_auto_limit(query_text, set_auto_limit)
return utils.gen_query_hash(query_text)
class BaseSQLQueryRunner(BaseQueryRunner):
def get_schema(self, get_stats=False):
schema_dict = {}
self._get_tables(schema_dict)
if settings.SCHEMA_RUN_TABLE_SIZE_CALCULATIONS and get_stats:
self._get_tables_stats(schema_dict)
return list(schema_dict.values())
def _get_tables(self, schema_dict):
return []
def _get_tables_stats(self, tables_dict):
for t in tables_dict.keys():
if isinstance(tables_dict[t], dict):
res = self._run_query_internal("select count(*) as cnt from %s" % t)
tables_dict[t]["size"] = res[0]["cnt"]
@property
def supports_auto_limit(self):
return True
def query_is_select_no_limit(self, query):
parsed_query_list = sqlparse.parse(query)
if len(parsed_query_list) == 0:
return False
parsed_query = parsed_query_list[0]
last_keyword_idx = find_last_keyword_idx(parsed_query)
# Either invalid query or query that is not select
if last_keyword_idx == -1 or parsed_query.tokens[0].value.upper() != "SELECT":
return False
no_limit = parsed_query.tokens[last_keyword_idx].value.upper() not in self.limit_keywords
return no_limit
def add_limit_to_query(self, query):
parsed_query = sqlparse.parse(query)[0]
limit_tokens = sqlparse.parse(self.limit_query)[0].tokens
length = len(parsed_query.tokens)
if not self.limit_after_select:
if parsed_query.tokens[length - 1].ttype == sqlparse.tokens.Punctuation:
parsed_query.tokens[length - 1 : length - 1] = limit_tokens
else:
parsed_query.tokens += limit_tokens
else:
for i in range(length - 1, -1, -1):
if parsed_query[i].value.upper() == "SELECT":
index = parsed_query.token_index(parsed_query[i + 1])
parsed_query = sqlparse.sql.Statement(
parsed_query.tokens[:index] + limit_tokens + parsed_query.tokens[index:]
)
break
return str(parsed_query)
def apply_auto_limit(self, query_text, should_apply_auto_limit):
queries = split_sql_statements(query_text)
if should_apply_auto_limit:
# we only check for last one in the list because it is the one that we show result
last_query = queries[-1]
if self.query_is_select_no_limit(last_query):
queries[-1] = self.add_limit_to_query(last_query)
return combine_sql_statements(queries)
class BaseHTTPQueryRunner(BaseQueryRunner):
should_annotate_query = False
response_error = "Endpoint returned unexpected status code"
requires_authentication = False
requires_url = True
url_title = "URL base path"
username_title = "HTTP Basic Auth Username"
password_title = "HTTP Basic Auth Password"
@classmethod
def configuration_schema(cls):
schema = {
"type": "object",
"properties": {
"url": {"type": "string", "title": cls.url_title},
"username": {"type": "string", "title": cls.username_title},
"password": {"type": "string", "title": cls.password_title},
},
"secret": ["password"],
"order": ["url", "username", "password"],
}
if cls.requires_url or cls.requires_authentication:
schema["required"] = []
if cls.requires_url:
schema["required"] += ["url"]
if cls.requires_authentication:
schema["required"] += ["username", "password"]
return schema
def get_auth(self):
username = self.configuration.get("username")
password = self.configuration.get("password")
if username and password:
return (username, password)
if self.requires_authentication:
raise ValueError("Username and Password required")
else:
return None
def get_response(self, url, auth=None, http_method="get", **kwargs):
# Get authentication values if not given
if auth is None:
auth = self.get_auth()
# Then call requests to get the response from the given endpoint
# URL optionally, with the additional requests parameters.
error = None
response = None
try:
response = requests_session.request(http_method, url, auth=auth, **kwargs)
# Raise a requests HTTP exception with the appropriate reason
# for 4xx and 5xx response status codes which is later caught
# and passed back.
response.raise_for_status()
# Any other responses (e.g. 2xx and 3xx):
if response.status_code != 200:
error = "{} ({}).".format(self.response_error, response.status_code)
except requests_or_advocate.HTTPError as exc:
logger.exception(exc)
error = "Failed to execute query. "
f"Return Code: {response.status_code} Reason: {response.text}"
except UnacceptableAddressException as exc:
logger.exception(exc)
error = "Can't query private addresses."
except requests_or_advocate.RequestException as exc:
# Catch all other requests exceptions and return the error.
logger.exception(exc)
error = str(exc)
# Return response and error.
return response, error
query_runners = {}
def register(query_runner_class):
global query_runners
if query_runner_class.enabled():
logger.debug(
"Registering %s (%s) query runner.",
query_runner_class.name(),
query_runner_class.type(),
)
query_runners[query_runner_class.type()] = query_runner_class
else:
logger.debug(
"%s query runner enabled but not supported, not registering. Either disable or install missing "
"dependencies.",
query_runner_class.name(),
)
def get_query_runner(query_runner_type, configuration):
query_runner_class = query_runners.get(query_runner_type, None)
if query_runner_class is None:
return None
return query_runner_class(configuration)
def get_configuration_schema_for_query_runner_type(query_runner_type):
query_runner_class = query_runners.get(query_runner_type, None)
if query_runner_class is None:
return None
return query_runner_class.configuration_schema()
def import_query_runners(query_runner_imports):
for runner_import in query_runner_imports:
__import__(runner_import)
def guess_type(value):
if isinstance(value, bool):
return TYPE_BOOLEAN
elif isinstance(value, int):
return TYPE_INTEGER
elif isinstance(value, float):
return TYPE_FLOAT
return guess_type_from_string(value)
def guess_type_from_string(string_value):
if string_value == "" or string_value is None:
return TYPE_STRING
try:
int(string_value)
return TYPE_INTEGER
except (ValueError, OverflowError):
pass
try:
float(string_value)
return TYPE_FLOAT
except (ValueError, OverflowError):
pass
if str(string_value).lower() in ("true", "false"):
return TYPE_BOOLEAN
try:
parser.parse(string_value)
return TYPE_DATETIME
except (ValueError, OverflowError):
pass
return TYPE_STRING
def with_ssh_tunnel(query_runner, details):
def tunnel(f):
@wraps(f)
def wrapper(*args, **kwargs):
try:
remote_host, remote_port = query_runner.host, query_runner.port
except NotImplementedError:
raise NotImplementedError("SSH tunneling is not implemented for this query runner yet.")
stack = ExitStack()
try:
bastion_address = (details["ssh_host"], details.get("ssh_port", 22))
remote_address = (remote_host, remote_port)
auth = {
"ssh_username": details["ssh_username"],
**settings.dynamic_settings.ssh_tunnel_auth(),
}
server = stack.enter_context(open_tunnel(bastion_address, remote_bind_address=remote_address, **auth))
except Exception as error:
raise type(error)("SSH tunnel: {}".format(str(error)))
with stack:
try:
query_runner.host, query_runner.port = server.local_bind_address
result = f(*args, **kwargs)
finally:
query_runner.host, query_runner.port = remote_host, remote_port
return result
return wrapper
query_runner.run_query = tunnel(query_runner.run_query)
return query_runner
================================================
FILE: redash/query_runner/amazon_elasticsearch.py
================================================
from . import register
from .elasticsearch2 import ElasticSearch2
try:
from botocore import credentials, session
from requests_aws_sign import AWSV4Sign
enabled = True
except ImportError:
enabled = False
class AmazonElasticsearchService(ElasticSearch2):
@classmethod
def name(cls):
return "Amazon Elasticsearch Service"
@classmethod
def enabled(cls):
return enabled
@classmethod
def type(cls):
return "aws_es"
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"server": {"type": "string", "title": "Endpoint"},
"region": {"type": "string"},
"access_key": {"type": "string", "title": "Access Key"},
"secret_key": {"type": "string", "title": "Secret Key"},
"use_aws_iam_profile": {
"type": "boolean",
"title": "Use AWS IAM Profile",
},
},
"secret": ["secret_key"],
"order": [
"server",
"region",
"access_key",
"secret_key",
"use_aws_iam_profile",
],
"required": ["server", "region"],
}
def __init__(self, configuration):
super(AmazonElasticsearchService, self).__init__(configuration)
region = configuration["region"]
cred = None
if configuration.get("use_aws_iam_profile", False):
cred = credentials.get_credentials(session.Session())
else:
cred = credentials.Credentials(
access_key=configuration.get("access_key", ""),
secret_key=configuration.get("secret_key", ""),
)
self.auth = AWSV4Sign(cred, region, "es")
def get_auth(self):
return self.auth
register(AmazonElasticsearchService)
================================================
FILE: redash/query_runner/arango.py
================================================
import logging
from redash.query_runner import (
TYPE_BOOLEAN,
TYPE_FLOAT,
TYPE_STRING,
BaseQueryRunner,
register,
)
logger = logging.getLogger(__name__)
try:
from arango import ArangoClient
enabled = True
except ImportError:
enabled = False
_TYPE_MAPPINGS = {
"boolean": TYPE_BOOLEAN,
"number": TYPE_FLOAT,
"string": TYPE_STRING,
"array": TYPE_STRING,
"object": TYPE_STRING,
}
class Arango(BaseQueryRunner):
noop_query = "RETURN {'id': 1}"
@classmethod
def name(cls):
return "ArangoDB"
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"user": {"type": "string"},
"password": {"type": "string"},
"host": {"type": "string", "default": "127.0.0.1"},
"port": {"type": "number", "default": 8529},
"dbname": {"type": "string", "title": "Database Name"},
"timeout": {"type": "number", "default": 0.0, "title": "AQL Timeout in seconds (0 = no timeout)"},
},
"order": ["host", "port", "user", "password", "dbname"],
"required": ["host", "user", "password", "dbname"],
"secret": ["password"],
}
@classmethod
def enabled(cls):
try:
import arango # noqa: F401
except ImportError:
return False
return True
@classmethod
def type(cls):
return "arangodb"
def run_query(self, query, user):
client = ArangoClient(hosts="{}:{}".format(self.configuration["host"], self.configuration.get("port", 8529)))
db = client.db(
self.configuration["dbname"], username=self.configuration["user"], password=self.configuration["password"]
)
try:
cursor = db.aql.execute(query, max_runtime=self.configuration.get("timeout", 0.0))
result = [i for i in cursor]
column_tuples = [(i, TYPE_STRING) for i in result[0].keys()]
columns = self.fetch_columns(column_tuples)
data = {
"columns": columns,
"rows": result,
}
error = None
except Exception:
raise
return data, error
register(Arango)
================================================
FILE: redash/query_runner/athena.py
================================================
import logging
import os
from redash.query_runner import (
TYPE_BOOLEAN,
TYPE_DATE,
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseQueryRunner,
register,
)
from redash.settings import parse_boolean
logger = logging.getLogger(__name__)
ANNOTATE_QUERY = parse_boolean(os.environ.get("ATHENA_ANNOTATE_QUERY", "true"))
SHOW_EXTRA_SETTINGS = parse_boolean(os.environ.get("ATHENA_SHOW_EXTRA_SETTINGS", "true"))
ASSUME_ROLE = parse_boolean(os.environ.get("ATHENA_ASSUME_ROLE", "false"))
OPTIONAL_CREDENTIALS = parse_boolean(os.environ.get("ATHENA_OPTIONAL_CREDENTIALS", "true"))
try:
import boto3
import pyathena
enabled = True
except ImportError:
enabled = False
_TYPE_MAPPINGS = {
"boolean": TYPE_BOOLEAN,
"tinyint": TYPE_INTEGER,
"smallint": TYPE_INTEGER,
"integer": TYPE_INTEGER,
"bigint": TYPE_INTEGER,
"double": TYPE_FLOAT,
"varchar": TYPE_STRING,
"timestamp": TYPE_DATETIME,
"date": TYPE_DATE,
"varbinary": TYPE_STRING,
"array": TYPE_STRING,
"map": TYPE_STRING,
"row": TYPE_STRING,
"decimal": TYPE_FLOAT,
}
class SimpleFormatter:
def format(self, operation, parameters=None):
return operation
class Athena(BaseQueryRunner):
noop_query = "SELECT 1"
@classmethod
def name(cls):
return "Amazon Athena"
@classmethod
def configuration_schema(cls):
schema = {
"type": "object",
"properties": {
"region": {"type": "string", "title": "AWS Region"},
"aws_access_key": {"type": "string", "title": "AWS Access Key"},
"aws_secret_key": {"type": "string", "title": "AWS Secret Key"},
"s3_staging_dir": {
"type": "string",
"title": "S3 Staging (Query Results) Bucket Path",
},
"schema": {
"type": "string",
"title": "Schema Name",
"default": "default",
},
"glue": {"type": "boolean", "title": "Use Glue Data Catalog"},
"catalog_ids": {
"type": "string",
"title": "Enter Glue Data Catalog IDs, separated by commas (leave blank for default catalog)",
},
"work_group": {
"type": "string",
"title": "Athena Work Group",
"default": "primary",
},
"cost_per_tb": {
"type": "number",
"title": "Athena cost per Tb scanned (USD)",
"default": 5,
},
"result_reuse_enable": {
"type": "boolean",
"title": "Reuse Athena query results",
},
"result_reuse_minutes": {
"type": "number",
"title": "Minutes to reuse Athena query results",
"default": 60,
},
},
"required": ["region", "s3_staging_dir"],
"extra_options": ["glue", "catalog_ids", "cost_per_tb", "result_reuse_enable", "result_reuse_minutes"],
"order": [
"region",
"s3_staging_dir",
"schema",
"work_group",
"cost_per_tb",
"result_reuse_enable",
"result_reuse_minutes",
],
"secret": ["aws_secret_key"],
}
if SHOW_EXTRA_SETTINGS:
schema["properties"].update(
{
"encryption_option": {
"type": "string",
"title": "Encryption Option",
},
"kms_key": {"type": "string", "title": "KMS Key"},
}
)
schema["extra_options"].append("encryption_option")
schema["extra_options"].append("kms_key")
if ASSUME_ROLE:
del schema["properties"]["aws_access_key"]
del schema["properties"]["aws_secret_key"]
schema["secret"] = []
schema["order"].insert(1, "iam_role")
schema["order"].insert(2, "external_id")
schema["properties"].update(
{
"iam_role": {"type": "string", "title": "IAM role to assume"},
"external_id": {
"type": "string",
"title": "External ID to be used while STS assume role",
},
}
)
else:
schema["order"].insert(1, "aws_access_key")
schema["order"].insert(2, "aws_secret_key")
if not OPTIONAL_CREDENTIALS and not ASSUME_ROLE:
schema["required"] += ["aws_access_key", "aws_secret_key"]
return schema
@classmethod
def enabled(cls):
return enabled
def annotate_query(self, query, metadata):
if ANNOTATE_QUERY:
return super(Athena, self).annotate_query(query, metadata)
return query
@classmethod
def type(cls):
return "athena"
def _get_iam_credentials(self, user=None):
if ASSUME_ROLE:
role_session_name = "redash" if user is None else user.email
sts = boto3.client("sts")
creds = sts.assume_role(
RoleArn=self.configuration.get("iam_role"),
RoleSessionName=role_session_name,
ExternalId=self.configuration.get("external_id"),
)
return {
"aws_access_key_id": creds["Credentials"]["AccessKeyId"],
"aws_secret_access_key": creds["Credentials"]["SecretAccessKey"],
"aws_session_token": creds["Credentials"]["SessionToken"],
"region_name": self.configuration["region"],
}
else:
return {
"aws_access_key_id": self.configuration.get("aws_access_key", None),
"aws_secret_access_key": self.configuration.get("aws_secret_key", None),
"region_name": self.configuration["region"],
}
def __get_schema_from_glue(self, catalog_id=""):
client = boto3.client("glue", **self._get_iam_credentials())
schema = {}
database_paginator = client.get_paginator("get_databases")
table_paginator = client.get_paginator("get_tables")
databases_iterator = database_paginator.paginate(
**({"CatalogId": catalog_id} if catalog_id != "" else {}),
)
for databases in databases_iterator:
for database in databases["DatabaseList"]:
iterator = table_paginator.paginate(
DatabaseName=database["Name"],
**({"CatalogId": catalog_id} if catalog_id != "" else {}),
)
for table in iterator.search("TableList[]"):
table_name = "%s.%s" % (database["Name"], table["Name"])
if "StorageDescriptor" not in table:
logger.warning("Glue table doesn't have StorageDescriptor: %s", table_name)
continue
if table_name not in schema:
schema[table_name] = {"name": table_name, "columns": []}
for column_data in table["StorageDescriptor"]["Columns"]:
column = {
"name": column_data["Name"],
"type": column_data["Type"] if "Type" in column_data else None,
}
schema[table_name]["columns"].append(column)
for partition in table.get("PartitionKeys", []):
partition_column = {
"name": partition["Name"],
"type": partition["Type"] if "Type" in partition else None,
}
schema[table_name]["columns"].append(partition_column)
return list(schema.values())
def get_schema(self, get_stats=False):
if self.configuration.get("glue", False):
catalog_ids = [id.strip() for id in self.configuration.get("catalog_ids", "").split(",")]
return sum([self.__get_schema_from_glue(catalog_id) for catalog_id in catalog_ids], [])
schema = {}
query = """
SELECT table_schema, table_name, column_name, data_type
FROM information_schema.columns
WHERE table_schema NOT IN ('information_schema')
"""
results, error = self.run_query(query, None)
if error is not None:
self._handle_run_query_error(error)
for row in results["rows"]:
table_name = "{0}.{1}".format(row["table_schema"], row["table_name"])
if table_name not in schema:
schema[table_name] = {"name": table_name, "columns": []}
schema[table_name]["columns"].append({"name": row["column_name"], "type": row["data_type"]})
return list(schema.values())
def run_query(self, query, user):
cursor = pyathena.connect(
s3_staging_dir=self.configuration["s3_staging_dir"],
schema_name=self.configuration.get("schema", "default"),
encryption_option=self.configuration.get("encryption_option", None),
kms_key=self.configuration.get("kms_key", None),
work_group=self.configuration.get("work_group", "primary"),
formatter=SimpleFormatter(),
result_reuse_enable=self.configuration.get("result_reuse_enable", False),
result_reuse_minutes=self.configuration.get("result_reuse_minutes", 60),
**self._get_iam_credentials(user=user),
).cursor()
try:
cursor.execute(query)
column_tuples = [(i[0], _TYPE_MAPPINGS.get(i[1], None)) for i in cursor.description]
columns = self.fetch_columns(column_tuples)
rows = [dict(zip(([c["name"] for c in columns]), r)) for i, r in enumerate(cursor.fetchall())]
qbytes = None
athena_query_id = None
try:
qbytes = cursor.data_scanned_in_bytes
except AttributeError as e:
logger.debug("Athena Upstream can't get data_scanned_in_bytes: %s", e)
try:
athena_query_id = cursor.query_id
except AttributeError as e:
logger.debug("Athena Upstream can't get query_id: %s", e)
price = self.configuration.get("cost_per_tb", 5)
data = {
"columns": columns,
"rows": rows,
"metadata": {
"data_scanned": qbytes,
"athena_query_id": athena_query_id,
"query_cost": price * qbytes * 10e-12,
},
}
error = None
except Exception:
if cursor.query_id:
cursor.cancel()
raise
return data, error
register(Athena)
================================================
FILE: redash/query_runner/axibase_tsd.py
================================================
import csv
import logging
import uuid
from redash.query_runner import (
TYPE_DATE,
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseQueryRunner,
InterruptException,
JobTimeoutException,
register,
)
from redash.utils import json_loads
logger = logging.getLogger(__name__)
try:
import atsd_client
from atsd_client.exceptions import SQLException
from atsd_client.services import MetricsService, SQLService
enabled = True
except ImportError:
enabled = False
types_map = {
"long": TYPE_INTEGER,
"bigint": TYPE_INTEGER,
"integer": TYPE_INTEGER,
"smallint": TYPE_INTEGER,
"float": TYPE_FLOAT,
"double": TYPE_FLOAT,
"decimal": TYPE_FLOAT,
"string": TYPE_STRING,
"date": TYPE_DATE,
"xsd:dateTimeStamp": TYPE_DATETIME,
}
def resolve_redash_type(type_in_atsd):
"""
Retrieve corresponding redash type
:param type_in_atsd: `str`
:return: redash type constant
"""
if isinstance(type_in_atsd, dict):
type_in_redash = types_map.get(type_in_atsd["base"])
else:
type_in_redash = types_map.get(type_in_atsd)
return type_in_redash
def generate_rows_and_columns(csv_response):
"""
Prepare rows and columns in redash format from ATSD csv response
:param csv_response: `str`
:return: prepared rows and columns
"""
meta, data = csv_response.split("\n", 1)
meta = meta[1:]
meta_with_padding = meta + "=" * (4 - len(meta) % 4)
meta_decoded = meta_with_padding.decode("base64")
meta_json = json_loads(meta_decoded)
meta_columns = meta_json["tableSchema"]["columns"]
reader = csv.reader(data.splitlines())
next(reader)
columns = [
{
"friendly_name": i["titles"],
"type": resolve_redash_type(i["datatype"]),
"name": i["name"],
}
for i in meta_columns
]
column_names = [c["name"] for c in columns]
rows = [dict(zip(column_names, row)) for row in reader]
return columns, rows
class AxibaseTSD(BaseQueryRunner):
noop_query = "SELECT 1"
@classmethod
def enabled(cls):
return enabled
@classmethod
def name(cls):
return "Axibase Time Series Database"
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"protocol": {"type": "string", "title": "Protocol", "default": "http"},
"hostname": {
"type": "string",
"title": "Host",
"default": "axibase_tsd_hostname",
},
"port": {"type": "number", "title": "Port", "default": 8088},
"username": {"type": "string"},
"password": {"type": "string", "title": "Password"},
"timeout": {
"type": "number",
"default": 600,
"title": "Connection Timeout",
},
"min_insert_date": {
"type": "string",
"title": "Metric Minimum Insert Date",
},
"expression": {"type": "string", "title": "Metric Filter"},
"limit": {"type": "number", "default": 5000, "title": "Metric Limit"},
"trust_certificate": {
"type": "boolean",
"title": "Trust SSL Certificate",
},
},
"required": ["username", "password", "hostname", "protocol", "port"],
"secret": ["password"],
}
def __init__(self, configuration):
super(AxibaseTSD, self).__init__(configuration)
self.url = "{0}://{1}:{2}".format(
self.configuration.get("protocol", "http"),
self.configuration.get("hostname", "localhost"),
self.configuration.get("port", 8088),
)
def run_query(self, query, user):
connection = atsd_client.connect_url(
self.url,
self.configuration.get("username"),
self.configuration.get("password"),
verify=self.configuration.get("trust_certificate", False),
timeout=self.configuration.get("timeout", 600),
)
sql = SQLService(connection)
query_id = str(uuid.uuid4())
try:
logger.debug("SQL running query: %s", query)
data = sql.query_with_params(
query,
{"outputFormat": "csv", "metadataFormat": "EMBED", "queryId": query_id},
)
columns, rows = generate_rows_and_columns(data)
data = {"columns": columns, "rows": rows}
error = None
except SQLException as e:
data = None
error = e.content
except (KeyboardInterrupt, InterruptException, JobTimeoutException):
sql.cancel_query(query_id)
raise
return data, error
def get_schema(self, get_stats=False):
connection = atsd_client.connect_url(
self.url,
self.configuration.get("username"),
self.configuration.get("password"),
verify=self.configuration.get("trust_certificate", False),
timeout=self.configuration.get("timeout", 600),
)
metrics = MetricsService(connection)
ml = metrics.list(
expression=self.configuration.get("expression", None),
minInsertDate=self.configuration.get("min_insert_date", None),
limit=self.configuration.get("limit", 5000),
)
metrics_list = [i.name for i in ml]
metrics_list.append("atsd_series")
schema = {}
default_columns = [
"entity",
"datetime",
"time",
"metric",
"value",
"text",
"tags",
"entity.tags",
"metric.tags",
]
for table_name in metrics_list:
schema[table_name] = {
"name": "'{}'".format(table_name),
"columns": default_columns,
}
values = list(schema.values())
return values
register(AxibaseTSD)
================================================
FILE: redash/query_runner/azure_kusto.py
================================================
from redash.query_runner import (
TYPE_BOOLEAN,
TYPE_DATE,
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseQueryRunner,
register,
)
from redash.utils import json_loads
try:
from azure.kusto.data import (
ClientRequestProperties,
KustoClient,
KustoConnectionStringBuilder,
)
from azure.kusto.data.exceptions import KustoServiceError
enabled = True
except ImportError:
enabled = False
TYPES_MAP = {
"boolean": TYPE_BOOLEAN,
"datetime": TYPE_DATETIME,
"date": TYPE_DATE,
"dynamic": TYPE_STRING,
"guid": TYPE_STRING,
"int": TYPE_INTEGER,
"long": TYPE_INTEGER,
"real": TYPE_FLOAT,
"string": TYPE_STRING,
"timespan": TYPE_STRING,
"decimal": TYPE_FLOAT,
}
def _get_data_scanned(kusto_response):
try:
metadata_table = next(
(table for table in kusto_response.tables if table.table_name == "QueryCompletionInformation"),
None,
)
if metadata_table:
resource_usage_json = next(
(row["Payload"] for row in metadata_table.rows if row["EventTypeName"] == "QueryResourceConsumption"),
"{}",
)
resource_usage = json_loads(resource_usage_json).get("resource_usage", {})
data_scanned = (
resource_usage["cache"]["shards"]["cold"]["hitbytes"]
+ resource_usage["cache"]["shards"]["cold"]["missbytes"]
+ resource_usage["cache"]["shards"]["hot"]["hitbytes"]
+ resource_usage["cache"]["shards"]["hot"]["missbytes"]
+ resource_usage["cache"]["shards"]["bypassbytes"]
)
except Exception:
data_scanned = 0
return int(data_scanned)
class AzureKusto(BaseQueryRunner):
should_annotate_query = False
noop_query = "let noop = datatable (Noop:string)[1]; noop"
def __init__(self, configuration):
super(AzureKusto, self).__init__(configuration)
self.syntax = "custom"
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"cluster": {"type": "string"},
"azure_ad_client_id": {"type": "string", "title": "Azure AD Client ID"},
"azure_ad_client_secret": {
"type": "string",
"title": "Azure AD Client Secret",
},
"azure_ad_tenant_id": {"type": "string", "title": "Azure AD Tenant Id"},
"database": {"type": "string"},
"msi": {"type": "boolean", "title": "Use Managed Service Identity"},
"user_msi": {
"type": "string",
"title": "User-assigned managed identity client ID",
},
},
"required": [
"cluster",
"database",
],
"order": [
"cluster",
"azure_ad_client_id",
"azure_ad_client_secret",
"azure_ad_tenant_id",
"database",
],
"secret": ["azure_ad_client_secret"],
}
@classmethod
def enabled(cls):
return enabled
@classmethod
def type(cls):
return "azure_kusto"
@classmethod
def name(cls):
return "Azure Data Explorer (Kusto)"
def run_query(self, query, user):
cluster = self.configuration["cluster"]
msi = self.configuration.get("msi", False)
# Managed Service Identity(MSI)
if msi:
# If user-assigned managed identity is used, the client ID must be provided
if self.configuration.get("user_msi"):
kcsb = KustoConnectionStringBuilder.with_aad_managed_service_identity_authentication(
cluster,
client_id=self.configuration["user_msi"],
)
else:
kcsb = KustoConnectionStringBuilder.with_aad_managed_service_identity_authentication(cluster)
# Service Principal auth
else:
aad_app_id = self.configuration.get("azure_ad_client_id")
app_key = self.configuration.get("azure_ad_client_secret")
authority_id = self.configuration.get("azure_ad_tenant_id")
if not (aad_app_id and app_key and authority_id):
raise ValueError(
"Azure AD Client ID, Client Secret, and Tenant ID are required for Service Principal authentication."
)
kcsb = KustoConnectionStringBuilder.with_aad_application_key_authentication(
connection_string=cluster,
aad_app_id=aad_app_id,
app_key=app_key,
authority_id=authority_id,
)
client = KustoClient(kcsb)
request_properties = ClientRequestProperties()
request_properties.application = "redash"
if user:
request_properties.user = user.email
request_properties.set_option("request_description", user.email)
db = self.configuration["database"]
try:
response = client.execute(db, query, request_properties)
result_cols = response.primary_results[0].columns
result_rows = response.primary_results[0].rows
columns = []
rows = []
for c in result_cols:
columns.append(
{
"name": c.column_name,
"friendly_name": c.column_name,
"type": TYPES_MAP.get(c.column_type, None),
}
)
# rows must be [{'column1': value, 'column2': value}]
for row in result_rows:
rows.append(row.to_dict())
error = None
data = {
"columns": columns,
"rows": rows,
"metadata": {"data_scanned": _get_data_scanned(response)},
}
except KustoServiceError as err:
data = None
error = str(err)
return data, error
def get_schema(self, get_stats=False):
query = ".show database schema as json"
results, error = self.run_query(query, None)
if error is not None:
self._handle_run_query_error(error)
schema_as_json = json_loads(results["rows"][0]["DatabaseSchema"])
tables_list = [
*(schema_as_json["Databases"][self.configuration["database"]]["Tables"].values()),
*(schema_as_json["Databases"][self.configuration["database"]]["MaterializedViews"].values()),
]
schema = {}
for table in tables_list:
table_name = table["Name"]
if table_name not in schema:
schema[table_name] = {"name": table_name, "columns": []}
for column in table["OrderedColumns"]:
schema[table_name]["columns"].append(
{"name": column["Name"], "type": TYPES_MAP.get(column["CslType"], None)}
)
return list(schema.values())
register(AzureKusto)
================================================
FILE: redash/query_runner/big_query.py
================================================
import datetime
import logging
import socket
import time
from base64 import b64decode
from redash import settings
from redash.query_runner import (
TYPE_BOOLEAN,
TYPE_DATE,
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseSQLQueryRunner,
InterruptException,
JobTimeoutException,
register,
)
from redash.utils import json_loads
logger = logging.getLogger(__name__)
try:
import apiclient.errors
import google.auth
from apiclient.discovery import build
from apiclient.errors import HttpError # noqa: F401
from google.oauth2.service_account import Credentials
enabled = True
except ImportError:
enabled = False
types_map = {
"INTEGER": TYPE_INTEGER,
"FLOAT": TYPE_FLOAT,
"BOOLEAN": TYPE_BOOLEAN,
"STRING": TYPE_STRING,
"TIMESTAMP": TYPE_DATETIME,
"DATETIME": TYPE_DATETIME,
"DATE": TYPE_DATE,
}
def transform_cell(field_type, cell_value):
if cell_value is None:
return None
if field_type == "INTEGER":
return int(cell_value)
elif field_type == "FLOAT":
return float(cell_value)
elif field_type == "BOOLEAN":
return cell_value.lower() == "true"
elif field_type == "TIMESTAMP":
return datetime.datetime.fromtimestamp(float(cell_value))
return cell_value
def transform_row(row, fields):
row_data = {}
for column_index, cell in enumerate(row["f"]):
field = fields[column_index]
if field.get("mode") == "REPEATED":
cell_value = [transform_cell(field["type"], item["v"]) for item in cell["v"]]
else:
cell_value = transform_cell(field["type"], cell["v"])
row_data[field["name"]] = cell_value
return row_data
def _load_key(filename):
f = open(filename, "rb")
try:
return f.read()
finally:
f.close()
def _get_query_results(jobs, project_id, location, job_id, start_index):
query_reply = jobs.getQueryResults(
projectId=project_id, location=location, jobId=job_id, startIndex=start_index
).execute()
logging.debug("query_reply %s", query_reply)
if not query_reply["jobComplete"]:
time.sleep(1)
return _get_query_results(jobs, project_id, location, job_id, start_index)
return query_reply
def _get_total_bytes_processed_for_resp(bq_response):
# BigQuery hides the total bytes processed for queries to tables with row-level access controls.
# For these queries the "totalBytesProcessed" field may not be defined in the response.
return int(bq_response.get("totalBytesProcessed", "0"))
class BigQuery(BaseSQLQueryRunner):
noop_query = "SELECT 1"
def __init__(self, configuration):
super().__init__(configuration)
self.should_annotate_query = configuration.get("useQueryAnnotation", False)
@classmethod
def enabled(cls):
return enabled
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"projectId": {"type": "string", "title": "Project ID"},
"jsonKeyFile": {"type": "string", "title": "JSON Key File (ADC is used if omitted)"},
"totalMBytesProcessedLimit": {
"type": "number",
"title": "Scanned Data Limit (MB)",
},
"userDefinedFunctionResourceUri": {
"type": "string",
"title": "UDF Source URIs (i.e. gs://bucket/date_utils.js, gs://bucket/string_utils.js )",
},
"useStandardSql": {
"type": "boolean",
"title": "Use Standard SQL",
"default": True,
},
"location": {"type": "string", "title": "Processing Location"},
"loadSchema": {"type": "boolean", "title": "Load Schema"},
"maximumBillingTier": {
"type": "number",
"title": "Maximum Billing Tier",
},
"useQueryAnnotation": {
"type": "boolean",
"title": "Use Query Annotation",
"default": False,
},
},
"required": ["projectId"],
"order": [
"projectId",
"jsonKeyFile",
"loadSchema",
"useStandardSql",
"location",
"totalMBytesProcessedLimit",
"maximumBillingTier",
"userDefinedFunctionResourceUri",
"useQueryAnnotation",
],
"secret": ["jsonKeyFile"],
}
def annotate_query(self, query, metadata):
# Remove "Job ID" before annotating the query to avoid cache misses
metadata = {k: v for k, v in metadata.items() if k != "Job ID"}
return super().annotate_query(query, metadata)
def _get_bigquery_service(self):
socket.setdefaulttimeout(settings.BIGQUERY_HTTP_TIMEOUT)
scopes = [
"https://www.googleapis.com/auth/bigquery",
"https://www.googleapis.com/auth/drive",
]
try:
key = json_loads(b64decode(self.configuration["jsonKeyFile"]))
creds = Credentials.from_service_account_info(key, scopes=scopes)
except KeyError:
creds = google.auth.default(scopes=scopes)[0]
return build("bigquery", "v2", credentials=creds, cache_discovery=False)
def _get_project_id(self):
return self.configuration["projectId"]
def _get_location(self):
return self.configuration.get("location")
def _get_total_bytes_processed(self, jobs, query):
job_data = {"query": query, "dryRun": True}
if self._get_location():
job_data["location"] = self._get_location()
if self.configuration.get("useStandardSql", False):
job_data["useLegacySql"] = False
response = jobs.query(projectId=self._get_project_id(), body=job_data).execute()
return _get_total_bytes_processed_for_resp(response)
def _get_job_data(self, query):
job_data = {"configuration": {"query": {"query": query}}}
if self._get_location():
job_data["jobReference"] = {"location": self._get_location()}
if self.configuration.get("useStandardSql", False):
job_data["configuration"]["query"]["useLegacySql"] = False
if self.configuration.get("userDefinedFunctionResourceUri"):
resource_uris = self.configuration["userDefinedFunctionResourceUri"].split(",")
job_data["configuration"]["query"]["userDefinedFunctionResources"] = [
{"resourceUri": resource_uri} for resource_uri in resource_uris
]
if "maximumBillingTier" in self.configuration:
job_data["configuration"]["query"]["maximumBillingTier"] = self.configuration["maximumBillingTier"]
return job_data
def _get_query_result(self, jobs, query):
project_id = self._get_project_id()
job_data = self._get_job_data(query)
insert_response = jobs.insert(projectId=project_id, body=job_data).execute()
self.current_job_id = insert_response["jobReference"]["jobId"]
self.current_job_location = insert_response["jobReference"]["location"]
current_row = 0
query_reply = _get_query_results(
jobs,
project_id=project_id,
location=self.current_job_location,
job_id=self.current_job_id,
start_index=current_row,
)
logger.debug("bigquery replied: %s", query_reply)
rows = []
while ("rows" in query_reply) and current_row < int(query_reply["totalRows"]):
for row in query_reply["rows"]:
rows.append(transform_row(row, query_reply["schema"]["fields"]))
current_row += len(query_reply["rows"])
query_result_request = {
"projectId": project_id,
"jobId": self.current_job_id,
"startIndex": current_row,
"location": self.current_job_location,
}
query_reply = jobs.getQueryResults(**query_result_request).execute()
columns = [
{
"name": f["name"],
"friendly_name": f["name"],
"type": "string" if f.get("mode") == "REPEATED" else types_map.get(f["type"], "string"),
}
for f in query_reply["schema"]["fields"]
]
data = {
"columns": columns,
"rows": rows,
"metadata": {"data_scanned": _get_total_bytes_processed_for_resp(query_reply)},
}
return data
def _get_columns_schema(self, table_data):
columns = []
for column in table_data.get("schema", {}).get("fields", []):
columns.extend(self._get_columns_schema_column(column))
project_id = self._get_project_id()
table_name = table_data["id"].replace("%s:" % project_id, "")
return {"name": table_name, "columns": columns}
def _get_columns_schema_column(self, column):
columns = []
if column["type"] == "RECORD":
for field in column["fields"]:
columns.append("{}.{}".format(column["name"], field["name"]))
else:
columns.append(column["name"])
return columns
def _get_project_datasets(self, project_id):
result = []
service = self._get_bigquery_service()
datasets = service.datasets().list(projectId=project_id).execute()
result.extend(datasets.get("datasets", []))
nextPageToken = datasets.get("nextPageToken", None)
while nextPageToken is not None:
datasets = service.datasets().list(projectId=project_id, pageToken=nextPageToken).execute()
result.extend(datasets.get("datasets", []))
nextPageToken = datasets.get("nextPageToken", None)
return result
def get_schema(self, get_stats=False):
if not self.configuration.get("loadSchema", False):
return []
project_id = self._get_project_id()
datasets = self._get_project_datasets(project_id)
query_base = """
SELECT table_schema, table_name, field_path, data_type, description
FROM `{dataset_id}`.INFORMATION_SCHEMA.COLUMN_FIELD_PATHS
WHERE table_schema NOT IN ('information_schema')
"""
table_query_base = """
SELECT table_schema, table_name, JSON_VALUE(option_value) as table_description
FROM `{dataset_id}`.INFORMATION_SCHEMA.TABLE_OPTIONS
WHERE table_schema NOT IN ('information_schema')
AND option_name = 'description'
"""
location_dataset_ids = {}
schema = {}
for dataset in datasets:
dataset_id = dataset["datasetReference"]["datasetId"]
location = dataset["location"]
if self._get_location() and location != self._get_location():
logger.debug("dataset location is different: %s", location)
continue
if location not in location_dataset_ids:
location_dataset_ids[location] = []
location_dataset_ids[location].append(dataset_id)
for location, datasets in location_dataset_ids.items():
queries = []
for dataset_id in datasets:
query = query_base.format(dataset_id=dataset_id)
queries.append(query)
query = "\nUNION ALL\n".join(queries)
results, error = self.run_query(query, None)
if error is not None:
self._handle_run_query_error(error)
for row in results["rows"]:
table_name = "{0}.{1}".format(row["table_schema"], row["table_name"])
if table_name not in schema:
schema[table_name] = {"name": table_name, "columns": []}
schema[table_name]["columns"].append(
{
"name": row["field_path"],
"type": row["data_type"],
"description": row["description"],
}
)
table_queries = []
for dataset_id in datasets:
table_query = table_query_base.format(dataset_id=dataset_id)
table_queries.append(table_query)
table_query = "\nUNION ALL\n".join(table_queries)
results, error = self.run_query(table_query, None)
if error is not None:
self._handle_run_query_error(error)
for row in results["rows"]:
table_name = "{0}.{1}".format(row["table_schema"], row["table_name"])
if table_name not in schema:
schema[table_name] = {"name": table_name, "columns": []}
if "table_description" in row:
schema[table_name]["description"] = row["table_description"]
return list(schema.values())
def run_query(self, query, user):
logger.debug("BigQuery got query: %s", query)
bigquery_service = self._get_bigquery_service()
jobs = bigquery_service.jobs()
try:
if "totalMBytesProcessedLimit" in self.configuration:
limitMB = self.configuration["totalMBytesProcessedLimit"]
processedMB = self._get_total_bytes_processed(jobs, query) / 1000.0 / 1000.0
if limitMB < processedMB:
return (
None,
"Larger than %d MBytes will be processed (%f MBytes)" % (limitMB, processedMB),
)
data = self._get_query_result(jobs, query)
error = None
except apiclient.errors.HttpError as e:
data = None
if e.resp.status in [400, 404]:
error = json_loads(e.content)["error"]["message"]
else:
error = e.content
except (KeyboardInterrupt, InterruptException, JobTimeoutException):
if self.current_job_id:
self._get_bigquery_service().jobs().cancel(
projectId=self._get_project_id(),
jobId=self.current_job_id,
location=self.current_job_location,
).execute()
raise
return data, error
register(BigQuery)
================================================
FILE: redash/query_runner/big_query_gce.py
================================================
import requests
try:
import google.auth
from apiclient.discovery import build
enabled = True
except ImportError:
enabled = False
from redash.query_runner import register
from .big_query import BigQuery
class BigQueryGCE(BigQuery):
@classmethod
def type(cls):
return "bigquery_gce"
@classmethod
def enabled(cls):
if not enabled:
return False
try:
# check if we're on a GCE instance
requests.get("http://metadata.google.internal")
except requests.exceptions.ConnectionError:
return False
return True
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"totalMBytesProcessedLimit": {
"type": "number",
"title": "Total MByte Processed Limit",
},
"userDefinedFunctionResourceUri": {
"type": "string",
"title": "UDF Source URIs (i.e. gs://bucket/date_utils.js, gs://bucket/string_utils.js )",
},
"useStandardSql": {
"type": "boolean",
"title": "Use Standard SQL",
"default": True,
},
"location": {
"type": "string",
"title": "Processing Location",
"default": "US",
},
"loadSchema": {"type": "boolean", "title": "Load Schema"},
},
}
def _get_project_id(self):
google.auth.default()[1]
def _get_bigquery_service(self):
creds = google.auth.default(scopes=["https://www.googleapis.com/auth/bigquery"])[0]
return build("bigquery", "v2", credentials=creds, cache_discovery=False)
register(BigQueryGCE)
================================================
FILE: redash/query_runner/cass.py
================================================
import logging
import os
import ssl
from base64 import b64decode
from tempfile import NamedTemporaryFile
from redash.query_runner import BaseQueryRunner, register
logger = logging.getLogger(__name__)
try:
from cassandra.auth import PlainTextAuthProvider
from cassandra.cluster import Cluster
from cassandra.util import sortedset
enabled = True
except ImportError:
enabled = False
def generate_ssl_options_dict(protocol, cert_path=None):
ssl_options = {"ssl_version": getattr(ssl, protocol)}
if cert_path is not None:
ssl_options["ca_certs"] = cert_path
ssl_options["cert_reqs"] = ssl.CERT_REQUIRED
return ssl_options
class Cassandra(BaseQueryRunner):
noop_query = "SELECT dateof(now()) FROM system.local"
@classmethod
def enabled(cls):
return enabled
@classmethod
def custom_json_encoder(cls, dec, o):
if isinstance(o, sortedset):
return list(o)
return None
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"host": {"type": "string"},
"port": {"type": "number", "default": 9042},
"keyspace": {"type": "string", "title": "Keyspace name"},
"username": {"type": "string", "title": "Username"},
"password": {"type": "string", "title": "Password"},
"protocol": {
"type": "number",
"title": "Protocol Version",
"default": 3,
},
"timeout": {"type": "number", "title": "Timeout", "default": 10},
"useSsl": {"type": "boolean", "title": "Use SSL", "default": False},
"sslCertificateFile": {"type": "string", "title": "SSL Certificate File"},
"sslProtocol": {
"type": "string",
"title": "SSL Protocol",
"enum": [
"PROTOCOL_SSLv23",
"PROTOCOL_TLS",
"PROTOCOL_TLS_CLIENT",
"PROTOCOL_TLS_SERVER",
"PROTOCOL_TLSv1",
"PROTOCOL_TLSv1_1",
"PROTOCOL_TLSv1_2",
],
},
},
"required": ["keyspace", "host", "useSsl"],
"secret": ["sslCertificateFile"],
}
@classmethod
def type(cls):
return "Cassandra"
def get_schema(self, get_stats=False):
query = """
select release_version from system.local;
"""
results, error = self.run_query(query, None)
release_version = results["rows"][0]["release_version"]
query = """
SELECT table_name, column_name
FROM system_schema.columns
WHERE keyspace_name ='{}';
""".format(
self.configuration["keyspace"]
)
if release_version.startswith("2"):
query = """
SELECT columnfamily_name AS table_name, column_name
FROM system.schema_columns
WHERE keyspace_name ='{}';
""".format(
self.configuration["keyspace"]
)
results, error = self.run_query(query, None)
schema = {}
for row in results["rows"]:
table_name = row["table_name"]
column_name = row["column_name"]
if table_name not in schema:
schema[table_name] = {"name": table_name, "columns": []}
schema[table_name]["columns"].append(column_name)
return list(schema.values())
def run_query(self, query, user):
connection = None
cert_path = self._generate_cert_file()
if self.configuration.get("username", "") and self.configuration.get("password", ""):
auth_provider = PlainTextAuthProvider(
username="{}".format(self.configuration.get("username", "")),
password="{}".format(self.configuration.get("password", "")),
)
connection = Cluster(
[self.configuration.get("host", "")],
auth_provider=auth_provider,
port=self.configuration.get("port", ""),
protocol_version=self.configuration.get("protocol", 3),
ssl_options=self._get_ssl_options(cert_path),
)
else:
connection = Cluster(
[self.configuration.get("host", "")],
port=self.configuration.get("port", ""),
protocol_version=self.configuration.get("protocol", 3),
ssl_options=self._get_ssl_options(cert_path),
)
session = connection.connect()
session.set_keyspace(self.configuration["keyspace"])
session.default_timeout = self.configuration.get("timeout", 10)
logger.debug("Cassandra running query: %s", query)
result = session.execute(query)
self._cleanup_cert_file(cert_path)
column_names = result.column_names
columns = self.fetch_columns([(c, "string") for c in column_names])
rows = [dict(zip(column_names, row)) for row in result]
data = {"columns": columns, "rows": rows}
return data, None
def _generate_cert_file(self):
cert_encoded_bytes = self.configuration.get("sslCertificateFile", None)
if cert_encoded_bytes:
with NamedTemporaryFile(mode="w", delete=False) as cert_file:
cert_bytes = b64decode(cert_encoded_bytes)
cert_file.write(cert_bytes.decode("utf-8"))
return cert_file.name
return None
def _cleanup_cert_file(self, cert_path):
if cert_path:
os.remove(cert_path)
def _get_ssl_options(self, cert_path):
ssl_options = None
if self.configuration.get("useSsl", False):
ssl_options = generate_ssl_options_dict(protocol=self.configuration["sslProtocol"], cert_path=cert_path)
return ssl_options
class ScyllaDB(Cassandra):
@classmethod
def type(cls):
return "scylla"
register(Cassandra)
register(ScyllaDB)
================================================
FILE: redash/query_runner/clickhouse.py
================================================
import logging
import re
from urllib.parse import urlparse
from uuid import uuid4
import requests
from redash.query_runner import (
TYPE_DATE,
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseSQLQueryRunner,
register,
split_sql_statements,
)
logger = logging.getLogger(__name__)
def split_multi_query(query):
return [st for st in split_sql_statements(query) if st != ""]
class ClickHouse(BaseSQLQueryRunner):
noop_query = "SELECT 1"
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"url": {"type": "string", "default": "http://127.0.0.1:8123"},
"user": {"type": "string", "default": "default"},
"password": {"type": "string"},
"dbname": {"type": "string", "title": "Database Name"},
"timeout": {
"type": "number",
"title": "Request Timeout",
"default": 30,
},
"verify": {
"type": "boolean",
"title": "Verify SSL certificate",
"default": True,
},
},
"order": ["url", "user", "password", "dbname"],
"required": ["dbname"],
"extra_options": ["timeout", "verify"],
"secret": ["password"],
}
@property
def _url(self):
return urlparse(self.configuration["url"])
@_url.setter
def _url(self, url):
self.configuration["url"] = url.geturl()
@property
def host(self):
return self._url.hostname
@host.setter
def host(self, host):
self._url = self._url._replace(netloc="{}:{}".format(host, self._url.port))
@property
def port(self):
return self._url.port
@port.setter
def port(self, port):
self._url = self._url._replace(netloc="{}:{}".format(self._url.hostname, port))
def _get_tables(self, schema):
query = """
SELECT database, table, name, type as data_type
FROM system.columns
WHERE database NOT IN ('system', 'information_schema', 'INFORMATION_SCHEMA')
"""
results, error = self.run_query(query, None)
if error is not None:
self._handle_run_query_error(error)
for row in results["rows"]:
table_name = "{}.{}".format(row["database"], row["table"])
if table_name not in schema:
schema[table_name] = {"name": table_name, "columns": []}
schema[table_name]["columns"].append({"name": row["name"], "type": row["data_type"]})
return list(schema.values())
def _send_query(self, data, session_id=None, session_check=None):
url = self.configuration.get("url", "http://127.0.0.1:8123")
timeout = self.configuration.get("timeout", 30)
params = {
"user": self.configuration.get("user", "default"),
"password": self.configuration.get("password", ""),
"database": self.configuration["dbname"],
"default_format": "JSON",
}
if session_id:
params["session_id"] = session_id
params["session_check"] = "1" if session_check else "0"
params["session_timeout"] = timeout
try:
verify = self.configuration.get("verify", True)
r = requests.post(
url,
data=data.encode("utf-8", "ignore"),
stream=False,
timeout=timeout,
params=params,
verify=verify,
)
if not r.ok:
raise Exception(r.text)
# In certain situations the response body can be empty even if the query was successful, for example
# when creating temporary tables.
if not r.text:
return {}
response = r.json()
if "exception" in response:
raise Exception(response["exception"])
return response
except requests.RequestException as e:
if e.response:
details = "({}, Status Code: {})".format(e.__class__.__name__, e.response.status_code)
else:
details = "({})".format(e.__class__.__name__)
raise Exception("Connection error to: {} {}.".format(url, details))
@staticmethod
def _define_column_type(column):
c = column.lower()
f = re.search(r"^nullable\((.*)\)$", c)
if f is not None:
c = f.group(1)
if c.startswith("int") or c.startswith("uint"):
return TYPE_INTEGER
elif c.startswith("float"):
return TYPE_FLOAT
elif c == "datetime":
return TYPE_DATETIME
elif c == "date":
return TYPE_DATE
else:
return TYPE_STRING
def _clickhouse_query(self, query, session_id=None, session_check=None):
logger.debug(f"{self.name()} is about to execute query: %s", query)
query += "\nFORMAT JSON"
response = self._send_query(query, session_id, session_check)
columns = []
columns_int64 = [] # db converts value to string if its type equals UInt64
columns_totals = {}
meta = response.get("meta", [])
for r in meta:
column_name = r["name"]
column_type = self._define_column_type(r["type"])
if r["type"] in ("Int64", "UInt64", "Nullable(Int64)", "Nullable(UInt64)"):
columns_int64.append(column_name)
else:
columns_totals[column_name] = "Total" if column_type == TYPE_STRING else None
columns.append({"name": column_name, "friendly_name": column_name, "type": column_type})
rows = response.get("data", [])
for row in rows:
for column in columns_int64:
try:
row[column] = int(row[column])
except TypeError:
row[column] = None
if "totals" in response:
totals = response["totals"]
for column, value in columns_totals.items():
totals[column] = value
rows.append(totals)
return {"columns": columns, "rows": rows}
def run_query(self, query, user):
queries = split_multi_query(query)
if not queries:
data = None
error = "Query is empty"
return data, error
try:
# If just one query was given no session is needed
if len(queries) == 1:
data = self._clickhouse_query(queries[0])
else:
# If more than one query was given, a session is needed. Parameter session_check must be false
# for the first query
session_id = "redash_{}".format(uuid4().hex)
data = self._clickhouse_query(queries[0], session_id, session_check=False)
for query in queries[1:]:
data = self._clickhouse_query(query, session_id, session_check=True)
error = None
except Exception as e:
data = None
logging.exception(e)
error = str(e)
return data, error
register(ClickHouse)
================================================
FILE: redash/query_runner/cloudwatch.py
================================================
import datetime
import yaml
from redash.query_runner import BaseQueryRunner, register
from redash.utils import parse_human_time
try:
import boto3
enabled = True
except ImportError:
enabled = False
def parse_response(results):
columns = [
{"name": "id", "type": "string"},
{"name": "label", "type": "string"},
{"name": "timestamp", "type": "datetime"},
{"name": "value", "type": "float"},
]
rows = []
for metric in results:
for i, value in enumerate(metric["Values"]):
rows.append(
{
"id": metric["Id"],
"label": metric["Label"],
"timestamp": metric["Timestamps"][i],
"value": value,
}
)
return rows, columns
def parse_query(query):
query = yaml.safe_load(query)
for timeKey in ["StartTime", "EndTime"]:
if isinstance(query.get(timeKey), str):
query[timeKey] = int(parse_human_time(query[timeKey]).timestamp())
if not query.get("EndTime"):
query["EndTime"] = int(datetime.datetime.now().timestamp())
return query
class CloudWatch(BaseQueryRunner):
should_annotate_query = False
@classmethod
def name(cls):
return "Amazon CloudWatch"
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"region": {"type": "string", "title": "AWS Region"},
"aws_access_key": {"type": "string", "title": "AWS Access Key"},
"aws_secret_key": {"type": "string", "title": "AWS Secret Key"},
},
"required": ["region", "aws_access_key", "aws_secret_key"],
"order": ["region", "aws_access_key", "aws_secret_key"],
"secret": ["aws_secret_key"],
}
@classmethod
def enabled(cls):
return enabled
def __init__(self, configuration):
super(CloudWatch, self).__init__(configuration)
self.syntax = "yaml"
def test_connection(self):
self.get_schema()
def _get_client(self):
cloudwatch = boto3.client(
"cloudwatch",
region_name=self.configuration.get("region"),
aws_access_key_id=self.configuration.get("aws_access_key"),
aws_secret_access_key=self.configuration.get("aws_secret_key"),
)
return cloudwatch
def get_schema(self, get_stats=False):
client = self._get_client()
paginator = client.get_paginator("list_metrics")
metrics = {}
for page in paginator.paginate():
for metric in page["Metrics"]:
if metric["Namespace"] not in metrics:
metrics[metric["Namespace"]] = {
"name": metric["Namespace"],
"columns": [],
}
if metric["MetricName"] not in metrics[metric["Namespace"]]["columns"]:
metrics[metric["Namespace"]]["columns"].append(metric["MetricName"])
return list(metrics.values())
def run_query(self, query, user):
cloudwatch = self._get_client()
query = parse_query(query)
results = []
paginator = cloudwatch.get_paginator("get_metric_data")
for page in paginator.paginate(**query):
results += page["MetricDataResults"]
rows, columns = parse_response(results)
return {"rows": rows, "columns": columns}, None
register(CloudWatch)
================================================
FILE: redash/query_runner/cloudwatch_insights.py
================================================
import datetime
import time
import yaml
from redash.query_runner import BaseQueryRunner, register
from redash.utils import parse_human_time
try:
import boto3
from botocore.exceptions import ParamValidationError # noqa: F401
enabled = True
except ImportError:
enabled = False
POLL_INTERVAL = 3
TIMEOUT = 180
def parse_response(response):
results = response["results"]
rows = []
field_orders = {}
for row in results:
record = {}
rows.append(record)
for order, col in enumerate(row):
if col["field"] == "@ptr":
continue
field = col["field"]
record[field] = col["value"]
field_orders[field] = max(field_orders.get(field, -1), order)
fields = sorted(field_orders, key=lambda f: field_orders[f])
cols = [
{
"name": f,
"type": "datetime" if f == "@timestamp" else "string",
"friendly_name": f,
}
for f in fields
]
return {
"columns": cols,
"rows": rows,
"metadata": {"data_scanned": response["statistics"]["bytesScanned"]},
}
def parse_query(query):
query = yaml.safe_load(query)
for timeKey in ["startTime", "endTime"]:
if isinstance(query.get(timeKey), str):
query[timeKey] = int(parse_human_time(query[timeKey]).timestamp())
if not query.get("endTime"):
query["endTime"] = int(datetime.datetime.now().timestamp())
return query
class CloudWatchInsights(BaseQueryRunner):
should_annotate_query = False
@classmethod
def name(cls):
return "Amazon CloudWatch Logs Insights"
@classmethod
def type(cls):
return "cloudwatch_insights"
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"region": {"type": "string", "title": "AWS Region"},
"aws_access_key": {"type": "string", "title": "AWS Access Key"},
"aws_secret_key": {"type": "string", "title": "AWS Secret Key"},
},
"required": ["region", "aws_access_key", "aws_secret_key"],
"order": ["region", "aws_access_key", "aws_secret_key"],
"secret": ["aws_secret_key"],
}
@classmethod
def enabled(cls):
return enabled
def __init__(self, configuration):
super(CloudWatchInsights, self).__init__(configuration)
self.syntax = "yaml"
def test_connection(self):
self.get_schema()
def _get_client(self):
cloudwatch = boto3.client(
"logs",
region_name=self.configuration.get("region"),
aws_access_key_id=self.configuration.get("aws_access_key"),
aws_secret_access_key=self.configuration.get("aws_secret_key"),
)
return cloudwatch
def get_schema(self, get_stats=False):
client = self._get_client()
log_groups = []
paginator = client.get_paginator("describe_log_groups")
for page in paginator.paginate():
for group in page["logGroups"]:
group_name = group["logGroupName"]
fields = client.get_log_group_fields(logGroupName=group_name)
log_groups.append(
{
"name": group_name,
"columns": [field["name"] for field in fields["logGroupFields"]],
}
)
return log_groups
def run_query(self, query, user):
logs = self._get_client()
query = parse_query(query)
query_id = logs.start_query(**query)["queryId"]
elapsed = 0
while True:
result = logs.get_query_results(queryId=query_id)
if result["status"] == "Complete":
data = parse_response(result)
break
if result["status"] in ("Failed", "Timeout", "Unknown", "Cancelled"):
raise Exception("CloudWatch Insights Query Execution Status: {}".format(result["status"]))
elif elapsed > TIMEOUT:
raise Exception("Request exceeded timeout.")
else:
time.sleep(POLL_INTERVAL)
elapsed += POLL_INTERVAL
return data, None
register(CloudWatchInsights)
================================================
FILE: redash/query_runner/corporate_memory.py
================================================
"""Provide the query runner for eccenca Corporate Memory.
seeAlso: https://documentation.eccenca.com/
seeAlso: https://eccenca.com/
"""
import json
import logging
from os import environ
from redash.query_runner import BaseQueryRunner
from . import register
try:
from cmem.cmempy.dp.proxy.graph import get_graphs_list
from cmem.cmempy.queries import ( # noqa: F401
QUERY_STRING,
QueryCatalog,
SparqlQuery,
)
enabled = True
except ImportError:
enabled = False
logger = logging.getLogger(__name__)
class CorporateMemoryQueryRunner(BaseQueryRunner):
"""Use eccenca Corporate Memory as redash data source"""
# These environment keys are used by cmempy
KNOWN_CONFIG_KEYS = (
"CMEM_BASE_PROTOCOL",
"CMEM_BASE_DOMAIN",
"CMEM_BASE_URI",
"SSL_VERIFY",
"REQUESTS_CA_BUNDLE",
"DP_API_ENDPOINT",
"DI_API_ENDPOINT",
"OAUTH_TOKEN_URI",
"OAUTH_GRANT_TYPE",
"OAUTH_USER",
"OAUTH_PASSWORD",
"OAUTH_CLIENT_ID",
"OAUTH_CLIENT_SECRET",
)
# These variables hold secret data and should NOT be logged
KNOWN_SECRET_KEYS = ("OAUTH_PASSWORD", "OAUTH_CLIENT_SECRET")
# This allows for an easy connection test
noop_query = "SELECT ?noop WHERE {BIND('noop' as ?noop)}"
# We do not want to have comment in our sparql queries
# FEATURE?: Implement annotate_query in case the metadata is useful somewhere
should_annotate_query = False
def __init__(self, configuration):
"""init the class and configuration"""
super(CorporateMemoryQueryRunner, self).__init__(configuration)
"""
FEATURE?: activate SPARQL support in the redash query editor
Currently SPARQL syntax seems not to be available for react-ace
component. However, the ace editor itself supports sparql mode:
https://github.com/ajaxorg/ace/blob/master/lib/ace/mode/sparql.js
then we can hopefully do: self.syntax = "sparql"
FEATURE?: implement the retrieve Query catalog URIs in order to use them in queries
FEATURE?: implement a way to use queries from the query catalog
FEATURE?: allow a checkbox to NOT use owl:imports imported graphs
FEATURE?: allow to use a context graph per data source
"""
self.configuration = configuration
def _setup_environment(self):
"""provide environment for cmempy
cmempy environment variables need to match key in the properties
object of the configuration_schema
"""
for key in self.KNOWN_CONFIG_KEYS:
if key in environ:
environ.pop(key)
value = self.configuration.get(key, None)
if value is not None:
environ[key] = str(value)
if key in self.KNOWN_SECRET_KEYS:
logger.info("{} set by config".format(key))
else:
logger.info("{} set by config to {}".format(key, environ[key]))
@staticmethod
def _transform_sparql_results(results):
"""transforms a SPARQL query result to a redash query result
source structure: SPARQL 1.1 Query Results JSON Format
- seeAlso: https://www.w3.org/TR/sparql11-results-json/
target structure: redash result set
there is no good documentation available
so here an example result set as needed for redash:
data = {
"columns": [ {"name": "name", "type": "string", "friendly_name": "friendly name"}],
"rows": [
{"name": "value 1"},
{"name": "value 2"}
]}
FEATURE?: During the sparql_row loop, we could check the data types of the
values and, in case they are all the same, choose something better than
just string.
"""
logger.info("results are: {}".format(results))
# Not sure why we do not use the json package here but all other
# query runner do it the same way :-)
sparql_results = results
# transform all bindings to redash rows
rows = []
for sparql_row in sparql_results["results"]["bindings"]:
row = {}
for var in sparql_results["head"]["vars"]:
try:
row[var] = sparql_row[var]["value"]
except KeyError:
# not bound SPARQL variables are set as empty strings
row[var] = ""
rows.append(row)
# transform all vars to redash columns
columns = []
for var in sparql_results["head"]["vars"]:
columns.append({"name": var, "friendly_name": var, "type": "string"})
# Not sure why we do not use the json package here but all other
# query runner do it the same way :-)
return {"columns": columns, "rows": rows}
@classmethod
def name(cls):
return "eccenca Corporate Memory (with SPARQL)"
@classmethod
def enabled(cls):
return enabled
@classmethod
def type(cls):
return "corporate_memory"
def run_query(self, query, user):
"""send a sparql query to corporate memory"""
query_text = query
logger.info("about to execute query (user='{}'): {}".format(user, query_text))
query = SparqlQuery(query_text)
query_type = query.get_query_type()
# type of None means, there is an error in the query
# so execution is at least tried on endpoint
if query_type not in ["SELECT", None]:
raise ValueError("Queries of type {} can not be processed by redash.".format(query_type))
self._setup_environment()
try:
data = self._transform_sparql_results(query.get_results())
except Exception as error:
logger.info("Error: {}".format(error))
try:
# try to load Problem Details for HTTP API JSON
details = json.loads(error.response.text)
error = ""
if "title" in details:
error += details["title"] + ": "
if "detail" in details:
error += details["detail"]
return None, error
except Exception:
pass
return None, error
error = None
return data, error
@classmethod
def configuration_schema(cls):
"""provide the configuration of the data source as json schema"""
return {
"type": "object",
"properties": {
"CMEM_BASE_URI": {"type": "string", "title": "Base URL"},
"OAUTH_GRANT_TYPE": {
"type": "string",
"title": "Grant Type",
"default": "client_credentials",
"extendedEnum": [
{"value": "client_credentials", "name": "client_credentials"},
{"value": "password", "name": "password"},
],
},
"OAUTH_CLIENT_ID": {
"type": "string",
"title": "Client ID (e.g. cmem-service-account)",
"default": "cmem-service-account",
},
"OAUTH_CLIENT_SECRET": {
"type": "string",
"title": "Client Secret - only needed for grant type 'client_credentials'",
},
"OAUTH_USER": {
"type": "string",
"title": "User account - only needed for grant type 'password'",
},
"OAUTH_PASSWORD": {
"type": "string",
"title": "User Password - only needed for grant type 'password'",
},
"SSL_VERIFY": {
"type": "boolean",
"title": "Verify SSL certificates for API requests",
"default": True,
},
"REQUESTS_CA_BUNDLE": {
"type": "string",
"title": "Path to the CA Bundle file (.pem)",
},
},
"required": ["CMEM_BASE_URI", "OAUTH_GRANT_TYPE", "OAUTH_CLIENT_ID"],
"secret": ["OAUTH_CLIENT_SECRET", "OAUTH_PASSWORD"],
"extra_options": [
"OAUTH_GRANT_TYPE",
"OAUTH_USER",
"OAUTH_PASSWORD",
"SSL_VERIFY",
"REQUESTS_CA_BUNDLE",
],
}
def get_schema(self, get_stats=False):
"""Get the schema structure (prefixes, graphs)."""
schema = dict()
schema["1"] = {
"name": "-> Common Prefixes <-",
"columns": self._get_common_prefixes_schema(),
}
schema["2"] = {"name": "-> Graphs <-", "columns": self._get_graphs_schema()}
# schema.update(self._get_query_schema())
logger.info(schema.values())
return schema.values()
def _get_graphs_schema(self):
"""Get a list of readable graph FROM clause strings."""
self._setup_environment()
graphs = []
for graph in get_graphs_list():
graphs.append("FROM <{}>".format(graph["iri"]))
return graphs
@staticmethod
def _get_common_prefixes_schema():
"""Get a list of SPARQL prefix declarations."""
common_prefixes = [
"PREFIX rdf: ",
"PREFIX rdfs: ",
"PREFIX owl: ",
"PREFIX schema: ",
"PREFIX dct: ",
"PREFIX skos: ",
]
return common_prefixes
register(CorporateMemoryQueryRunner)
================================================
FILE: redash/query_runner/couchbase.py
================================================
import datetime
import logging
from redash.query_runner import (
TYPE_BOOLEAN,
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseQueryRunner,
register,
)
logger = logging.getLogger(__name__)
try:
import httplib2 # noqa: F401
import requests
except ImportError as e:
logger.error("Failed to import: " + str(e))
TYPES_MAP = {
str: TYPE_STRING,
bytes: TYPE_STRING,
int: TYPE_INTEGER,
float: TYPE_FLOAT,
bool: TYPE_BOOLEAN,
datetime.datetime: TYPE_DATETIME,
datetime.datetime: TYPE_STRING,
}
def _get_column_by_name(columns, column_name):
for c in columns:
if "name" in c and c["name"] == column_name:
return c
return None
def parse_results(results):
rows = []
columns = []
for row in results:
parsed_row = {}
for key in row:
if isinstance(row[key], dict):
for inner_key in row[key]:
column_name = "{}.{}".format(key, inner_key)
if _get_column_by_name(columns, column_name) is None:
columns.append(
{
"name": column_name,
"friendly_name": column_name,
"type": TYPES_MAP.get(type(row[key][inner_key]), TYPE_STRING),
}
)
parsed_row[column_name] = row[key][inner_key]
else:
if _get_column_by_name(columns, key) is None:
columns.append(
{
"name": key,
"friendly_name": key,
"type": TYPES_MAP.get(type(row[key]), TYPE_STRING),
}
)
parsed_row[key] = row[key]
rows.append(parsed_row)
return rows, columns
class Couchbase(BaseQueryRunner):
should_annotate_query = False
noop_query = "Select 1"
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"protocol": {"type": "string", "default": "http"},
"host": {"type": "string"},
"port": {
"type": "string",
"title": "Port (Defaults: 8095 - Analytics, 8093 - N1QL)",
"default": "8095",
},
"user": {"type": "string"},
"password": {"type": "string"},
},
"required": ["host", "user", "password"],
"order": ["protocol", "host", "port", "user", "password"],
"secret": ["password"],
}
def __init__(self, configuration):
super(Couchbase, self).__init__(configuration)
@classmethod
def enabled(cls):
return True
def test_connection(self):
self.call_service(self.noop_query, "")
def get_buckets(self, query, name_param):
defaultColumns = ["meta().id"]
result = self.call_service(query, "").json()["results"]
schema = {}
for row in result:
table_name = row.get(name_param)
schema[table_name] = {"name": table_name, "columns": defaultColumns}
return list(schema.values())
def get_schema(self, get_stats=False):
try:
# Try fetch from Analytics
return self.get_buckets(
"SELECT ds.GroupName as name FROM Metadata.`Dataset` ds where ds.DataverseName <> 'Metadata'",
"name",
)
except Exception:
# Try fetch from N1QL
return self.get_buckets("select name from system:keyspaces", "name")
def call_service(self, query, user):
try:
user = self.configuration.get("user")
password = self.configuration.get("password")
protocol = self.configuration.get("protocol", "http")
host = self.configuration.get("host")
port = self.configuration.get("port", 8095)
params = {"statement": query}
url = "%s://%s:%s/query/service" % (protocol, host, port)
r = requests.post(url, params=params, auth=(user, password))
r.raise_for_status()
return r
except requests.exceptions.HTTPError as err:
if err.response.status_code == 401:
raise Exception("Wrong username/password")
raise Exception("Couchbase connection error")
def run_query(self, query, user):
result = self.call_service(query, user)
rows, columns = parse_results(result.json()["results"])
data = {"columns": columns, "rows": rows}
return data, None
@classmethod
def name(cls):
return "Couchbase"
register(Couchbase)
================================================
FILE: redash/query_runner/csv.py
================================================
import io
import logging
import yaml
from redash.query_runner import BaseQueryRunner, NotSupported, register
from redash.utils.requests_session import (
UnacceptableAddressException,
requests_or_advocate,
)
logger = logging.getLogger(__name__)
try:
import numpy as np
import pandas as pd
enabled = True
except ImportError:
enabled = False
class CSV(BaseQueryRunner):
should_annotate_query = False
@classmethod
def name(cls):
return "CSV"
@classmethod
def enabled(cls):
return enabled
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {},
}
def __init__(self, configuration):
super(CSV, self).__init__(configuration)
self.syntax = "yaml"
def test_connection(self):
pass
def run_query(self, query, user):
path = ""
ua = ""
args = {}
try:
args = yaml.safe_load(query)
path = args["url"]
args.pop("url", None)
ua = args["user-agent"]
args.pop("user-agent", None)
except Exception:
pass
try:
response = requests_or_advocate.get(url=path, headers={"User-agent": ua})
workbook = pd.read_csv(io.BytesIO(response.content), sep=",", **args)
df = workbook.copy()
data = {"columns": [], "rows": []}
conversions = [
{
"pandas_type": np.integer,
"redash_type": "integer",
},
{
"pandas_type": np.inexact,
"redash_type": "float",
},
{
"pandas_type": np.datetime64,
"redash_type": "datetime",
"to_redash": lambda x: x.strftime("%Y-%m-%d %H:%M:%S"),
},
{"pandas_type": np.bool_, "redash_type": "boolean"},
{"pandas_type": np.object_, "redash_type": "string"},
]
labels = []
for dtype, label in zip(df.dtypes, df.columns):
for conversion in conversions:
if issubclass(dtype.type, conversion["pandas_type"]):
data["columns"].append(
{"name": label, "friendly_name": label, "type": conversion["redash_type"]}
)
labels.append(label)
func = conversion.get("to_redash")
if func:
df[label] = df[label].apply(func)
break
data["rows"] = df[labels].replace({np.nan: None}).to_dict(orient="records")
error = None
except KeyboardInterrupt:
error = "Query cancelled by user."
data = None
except UnacceptableAddressException:
error = "Can't query private addresses."
data = None
except Exception as e:
error = "Error reading {0}. {1}".format(path, str(e))
data = None
return data, error
def get_schema(self):
raise NotSupported()
register(CSV)
================================================
FILE: redash/query_runner/databend.py
================================================
try:
import re
from databend_sqlalchemy import connector
enabled = True
except ImportError:
enabled = False
from redash.query_runner import (
TYPE_DATE,
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseQueryRunner,
register,
)
class Databend(BaseQueryRunner):
noop_query = "SELECT 1"
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"host": {"type": "string", "default": "localhost"},
"port": {"type": "string", "default": "8000"},
"username": {"type": "string"},
"password": {"type": "string", "default": ""},
"database": {"type": "string"},
"secure": {"type": "boolean", "default": False},
},
"order": ["username", "password", "host", "port", "database"],
"required": ["username", "database"],
"secret": ["password"],
}
@classmethod
def name(cls):
return "Databend"
@classmethod
def type(cls):
return "databend"
@classmethod
def enabled(cls):
return enabled
@staticmethod
def _define_column_type(column_type):
c = column_type.lower()
f = re.search(r"^nullable\((.*)\)$", c)
if f is not None:
c = f.group(1)
if c.startswith("int") or c.startswith("uint"):
return TYPE_INTEGER
elif c.startswith("float"):
return TYPE_FLOAT
elif c == "datetime":
return TYPE_DATETIME
elif c == "date":
return TYPE_DATE
else:
return TYPE_STRING
def run_query(self, query, user):
host = self.configuration.get("host") or "localhost"
port = self.configuration.get("port") or "8000"
username = self.configuration.get("username") or "root"
password = self.configuration.get("password") or ""
database = self.configuration.get("database") or "default"
secure = self.configuration.get("secure") or False
connection = connector.connect(f"databend://{username}:{password}@{host}:{port}/{database}?secure={secure}")
cursor = connection.cursor()
try:
cursor.execute(query)
columns = self.fetch_columns([(i[0], self._define_column_type(i[1])) for i in cursor.description])
rows = [dict(zip((column["name"] for column in columns), row)) for row in cursor]
data = {"columns": columns, "rows": rows}
error = None
finally:
connection.close()
return data, error
def get_schema(self, get_stats=False):
query = """
SELECT TABLE_SCHEMA,
TABLE_NAME,
COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA NOT IN ('information_schema', 'system')
"""
results, error = self.run_query(query, None)
if error is not None:
self._handle_run_query_error(error)
schema = {}
for row in results["rows"]:
table_name = "{}.{}".format(row["table_schema"], row["table_name"])
if table_name not in schema:
schema[table_name] = {"name": table_name, "columns": []}
schema[table_name]["columns"].append(row["column_name"])
return list(schema.values())
def _get_tables(self):
query = """
SELECT TABLE_SCHEMA,
TABLE_NAME,
COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA NOT IN ('information_schema', 'system')
"""
results, error = self.run_query(query, None)
if error is not None:
self._handle_run_query_error(error)
schema = {}
for row in results["rows"]:
table_name = "{}.{}".format(row["table_schema"], row["table_name"])
if table_name not in schema:
schema[table_name] = {"name": table_name, "columns": []}
schema[table_name]["columns"].append(row["column_name"])
return list(schema.values())
register(Databend)
================================================
FILE: redash/query_runner/databricks.py
================================================
import datetime
import logging
import os
from redash import __version__, statsd_client
from redash.query_runner import (
TYPE_BOOLEAN,
TYPE_DATE,
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseSQLQueryRunner,
NotSupported,
register,
split_sql_statements,
)
from redash.settings import cast_int_or_default
try:
import pyodbc
enabled = True
except ImportError:
enabled = False
TYPES_MAP = {
str: TYPE_STRING,
bool: TYPE_BOOLEAN,
datetime.date: TYPE_DATE,
datetime.datetime: TYPE_DATETIME,
int: TYPE_INTEGER,
float: TYPE_FLOAT,
}
ROW_LIMIT = cast_int_or_default(os.environ.get("DATABRICKS_ROW_LIMIT"), 20000)
logger = logging.getLogger(__name__)
def _build_odbc_connection_string(**kwargs):
return ";".join([f"{k}={v}" for k, v in kwargs.items()])
class Databricks(BaseSQLQueryRunner):
noop_query = "SELECT 1"
should_annotate_query = False
@classmethod
def type(cls):
return "databricks"
@classmethod
def enabled(cls):
return enabled
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"host": {"type": "string"},
"http_path": {"type": "string", "title": "HTTP Path"},
# We're using `http_password` here for legacy reasons
"http_password": {"type": "string", "title": "Access Token"},
},
"order": ["host", "http_path", "http_password"],
"secret": ["http_password"],
"required": ["host", "http_path", "http_password"],
}
def _get_cursor(self):
user_agent = "Redash/{} (Databricks)".format(__version__.split("-")[0])
connection_string = _build_odbc_connection_string(
Driver="Simba",
UID="token",
PORT="443",
SSL="1",
THRIFTTRANSPORT="2",
SPARKSERVERTYPE="3",
AUTHMECH=3,
# Use the query as is without rewriting:
UseNativeQuery="1",
# Automatically reconnect to the cluster if an error occurs
AutoReconnect="1",
# Minimum interval between consecutive polls for query execution status (1ms)
AsyncExecPollInterval="1",
UserAgentEntry=user_agent,
HOST=self.configuration["host"],
PWD=self.configuration["http_password"],
HTTPPath=self.configuration["http_path"],
)
connection = pyodbc.connect(connection_string, autocommit=True)
return connection.cursor()
def run_query(self, query, user):
try:
cursor = self._get_cursor()
statements = split_sql_statements(query)
for stmt in statements:
cursor.execute(stmt)
if cursor.description is not None:
result_set = cursor.fetchmany(ROW_LIMIT)
columns = self.fetch_columns([(i[0], TYPES_MAP.get(i[1], TYPE_STRING)) for i in cursor.description])
rows = [dict(zip((column["name"] for column in columns), row)) for row in result_set]
data = {"columns": columns, "rows": rows}
if len(result_set) >= ROW_LIMIT and cursor.fetchone() is not None:
logger.warning("Truncated result set.")
statsd_client.incr("redash.query_runner.databricks.truncated")
data["truncated"] = True
error = None
else:
error = None
data = {
"columns": [{"name": "result", "type": TYPE_STRING}],
"rows": [{"result": "No data was returned."}],
}
cursor.close()
except pyodbc.Error as e:
if len(e.args) > 1:
error = str(e.args[1])
else:
error = str(e)
data = None
return data, error
def get_schema(self):
raise NotSupported()
def get_databases(self):
query = "SHOW DATABASES"
results, error = self.run_query(query, None)
if error is not None:
self._handle_run_query_error(error)
first_column_name = results["columns"][0]["name"]
return [row[first_column_name] for row in results["rows"]]
def get_database_tables(self, database_name):
schema = {}
cursor = self._get_cursor()
cursor.tables(schema=database_name)
for table in cursor:
table_name = "{}.{}".format(table[1], table[2])
if table_name not in schema:
schema[table_name] = {"name": table_name, "columns": []}
return list(schema.values())
def get_database_tables_with_columns(self, database_name):
schema = {}
cursor = self._get_cursor()
# load tables first, otherwise tables without columns are not showed
cursor.tables(schema=database_name)
for table in cursor:
table_name = "{}.{}".format(table[1], table[2])
if table_name not in schema:
schema[table_name] = {"name": table_name, "columns": []}
cursor.columns(schema=database_name)
for column in cursor:
table_name = "{}.{}".format(column[1], column[2])
if table_name not in schema:
schema[table_name] = {"name": table_name, "columns": []}
schema[table_name]["columns"].append({"name": column[3], "type": column[5]})
return list(schema.values())
def get_table_columns(self, database_name, table_name):
cursor = self._get_cursor()
cursor.columns(schema=database_name, table=table_name)
return [{"name": column[3], "type": column[5]} for column in cursor]
register(Databricks)
================================================
FILE: redash/query_runner/db2.py
================================================
import logging
from redash.query_runner import (
TYPE_DATE,
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseSQLQueryRunner,
InterruptException,
JobTimeoutException,
register,
)
logger = logging.getLogger(__name__)
try:
import select
import ibm_db_dbi
types_map = {
ibm_db_dbi.NUMBER: TYPE_INTEGER,
ibm_db_dbi.BIGINT: TYPE_INTEGER,
ibm_db_dbi.ROWID: TYPE_INTEGER,
ibm_db_dbi.FLOAT: TYPE_FLOAT,
ibm_db_dbi.DECIMAL: TYPE_FLOAT,
ibm_db_dbi.DATE: TYPE_DATE,
ibm_db_dbi.TIME: TYPE_DATETIME,
ibm_db_dbi.DATETIME: TYPE_DATETIME,
ibm_db_dbi.BINARY: TYPE_STRING,
ibm_db_dbi.XML: TYPE_STRING,
ibm_db_dbi.TEXT: TYPE_STRING,
ibm_db_dbi.STRING: TYPE_STRING,
}
enabled = True
except ImportError:
enabled = False
class DB2(BaseSQLQueryRunner):
noop_query = "SELECT 1 FROM SYSIBM.SYSDUMMY1"
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"user": {"type": "string"},
"password": {"type": "string"},
"host": {"type": "string", "default": "127.0.0.1"},
"port": {"type": "number", "default": 50000},
"dbname": {"type": "string", "title": "Database Name"},
},
"order": ["host", "port", "user", "password", "dbname"],
"required": ["dbname"],
"secret": ["password"],
}
@classmethod
def type(cls):
return "db2"
@classmethod
def enabled(cls):
try:
import ibm_db # noqa: F401
except ImportError:
return False
return True
def _get_definitions(self, schema, query):
results, error = self.run_query(query, None)
if error is not None:
self._handle_run_query_error(error)
for row in results["rows"]:
if row["TABLE_SCHEMA"] != "public":
table_name = "{}.{}".format(row["TABLE_SCHEMA"], row["TABLE_NAME"])
else:
table_name = row["TABLE_NAME"]
if table_name not in schema:
schema[table_name] = {"name": table_name, "columns": []}
schema[table_name]["columns"].append(row["COLUMN_NAME"])
def _get_tables(self, schema):
query = """
SELECT rtrim(t.TABSCHEMA) as table_schema,
t.TABNAME as table_name,
c.COLNAME as column_name
from syscat.tables t
join syscat.columns c
on t.TABSCHEMA = c.TABSCHEMA AND t.TABNAME = c.TABNAME
WHERE t.type IN ('T') and t.TABSCHEMA not in ('SYSIBM')
"""
self._get_definitions(schema, query)
return list(schema.values())
def _get_connection(self):
self.connection_string = "DATABASE={};HOSTNAME={};PORT={};PROTOCOL=TCPIP;UID={};PWD={};".format(
self.configuration["dbname"],
self.configuration["host"],
self.configuration["port"],
self.configuration["user"],
self.configuration["password"],
)
connection = ibm_db_dbi.connect(self.connection_string, "", "")
return connection
def run_query(self, query, user):
connection = self._get_connection()
cursor = connection.cursor()
try:
cursor.execute(query)
if cursor.description is not None:
columns = self.fetch_columns([(i[0], types_map.get(i[1], None)) for i in cursor.description])
rows = [dict(zip((column["name"] for column in columns), row)) for row in cursor]
data = {"columns": columns, "rows": rows}
error = None
else:
error = "Query completed but it returned no data."
data = None
except (select.error, OSError):
error = "Query interrupted. Please retry."
data = None
except ibm_db_dbi.DatabaseError as e:
error = str(e)
data = None
except (KeyboardInterrupt, InterruptException, JobTimeoutException):
connection.cancel()
raise
finally:
connection.close()
return data, error
register(DB2)
================================================
FILE: redash/query_runner/dgraph.py
================================================
import json
try:
import pydgraph
enabled = True
except ImportError:
enabled = False
from redash.query_runner import BaseQueryRunner, register
def reduce_item(reduced_item, key, value):
"""From https://github.com/vinay20045/json-to-csv"""
# Reduction Condition 1
if isinstance(value, list):
for i, sub_item in enumerate(value):
reduce_item(reduced_item, "{}.{}".format(key, i), sub_item)
# Reduction Condition 2
elif isinstance(value, dict):
sub_keys = value.keys()
for sub_key in sub_keys:
reduce_item(reduced_item, "{}.{}".format(key, sub_key), value[sub_key])
# Base Condition
else:
reduced_item[key] = value
class Dgraph(BaseQueryRunner):
should_annotate_query = False
noop_query = """
{
test() {
}
}
"""
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"user": {"type": "string"},
"password": {"type": "string"},
"servers": {"type": "string"},
},
"order": ["servers", "user", "password"],
"required": ["servers"],
"secret": ["password"],
}
@classmethod
def type(cls):
return "dgraph"
@classmethod
def enabled(cls):
return enabled
def run_dgraph_query_raw(self, query):
servers = self.configuration.get("servers")
client_stub = pydgraph.DgraphClientStub(servers)
client = pydgraph.DgraphClient(client_stub)
txn = client.txn(read_only=True)
try:
response_raw = txn.query(query)
data = json.loads(response_raw.json)
return data
except Exception as e:
raise e
finally:
txn.discard()
client_stub.close()
def run_query(self, query, user):
data = None
error = None
try:
data = self.run_dgraph_query_raw(query)
first_key = next(iter(list(data.keys())))
first_node = data[first_key]
data_to_be_processed = first_node
processed_data = []
header = []
# use logic from https://github.com/vinay20045/json-to-csv
for item in data_to_be_processed:
reduced_item = {}
reduce_item(reduced_item, first_key, item)
header += reduced_item.keys()
processed_data.append(reduced_item)
header = list(set(header))
columns = [{"name": c, "friendly_name": c, "type": "string"} for c in header]
# finally, assemble both the columns and data
data = {"columns": columns, "rows": processed_data}
except Exception as e:
error = e
return data, error
def get_schema(self, get_stats=False):
"""Queries Dgraph for all the predicates, their types, their tokenizers, etc.
Dgraph only has one schema, and there's no such things as columns"""
query = "schema {}"
results = self.run_dgraph_query_raw(query)
schema = {}
for row in results["schema"]:
table_name = row["predicate"]
if table_name not in schema:
schema[table_name] = {"name": table_name, "columns": []}
return list(schema.values())
register(Dgraph)
================================================
FILE: redash/query_runner/drill.py
================================================
import logging
import os
import re
from dateutil import parser
from redash.query_runner import (
TYPE_BOOLEAN,
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_INTEGER,
BaseHTTPQueryRunner,
guess_type,
register,
)
logger = logging.getLogger(__name__)
# Convert Drill string value to actual type
def convert_type(string_value, actual_type):
if string_value is None or string_value == "":
return ""
if actual_type == TYPE_INTEGER:
return int(string_value)
if actual_type == TYPE_FLOAT:
return float(string_value)
if actual_type == TYPE_BOOLEAN:
return str(string_value).lower() == "true"
if actual_type == TYPE_DATETIME:
return parser.parse(string_value)
return str(string_value)
# Parse Drill API response and translate it to accepted format
def parse_response(data):
cols = data["columns"]
rows = data["rows"]
if len(cols) == 0:
return {"columns": [], "rows": []}
first_row = rows[0]
columns = []
types = {}
for c in cols:
columns.append({"name": c, "type": guess_type(first_row[c]), "friendly_name": c})
for col in columns:
types[col["name"]] = col["type"]
for row in rows:
for key, value in row.items():
row[key] = convert_type(value, types[key])
return {"columns": columns, "rows": rows}
class Drill(BaseHTTPQueryRunner):
noop_query = "select version from sys.version"
response_error = "Drill API returned unexpected status code"
requires_authentication = False
requires_url = True
url_title = "Drill URL"
username_title = "Username"
password_title = "Password"
@classmethod
def name(cls):
return "Apache Drill"
@classmethod
def configuration_schema(cls):
schema = super(Drill, cls).configuration_schema()
# Since Drill itself can act as aggregator of various datasources,
# it can contain quite a lot of schemas in `INFORMATION_SCHEMA`
# We added this to improve user experience and let users focus only on desired schemas.
schema["properties"]["allowed_schemas"] = {
"type": "string",
"title": "List of schemas to use in schema browser (comma separated)",
}
schema["order"] += ["allowed_schemas"]
return schema
def run_query(self, query, user):
drill_url = os.path.join(self.configuration["url"], "query.json")
payload = {"queryType": "SQL", "query": query}
response, error = self.get_response(drill_url, http_method="post", json=payload)
if error is not None:
return None, error
return parse_response(response.json()), None
def get_schema(self, get_stats=False):
query = """
SELECT DISTINCT
TABLE_SCHEMA,
TABLE_NAME,
COLUMN_NAME
FROM
INFORMATION_SCHEMA.`COLUMNS`
WHERE
TABLE_SCHEMA not in ('INFORMATION_SCHEMA', 'information_schema', 'sys')
and TABLE_SCHEMA not like '%.information_schema'
and TABLE_SCHEMA not like '%.INFORMATION_SCHEMA'
"""
allowed_schemas = self.configuration.get("allowed_schemas")
if allowed_schemas:
query += "and TABLE_SCHEMA in ({})".format(
", ".join(
[
"'{}'".format(re.sub("[^a-zA-Z0-9_.`]", "", allowed_schema))
for allowed_schema in allowed_schemas.split(",")
]
)
)
results, error = self.run_query(query, None)
if error is not None:
self._handle_run_query_error(error)
schema = {}
for row in results["rows"]:
table_name = "{}.{}".format(row["TABLE_SCHEMA"], row["TABLE_NAME"])
if table_name not in schema:
schema[table_name] = {"name": table_name, "columns": []}
schema[table_name]["columns"].append(row["COLUMN_NAME"])
return list(schema.values())
register(Drill)
================================================
FILE: redash/query_runner/druid.py
================================================
try:
from pydruid.db import connect
enabled = True
except ImportError:
enabled = False
from redash.query_runner import (
TYPE_BOOLEAN,
TYPE_INTEGER,
TYPE_STRING,
BaseQueryRunner,
register,
)
TYPES_MAP = {1: TYPE_STRING, 2: TYPE_INTEGER, 3: TYPE_BOOLEAN}
class Druid(BaseQueryRunner):
noop_query = "SELECT 1"
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"host": {"type": "string", "default": "localhost"},
"port": {"type": "number", "default": 8082},
"scheme": {"type": "string", "default": "http"},
"user": {"type": "string"},
"password": {"type": "string"},
},
"order": ["scheme", "host", "port", "user", "password"],
"required": ["host"],
"secret": ["password"],
}
@classmethod
def enabled(cls):
return enabled
def run_query(self, query, user):
connection = connect(
host=self.configuration["host"],
port=self.configuration["port"],
path="/druid/v2/sql/",
scheme=(self.configuration.get("scheme") or "http"),
user=(self.configuration.get("user") or None),
password=(self.configuration.get("password") or None),
)
cursor = connection.cursor()
try:
cursor.execute(query)
columns = self.fetch_columns([(i[0], TYPES_MAP.get(i[1], None)) for i in cursor.description])
rows = [dict(zip((column["name"] for column in columns), row)) for row in cursor]
data = {"columns": columns, "rows": rows}
error = None
finally:
connection.close()
return data, error
def get_schema(self, get_stats=False):
query = """
SELECT TABLE_SCHEMA,
TABLE_NAME,
COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA <> 'INFORMATION_SCHEMA'
"""
results, error = self.run_query(query, None)
if error is not None:
self._handle_run_query_error(error)
schema = {}
for row in results["rows"]:
table_name = "{}.{}".format(row["TABLE_SCHEMA"], row["TABLE_NAME"])
if table_name not in schema:
schema[table_name] = {"name": table_name, "columns": []}
schema[table_name]["columns"].append(row["COLUMN_NAME"])
return list(schema.values())
register(Druid)
================================================
FILE: redash/query_runner/duckdb.py
================================================
import logging
from redash.query_runner import (
TYPE_BOOLEAN,
TYPE_DATE,
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseSQLQueryRunner,
InterruptException,
register,
)
logger = logging.getLogger(__name__)
try:
import duckdb
enabled = True
except ImportError:
enabled = False
# Map DuckDB types to Redash column types
TYPES_MAP = {
"BOOLEAN": TYPE_BOOLEAN,
"TINYINT": TYPE_INTEGER,
"SMALLINT": TYPE_INTEGER,
"INTEGER": TYPE_INTEGER,
"BIGINT": TYPE_INTEGER,
"HUGEINT": TYPE_INTEGER,
"REAL": TYPE_FLOAT,
"DOUBLE": TYPE_FLOAT,
"DECIMAL": TYPE_FLOAT,
"VARCHAR": TYPE_STRING,
"BLOB": TYPE_STRING,
"DATE": TYPE_DATE,
"TIMESTAMP": TYPE_DATETIME,
"TIMESTAMP WITH TIME ZONE": TYPE_DATETIME,
"TIME": TYPE_DATETIME,
"INTERVAL": TYPE_STRING,
"UUID": TYPE_STRING,
"JSON": TYPE_STRING,
"STRUCT": TYPE_STRING,
"MAP": TYPE_STRING,
"UNION": TYPE_STRING,
}
class DuckDB(BaseSQLQueryRunner):
noop_query = "SELECT 1"
def __init__(self, configuration):
super().__init__(configuration)
self.dbpath = configuration.get("dbpath", ":memory:")
exts = configuration.get("extensions", "")
self.extensions = [e.strip() for e in exts.split(",") if e.strip()]
self._connect()
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"dbpath": {
"type": "string",
"title": "Database Path",
"default": ":memory:",
},
"extensions": {
"type": "string",
"title": "Extensions (comma separated)",
},
},
"order": ["dbpath", "extensions"],
"required": ["dbpath"],
}
@classmethod
def enabled(cls) -> bool:
return enabled
def _connect(self) -> None:
self.con = duckdb.connect(self.dbpath)
for ext in self.extensions:
try:
if "." in ext:
prefix, name = ext.split(".", 1)
if prefix == "community":
self.con.execute(f"INSTALL {name} FROM community")
self.con.execute(f"LOAD {name}")
else:
raise Exception("Unknown extension prefix.")
else:
self.con.execute(f"INSTALL {ext}")
self.con.execute(f"LOAD {ext}")
except Exception as e:
logger.warning("Failed to load extension %s: %s", ext, e)
def run_query(self, query, user) -> tuple:
try:
cursor = self.con.cursor()
cursor.execute(query)
columns = self.fetch_columns(
[(d[0], TYPES_MAP.get(d[1].upper(), TYPE_STRING)) for d in cursor.description]
)
rows = [dict(zip((col["name"] for col in columns), row)) for row in cursor.fetchall()]
data = {"columns": columns, "rows": rows}
return data, None
except duckdb.InterruptException:
raise InterruptException("Query cancelled by user.")
except Exception as e:
logger.exception("Error running query: %s", e)
return None, str(e)
def get_schema(self, get_stats=False) -> list:
tables_query = """
SELECT table_catalog, table_schema, table_name FROM information_schema.tables
WHERE table_schema NOT IN ('information_schema', 'pg_catalog');
"""
tables_results, error = self.run_query(tables_query, None)
if error:
raise Exception(f"Failed to get tables: {error}")
schema = {}
for table_row in tables_results["rows"]:
# Include catalog (database) in the full table name for MotherDuck support
catalog = table_row["table_catalog"]
schema_name = table_row["table_schema"]
table_name = table_row["table_name"]
# Skip catalog prefix for default local databases (memory, temp)
# but include it for MotherDuck and attached databases
if catalog.lower() in ("memory", "temp", "system"):
full_table_name = f"{schema_name}.{table_name}"
describe_query = f'DESCRIBE "{schema_name}"."{table_name}";'
else:
full_table_name = f"{catalog}.{schema_name}.{table_name}"
describe_query = f'DESCRIBE "{catalog}"."{schema_name}"."{table_name}";'
schema[full_table_name] = {"name": full_table_name, "columns": []}
columns_results, error = self.run_query(describe_query, None)
if error:
logger.warning("Failed to describe table %s: %s", full_table_name, error)
continue
for col_row in columns_results["rows"]:
col = {"name": col_row["column_name"], "type": col_row["column_type"]}
schema[full_table_name]["columns"].append(col)
if col_row["column_type"].startswith("STRUCT("):
schema[full_table_name]["columns"].extend(
self._expand_struct_fields(col["name"], col_row["column_type"])
)
return list(schema.values())
def _expand_struct_fields(self, base_name: str, struct_type: str) -> list:
"""Recursively expand STRUCT(...) definitions into pseudo-columns."""
fields = []
# strip STRUCT( ... )
inner = struct_type[len("STRUCT(") : -1].strip()
# careful: nested structs, so parse comma-separated parts properly
depth, current, parts = 0, [], []
for c in inner:
if c == "(":
depth += 1
elif c == ")":
depth -= 1
if c == "," and depth == 0:
parts.append("".join(current).strip())
current = []
else:
current.append(c)
if current:
parts.append("".join(current).strip())
for part in parts:
# each part looks like: "fieldname TYPE"
fname, ftype = part.split(" ", 1)
colname = f"{base_name}.{fname}"
fields.append({"name": colname, "type": ftype})
if ftype.startswith("STRUCT("):
fields.extend(self._expand_struct_fields(colname, ftype))
return fields
register(DuckDB)
================================================
FILE: redash/query_runner/e6data.py
================================================
import logging
from redash.query_runner import (
TYPE_BOOLEAN,
TYPE_DATE,
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseQueryRunner,
register,
)
try:
from e6data_python_connector import Connection
enabled = True
except ImportError:
enabled = False
logger = logging.getLogger(__name__)
E6DATA_TYPES_MAPPING = {
"INT": TYPE_INTEGER,
"BYTE": TYPE_INTEGER,
"INTEGER": TYPE_INTEGER,
"LONG": TYPE_INTEGER,
"SHORT": TYPE_INTEGER,
"FLOAT": TYPE_FLOAT,
"DOUBLE": TYPE_FLOAT,
"STRING": TYPE_STRING,
"DATETIME": TYPE_DATETIME,
"BINARY": TYPE_INTEGER,
"ARRAY": TYPE_STRING,
"MAP": TYPE_STRING,
"STRUCT": TYPE_STRING,
"UNION_TYPE": TYPE_STRING,
"DECIMAL_TYPE": TYPE_FLOAT,
"DATE": TYPE_DATE,
"INT96": TYPE_INTEGER,
"BOOLEAN": TYPE_BOOLEAN,
"CHAR": TYPE_STRING,
}
class e6data(BaseQueryRunner):
limit_query = " LIMIT 1000"
should_annotate_query = False
def __init__(self, configuration):
super().__init__(configuration)
self.connection = Connection(
host=self.configuration.get("host"),
port=self.configuration.get("port"),
username=self.configuration.get("username"),
database=self.configuration.get("database"),
password=self.configuration.get("password"),
)
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"host": {"type": "string"},
"port": {"type": "number"},
"username": {"type": "string"},
"password": {"type": "string"},
"catalog": {"type": "string"},
"database": {"type": "string"},
},
"order": [
"host",
"port",
"username",
"password",
"catalog",
"database",
],
"required": ["host", "port", "username", "password", "catalog", "database"],
"secret": ["password"],
}
@classmethod
def enabled(cls):
return enabled
@classmethod
def type(cls):
return "e6data"
def run_query(self, query, user):
cursor = None
try:
cursor = self.connection.cursor(catalog_name=self.configuration.get("catalog"))
cursor.execute(query)
results = cursor.fetchall()
description = cursor.description
columns = []
for c in description:
column_name, column_type = c[0], E6DATA_TYPES_MAPPING.get(c[1], None)
columns.append({"name": column_name, "type": column_type})
rows = [dict(zip([c["name"] for c in columns], r)) for r in results]
data = {"columns": columns, "rows": rows}
error = None
except Exception as error:
logger.debug(error)
data = None
finally:
if cursor is not None:
cursor.clear()
cursor.close()
return data, error
def test_connection(self):
self.noop_query = "SELECT 1"
data, error = self.run_query(self.noop_query, None)
if error is not None:
raise Exception(error)
def get_schema(self, get_stats=False):
tables = self.connection.get_tables(self.configuration.get("catalog"), self.configuration.get("database"))
schema = list()
for table_name in tables:
columns = self.connection.get_columns(
self.configuration.get("catalog"),
self.configuration.get("database"),
table_name,
)
columns_with_type = []
for column in columns:
redash_type = E6DATA_TYPES_MAPPING.get(column["fieldType"], None)
columns_with_type.append({"name": column["fieldName"], "type": redash_type})
table_schema = {"name": table_name, "columns": columns_with_type}
schema.append(table_schema)
return schema
register(e6data)
================================================
FILE: redash/query_runner/elasticsearch.py
================================================
import logging
import urllib.error
import urllib.parse
import urllib.request
import requests
from requests.auth import HTTPBasicAuth
from redash.query_runner import (
TYPE_BOOLEAN,
TYPE_DATE,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseQueryRunner,
JobTimeoutException,
register,
)
from redash.utils import json_loads
try:
import http.client as http_client
except ImportError:
# Python 2
import http.client as http_client
logger = logging.getLogger(__name__)
ELASTICSEARCH_TYPES_MAPPING = {
"integer": TYPE_INTEGER,
"long": TYPE_INTEGER,
"float": TYPE_FLOAT,
"double": TYPE_FLOAT,
"boolean": TYPE_BOOLEAN,
"string": TYPE_STRING,
"date": TYPE_DATE,
"object": TYPE_STRING,
# "geo_point" TODO: Need to split to 2 fields somehow
}
ELASTICSEARCH_BUILTIN_FIELDS_MAPPING = {"_id": "Id", "_score": "Score"}
PYTHON_TYPES_MAPPING = {
str: TYPE_STRING,
bytes: TYPE_STRING,
bool: TYPE_BOOLEAN,
int: TYPE_INTEGER,
float: TYPE_FLOAT,
}
class BaseElasticSearch(BaseQueryRunner):
should_annotate_query = False
DEBUG_ENABLED = False
deprecated = True
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"server": {"type": "string", "title": "Base URL"},
"basic_auth_user": {"type": "string", "title": "Basic Auth User"},
"basic_auth_password": {
"type": "string",
"title": "Basic Auth Password",
},
},
"order": ["server", "basic_auth_user", "basic_auth_password"],
"secret": ["basic_auth_password"],
"required": ["server"],
}
@classmethod
def enabled(cls):
return False
def __init__(self, configuration):
super(BaseElasticSearch, self).__init__(configuration)
self.syntax = "json"
if self.DEBUG_ENABLED:
http_client.HTTPConnection.debuglevel = 1
# you need to initialize logging, otherwise you will not see anything from requests
logging.basicConfig()
logging.getLogger().setLevel(logging.DEBUG)
requests_log = logging.getLogger("requests.packages.urllib3")
requests_log.setLevel(logging.DEBUG)
requests_log.propagate = True
logger.setLevel(logging.DEBUG)
self.server_url = self.configuration.get("server", "")
if self.server_url and self.server_url[-1] == "/":
self.server_url = self.server_url[:-1]
basic_auth_user = self.configuration.get("basic_auth_user", None)
basic_auth_password = self.configuration.get("basic_auth_password", None)
self.auth = None
if basic_auth_user and basic_auth_password:
self.auth = HTTPBasicAuth(basic_auth_user, basic_auth_password)
def _get_mappings(self, url):
mappings = {}
error = None
try:
r = requests.get(url, auth=self.auth)
r.raise_for_status()
mappings = r.json()
except requests.HTTPError as e:
logger.exception(e)
error = "Failed to execute query. Return Code: {0} Reason: {1}".format(r.status_code, r.text)
mappings = None
except requests.exceptions.RequestException as e:
logger.exception(e)
error = "Connection refused"
mappings = None
return mappings, error
def _get_query_mappings(self, url):
mappings_data, error = self._get_mappings(url)
if error:
return mappings_data, error
mappings = {}
for index_name in mappings_data:
index_mappings = mappings_data[index_name]
for m in index_mappings.get("mappings", {}):
if not isinstance(index_mappings["mappings"][m], dict):
continue
if "properties" not in index_mappings["mappings"][m]:
continue
for property_name in index_mappings["mappings"][m]["properties"]:
property_data = index_mappings["mappings"][m]["properties"][property_name]
if property_name not in mappings:
property_type = property_data.get("type", None)
if property_type:
if property_type in ELASTICSEARCH_TYPES_MAPPING:
mappings[property_name] = ELASTICSEARCH_TYPES_MAPPING[property_type]
else:
mappings[property_name] = TYPE_STRING
# raise Exception("Unknown property type: {0}".format(property_type))
return mappings, error
def get_schema(self, *args, **kwargs):
def parse_doc(doc, path=None):
"""Recursively parse a doc type dictionary"""
path = path or []
result = []
for field, description in doc["properties"].items():
if "properties" in description:
result.extend(parse_doc(description, path + [field]))
else:
result.append(".".join(path + [field]))
return result
schema = {}
url = "{0}/_mappings".format(self.server_url)
mappings, error = self._get_mappings(url)
if mappings:
# make a schema for each index
# the index contains a mappings dict with documents
# in a hierarchical format
for name, index in mappings.items():
columns = []
schema[name] = {"name": name}
for doc, items in index["mappings"].items():
columns.extend(parse_doc(items))
# remove duplicates
# sort alphabetically
schema[name]["columns"] = sorted(set(columns))
return list(schema.values())
def _parse_results(self, mappings, result_fields, raw_result, result_columns, result_rows): # noqa: C901
def add_column_if_needed(mappings, column_name, friendly_name, result_columns, result_columns_index):
if friendly_name not in result_columns_index:
result_columns.append(
{
"name": friendly_name,
"friendly_name": friendly_name,
"type": mappings.get(column_name, "string"),
}
)
result_columns_index[friendly_name] = result_columns[-1]
def get_row(rows, row):
if row is None:
row = {}
rows.append(row)
return row
def collect_value(mappings, row, key, value, type):
if result_fields and key not in result_fields_index:
return
mappings[key] = type
add_column_if_needed(mappings, key, key, result_columns, result_columns_index)
row[key] = value
def collect_aggregations(mappings, rows, parent_key, data, row, result_columns, result_columns_index):
if isinstance(data, dict):
for key, value in data.items():
val = collect_aggregations(
mappings,
rows,
parent_key if key == "buckets" else key,
value,
row,
result_columns,
result_columns_index,
)
if val:
row = get_row(rows, row)
collect_value(mappings, row, key, val, "long")
for data_key in ["value", "doc_count"]:
if data_key not in data:
continue
if "key" in data and len(list(data.keys())) == 2:
key_is_string = "key_as_string" in data
collect_value(
mappings,
row,
data["key"] if not key_is_string else data["key_as_string"],
data[data_key],
"long" if not key_is_string else "string",
)
else:
return data[data_key]
elif isinstance(data, list):
for value in data:
result_row = get_row(rows, row)
collect_aggregations(
mappings,
rows,
parent_key,
value,
result_row,
result_columns,
result_columns_index,
)
if "doc_count" in value:
collect_value(
mappings,
result_row,
"doc_count",
value["doc_count"],
"integer",
)
if "key" in value:
if "key_as_string" in value:
collect_value(
mappings,
result_row,
parent_key,
value["key_as_string"],
"string",
)
else:
collect_value(mappings, result_row, parent_key, value["key"], "string")
return None
result_columns_index = {c["name"]: c for c in result_columns}
result_fields_index = {}
if result_fields:
for r in result_fields:
result_fields_index[r] = None
if "error" in raw_result:
error = raw_result["error"]
if len(error) > 10240:
error = error[:10240] + "... continues"
raise Exception(error)
elif "aggregations" in raw_result:
if result_fields:
for field in result_fields:
add_column_if_needed(mappings, field, field, result_columns, result_columns_index)
for key, data in raw_result["aggregations"].items():
collect_aggregations(
mappings,
result_rows,
key,
data,
None,
result_columns,
result_columns_index,
)
logger.debug("result_rows %s", str(result_rows))
logger.debug("result_columns %s", str(result_columns))
elif "hits" in raw_result and "hits" in raw_result["hits"]:
if result_fields:
for field in result_fields:
add_column_if_needed(mappings, field, field, result_columns, result_columns_index)
for h in raw_result["hits"]["hits"]:
row = {}
column_name = "_source" if "_source" in h else "fields"
for column in h[column_name]:
if result_fields and column not in result_fields_index:
continue
add_column_if_needed(mappings, column, column, result_columns, result_columns_index)
value = h[column_name][column]
row[column] = value[0] if isinstance(value, list) and len(value) == 1 else value
result_rows.append(row)
else:
raise Exception("Redash failed to parse the results it got from Elasticsearch.")
def test_connection(self):
try:
r = requests.get("{0}/_cluster/health".format(self.server_url), auth=self.auth)
r.raise_for_status()
except requests.HTTPError as e:
logger.exception(e)
raise Exception("Failed to execute query. Return Code: {0} Reason: {1}".format(r.status_code, r.text))
except requests.exceptions.RequestException as e:
logger.exception(e)
raise Exception("Connection refused")
class Kibana(BaseElasticSearch):
@classmethod
def enabled(cls):
return True
def _execute_simple_query(self, url, auth, _from, mappings, result_fields, result_columns, result_rows):
url += "&from={0}".format(_from)
r = requests.get(url, auth=self.auth)
r.raise_for_status()
raw_result = r.json()
self._parse_results(mappings, result_fields, raw_result, result_columns, result_rows)
total = raw_result["hits"]["total"]
result_size = len(raw_result["hits"]["hits"])
logger.debug("Result Size: {0} Total: {1}".format(result_size, total))
return raw_result["hits"]["total"]
def run_query(self, query, user):
try:
error = None
logger.debug(query)
query_params = json_loads(query)
index_name = query_params["index"]
query_data = query_params["query"]
size = int(query_params.get("size", 500))
limit = int(query_params.get("limit", 500))
result_fields = query_params.get("fields", None)
sort = query_params.get("sort", None)
if not self.server_url:
error = "Missing configuration key 'server'"
return None, error
url = "{0}/{1}/_search?".format(self.server_url, index_name)
mapping_url = "{0}/{1}/_mapping".format(self.server_url, index_name)
mappings, error = self._get_query_mappings(mapping_url)
if error:
return None, error
if sort:
url += "&sort={0}".format(urllib.parse.quote_plus(sort))
url += "&q={0}".format(urllib.parse.quote_plus(query_data))
logger.debug("Using URL: {0}".format(url))
logger.debug("Using Query: {0}".format(query_data))
result_columns = []
result_rows = []
if isinstance(query_data, str):
_from = 0
while True:
query_size = size if limit >= (_from + size) else (limit - _from)
self._execute_simple_query(
url + "&size={0}".format(query_size),
self.auth,
_from,
mappings,
result_fields,
result_columns,
result_rows,
)
_from += size
if _from >= limit:
break
else:
# TODO: Handle complete ElasticSearch queries (JSON based sent over HTTP POST)
raise Exception("Advanced queries are not supported")
data = {"columns": result_columns, "rows": result_rows}
except requests.HTTPError as e:
logger.exception(e)
r = e.response
error = "Failed to execute query. Return Code: {0} Reason: {1}".format(r.status_code, r.text)
data = None
except requests.exceptions.RequestException as e:
logger.exception(e)
error = "Connection refused"
data = None
return data, error
class ElasticSearch(BaseElasticSearch):
@classmethod
def enabled(cls):
return True
@classmethod
def name(cls):
return "Elasticsearch"
def run_query(self, query, user):
try:
error = None
logger.debug(query)
query_dict = json_loads(query)
index_name = query_dict.pop("index", "")
result_fields = query_dict.pop("result_fields", None)
if not self.server_url:
error = "Missing configuration key 'server'"
return None, error
url = "{0}/{1}/_search".format(self.server_url, index_name)
mapping_url = "{0}/{1}/_mapping".format(self.server_url, index_name)
mappings, error = self._get_query_mappings(mapping_url)
if error:
return None, error
logger.debug("Using URL: %s", url)
logger.debug("Using query: %s", query_dict)
r = requests.get(url, json=query_dict, auth=self.auth)
r.raise_for_status()
logger.debug("Result: %s", r.json())
result_columns = []
result_rows = []
self._parse_results(mappings, result_fields, r.json(), result_columns, result_rows)
data = {"columns": result_columns, "rows": result_rows}
except (KeyboardInterrupt, JobTimeoutException) as e:
logger.exception(e)
raise
except requests.HTTPError as e:
logger.exception(e)
error = "Failed to execute query. Return Code: {0} Reason: {1}".format(r.status_code, r.text)
data = None
except requests.exceptions.RequestException as e:
logger.exception(e)
error = "Connection refused"
data = None
return data, error
register(Kibana)
register(ElasticSearch)
================================================
FILE: redash/query_runner/elasticsearch2.py
================================================
import json
import logging
from typing import Optional, Tuple
from redash.query_runner import (
TYPE_BOOLEAN,
TYPE_DATE,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseHTTPQueryRunner,
register,
)
logger = logging.getLogger(__name__)
ELASTICSEARCH_TYPES_MAPPING = {
"integer": TYPE_INTEGER,
"long": TYPE_INTEGER,
"float": TYPE_FLOAT,
"double": TYPE_FLOAT,
"boolean": TYPE_BOOLEAN,
"string": TYPE_STRING,
"date": TYPE_DATE,
"object": TYPE_STRING,
}
TYPES_MAP = {
str: TYPE_STRING,
int: TYPE_INTEGER,
float: TYPE_FLOAT,
bool: TYPE_BOOLEAN,
}
class ElasticSearch2(BaseHTTPQueryRunner):
should_annotate_query = False
@classmethod
def name(cls):
return "Elasticsearch"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.syntax = "json"
def get_response(self, url, auth=None, http_method="get", **kwargs):
url = "{}{}".format(self.configuration["url"], url)
headers = kwargs.pop("headers", {})
headers["Accept"] = "application/json"
return super().get_response(url, auth, http_method, headers=headers, **kwargs)
def test_connection(self):
_, error = self.get_response("/_cluster/health")
if error is not None:
raise Exception(error)
def run_query(self, query, user):
query, url, result_fields = self._build_query(query)
response, error = self.get_response(url, http_method="post", json=query)
query_results = response.json()
data = self._parse_results(result_fields, query_results)
error = None
return data, error
def _build_query(self, query: str) -> Tuple[dict, str, Optional[list]]:
query = json.loads(query)
index_name = query.pop("index", "")
result_fields = query.pop("result_fields", None)
url = "/{}/_search".format(index_name)
return query, url, result_fields
@classmethod
def _parse_mappings(cls, mappings_data: dict):
mappings = {}
def _parse_properties(prefix: str, properties: dict):
for property_name, property_data in properties.items():
if property_name not in mappings:
property_type = property_data.get("type", None)
nested_properties = property_data.get("properties", None)
if property_type:
mappings[index_name][prefix + property_name] = ELASTICSEARCH_TYPES_MAPPING.get(
property_type, TYPE_STRING
)
elif nested_properties:
new_prefix = prefix + property_name + "."
_parse_properties(new_prefix, nested_properties)
for index_name in mappings_data:
mappings[index_name] = {}
index_mappings = mappings_data[index_name]
try:
for m in index_mappings.get("mappings", {}):
_parse_properties("", index_mappings["mappings"][m]["properties"])
except KeyError:
_parse_properties("", index_mappings["mappings"]["properties"])
return mappings
def get_mappings(self):
response, error = self.get_response("/_mappings")
return self._parse_mappings(response.json())
def get_schema(self, *args, **kwargs):
schema = {}
for name, columns in self.get_mappings().items():
schema[name] = {"name": name, "columns": list(columns.keys())}
return list(schema.values())
@classmethod
def _parse_results(cls, result_fields, raw_result): # noqa: C901
result_columns = []
result_rows = []
result_columns_index = {c["name"]: c for c in result_columns}
result_fields_index = {}
def add_column_if_needed(column_name, value=None):
if column_name not in result_columns_index:
result_columns.append(
{
"name": column_name,
"friendly_name": column_name,
"type": TYPES_MAP.get(type(value), TYPE_STRING),
}
)
result_columns_index[column_name] = result_columns[-1]
def get_row(rows, row):
if row is None:
row = {}
rows.append(row)
return row
def collect_value(row, key, value):
if result_fields and key not in result_fields_index:
return
add_column_if_needed(key, value)
row[key] = value
def parse_bucket_to_row(data, row, agg_key):
sub_agg_key = ""
for key, item in data.items():
if key == "key_as_string":
continue
if key == "key":
if "key_as_string" in data:
collect_value(row, agg_key, data["key_as_string"])
else:
collect_value(row, agg_key, data["key"])
continue
if isinstance(item, (str, int, float)):
collect_value(row, agg_key + "." + key, item)
elif isinstance(item, dict):
if "buckets" not in item:
for sub_key, sub_item in item.items():
collect_value(
row,
agg_key + "." + key + "." + sub_key,
sub_item,
)
else:
sub_agg_key = key
return sub_agg_key
def parse_buckets_list(rows, parent_key, data, row, depth):
if len(rows) > 0 and depth == 0:
row = rows.pop()
for value in data:
row = row.copy()
sub_agg_key = parse_bucket_to_row(value, row, parent_key)
if sub_agg_key == "":
rows.append(row)
else:
depth += 1
parse_buckets_list(rows, sub_agg_key, value[sub_agg_key]["buckets"], row, depth)
def collect_aggregations(rows, parent_key, data, row, depth):
row = get_row(rows, row)
parse_bucket_to_row(data, row, parent_key)
if "buckets" in data:
parse_buckets_list(rows, parent_key, data["buckets"], row, depth)
return None
def get_flatten_results(dd, separator=".", prefix=""):
if isinstance(dd, dict):
return {
prefix + separator + k if prefix else k: v
for kk, vv in dd.items()
for k, v in get_flatten_results(vv, separator, kk).items()
}
elif isinstance(dd, list) and len(dd) == 1:
return {prefix: dd[0]}
else:
return {prefix: dd}
if result_fields:
for r in result_fields:
result_fields_index[r] = None
if "error" in raw_result:
error = raw_result["error"]
if len(error) > 10240:
error = error[:10240] + "... continues"
raise Exception(error)
elif "aggregations" in raw_result:
for key, data in raw_result["aggregations"].items():
collect_aggregations(result_rows, key, data, None, 0)
elif "hits" in raw_result and "hits" in raw_result["hits"]:
for h in raw_result["hits"]["hits"]:
row = {}
fields_parameter_name = "_source" if "_source" in h else "fields"
for column in h[fields_parameter_name]:
if result_fields and column not in result_fields_index:
continue
unested_results = get_flatten_results({column: h[fields_parameter_name][column]})
for column_name, value in unested_results.items():
add_column_if_needed(column_name, value=value)
row[column_name] = value
result_rows.append(row)
else:
raise Exception("Redash failed to parse the results it got from Elasticsearch.")
return {"columns": result_columns, "rows": result_rows}
class OpenDistroSQLElasticSearch(ElasticSearch2):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.syntax = "sql"
def _build_query(self, query: str) -> Tuple[dict, str, Optional[list]]:
sql_query = {"query": query}
sql_query_url = "/_opendistro/_sql"
return sql_query, sql_query_url, None
@classmethod
def name(cls):
return "Open Distro SQL Elasticsearch"
@classmethod
def type(cls):
return "elasticsearch2_OpenDistroSQLElasticSearch"
class XPackSQLElasticSearch(ElasticSearch2):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.syntax = "sql"
def _build_query(self, query: str) -> Tuple[dict, str, Optional[list]]:
sql_query = {"query": query}
sql_query_url = "/_xpack/sql"
return sql_query, sql_query_url, None
@classmethod
def _parse_results(cls, result_fields, raw_result):
error = raw_result.get("error")
if error:
raise Exception(error)
rv = {
"columns": [
{
"name": c["name"],
"friendly_name": c["name"],
"type": ELASTICSEARCH_TYPES_MAPPING.get(c["type"], "string"),
}
for c in raw_result["columns"]
],
"rows": [],
}
query_results_rows = raw_result["rows"]
for query_results_row in query_results_rows:
result_row = dict()
for column, column_value in zip(rv["columns"], query_results_row):
result_row[column["name"]] = column_value
rv["rows"].append(result_row)
return rv
@classmethod
def name(cls):
return "X-Pack SQL Elasticsearch"
@classmethod
def type(cls):
return "elasticsearch2_XPackSQLElasticSearch"
register(ElasticSearch2)
register(OpenDistroSQLElasticSearch)
register(XPackSQLElasticSearch)
================================================
FILE: redash/query_runner/exasol.py
================================================
import datetime
from redash.query_runner import (
TYPE_DATE,
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseQueryRunner,
register,
)
def _exasol_type_mapper(val, data_type):
if val is None:
return None
elif data_type["type"] == "DECIMAL":
if data_type["scale"] == 0 and data_type["precision"] < 16:
return int(val)
elif data_type["scale"] == 0 and data_type["precision"] >= 16:
return val
else:
return float(val)
elif data_type["type"] == "DATE":
return datetime.date(int(val[0:4]), int(val[5:7]), int(val[8:10]))
elif data_type["type"] == "TIMESTAMP":
return datetime.datetime(
int(val[0:4]),
int(val[5:7]),
int(val[8:10]), # year, month, day
int(val[11:13]),
int(val[14:16]),
int(val[17:19]), # hour, minute, second
int(val[20:26].ljust(6, "0")) if len(val) > 20 else 0,
) # microseconds (if available)
else:
return val
def _type_mapper(data_type):
if data_type["type"] == "DECIMAL":
if data_type["scale"] == 0 and data_type["precision"] < 16:
return TYPE_INTEGER
elif data_type["scale"] == 0 and data_type["precision"] >= 16:
return TYPE_STRING
else:
return TYPE_FLOAT
elif data_type["type"] == "DATE":
return TYPE_DATE
elif data_type["type"] == "TIMESTAMP":
return TYPE_DATETIME
else:
return TYPE_STRING
try:
import pyexasol
enabled = True
except ImportError:
enabled = False
class Exasol(BaseQueryRunner):
noop_query = "SELECT 1 FROM DUAL"
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"user": {"type": "string"},
"password": {"type": "string"},
"host": {"type": "string"},
"port": {"type": "number", "default": 8563},
"encrypted": {"type": "boolean", "title": "Enable SSL Encryption"},
},
"required": ["host", "port", "user", "password"],
"order": ["host", "port", "user", "password", "encrypted"],
"secret": ["password"],
}
def _get_connection(self):
exahost = "%s:%s" % (
self.configuration.get("host", None),
self.configuration.get("port", 8563),
)
return pyexasol.connect(
dsn=exahost,
user=self.configuration.get("user", None),
password=self.configuration.get("password", None),
encryption=self.configuration.get("encrypted", True),
compression=True,
json_lib="rapidjson",
fetch_mapper=_exasol_type_mapper,
)
def run_query(self, query, user):
connection = self._get_connection()
statement = None
error = None
try:
statement = connection.execute(query)
columns = [
{"name": n, "friendly_name": n, "type": _type_mapper(t)} for (n, t) in statement.columns().items()
]
cnames = statement.column_names()
rows = [dict(zip(cnames, row)) for row in statement]
data = {"columns": columns, "rows": rows}
finally:
if statement is not None:
statement.close()
connection.close()
return data, error
def get_schema(self, get_stats=False):
query = """
SELECT
COLUMN_SCHEMA,
COLUMN_TABLE,
COLUMN_NAME
FROM EXA_ALL_COLUMNS
"""
connection = self._get_connection()
statement = None
try:
statement = connection.execute(query)
result = {}
for schema, table_name, column in statement:
table_name_with_schema = "%s.%s" % (schema, table_name)
if table_name_with_schema not in result:
result[table_name_with_schema] = {
"name": table_name_with_schema,
"columns": [],
}
result[table_name_with_schema]["columns"].append(column)
finally:
if statement is not None:
statement.close()
connection.close()
return result.values()
@classmethod
def enabled(cls):
return enabled
register(Exasol)
================================================
FILE: redash/query_runner/excel.py
================================================
import logging
import yaml
from redash.query_runner import BaseQueryRunner, NotSupported, register
from redash.utils.requests_session import (
UnacceptableAddressException,
requests_or_advocate,
)
logger = logging.getLogger(__name__)
try:
import numpy as np
import openpyxl # noqa: F401
import pandas as pd
import xlrd # noqa: F401
enabled = True
except ImportError:
enabled = False
class Excel(BaseQueryRunner):
should_annotate_query = False
@classmethod
def enabled(cls):
return enabled
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {},
}
def __init__(self, configuration):
super(Excel, self).__init__(configuration)
self.syntax = "yaml"
def test_connection(self):
pass
def run_query(self, query, user):
path = ""
ua = ""
args = {}
try:
args = yaml.safe_load(query)
path = args["url"]
args.pop("url", None)
ua = args["user-agent"]
args.pop("user-agent", None)
except Exception:
pass
try:
response = requests_or_advocate.get(url=path, headers={"User-agent": ua})
workbook = pd.read_excel(response.content, **args)
df = workbook.copy()
data = {"columns": [], "rows": []}
conversions = [
{
"pandas_type": np.integer,
"redash_type": "integer",
},
{
"pandas_type": np.inexact,
"redash_type": "float",
},
{
"pandas_type": np.datetime64,
"redash_type": "datetime",
"to_redash": lambda x: x.strftime("%Y-%m-%d %H:%M:%S"),
},
{"pandas_type": np.bool_, "redash_type": "boolean"},
{"pandas_type": np.object_, "redash_type": "string"},
]
labels = []
for dtype, label in zip(df.dtypes, df.columns):
for conversion in conversions:
if issubclass(dtype.type, conversion["pandas_type"]):
data["columns"].append(
{"name": label, "friendly_name": label, "type": conversion["redash_type"]}
)
labels.append(label)
func = conversion.get("to_redash")
if func:
df[label] = df[label].apply(func)
break
data["rows"] = df[labels].replace({np.nan: None}).to_dict(orient="records")
error = None
except KeyboardInterrupt:
error = "Query cancelled by user."
data = None
except UnacceptableAddressException:
error = "Can't query private addresses."
data = None
except Exception as e:
error = "Error reading {0}. {1}".format(path, str(e))
data = None
return data, error
def get_schema(self):
raise NotSupported()
register(Excel)
================================================
FILE: redash/query_runner/files/rds-combined-ca-bundle.pem
================================================
-----BEGIN CERTIFICATE-----
MIIEEjCCAvqgAwIBAgIJAM2ZN/+nPi27MA0GCSqGSIb3DQEBCwUAMIGVMQswCQYD
VQQGEwJVUzEQMA4GA1UEBwwHU2VhdHRsZTETMBEGA1UECAwKV2FzaGluZ3RvbjEi
MCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjETMBEGA1UECwwKQW1h
em9uIFJEUzEmMCQGA1UEAwwdQW1hem9uIFJEUyBhZi1zb3V0aC0xIFJvb3QgQ0Ew
HhcNMTkxMDI4MTgwNTU4WhcNMjQxMDI2MTgwNTU4WjCBlTELMAkGA1UEBhMCVVMx
EDAOBgNVBAcMB1NlYXR0bGUxEzARBgNVBAgMCldhc2hpbmd0b24xIjAgBgNVBAoM
GUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4xEzARBgNVBAsMCkFtYXpvbiBSRFMx
JjAkBgNVBAMMHUFtYXpvbiBSRFMgYWYtc291dGgtMSBSb290IENBMIIBIjANBgkq
hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwR2351uPMZaJk2gMGT+1sk8HE9MQh2rc
/sCnbxGn2p1c7Oi9aBbd/GiFijeJb2BXvHU+TOq3d3Jjqepq8tapXVt4ojbTJNyC
J5E7r7KjTktKdLxtBE1MK25aY+IRJjtdU6vG3KiPKUT1naO3xs3yt0F76WVuFivd
9OHv2a+KHvPkRUWIxpmAHuMY9SIIMmEZtVE7YZGx5ah0iO4JzItHcbVR0y0PBH55
arpFBddpIVHCacp1FUPxSEWkOpI7q0AaU4xfX0fe1BV5HZYRKpBOIp1TtZWvJD+X
jGUtL1BEsT5vN5g9MkqdtYrC+3SNpAk4VtpvJrdjraI/hhvfeXNnAwIDAQABo2Mw
YTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUEEi/
WWMcBJsoGXg+EZwkQ0MscZQwHwYDVR0jBBgwFoAUEEi/WWMcBJsoGXg+EZwkQ0Ms
cZQwDQYJKoZIhvcNAQELBQADggEBAGDZ5js5Pc/gC58LJrwMPXFhJDBS8QuDm23C
FFUdlqucskwOS3907ErK1ZkmVJCIqFLArHqskFXMAkRZ2PNR7RjWLqBs+0znG5yH
hRKb4DXzhUFQ18UBRcvT6V6zN97HTRsEEaNhM/7k8YLe7P8vfNZ28VIoJIGGgv9D
wQBBvkxQ71oOmAG0AwaGD0ORGUfbYry9Dz4a4IcUsZyRWRMADixgrFv6VuETp26s
/+z+iqNaGWlELBKh3iQCT6Y/1UnkPLO42bxrCSyOvshdkYN58Q2gMTE1SVTqyo8G
Lw8lLAz9bnvUSgHzB3jRrSx6ggF/WRMRYlR++y6LXP4SAsSAaC0=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEEjCCAvqgAwIBAgIJAJYM4LxvTZA6MA0GCSqGSIb3DQEBCwUAMIGVMQswCQYD
VQQGEwJVUzEQMA4GA1UEBwwHU2VhdHRsZTETMBEGA1UECAwKV2FzaGluZ3RvbjEi
MCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjETMBEGA1UECwwKQW1h
em9uIFJEUzEmMCQGA1UEAwwdQW1hem9uIFJEUyBldS1zb3V0aC0xIFJvb3QgQ0Ew
HhcNMTkxMDMwMjAyMDM2WhcNMjQxMDI4MjAyMDM2WjCBlTELMAkGA1UEBhMCVVMx
EDAOBgNVBAcMB1NlYXR0bGUxEzARBgNVBAgMCldhc2hpbmd0b24xIjAgBgNVBAoM
GUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4xEzARBgNVBAsMCkFtYXpvbiBSRFMx
JjAkBgNVBAMMHUFtYXpvbiBSRFMgZXUtc291dGgtMSBSb290IENBMIIBIjANBgkq
hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqM921jXCXeqpRNCS9CBPOe5N7gMaEt+D
s5uR3riZbqzRlHGiF1jZihkXfHAIQewDwy+Yz+Oec1aEZCQMhUHxZJPusuX0cJfj
b+UluFqHIijL2TfXJ3D0PVLLoNTQJZ8+GAPECyojAaNuoHbdVqxhOcznMsXIXVFq
yVLKDGvyKkJjai/iSPDrQMXufg3kWt0ISjNLvsG5IFXgP4gttsM8i0yvRd4QcHoo
DjvH7V3cS+CQqW5SnDrGnHToB0RLskE1ET+oNOfeN9PWOxQprMOX/zmJhnJQlTqD
QP7jcf7SddxrKFjuziFiouskJJyNDsMjt1Lf60+oHZhed2ogTeifGwIDAQABo2Mw
YTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUFBAF
cgJe/BBuZiGeZ8STfpkgRYQwHwYDVR0jBBgwFoAUFBAFcgJe/BBuZiGeZ8STfpkg
RYQwDQYJKoZIhvcNAQELBQADggEBAKAYUtlvDuX2UpZW9i1QgsjFuy/ErbW0dLHU
e/IcFtju2z6RLZ+uF+5A8Kme7IKG1hgt8s+w9TRVQS/7ukQzoK3TaN6XKXRosjtc
o9Rm4gYWM8bmglzY1TPNaiI4HC7546hSwJhubjN0bXCuj/0sHD6w2DkiGuwKNAef
yTu5vZhPkeNyXLykxkzz7bNp2/PtMBnzIp+WpS7uUDmWyScGPohKMq5PqvL59z+L
ZI3CYeMZrJ5VpXUg3fNNIz/83N3G0sk7wr0ohs/kHTP7xPOYB0zD7Ku4HA0Q9Swf
WX0qr6UQgTPMjfYDLffI7aEId0gxKw1eGYc6Cq5JAZ3ipi/cBFc=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEEjCCAvqgAwIBAgIJANew34ehz5l8MA0GCSqGSIb3DQEBCwUAMIGVMQswCQYD
VQQGEwJVUzEQMA4GA1UEBwwHU2VhdHRsZTETMBEGA1UECAwKV2FzaGluZ3RvbjEi
MCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjETMBEGA1UECwwKQW1h
em9uIFJEUzEmMCQGA1UEAwwdQW1hem9uIFJEUyBtZS1zb3V0aC0xIFJvb3QgQ0Ew
HhcNMTkwNTEwMjE0ODI3WhcNMjQwNTA4MjE0ODI3WjCBlTELMAkGA1UEBhMCVVMx
EDAOBgNVBAcMB1NlYXR0bGUxEzARBgNVBAgMCldhc2hpbmd0b24xIjAgBgNVBAoM
GUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4xEzARBgNVBAsMCkFtYXpvbiBSRFMx
JjAkBgNVBAMMHUFtYXpvbiBSRFMgbWUtc291dGgtMSBSb290IENBMIIBIjANBgkq
hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAp7BYV88MukcY+rq0r79+C8UzkT30fEfT
aPXbx1d6M7uheGN4FMaoYmL+JE1NZPaMRIPTHhFtLSdPccInvenRDIatcXX+jgOk
UA6lnHQ98pwN0pfDUyz/Vph4jBR9LcVkBbe0zdoKKp+HGbMPRU0N2yNrog9gM5O8
gkU/3O2csJ/OFQNnj4c2NQloGMUpEmedwJMOyQQfcUyt9CvZDfIPNnheUS29jGSw
ERpJe/AENu8Pxyc72jaXQuD+FEi2Ck6lBkSlWYQFhTottAeGvVFNCzKszCntrtqd
rdYUwurYsLTXDHv9nW2hfDUQa0mhXf9gNDOBIVAZugR9NqNRNyYLHQIDAQABo2Mw
YTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU54cf
DjgwBx4ycBH8+/r8WXdaiqYwHwYDVR0jBBgwFoAU54cfDjgwBx4ycBH8+/r8WXda
iqYwDQYJKoZIhvcNAQELBQADggEBAIIMTSPx/dR7jlcxggr+O6OyY49Rlap2laKA
eC/XI4ySP3vQkIFlP822U9Kh8a9s46eR0uiwV4AGLabcu0iKYfXjPkIprVCqeXV7
ny9oDtrbflyj7NcGdZLvuzSwgl9SYTJp7PVCZtZutsPYlbJrBPHwFABvAkMvRtDB
hitIg4AESDGPoCl94sYHpfDfjpUDMSrAMDUyO6DyBdZH5ryRMAs3lGtsmkkNUrso
aTW6R05681Z0mvkRdb+cdXtKOSuDZPoe2wJJIaz3IlNQNSrB5TImMYgmt6iAsFhv
3vfTSTKrZDNTJn4ybG6pq1zWExoXsktZPylJly6R3RBwV6nwqBM=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEBjCCAu6gAwIBAgIJAMc0ZzaSUK51MA0GCSqGSIb3DQEBCwUAMIGPMQswCQYD
VQQGEwJVUzEQMA4GA1UEBwwHU2VhdHRsZTETMBEGA1UECAwKV2FzaGluZ3RvbjEi
MCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjETMBEGA1UECwwKQW1h
em9uIFJEUzEgMB4GA1UEAwwXQW1hem9uIFJEUyBSb290IDIwMTkgQ0EwHhcNMTkw
ODIyMTcwODUwWhcNMjQwODIyMTcwODUwWjCBjzELMAkGA1UEBhMCVVMxEDAOBgNV
BAcMB1NlYXR0bGUxEzARBgNVBAgMCldhc2hpbmd0b24xIjAgBgNVBAoMGUFtYXpv
biBXZWIgU2VydmljZXMsIEluYy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxIDAeBgNV
BAMMF0FtYXpvbiBSRFMgUm9vdCAyMDE5IENBMIIBIjANBgkqhkiG9w0BAQEFAAOC
AQ8AMIIBCgKCAQEArXnF/E6/Qh+ku3hQTSKPMhQQlCpoWvnIthzX6MK3p5a0eXKZ
oWIjYcNNG6UwJjp4fUXl6glp53Jobn+tWNX88dNH2n8DVbppSwScVE2LpuL+94vY
0EYE/XxN7svKea8YvlrqkUBKyxLxTjh+U/KrGOaHxz9v0l6ZNlDbuaZw3qIWdD/I
6aNbGeRUVtpM6P+bWIoxVl/caQylQS6CEYUk+CpVyJSkopwJlzXT07tMoDL5WgX9
O08KVgDNz9qP/IGtAcRduRcNioH3E9v981QO1zt/Gpb2f8NqAjUUCUZzOnij6mx9
McZ+9cWX88CRzR0vQODWuZscgI08NvM69Fn2SQIDAQABo2MwYTAOBgNVHQ8BAf8E
BAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUc19g2LzLA5j0Kxc0LjZa
pmD/vB8wHwYDVR0jBBgwFoAUc19g2LzLA5j0Kxc0LjZapmD/vB8wDQYJKoZIhvcN
AQELBQADggEBAHAG7WTmyjzPRIM85rVj+fWHsLIvqpw6DObIjMWokpliCeMINZFV
ynfgBKsf1ExwbvJNzYFXW6dihnguDG9VMPpi2up/ctQTN8tm9nDKOy08uNZoofMc
NUZxKCEkVKZv+IL4oHoeayt8egtv3ujJM6V14AstMQ6SwvwvA93EP/Ug2e4WAXHu
cbI1NAbUgVDqp+DRdfvZkgYKryjTWd/0+1fS8X1bBZVWzl7eirNVnHbSH2ZDpNuY
0SBd8dj5F6ld3t58ydZbrTHze7JJOd8ijySAp4/kiu9UfZWuTPABzDa/DSdz9Dk/
zPW4CXXvhLmE02TA9/HeCw3KEHIwicNuEfw=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEEDCCAvigAwIBAgIJAKFMXyltvuRdMA0GCSqGSIb3DQEBCwUAMIGUMQswCQYD
VQQGEwJVUzEQMA4GA1UEBwwHU2VhdHRsZTETMBEGA1UECAwKV2FzaGluZ3RvbjEi
MCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjETMBEGA1UECwwKQW1h
em9uIFJEUzElMCMGA1UEAwwcQW1hem9uIFJEUyBCZXRhIFJvb3QgMjAxOSBDQTAe
Fw0xOTA4MTkxNzM4MjZaFw0yNDA4MTkxNzM4MjZaMIGUMQswCQYDVQQGEwJVUzEQ
MA4GA1UEBwwHU2VhdHRsZTETMBEGA1UECAwKV2FzaGluZ3RvbjEiMCAGA1UECgwZ
QW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzEl
MCMGA1UEAwwcQW1hem9uIFJEUyBCZXRhIFJvb3QgMjAxOSBDQTCCASIwDQYJKoZI
hvcNAQEBBQADggEPADCCAQoCggEBAMkZdnIH9ndatGAcFo+DppGJ1HUt4x+zeO+0
ZZ29m0sfGetVulmTlv2d5b66e+QXZFWpcPQMouSxxYTW08TbrQiZngKr40JNXftA
atvzBqIImD4II0ZX5UEVj2h98qe/ypW5xaDN7fEa5e8FkYB1TEemPaWIbNXqchcL
tV7IJPr3Cd7Z5gZJlmujIVDPpMuSiNaal9/6nT9oqN+JSM1fx5SzrU5ssg1Vp1vv
5Xab64uOg7wCJRB9R2GC9XD04odX6VcxUAGrZo6LR64ZSifupo3l+R5sVOc5i8NH
skdboTzU9H7+oSdqoAyhIU717PcqeDum23DYlPE2nGBWckE+eT8CAwEAAaNjMGEw
DgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFK2hDBWl
sbHzt/EHd0QYOooqcFPhMB8GA1UdIwQYMBaAFK2hDBWlsbHzt/EHd0QYOooqcFPh
MA0GCSqGSIb3DQEBCwUAA4IBAQAO/718k8EnOqJDx6wweUscGTGL/QdKXUzTVRAx
JUsjNUv49mH2HQVEW7oxszfH6cPCaupNAddMhQc4C/af6GHX8HnqfPDk27/yBQI+
yBBvIanGgxv9c9wBbmcIaCEWJcsLp3HzXSYHmjiqkViXwCpYfkoV3Ns2m8bp+KCO
y9XmcCKRaXkt237qmoxoh2sGmBHk2UlQtOsMC0aUQ4d7teAJG0q6pbyZEiPyKZY1
XR/UVxMJL0Q4iVpcRS1kaNCMfqS2smbLJeNdsan8pkw1dvPhcaVTb7CvjhJtjztF
YfDzAI5794qMlWxwilKMmUvDlPPOTen8NNHkLwWvyFCH7Doh
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEFjCCAv6gAwIBAgIJAMzYZJ+R9NBVMA0GCSqGSIb3DQEBCwUAMIGXMQswCQYD
VQQGEwJVUzEQMA4GA1UEBwwHU2VhdHRsZTETMBEGA1UECAwKV2FzaGluZ3RvbjEi
MCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjETMBEGA1UECwwKQW1h
em9uIFJEUzEoMCYGA1UEAwwfQW1hem9uIFJEUyBQcmV2aWV3IFJvb3QgMjAxOSBD
QTAeFw0xOTA4MjEyMjI5NDlaFw0yNDA4MjEyMjI5NDlaMIGXMQswCQYDVQQGEwJV
UzEQMA4GA1UEBwwHU2VhdHRsZTETMBEGA1UECAwKV2FzaGluZ3RvbjEiMCAGA1UE
CgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjETMBEGA1UECwwKQW1hem9uIFJE
UzEoMCYGA1UEAwwfQW1hem9uIFJEUyBQcmV2aWV3IFJvb3QgMjAxOSBDQTCCASIw
DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAM7kkS6vjgKKQTPynC2NjdN5aPPV
O71G0JJS/2ARVBVJd93JLiGovVJilfWYfwZCs4gTRSSjrUD4D4HyqCd6A+eEEtJq
M0DEC7i0dC+9WNTsPszuB206Jy2IUmxZMIKJAA1NHSbIMjB+b6/JhbSUi7nKdbR/
brj83bF+RoSA+ogrgX7mQbxhmFcoZN9OGaJgYKsKWUt5Wqv627KkGodUK8mDepgD
S3ZfoRQRx3iceETpcmHJvaIge6+vyDX3d9Z22jmvQ4AKv3py2CmU2UwuhOltFDwB
0ddtb39vgwrJxaGfiMRHpEP1DfNLWHAnA69/pgZPwIggidS+iBPUhgucMp8CAwEA
AaNjMGEwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYE
FGnTGpQuQ2H/DZlXMQijZEhjs7TdMB8GA1UdIwQYMBaAFGnTGpQuQ2H/DZlXMQij
ZEhjs7TdMA0GCSqGSIb3DQEBCwUAA4IBAQC3xz1vQvcXAfpcZlngiRWeqU8zQAMQ
LZPCFNv7PVk4pmqX+ZiIRo4f9Zy7TrOVcboCnqmP/b/mNq0gVF4O+88jwXJZD+f8
/RnABMZcnGU+vK0YmxsAtYU6TIb1uhRFmbF8K80HHbj9vSjBGIQdPCbvmR2zY6VJ
BYM+w9U9hp6H4DVMLKXPc1bFlKA5OBTgUtgkDibWJKFOEPW3UOYwp9uq6pFoN0AO
xMTldqWFsOF3bJIlvOY0c/1EFZXu3Ns6/oCP//Ap9vumldYMUZWmbK+gK33FPOXV
8BQ6jNC29icv7lLDpRPwjibJBXX+peDR5UK4FdYcswWEB1Tix5X8dYu6
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIECTCCAvGgAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwgZUxCzAJBgNVBAYTAlVT
MRAwDgYDVQQHDAdTZWF0dGxlMRMwEQYDVQQIDApXYXNoaW5ndG9uMSIwIAYDVQQK
DBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMuMRMwEQYDVQQLDApBbWF6b24gUkRT
MSYwJAYDVQQDDB1BbWF6b24gUkRTIGFmLXNvdXRoLTEgUm9vdCBDQTAeFw0xOTEw
MjgxODA2NTNaFw0yNDEwMjgxODA2NTNaMIGQMQswCQYDVQQGEwJVUzETMBEGA1UE
CAwKV2FzaGluZ3RvbjEQMA4GA1UEBwwHU2VhdHRsZTEiMCAGA1UECgwZQW1hem9u
IFdlYiBTZXJ2aWNlcywgSW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzEhMB8GA1UE
AwwYQW1hem9uIFJEUyBhZi1zb3V0aC0xIENBMIIBIjANBgkqhkiG9w0BAQEFAAOC
AQ8AMIIBCgKCAQEAvtV1OqmFa8zCVQSKOvPUJERLVFtd4rZmDpImc5rIoeBk7w/P
9lcKUJjO8R/w1a2lJXx3oQ81tiY0Piw6TpT62YWVRMWrOw8+Vxq1dNaDSFp9I8d0
UHillSSbOk6FOrPDp+R6AwbGFqUDebbN5LFFoDKbhNmH1BVS0a6YNKpGigLRqhka
cClPslWtPqtjbaP3Jbxl26zWzLo7OtZl98dR225pq8aApNBwmtgA7Gh60HK/cX0t
32W94n8D+GKSg6R4MKredVFqRTi9hCCNUu0sxYPoELuM+mHiqB5NPjtm92EzCWs+
+vgWhMc6GxG+82QSWx1Vj8sgLqtE/vLrWddf5QIDAQABo2YwZDAOBgNVHQ8BAf8E
BAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUuLB4gYVJrSKJj/Gz
pqc6yeA+RcAwHwYDVR0jBBgwFoAUEEi/WWMcBJsoGXg+EZwkQ0MscZQwDQYJKoZI
hvcNAQELBQADggEBABauYOZxUhe9/RhzGJ8MsWCz8eKcyDVd4FCnY6Qh+9wcmYNT
LtnD88LACtJKb/b81qYzcB0Em6+zVJ3Z9jznfr6buItE6es9wAoja22Xgv44BTHL
rimbgMwpTt3uEMXDffaS0Ww6YWb3pSE0XYI2ISMWz+xRERRf+QqktSaL39zuiaW5
tfZMre+YhohRa/F0ZQl3RCd6yFcLx4UoSPqQsUl97WhYzwAxZZfwvLJXOc4ATt3u
VlCUylNDkaZztDJc/yN5XQoK9W5nOt2cLu513MGYKbuarQr8f+gYU8S+qOyuSRSP
NRITzwCRVnsJE+2JmcRInn/NcanB7uOGqTvJ9+c=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIECTCCAvGgAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwgZUxCzAJBgNVBAYTAlVT
MRAwDgYDVQQHDAdTZWF0dGxlMRMwEQYDVQQIDApXYXNoaW5ndG9uMSIwIAYDVQQK
DBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMuMRMwEQYDVQQLDApBbWF6b24gUkRT
MSYwJAYDVQQDDB1BbWF6b24gUkRTIGV1LXNvdXRoLTEgUm9vdCBDQTAeFw0xOTEw
MzAyMDIxMzBaFw0yNDEwMzAyMDIxMzBaMIGQMQswCQYDVQQGEwJVUzETMBEGA1UE
CAwKV2FzaGluZ3RvbjEQMA4GA1UEBwwHU2VhdHRsZTEiMCAGA1UECgwZQW1hem9u
IFdlYiBTZXJ2aWNlcywgSW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzEhMB8GA1UE
AwwYQW1hem9uIFJEUyBldS1zb3V0aC0xIENBMIIBIjANBgkqhkiG9w0BAQEFAAOC
AQ8AMIIBCgKCAQEAtEyjYcajx6xImJn8Vz1zjdmL4ANPgQXwF7+tF7xccmNAZETb
bzb3I9i5fZlmrRaVznX+9biXVaGxYzIUIR3huQ3Q283KsDYnVuGa3mk690vhvJbB
QIPgKa5mVwJppnuJm78KqaSpi0vxyCPe3h8h6LLFawVyWrYNZ4okli1/U582eef8
RzJp/Ear3KgHOLIiCdPDF0rjOdCG1MOlDLixVnPn9IYOciqO+VivXBg+jtfc5J+L
AaPm0/Yx4uELt1tkbWkm4BvTU/gBOODnYziITZM0l6Fgwvbwgq5duAtKW+h031lC
37rEvrclqcp4wrsUYcLAWX79ZyKIlRxcAdvEhQIDAQABo2YwZDAOBgNVHQ8BAf8E
BAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU7zPyc0azQxnBCe7D
b9KAadH1QSEwHwYDVR0jBBgwFoAUFBAFcgJe/BBuZiGeZ8STfpkgRYQwDQYJKoZI
hvcNAQELBQADggEBAFGaNiYxg7yC/xauXPlaqLCtwbm2dKyK9nIFbF/7be8mk7Q3
MOA0of1vGHPLVQLr6bJJpD9MAbUcm4cPAwWaxwcNpxOjYOFDaq10PCK4eRAxZWwF
NJRIRmGsl8NEsMNTMCy8X+Kyw5EzH4vWFl5Uf2bGKOeFg0zt43jWQVOX6C+aL3Cd
pRS5MhmYpxMG8irrNOxf4NVFE2zpJOCm3bn0STLhkDcV/ww4zMzObTJhiIb5wSWn
EXKKWhUXuRt7A2y1KJtXpTbSRHQxE++69Go1tWhXtRiULCJtf7wF2Ksm0RR/AdXT
1uR1vKyH5KBJPX3ppYkQDukoHTFR0CpB+G84NLo=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIECTCCAvGgAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwgZUxCzAJBgNVBAYTAlVT
MRAwDgYDVQQHDAdTZWF0dGxlMRMwEQYDVQQIDApXYXNoaW5ndG9uMSIwIAYDVQQK
DBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMuMRMwEQYDVQQLDApBbWF6b24gUkRT
MSYwJAYDVQQDDB1BbWF6b24gUkRTIG1lLXNvdXRoLTEgUm9vdCBDQTAeFw0xOTA1
MTAyMTU4NDNaFw0yNTA2MDExMjAwMDBaMIGQMQswCQYDVQQGEwJVUzETMBEGA1UE
CAwKV2FzaGluZ3RvbjEQMA4GA1UEBwwHU2VhdHRsZTEiMCAGA1UECgwZQW1hem9u
IFdlYiBTZXJ2aWNlcywgSW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzEhMB8GA1UE
AwwYQW1hem9uIFJEUyBtZS1zb3V0aC0xIENBMIIBIjANBgkqhkiG9w0BAQEFAAOC
AQ8AMIIBCgKCAQEAudOYPZH+ihJAo6hNYMB5izPVBe3TYhnZm8+X3IoaaYiKtsp1
JJhkTT0CEejYIQ58Fh4QrMUyWvU8qsdK3diNyQRoYLbctsBPgxBR1u07eUJDv38/
C1JlqgHmMnMi4y68Iy7ymv50QgAMuaBqgEBRI1R6Lfbyrb2YvH5txjJyTVMwuCfd
YPAtZVouRz0JxmnfsHyxjE+So56uOKTDuw++Ho4HhZ7Qveej7XB8b+PIPuroknd3
FQB5RVbXRvt5ZcVD4F2fbEdBniF7FAF4dEiofVCQGQ2nynT7dZdEIPfPdH3n7ZmE
lAOmwHQ6G83OsiHRBLnbp+QZRgOsjkHJxT20bQIDAQABo2YwZDAOBgNVHQ8BAf8E
BAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUOEVDM7VomRH4HVdA
QvIMNq2tXOcwHwYDVR0jBBgwFoAU54cfDjgwBx4ycBH8+/r8WXdaiqYwDQYJKoZI
hvcNAQELBQADggEBAHhvMssj+Th8IpNePU6RH0BiL6o9c437R3Q4IEJeFdYL+nZz
PW/rELDPvLRUNMfKM+KzduLZ+l29HahxefejYPXtvXBlq/E/9czFDD4fWXg+zVou
uDXhyrV4kNmP4S0eqsAP/jQHPOZAMFA4yVwO9hlqmePhyDnszCh9c1PfJSBh49+b
4w7i/L3VBOMt8j3EKYvqz0gVfpeqhJwL4Hey8UbVfJRFJMJzfNHpePqtDRAY7yjV
PYquRaV2ab/E+/7VFkWMM4tazYz/qsYA2jSH+4xDHvYk8LnsbcrF9iuidQmEc5sb
FgcWaSKG4DJjcI5k7AJLWcXyTDt21Ci43LE+I9Q=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIECDCCAvCgAwIBAgICVIYwDQYJKoZIhvcNAQELBQAwgY8xCzAJBgNVBAYTAlVT
MRAwDgYDVQQHDAdTZWF0dGxlMRMwEQYDVQQIDApXYXNoaW5ndG9uMSIwIAYDVQQK
DBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMuMRMwEQYDVQQLDApBbWF6b24gUkRT
MSAwHgYDVQQDDBdBbWF6b24gUkRTIFJvb3QgMjAxOSBDQTAeFw0xOTA5MDQxNzEz
MDRaFw0yNDA4MjIxNzA4NTBaMIGVMQswCQYDVQQGEwJVUzETMBEGA1UECAwKV2Fz
aGluZ3RvbjEQMA4GA1UEBwwHU2VhdHRsZTEiMCAGA1UECgwZQW1hem9uIFdlYiBT
ZXJ2aWNlcywgSW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzEmMCQGA1UEAwwdQW1h
em9uIFJEUyBhcC1zb3V0aC0xIDIwMTkgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IB
DwAwggEKAoIBAQDUYOz1hGL42yUCrcsMSOoU8AeD/3KgZ4q7gP+vAz1WnY9K/kim
eWN/2Qqzlo3+mxSFQFyD4MyV3+CnCPnBl9Sh1G/F6kThNiJ7dEWSWBQGAB6HMDbC
BaAsmUc1UIz8sLTL3fO+S9wYhA63Wun0Fbm/Rn2yk/4WnJAaMZcEtYf6e0KNa0LM
p/kN/70/8cD3iz3dDR8zOZFpHoCtf0ek80QqTich0A9n3JLxR6g6tpwoYviVg89e
qCjQ4axxOkWWeusLeTJCcY6CkVyFvDAKvcUl1ytM5AiaUkXblE7zDFXRM4qMMRdt
lPm8d3pFxh0fRYk8bIKnpmtOpz3RIctDrZZxAgMBAAGjZjBkMA4GA1UdDwEB/wQE
AwIBBjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBT99wKJftD3jb4sHoHG
i3uGlH6W6TAfBgNVHSMEGDAWgBRzX2DYvMsDmPQrFzQuNlqmYP+8HzANBgkqhkiG
9w0BAQsFAAOCAQEAZ17hhr3dII3hUfuHQ1hPWGrpJOX/G9dLzkprEIcCidkmRYl+
hu1Pe3caRMh/17+qsoEErmnVq5jNY9X1GZL04IZH8YbHc7iRHw3HcWAdhN8633+K
jYEB2LbJ3vluCGnCejq9djDb6alOugdLMJzxOkHDhMZ6/gYbECOot+ph1tQuZXzD
tZ7prRsrcuPBChHlPjmGy8M9z8u+kF196iNSUGC4lM8vLkHM7ycc1/ZOwRq9aaTe
iOghbQQyAEe03MWCyDGtSmDfr0qEk+CHN+6hPiaL8qKt4s+V9P7DeK4iW08ny8Ox
AVS7u0OK/5+jKMAMrKwpYrBydOjTUTHScocyNw==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEBzCCAu+gAwIBAgICQ2QwDQYJKoZIhvcNAQELBQAwgY8xCzAJBgNVBAYTAlVT
MRAwDgYDVQQHDAdTZWF0dGxlMRMwEQYDVQQIDApXYXNoaW5ndG9uMSIwIAYDVQQK
DBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMuMRMwEQYDVQQLDApBbWF6b24gUkRT
MSAwHgYDVQQDDBdBbWF6b24gUkRTIFJvb3QgMjAxOSBDQTAeFw0xOTA5MDUxODQ2
MjlaFw0yNDA4MjIxNzA4NTBaMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECAwKV2Fz
aGluZ3RvbjEQMA4GA1UEBwwHU2VhdHRsZTEiMCAGA1UECgwZQW1hem9uIFdlYiBT
ZXJ2aWNlcywgSW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzElMCMGA1UEAwwcQW1h
em9uIFJEUyBzYS1lYXN0LTEgMjAxOSBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEP
ADCCAQoCggEBAMMvR+ReRnOzqJzoaPipNTt1Z2VA968jlN1+SYKUrYM3No+Vpz0H
M6Tn0oYB66ByVsXiGc28ulsqX1HbHsxqDPwvQTKvO7SrmDokoAkjJgLocOLUAeld
5AwvUjxGRP6yY90NV7X786MpnYb2Il9DIIaV9HjCmPt+rjy2CZjS0UjPjCKNfB8J
bFjgW6GGscjeyGb/zFwcom5p4j0rLydbNaOr9wOyQrtt3ZQWLYGY9Zees/b8pmcc
Jt+7jstZ2UMV32OO/kIsJ4rMUn2r/uxccPwAc1IDeRSSxOrnFKhW3Cu69iB3bHp7
JbawY12g7zshE4I14sHjv3QoXASoXjx4xgMCAwEAAaNmMGQwDgYDVR0PAQH/BAQD
AgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFI1Fc/Ql2jx+oJPgBVYq
ccgP0pQ8MB8GA1UdIwQYMBaAFHNfYNi8ywOY9CsXNC42WqZg/7wfMA0GCSqGSIb3
DQEBCwUAA4IBAQB4VVVabVp70myuYuZ3vltQIWqSUMhkaTzehMgGcHjMf9iLoZ/I
93KiFUSGnek5cRePyS9wcpp0fcBT3FvkjpUdCjVtdttJgZFhBxgTd8y26ImdDDMR
4+BUuhI5msvjL08f+Vkkpu1GQcGmyFVPFOy/UY8iefu+QyUuiBUnUuEDd49Hw0Fn
/kIPII6Vj82a2mWV/Q8e+rgN8dIRksRjKI03DEoP8lhPlsOkhdwU6Uz9Vu6NOB2Q
Ls1kbcxAc7cFSyRVJEhh12Sz9d0q/CQSTFsVJKOjSNQBQfVnLz1GwO/IieUEAr4C
jkTntH0r1LX5b/GwN4R887LvjAEdTbg1his7
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIECDCCAvCgAwIBAgIDAIkHMA0GCSqGSIb3DQEBCwUAMIGPMQswCQYDVQQGEwJV
UzEQMA4GA1UEBwwHU2VhdHRsZTETMBEGA1UECAwKV2FzaGluZ3RvbjEiMCAGA1UE
CgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjETMBEGA1UECwwKQW1hem9uIFJE
UzEgMB4GA1UEAwwXQW1hem9uIFJEUyBSb290IDIwMTkgQ0EwHhcNMTkwOTA2MTc0
MDIxWhcNMjQwODIyMTcwODUwWjCBlDELMAkGA1UEBhMCVVMxEzARBgNVBAgMCldh
c2hpbmd0b24xEDAOBgNVBAcMB1NlYXR0bGUxIjAgBgNVBAoMGUFtYXpvbiBXZWIg
U2VydmljZXMsIEluYy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxJTAjBgNVBAMMHEFt
YXpvbiBSRFMgdXMtd2VzdC0xIDIwMTkgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IB
DwAwggEKAoIBAQDD2yzbbAl77OofTghDMEf624OvU0eS9O+lsdO0QlbfUfWa1Kd6
0WkgjkLZGfSRxEHMCnrv4UPBSK/Qwn6FTjkDLgemhqBtAnplN4VsoDL+BkRX4Wwq
/dSQJE2b+0hm9w9UMVGFDEq1TMotGGTD2B71eh9HEKzKhGzqiNeGsiX4VV+LJzdH
uM23eGisNqmd4iJV0zcAZ+Gbh2zK6fqTOCvXtm7Idccv8vZZnyk1FiWl3NR4WAgK
AkvWTIoFU3Mt7dIXKKClVmvssG8WHCkd3Xcb4FHy/G756UZcq67gMMTX/9fOFM/v
l5C0+CHl33Yig1vIDZd+fXV1KZD84dEJfEvHAgMBAAGjZjBkMA4GA1UdDwEB/wQE
AwIBBjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBR+ap20kO/6A7pPxo3+
T3CfqZpQWjAfBgNVHSMEGDAWgBRzX2DYvMsDmPQrFzQuNlqmYP+8HzANBgkqhkiG
9w0BAQsFAAOCAQEAHCJky2tPjPttlDM/RIqExupBkNrnSYnOK4kr9xJ3sl8UF2DA
PAnYsjXp3rfcjN/k/FVOhxwzi3cXJF/2Tjj39Bm/OEfYTOJDNYtBwB0VVH4ffa/6
tZl87jaIkrxJcreeeHqYMnIxeN0b/kliyA+a5L2Yb0VPjt9INq34QDc1v74FNZ17
4z8nr1nzg4xsOWu0Dbjo966lm4nOYIGBRGOKEkHZRZ4mEiMgr3YLkv8gSmeitx57
Z6dVemNtUic/LVo5Iqw4n3TBS0iF2C1Q1xT/s3h+0SXZlfOWttzSluDvoMv5PvCd
pFjNn+aXLAALoihL1MJSsxydtsLjOBro5eK0Vw==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEDDCCAvSgAwIBAgICOFAwDQYJKoZIhvcNAQELBQAwgY8xCzAJBgNVBAYTAlVT
MRAwDgYDVQQHDAdTZWF0dGxlMRMwEQYDVQQIDApXYXNoaW5ndG9uMSIwIAYDVQQK
DBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMuMRMwEQYDVQQLDApBbWF6b24gUkRT
MSAwHgYDVQQDDBdBbWF6b24gUkRTIFJvb3QgMjAxOSBDQTAeFw0xOTA5MTAxNzQ2
MjFaFw0yNDA4MjIxNzA4NTBaMIGZMQswCQYDVQQGEwJVUzETMBEGA1UECAwKV2Fz
aGluZ3RvbjEQMA4GA1UEBwwHU2VhdHRsZTEiMCAGA1UECgwZQW1hem9uIFdlYiBT
ZXJ2aWNlcywgSW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzEqMCgGA1UEAwwhQW1h
em9uIFJEUyBhcC1ub3J0aGVhc3QtMiAyMDE5IENBMIIBIjANBgkqhkiG9w0BAQEF
AAOCAQ8AMIIBCgKCAQEAzU72e6XbaJbi4HjJoRNjKxzUEuChKQIt7k3CWzNnmjc5
8I1MjCpa2W1iw1BYVysXSNSsLOtUsfvBZxi/1uyMn5ZCaf9aeoA9UsSkFSZBjOCN
DpKPCmfV1zcEOvJz26+1m8WDg+8Oa60QV0ou2AU1tYcw98fOQjcAES0JXXB80P2s
3UfkNcnDz+l4k7j4SllhFPhH6BQ4lD2NiFAP4HwoG6FeJUn45EPjzrydxjq6v5Fc
cQ8rGuHADVXotDbEhaYhNjIrsPL+puhjWfhJjheEw8c4whRZNp6gJ/b6WEes/ZhZ
h32DwsDsZw0BfRDUMgUn8TdecNexHUw8vQWeC181hwIDAQABo2YwZDAOBgNVHQ8B
Af8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUwW9bWgkWkr0U
lrOsq2kvIdrECDgwHwYDVR0jBBgwFoAUc19g2LzLA5j0Kxc0LjZapmD/vB8wDQYJ
KoZIhvcNAQELBQADggEBAEugF0Gj7HVhX0ehPZoGRYRt3PBuI2YjfrrJRTZ9X5wc
9T8oHmw07mHmNy1qqWvooNJg09bDGfB0k5goC2emDiIiGfc/kvMLI7u+eQOoMKj6
mkfCncyRN3ty08Po45vTLBFZGUvtQmjM6yKewc4sXiASSBmQUpsMbiHRCL72M5qV
obcJOjGcIdDTmV1BHdWT+XcjynsGjUqOvQWWhhLPrn4jWe6Xuxll75qlrpn3IrIx
CRBv/5r7qbcQJPOgwQsyK4kv9Ly8g7YT1/vYBlR3cRsYQjccw5ceWUj2DrMVWhJ4
prf+E3Aa4vYmLLOUUvKnDQ1k3RGNu56V0tonsQbfsaM=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIECjCCAvKgAwIBAgICEzUwDQYJKoZIhvcNAQELBQAwgY8xCzAJBgNVBAYTAlVT
MRAwDgYDVQQHDAdTZWF0dGxlMRMwEQYDVQQIDApXYXNoaW5ndG9uMSIwIAYDVQQK
DBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMuMRMwEQYDVQQLDApBbWF6b24gUkRT
MSAwHgYDVQQDDBdBbWF6b24gUkRTIFJvb3QgMjAxOSBDQTAeFw0xOTA5MTAyMDUy
MjVaFw0yNDA4MjIxNzA4NTBaMIGXMQswCQYDVQQGEwJVUzETMBEGA1UECAwKV2Fz
aGluZ3RvbjEQMA4GA1UEBwwHU2VhdHRsZTEiMCAGA1UECgwZQW1hem9uIFdlYiBT
ZXJ2aWNlcywgSW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzEoMCYGA1UEAwwfQW1h
em9uIFJEUyBjYS1jZW50cmFsLTEgMjAxOSBDQTCCASIwDQYJKoZIhvcNAQEBBQAD
ggEPADCCAQoCggEBAOxHqdcPSA2uBjsCP4DLSlqSoPuQ/X1kkJLusVRKiQE2zayB
viuCBt4VB9Qsh2rW3iYGM+usDjltGnI1iUWA5KHcvHszSMkWAOYWLiMNKTlg6LCp
XnE89tvj5dIH6U8WlDvXLdjB/h30gW9JEX7S8supsBSci2GxEzb5mRdKaDuuF/0O
qvz4YE04pua3iZ9QwmMFuTAOYzD1M72aOpj+7Ac+YLMM61qOtU+AU6MndnQkKoQi
qmUN2A9IFaqHFzRlSdXwKCKUA4otzmz+/N3vFwjb5F4DSsbsrMfjeHMo6o/nb6Nh
YDb0VJxxPee6TxSuN7CQJ2FxMlFUezcoXqwqXD0CAwEAAaNmMGQwDgYDVR0PAQH/
BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFDGGpon9WfIpsggE
CxHq8hZ7E2ESMB8GA1UdIwQYMBaAFHNfYNi8ywOY9CsXNC42WqZg/7wfMA0GCSqG
SIb3DQEBCwUAA4IBAQAvpeQYEGZvoTVLgV9rd2+StPYykMsmFjWQcyn3dBTZRXC2
lKq7QhQczMAOhEaaN29ZprjQzsA2X/UauKzLR2Uyqc2qOeO9/YOl0H3qauo8C/W9
r8xqPbOCDLEXlOQ19fidXyyEPHEq5WFp8j+fTh+s8WOx2M7IuC0ANEetIZURYhSp
xl9XOPRCJxOhj7JdelhpweX0BJDNHeUFi0ClnFOws8oKQ7sQEv66d5ddxqqZ3NVv
RbCvCtEutQMOUMIuaygDlMn1anSM8N7Wndx8G6+Uy67AnhjGx7jw/0YPPxopEj6x
JXP8j0sJbcT9K/9/fPVLNT25RvQ/93T2+IQL4Ca2
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEBzCCAu+gAwIBAgICYpgwDQYJKoZIhvcNAQELBQAwgY8xCzAJBgNVBAYTAlVT
MRAwDgYDVQQHDAdTZWF0dGxlMRMwEQYDVQQIDApXYXNoaW5ndG9uMSIwIAYDVQQK
DBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMuMRMwEQYDVQQLDApBbWF6b24gUkRT
MSAwHgYDVQQDDBdBbWF6b24gUkRTIFJvb3QgMjAxOSBDQTAeFw0xOTA5MTExNzMx
NDhaFw0yNDA4MjIxNzA4NTBaMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECAwKV2Fz
aGluZ3RvbjEQMA4GA1UEBwwHU2VhdHRsZTEiMCAGA1UECgwZQW1hem9uIFdlYiBT
ZXJ2aWNlcywgSW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzElMCMGA1UEAwwcQW1h
em9uIFJEUyBldS13ZXN0LTEgMjAxOSBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEP
ADCCAQoCggEBAMk3YdSZ64iAYp6MyyKtYJtNzv7zFSnnNf6vv0FB4VnfITTMmOyZ
LXqKAT2ahZ00hXi34ewqJElgU6eUZT/QlzdIu359TEZyLVPwURflL6SWgdG01Q5X
O++7fSGcBRyIeuQWs9FJNIIqK8daF6qw0Rl5TXfu7P9dBc3zkgDXZm2DHmxGDD69
7liQUiXzoE1q2Z9cA8+jirDioJxN9av8hQt12pskLQumhlArsMIhjhHRgF03HOh5
tvi+RCfihVOxELyIRTRpTNiIwAqfZxxTWFTgfn+gijTmd0/1DseAe82aYic8JbuS
EMbrDduAWsqrnJ4GPzxHKLXX0JasCUcWyMECAwEAAaNmMGQwDgYDVR0PAQH/BAQD
AgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFPLtsq1NrwJXO13C9eHt
sLY11AGwMB8GA1UdIwQYMBaAFHNfYNi8ywOY9CsXNC42WqZg/7wfMA0GCSqGSIb3
DQEBCwUAA4IBAQAnWBKj5xV1A1mYd0kIgDdkjCwQkiKF5bjIbGkT3YEFFbXoJlSP
0lZZ/hDaOHI8wbLT44SzOvPEEmWF9EE7SJzkvSdQrUAWR9FwDLaU427ALI3ngNHy
lGJ2hse1fvSRNbmg8Sc9GBv8oqNIBPVuw+AJzHTacZ1OkyLZrz1c1QvwvwN2a+Jd
vH0V0YIhv66llKcYDMUQJAQi4+8nbRxXWv6Gq3pvrFoorzsnkr42V3JpbhnYiK+9
nRKd4uWl62KRZjGkfMbmsqZpj2fdSWMY1UGyN1k+kDmCSWYdrTRDP0xjtIocwg+A
J116n4hV/5mbA0BaPiS2krtv17YAeHABZcvz
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIECjCCAvKgAwIBAgICV2YwDQYJKoZIhvcNAQELBQAwgY8xCzAJBgNVBAYTAlVT
MRAwDgYDVQQHDAdTZWF0dGxlMRMwEQYDVQQIDApXYXNoaW5ndG9uMSIwIAYDVQQK
DBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMuMRMwEQYDVQQLDApBbWF6b24gUkRT
MSAwHgYDVQQDDBdBbWF6b24gUkRTIFJvb3QgMjAxOSBDQTAeFw0xOTA5MTExOTM2
MjBaFw0yNDA4MjIxNzA4NTBaMIGXMQswCQYDVQQGEwJVUzETMBEGA1UECAwKV2Fz
aGluZ3RvbjEQMA4GA1UEBwwHU2VhdHRsZTEiMCAGA1UECgwZQW1hem9uIFdlYiBT
ZXJ2aWNlcywgSW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzEoMCYGA1UEAwwfQW1h
em9uIFJEUyBldS1jZW50cmFsLTEgMjAxOSBDQTCCASIwDQYJKoZIhvcNAQEBBQAD
ggEPADCCAQoCggEBAMEx54X2pHVv86APA0RWqxxRNmdkhAyp2R1cFWumKQRofoFv
n+SPXdkpIINpMuEIGJANozdiEz7SPsrAf8WHyD93j/ZxrdQftRcIGH41xasetKGl
I67uans8d+pgJgBKGb/Z+B5m+UsIuEVekpvgpwKtmmaLFC/NCGuSsJoFsRqoa6Gh
m34W6yJoY87UatddCqLY4IIXaBFsgK9Q/wYzYLbnWM6ZZvhJ52VMtdhcdzeTHNW0
5LGuXJOF7Ahb4JkEhoo6TS2c0NxB4l4MBfBPgti+O7WjR3FfZHpt18A6Zkq6A2u6
D/oTSL6c9/3sAaFTFgMyL3wHb2YlW0BPiljZIqECAwEAAaNmMGQwDgYDVR0PAQH/
BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFOcAToAc6skWffJa
TnreaswAfrbcMB8GA1UdIwQYMBaAFHNfYNi8ywOY9CsXNC42WqZg/7wfMA0GCSqG
SIb3DQEBCwUAA4IBAQA1d0Whc1QtspK496mFWfFEQNegLh0a9GWYlJm+Htcj5Nxt
DAIGXb+8xrtOZFHmYP7VLCT5Zd2C+XytqseK/+s07iAr0/EPF+O2qcyQWMN5KhgE
cXw2SwuP9FPV3i+YAm11PBVeenrmzuk9NrdHQ7TxU4v7VGhcsd2C++0EisrmquWH
mgIfmVDGxphwoES52cY6t3fbnXmTkvENvR+h3rj+fUiSz0aSo+XZUGHPgvuEKM/W
CBD9Smc9CBoBgvy7BgHRgRUmwtABZHFUIEjHI5rIr7ZvYn+6A0O6sogRfvVYtWFc
qpyrW1YX8mD0VlJ8fGKM3G+aCOsiiPKDV/Uafrm+
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIECDCCAvCgAwIBAgICGAcwDQYJKoZIhvcNAQELBQAwgY8xCzAJBgNVBAYTAlVT
MRAwDgYDVQQHDAdTZWF0dGxlMRMwEQYDVQQIDApXYXNoaW5ndG9uMSIwIAYDVQQK
DBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMuMRMwEQYDVQQLDApBbWF6b24gUkRT
MSAwHgYDVQQDDBdBbWF6b24gUkRTIFJvb3QgMjAxOSBDQTAeFw0xOTA5MTIxODE5
NDRaFw0yNDA4MjIxNzA4NTBaMIGVMQswCQYDVQQGEwJVUzETMBEGA1UECAwKV2Fz
aGluZ3RvbjEQMA4GA1UEBwwHU2VhdHRsZTEiMCAGA1UECgwZQW1hem9uIFdlYiBT
ZXJ2aWNlcywgSW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzEmMCQGA1UEAwwdQW1h
em9uIFJEUyBldS1ub3J0aC0xIDIwMTkgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IB
DwAwggEKAoIBAQCiIYnhe4UNBbdBb/nQxl5giM0XoVHWNrYV5nB0YukA98+TPn9v
Aoj1RGYmtryjhrf01Kuv8SWO+Eom95L3zquoTFcE2gmxCfk7bp6qJJ3eHOJB+QUO
XsNRh76fwDzEF1yTeZWH49oeL2xO13EAx4PbZuZpZBttBM5zAxgZkqu4uWQczFEs
JXfla7z2fvWmGcTagX10O5C18XaFroV0ubvSyIi75ue9ykg/nlFAeB7O0Wxae88e
uhiBEFAuLYdqWnsg3459NfV8Yi1GnaitTym6VI3tHKIFiUvkSiy0DAlAGV2iiyJE
q+DsVEO4/hSINJEtII4TMtysOsYPpINqeEzRAgMBAAGjZjBkMA4GA1UdDwEB/wQE
AwIBBjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBRR0UpnbQyjnHChgmOc
hnlc0PogzTAfBgNVHSMEGDAWgBRzX2DYvMsDmPQrFzQuNlqmYP+8HzANBgkqhkiG
9w0BAQsFAAOCAQEAKJD4xVzSf4zSGTBJrmamo86jl1NHQxXUApAZuBZEc8tqC6TI
T5CeoSr9CMuVC8grYyBjXblC4OsM5NMvmsrXl/u5C9dEwtBFjo8mm53rOOIm1fxl
I1oYB/9mtO9ANWjkykuLzWeBlqDT/i7ckaKwalhLODsRDO73vRhYNjsIUGloNsKe
pxw3dzHwAZx4upSdEVG4RGCZ1D0LJ4Gw40OfD69hfkDfRVVxKGrbEzqxXRvovmDc
tKLdYZO/6REoca36v4BlgIs1CbUXJGLSXUwtg7YXGLSVBJ/U0+22iGJmBSNcoyUN
cjPFD9JQEhDDIYYKSGzIYpvslvGc4T5ISXFiuQ==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEBzCCAu+gAwIBAgICZIEwDQYJKoZIhvcNAQELBQAwgY8xCzAJBgNVBAYTAlVT
MRAwDgYDVQQHDAdTZWF0dGxlMRMwEQYDVQQIDApXYXNoaW5ndG9uMSIwIAYDVQQK
DBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMuMRMwEQYDVQQLDApBbWF6b24gUkRT
MSAwHgYDVQQDDBdBbWF6b24gUkRTIFJvb3QgMjAxOSBDQTAeFw0xOTA5MTIyMTMy
MzJaFw0yNDA4MjIxNzA4NTBaMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECAwKV2Fz
aGluZ3RvbjEQMA4GA1UEBwwHU2VhdHRsZTEiMCAGA1UECgwZQW1hem9uIFdlYiBT
ZXJ2aWNlcywgSW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzElMCMGA1UEAwwcQW1h
em9uIFJEUyBldS13ZXN0LTIgMjAxOSBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEP
ADCCAQoCggEBALGiwqjiF7xIjT0Sx7zB3764K2T2a1DHnAxEOr+/EIftWKxWzT3u
PFwS2eEZcnKqSdRQ+vRzonLBeNLO4z8aLjQnNbkizZMBuXGm4BqRm1Kgq3nlLDQn
7YqdijOq54SpShvR/8zsO4sgMDMmHIYAJJOJqBdaus2smRt0NobIKc0liy7759KB
6kmQ47Gg+kfIwxrQA5zlvPLeQImxSoPi9LdbRoKvu7Iot7SOa+jGhVBh3VdqndJX
7tm/saj4NE375csmMETFLAOXjat7zViMRwVorX4V6AzEg1vkzxXpA9N7qywWIT5Y
fYaq5M8i6vvLg0CzrH9fHORtnkdjdu1y+0MCAwEAAaNmMGQwDgYDVR0PAQH/BAQD
AgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFFOhOx1yt3Z7mvGB9jBv
2ymdZwiOMB8GA1UdIwQYMBaAFHNfYNi8ywOY9CsXNC42WqZg/7wfMA0GCSqGSIb3
DQEBCwUAA4IBAQBehqY36UGDvPVU9+vtaYGr38dBbp+LzkjZzHwKT1XJSSUc2wqM
hnCIQKilonrTIvP1vmkQi8qHPvDRtBZKqvz/AErW/ZwQdZzqYNFd+BmOXaeZWV0Q
oHtDzXmcwtP8aUQpxN0e1xkWb1E80qoy+0uuRqb/50b/R4Q5qqSfJhkn6z8nwB10
7RjLtJPrK8igxdpr3tGUzfAOyiPrIDncY7UJaL84GFp7WWAkH0WG3H8Y8DRcRXOU
mqDxDLUP3rNuow3jnGxiUY+gGX5OqaZg4f4P6QzOSmeQYs6nLpH0PiN00+oS1BbD
bpWdZEttILPI+vAYkU4QuBKKDjJL6HbSd+cn
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIECDCCAvCgAwIBAgIDAIVCMA0GCSqGSIb3DQEBCwUAMIGPMQswCQYDVQQGEwJV
UzEQMA4GA1UEBwwHU2VhdHRsZTETMBEGA1UECAwKV2FzaGluZ3RvbjEiMCAGA1UE
CgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjETMBEGA1UECwwKQW1hem9uIFJE
UzEgMB4GA1UEAwwXQW1hem9uIFJEUyBSb290IDIwMTkgQ0EwHhcNMTkwOTEzMTcw
NjQxWhcNMjQwODIyMTcwODUwWjCBlDELMAkGA1UEBhMCVVMxEzARBgNVBAgMCldh
c2hpbmd0b24xEDAOBgNVBAcMB1NlYXR0bGUxIjAgBgNVBAoMGUFtYXpvbiBXZWIg
U2VydmljZXMsIEluYy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxJTAjBgNVBAMMHEFt
YXpvbiBSRFMgdXMtZWFzdC0yIDIwMTkgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IB
DwAwggEKAoIBAQDE+T2xYjUbxOp+pv+gRA3FO24+1zCWgXTDF1DHrh1lsPg5k7ht
2KPYzNc+Vg4E+jgPiW0BQnA6jStX5EqVh8BU60zELlxMNvpg4KumniMCZ3krtMUC
au1NF9rM7HBh+O+DYMBLK5eSIVt6lZosOb7bCi3V6wMLA8YqWSWqabkxwN4w0vXI
8lu5uXXFRemHnlNf+yA/4YtN4uaAyd0ami9+klwdkZfkrDOaiy59haOeBGL8EB/c
dbJJlguHH5CpCscs3RKtOOjEonXnKXldxarFdkMzi+aIIjQ8GyUOSAXHtQHb3gZ4
nS6Ey0CMlwkB8vUObZU9fnjKJcL5QCQqOfwvAgMBAAGjZjBkMA4GA1UdDwEB/wQE
AwIBBjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBQUPuRHohPxx4VjykmH
6usGrLL1ETAfBgNVHSMEGDAWgBRzX2DYvMsDmPQrFzQuNlqmYP+8HzANBgkqhkiG
9w0BAQsFAAOCAQEAUdR9Vb3y33Yj6X6KGtuthZ08SwjImVQPtknzpajNE5jOJAh8
quvQnU9nlnMO85fVDU1Dz3lLHGJ/YG1pt1Cqq2QQ200JcWCvBRgdvH6MjHoDQpqZ
HvQ3vLgOGqCLNQKFuet9BdpsHzsctKvCVaeBqbGpeCtt3Hh/26tgx0rorPLw90A2
V8QSkZJjlcKkLa58N5CMM8Xz8KLWg3MZeT4DmlUXVCukqK2RGuP2L+aME8dOxqNv
OnOz1zrL5mR2iJoDpk8+VE/eBDmJX40IJk6jBjWoxAO/RXq+vBozuF5YHN1ujE92
tO8HItgTp37XT8bJBAiAnt5mxw+NLSqtxk2QdQ==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEDDCCAvSgAwIBAgICY4kwDQYJKoZIhvcNAQELBQAwgY8xCzAJBgNVBAYTAlVT
MRAwDgYDVQQHDAdTZWF0dGxlMRMwEQYDVQQIDApXYXNoaW5ndG9uMSIwIAYDVQQK
DBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMuMRMwEQYDVQQLDApBbWF6b24gUkRT
MSAwHgYDVQQDDBdBbWF6b24gUkRTIFJvb3QgMjAxOSBDQTAeFw0xOTA5MTMyMDEx
NDJaFw0yNDA4MjIxNzA4NTBaMIGZMQswCQYDVQQGEwJVUzETMBEGA1UECAwKV2Fz
aGluZ3RvbjEQMA4GA1UEBwwHU2VhdHRsZTEiMCAGA1UECgwZQW1hem9uIFdlYiBT
ZXJ2aWNlcywgSW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzEqMCgGA1UEAwwhQW1h
em9uIFJEUyBhcC1zb3V0aGVhc3QtMSAyMDE5IENBMIIBIjANBgkqhkiG9w0BAQEF
AAOCAQ8AMIIBCgKCAQEAr5u9OuLL/OF/fBNUX2kINJLzFl4DnmrhnLuSeSnBPgbb
qddjf5EFFJBfv7IYiIWEFPDbDG5hoBwgMup5bZDbas+ZTJTotnnxVJTQ6wlhTmns
eHECcg2pqGIKGrxZfbQhlj08/4nNAPvyYCTS0bEcmQ1emuDPyvJBYDDLDU6AbCB5
6Z7YKFQPTiCBblvvNzchjLWF9IpkqiTsPHiEt21sAdABxj9ityStV3ja/W9BfgxH
wzABSTAQT6FbDwmQMo7dcFOPRX+hewQSic2Rn1XYjmNYzgEHisdUsH7eeXREAcTw
61TRvaLH8AiOWBnTEJXPAe6wYfrcSd1pD0MXpoB62wIDAQABo2YwZDAOBgNVHQ8B
Af8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUytwMiomQOgX5
Ichd+2lDWRUhkikwHwYDVR0jBBgwFoAUc19g2LzLA5j0Kxc0LjZapmD/vB8wDQYJ
KoZIhvcNAQELBQADggEBACf6lRDpfCD7BFRqiWM45hqIzffIaysmVfr+Jr+fBTjP
uYe/ba1omSrNGG23bOcT9LJ8hkQJ9d+FxUwYyICQNWOy6ejicm4z0C3VhphbTPqj
yjpt9nG56IAcV8BcRJh4o/2IfLNzC/dVuYJV8wj7XzwlvjysenwdrJCoLadkTr1h
eIdG6Le07sB9IxrGJL9e04afk37h7c8ESGSE4E+oS4JQEi3ATq8ne1B9DQ9SasXi
IRmhNAaISDzOPdyLXi9N9V9Lwe/DHcja7hgLGYx3UqfjhLhOKwp8HtoZORixAmOI
HfILgNmwyugAbuZoCazSKKBhQ0wgO0WZ66ZKTMG8Oho=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEBzCCAu+gAwIBAgICUYkwDQYJKoZIhvcNAQELBQAwgY8xCzAJBgNVBAYTAlVT
MRAwDgYDVQQHDAdTZWF0dGxlMRMwEQYDVQQIDApXYXNoaW5ndG9uMSIwIAYDVQQK
DBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMuMRMwEQYDVQQLDApBbWF6b24gUkRT
MSAwHgYDVQQDDBdBbWF6b24gUkRTIFJvb3QgMjAxOSBDQTAeFw0xOTA5MTYxODIx
MTVaFw0yNDA4MjIxNzA4NTBaMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECAwKV2Fz
aGluZ3RvbjEQMA4GA1UEBwwHU2VhdHRsZTEiMCAGA1UECgwZQW1hem9uIFdlYiBT
ZXJ2aWNlcywgSW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzElMCMGA1UEAwwcQW1h
em9uIFJEUyB1cy13ZXN0LTIgMjAxOSBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEP
ADCCAQoCggEBANCEZBZyu6yJQFZBJmSUZfSZd3Ui2gitczMKC4FLr0QzkbxY+cLa
uVONIOrPt4Rwi+3h/UdnUg917xao3S53XDf1TDMFEYp4U8EFPXqCn/GXBIWlU86P
PvBN+gzw3nS+aco7WXb+woTouvFVkk8FGU7J532llW8o/9ydQyDIMtdIkKTuMfho
OiNHSaNc+QXQ32TgvM9A/6q7ksUoNXGCP8hDOkSZ/YOLiI5TcdLh/aWj00ziL5bj
pvytiMZkilnc9dLY9QhRNr0vGqL0xjmWdoEXz9/OwjmCihHqJq+20MJPsvFm7D6a
2NKybR9U+ddrjb8/iyLOjURUZnj5O+2+OPcCAwEAAaNmMGQwDgYDVR0PAQH/BAQD
AgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFEBxMBdv81xuzqcK5TVu
pHj+Aor8MB8GA1UdIwQYMBaAFHNfYNi8ywOY9CsXNC42WqZg/7wfMA0GCSqGSIb3
DQEBCwUAA4IBAQBZkfiVqGoJjBI37aTlLOSjLcjI75L5wBrwO39q+B4cwcmpj58P
3sivv+jhYfAGEbQnGRzjuFoyPzWnZ1DesRExX+wrmHsLLQbF2kVjLZhEJMHF9eB7
GZlTPdTzHErcnuXkwA/OqyXMpj9aghcQFuhCNguEfnROY9sAoK2PTfnTz9NJHL+Q
UpDLEJEUfc0GZMVWYhahc0x38ZnSY2SKacIPECQrTI0KpqZv/P+ijCEcMD9xmYEb
jL4en+XKS1uJpw5fIU5Sj0MxhdGstH6S84iAE5J3GM3XHklGSFwwqPYvuTXvANH6
uboynxRgSae59jIlAK6Jrr6GWMwQRbgcaAlW
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEDDCCAvSgAwIBAgICEkYwDQYJKoZIhvcNAQELBQAwgY8xCzAJBgNVBAYTAlVT
MRAwDgYDVQQHDAdTZWF0dGxlMRMwEQYDVQQIDApXYXNoaW5ndG9uMSIwIAYDVQQK
DBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMuMRMwEQYDVQQLDApBbWF6b24gUkRT
MSAwHgYDVQQDDBdBbWF6b24gUkRTIFJvb3QgMjAxOSBDQTAeFw0xOTA5MTYxOTUz
NDdaFw0yNDA4MjIxNzA4NTBaMIGZMQswCQYDVQQGEwJVUzETMBEGA1UECAwKV2Fz
aGluZ3RvbjEQMA4GA1UEBwwHU2VhdHRsZTEiMCAGA1UECgwZQW1hem9uIFdlYiBT
ZXJ2aWNlcywgSW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzEqMCgGA1UEAwwhQW1h
em9uIFJEUyBhcC1zb3V0aGVhc3QtMiAyMDE5IENBMIIBIjANBgkqhkiG9w0BAQEF
AAOCAQ8AMIIBCgKCAQEAufodI2Flker8q7PXZG0P0vmFSlhQDw907A6eJuF/WeMo
GHnll3b4S6nC3oRS3nGeRMHbyU2KKXDwXNb3Mheu+ox+n5eb/BJ17eoj9HbQR1cd
gEkIciiAltf8gpMMQH4anP7TD+HNFlZnP7ii3geEJB2GGXSxgSWvUzH4etL67Zmn
TpGDWQMB0T8lK2ziLCMF4XAC/8xDELN/buHCNuhDpxpPebhct0T+f6Arzsiswt2j
7OeNeLLZwIZvVwAKF7zUFjC6m7/VmTQC8nidVY559D6l0UhhU0Co/txgq3HVsMOH
PbxmQUwJEKAzQXoIi+4uZzHFZrvov/nDTNJUhC6DqwIDAQABo2YwZDAOBgNVHQ8B
Af8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUwaZpaCme+EiV
M5gcjeHZSTgOn4owHwYDVR0jBBgwFoAUc19g2LzLA5j0Kxc0LjZapmD/vB8wDQYJ
KoZIhvcNAQELBQADggEBAAR6a2meCZuXO2TF9bGqKGtZmaah4pH2ETcEVUjkvXVz
sl+ZKbYjrun+VkcMGGKLUjS812e7eDF726ptoku9/PZZIxlJB0isC/0OyixI8N4M
NsEyvp52XN9QundTjkl362bomPnHAApeU0mRbMDRR2JdT70u6yAzGLGsUwMkoNnw
1VR4XKhXHYGWo7KMvFrZ1KcjWhubxLHxZWXRulPVtGmyWg/MvE6KF+2XMLhojhUL
+9jB3Fpn53s6KMx5tVq1x8PukHmowcZuAF8k+W4gk8Y68wIwynrdZrKRyRv6CVtR
FZ8DeJgoNZT3y/GT254VqMxxfuy2Ccb/RInd16tEvVk=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEDDCCAvSgAwIBAgICOYIwDQYJKoZIhvcNAQELBQAwgY8xCzAJBgNVBAYTAlVT
MRAwDgYDVQQHDAdTZWF0dGxlMRMwEQYDVQQIDApXYXNoaW5ndG9uMSIwIAYDVQQK
DBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMuMRMwEQYDVQQLDApBbWF6b24gUkRT
MSAwHgYDVQQDDBdBbWF6b24gUkRTIFJvb3QgMjAxOSBDQTAeFw0xOTA5MTcyMDA1
MjlaFw0yNDA4MjIxNzA4NTBaMIGZMQswCQYDVQQGEwJVUzETMBEGA1UECAwKV2Fz
aGluZ3RvbjEQMA4GA1UEBwwHU2VhdHRsZTEiMCAGA1UECgwZQW1hem9uIFdlYiBT
ZXJ2aWNlcywgSW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzEqMCgGA1UEAwwhQW1h
em9uIFJEUyBhcC1ub3J0aGVhc3QtMyAyMDE5IENBMIIBIjANBgkqhkiG9w0BAQEF
AAOCAQ8AMIIBCgKCAQEA4dMak8W+XW8y/2F6nRiytFiA4XLwePadqWebGtlIgyCS
kbug8Jv5w7nlMkuxOxoUeD4WhI6A9EkAn3r0REM/2f0aYnd2KPxeqS2MrtdxxHw1
xoOxk2x0piNSlOz6yog1idsKR5Wurf94fvM9FdTrMYPPrDabbGqiBMsZZmoHLvA3
Z+57HEV2tU0Ei3vWeGIqnNjIekS+E06KhASxrkNU5vi611UsnYZlSi0VtJsH4UGV
LhnHl53aZL0YFO5mn/fzuNG/51qgk/6EFMMhaWInXX49Dia9FnnuWXwVwi6uX1Wn
7kjoHi5VtmC8ZlGEHroxX2DxEr6bhJTEpcLMnoQMqwIDAQABo2YwZDAOBgNVHQ8B
Af8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUsUI5Cb3SWB8+
gv1YLN/ABPMdxSAwHwYDVR0jBBgwFoAUc19g2LzLA5j0Kxc0LjZapmD/vB8wDQYJ
KoZIhvcNAQELBQADggEBAJAF3E9PM1uzVL8YNdzb6fwJrxxqI2shvaMVmC1mXS+w
G0zh4v2hBZOf91l1EO0rwFD7+fxoI6hzQfMxIczh875T6vUXePKVOCOKI5wCrDad
zQbVqbFbdhsBjF4aUilOdtw2qjjs9JwPuB0VXN4/jY7m21oKEOcnpe36+7OiSPjN
xngYewCXKrSRqoj3mw+0w/+exYj3Wsush7uFssX18av78G+ehKPIVDXptOCP/N7W
8iKVNeQ2QGTnu2fzWsGUSvMGyM7yqT+h1ILaT//yQS8er511aHMLc142bD4D9VSy
DgactwPDTShK/PXqhvNey9v/sKXm4XatZvwcc8KYlW4=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEDDCCAvSgAwIBAgICcEUwDQYJKoZIhvcNAQELBQAwgY8xCzAJBgNVBAYTAlVT
MRAwDgYDVQQHDAdTZWF0dGxlMRMwEQYDVQQIDApXYXNoaW5ndG9uMSIwIAYDVQQK
DBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMuMRMwEQYDVQQLDApBbWF6b24gUkRT
MSAwHgYDVQQDDBdBbWF6b24gUkRTIFJvb3QgMjAxOSBDQTAeFw0xOTA5MTgxNjU2
MjBaFw0yNDA4MjIxNzA4NTBaMIGZMQswCQYDVQQGEwJVUzETMBEGA1UECAwKV2Fz
aGluZ3RvbjEQMA4GA1UEBwwHU2VhdHRsZTEiMCAGA1UECgwZQW1hem9uIFdlYiBT
ZXJ2aWNlcywgSW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzEqMCgGA1UEAwwhQW1h
em9uIFJEUyBhcC1ub3J0aGVhc3QtMSAyMDE5IENBMIIBIjANBgkqhkiG9w0BAQEF
AAOCAQ8AMIIBCgKCAQEAndtkldmHtk4TVQAyqhAvtEHSMb6pLhyKrIFved1WO3S7
+I+bWwv9b2W/ljJxLq9kdT43bhvzonNtI4a1LAohS6bqyirmk8sFfsWT3akb+4Sx
1sjc8Ovc9eqIWJCrUiSvv7+cS7ZTA9AgM1PxvHcsqrcUXiK3Jd/Dax9jdZE1e15s
BEhb2OEPE+tClFZ+soj8h8Pl2Clo5OAppEzYI4LmFKtp1X/BOf62k4jviXuCSst3
UnRJzE/CXtjmN6oZySVWSe0rQYuyqRl6//9nK40cfGKyxVnimB8XrrcxUN743Vud
QQVU0Esm8OVTX013mXWQXJHP2c0aKkog8LOga0vobQIDAQABo2YwZDAOBgNVHQ8B
Af8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQULmoOS1mFSjj+
snUPx4DgS3SkLFYwHwYDVR0jBBgwFoAUc19g2LzLA5j0Kxc0LjZapmD/vB8wDQYJ
KoZIhvcNAQELBQADggEBAAkVL2P1M2/G9GM3DANVAqYOwmX0Xk58YBHQu6iiQg4j
b4Ky/qsZIsgT7YBsZA4AOcPKQFgGTWhe9pvhmXqoN3RYltN8Vn7TbUm/ZVDoMsrM
gwv0+TKxW1/u7s8cXYfHPiTzVSJuOogHx99kBW6b2f99GbP7O1Sv3sLq4j6lVvBX
Fiacf5LAWC925nvlTzLlBgIc3O9xDtFeAGtZcEtxZJ4fnGXiqEnN4539+nqzIyYq
nvlgCzyvcfRAxwltrJHuuRu6Maw5AGcd2Y0saMhqOVq9KYKFKuD/927BTrbd2JVf
2sGWyuPZPCk3gq+5pCjbD0c6DkhcMGI6WwxvM5V/zSM=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEBzCCAu+gAwIBAgICJDQwDQYJKoZIhvcNAQELBQAwgY8xCzAJBgNVBAYTAlVT
MRAwDgYDVQQHDAdTZWF0dGxlMRMwEQYDVQQIDApXYXNoaW5ndG9uMSIwIAYDVQQK
DBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMuMRMwEQYDVQQLDApBbWF6b24gUkRT
MSAwHgYDVQQDDBdBbWF6b24gUkRTIFJvb3QgMjAxOSBDQTAeFw0xOTA5MTgxNzAz
MTVaFw0yNDA4MjIxNzA4NTBaMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECAwKV2Fz
aGluZ3RvbjEQMA4GA1UEBwwHU2VhdHRsZTEiMCAGA1UECgwZQW1hem9uIFdlYiBT
ZXJ2aWNlcywgSW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzElMCMGA1UEAwwcQW1h
em9uIFJEUyBldS13ZXN0LTMgMjAxOSBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEP
ADCCAQoCggEBAL9bL7KE0n02DLVtlZ2PL+g/BuHpMYFq2JnE2RgompGurDIZdjmh
1pxfL3nT+QIVMubuAOy8InRfkRxfpxyjKYdfLJTPJG+jDVL+wDcPpACFVqoV7Prg
pVYEV0lc5aoYw4bSeYFhdzgim6F8iyjoPnObjll9mo4XsHzSoqJLCd0QC+VG9Fw2
q+GDRZrLRmVM2oNGDRbGpGIFg77aRxRapFZa8SnUgs2AqzuzKiprVH5i0S0M6dWr
i+kk5epmTtkiDHceX+dP/0R1NcnkCPoQ9TglyXyPdUdTPPRfKCq12dftqll+u4mV
ARdN6WFjovxax8EAP2OAUTi1afY+1JFMj+sCAwEAAaNmMGQwDgYDVR0PAQH/BAQD
AgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFLfhrbrO5exkCVgxW0x3
Y2mAi8lNMB8GA1UdIwQYMBaAFHNfYNi8ywOY9CsXNC42WqZg/7wfMA0GCSqGSIb3
DQEBCwUAA4IBAQAigQ5VBNGyw+OZFXwxeJEAUYaXVoP/qrhTOJ6mCE2DXUVEoJeV
SxScy/TlFA9tJXqmit8JH8VQ/xDL4ubBfeMFAIAo4WzNWDVoeVMqphVEcDWBHsI1
AETWzfsapRS9yQekOMmxg63d/nV8xewIl8aNVTHdHYXMqhhik47VrmaVEok1UQb3
O971RadLXIEbVd9tjY5bMEHm89JsZDnDEw1hQXBb67Elu64OOxoKaHBgUH8AZn/2
zFsL1ynNUjOhCSAA15pgd1vjwc0YsBbAEBPcHBWYBEyME6NLNarjOzBl4FMtATSF
wWCKRGkvqN8oxYhwR2jf2rR5Mu4DWkK5Q8Ep
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEBzCCAu+gAwIBAgICJVUwDQYJKoZIhvcNAQELBQAwgY8xCzAJBgNVBAYTAlVT
MRAwDgYDVQQHDAdTZWF0dGxlMRMwEQYDVQQIDApXYXNoaW5ndG9uMSIwIAYDVQQK
DBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMuMRMwEQYDVQQLDApBbWF6b24gUkRT
MSAwHgYDVQQDDBdBbWF6b24gUkRTIFJvb3QgMjAxOSBDQTAeFw0xOTA5MTkxODE2
NTNaFw0yNDA4MjIxNzA4NTBaMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECAwKV2Fz
aGluZ3RvbjEQMA4GA1UEBwwHU2VhdHRsZTEiMCAGA1UECgwZQW1hem9uIFdlYiBT
ZXJ2aWNlcywgSW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzElMCMGA1UEAwwcQW1h
em9uIFJEUyB1cy1lYXN0LTEgMjAxOSBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEP
ADCCAQoCggEBAM3i/k2u6cqbMdcISGRvh+m+L0yaSIoOXjtpNEoIftAipTUYoMhL
InXGlQBVA4shkekxp1N7HXe1Y/iMaPEyb3n+16pf3vdjKl7kaSkIhjdUz3oVUEYt
i8Z/XeJJ9H2aEGuiZh3kHixQcZczn8cg3dA9aeeyLSEnTkl/npzLf//669Ammyhs
XcAo58yvT0D4E0D/EEHf2N7HRX7j/TlyWvw/39SW0usiCrHPKDLxByLojxLdHzso
QIp/S04m+eWn6rmD+uUiRteN1hI5ncQiA3wo4G37mHnUEKo6TtTUh+sd/ku6a8HK
glMBcgqudDI90s1OpuIAWmuWpY//8xEG2YECAwEAAaNmMGQwDgYDVR0PAQH/BAQD
AgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFPqhoWZcrVY9mU7tuemR
RBnQIj1jMB8GA1UdIwQYMBaAFHNfYNi8ywOY9CsXNC42WqZg/7wfMA0GCSqGSIb3
DQEBCwUAA4IBAQB6zOLZ+YINEs72heHIWlPZ8c6WY8MDU+Be5w1M+BK2kpcVhCUK
PJO4nMXpgamEX8DIiaO7emsunwJzMSvavSPRnxXXTKIc0i/g1EbiDjnYX9d85DkC
E1LaAUCmCZBVi9fIe0H2r9whIh4uLWZA41oMnJx/MOmo3XyMfQoWcqaSFlMqfZM4
0rNoB/tdHLNuV4eIdaw2mlHxdWDtF4oH+HFm+2cVBUVC1jXKrFv/euRVtsTT+A6i
h2XBHKxQ1Y4HgAn0jACP2QSPEmuoQEIa57bEKEcZsBR8SDY6ZdTd2HLRIApcCOSF
MRM8CKLeF658I0XgF8D5EsYoKPsA+74Z+jDH
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEETCCAvmgAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwgZQxCzAJBgNVBAYTAlVT
MRAwDgYDVQQHDAdTZWF0dGxlMRMwEQYDVQQIDApXYXNoaW5ndG9uMSIwIAYDVQQK
DBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMuMRMwEQYDVQQLDApBbWF6b24gUkRT
MSUwIwYDVQQDDBxBbWF6b24gUkRTIEJldGEgUm9vdCAyMDE5IENBMB4XDTE5MDgy
MDE3MTAwN1oXDTI0MDgxOTE3MzgyNlowgZkxCzAJBgNVBAYTAlVTMRMwEQYDVQQI
DApXYXNoaW5ndG9uMRAwDgYDVQQHDAdTZWF0dGxlMSIwIAYDVQQKDBlBbWF6b24g
V2ViIFNlcnZpY2VzLCBJbmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMSowKAYDVQQD
DCFBbWF6b24gUkRTIEJldGEgdXMtZWFzdC0xIDIwMTkgQ0EwggEiMA0GCSqGSIb3
DQEBAQUAA4IBDwAwggEKAoIBAQDTNCOlotQcLP8TP82U2+nk0bExVuuMVOgFeVMx
vbUHZQeIj9ikjk+jm6eTDnnkhoZcmJiJgRy+5Jt69QcRbb3y3SAU7VoHgtraVbxF
QDh7JEHI9tqEEVOA5OvRrDRcyeEYBoTDgh76ROco2lR+/9uCvGtHVrMCtG7BP7ZB
sSVNAr1IIRZZqKLv2skKT/7mzZR2ivcw9UeBBTUf8xsfiYVBvMGoEsXEycjYdf6w
WV+7XS7teNOc9UgsFNN+9AhIBc1jvee5E//72/4F8pAttAg/+mmPUyIKtekNJ4gj
OAR2VAzGx1ybzWPwIgOudZFHXFduxvq4f1hIRPH0KbQ/gkRrAgMBAAGjZjBkMA4G
A1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBTkvpCD
6C43rar9TtJoXr7q8dkrrjAfBgNVHSMEGDAWgBStoQwVpbGx87fxB3dEGDqKKnBT
4TANBgkqhkiG9w0BAQsFAAOCAQEAJd9fOSkwB3uVdsS+puj6gCER8jqmhd3g/J5V
Zjk9cKS8H0e8pq/tMxeJ8kpurPAzUk5RkCspGt2l0BSwmf3ahr8aJRviMX6AuW3/
g8aKplTvq/WMNGKLXONa3Sq8591J+ce8gtOX/1rDKmFI4wQ/gUzOSYiT991m7QKS
Fr6HMgFuz7RNJbb3Fy5cnurh8eYWA7mMv7laiLwTNsaro5qsqErD5uXuot6o9beT
a+GiKinEur35tNxAr47ax4IRubuIzyfCrezjfKc5raVV2NURJDyKP0m0CCaffAxE
qn2dNfYc3v1D8ypg3XjHlOzRo32RB04o8ALHMD9LSwsYDLpMag==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEFzCCAv+gAwIBAgICFSUwDQYJKoZIhvcNAQELBQAwgZcxCzAJBgNVBAYTAlVT
MRAwDgYDVQQHDAdTZWF0dGxlMRMwEQYDVQQIDApXYXNoaW5ndG9uMSIwIAYDVQQK
DBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMuMRMwEQYDVQQLDApBbWF6b24gUkRT
MSgwJgYDVQQDDB9BbWF6b24gUkRTIFByZXZpZXcgUm9vdCAyMDE5IENBMB4XDTE5
MDgyMTIyMzk0N1oXDTI0MDgyMTIyMjk0OVowgZwxCzAJBgNVBAYTAlVTMRMwEQYD
VQQIDApXYXNoaW5ndG9uMRAwDgYDVQQHDAdTZWF0dGxlMSIwIAYDVQQKDBlBbWF6
b24gV2ViIFNlcnZpY2VzLCBJbmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMS0wKwYD
VQQDDCRBbWF6b24gUkRTIFByZXZpZXcgdXMtZWFzdC0yIDIwMTkgQ0EwggEiMA0G
CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQD0dB/U7qRnSf05wOi7m10Pa2uPMTJv
r6U/3Y17a5prq5Zr4++CnSUYarG51YuIf355dKs+7Lpzs782PIwCmLpzAHKWzix6
pOaTQ+WZ0+vUMTxyqgqWbsBgSCyP7pVBiyqnmLC/L4az9XnscrbAX4pNaoJxsuQe
mzBo6yofjQaAzCX69DuqxFkVTRQnVy7LCFkVaZtjNAftnAHJjVgQw7lIhdGZp9q9
IafRt2gteihYfpn+EAQ/t/E4MnhrYs4CPLfS7BaYXBycEKC5Muj1l4GijNNQ0Efo
xG8LSZz7SNgUvfVwiNTaqfLP3AtEAWiqxyMyh3VO+1HpCjT7uNBFtmF3AgMBAAGj
ZjBkMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQW
BBQtinkdrj+0B2+qdXngV2tgHnPIujAfBgNVHSMEGDAWgBRp0xqULkNh/w2ZVzEI
o2RIY7O03TANBgkqhkiG9w0BAQsFAAOCAQEAtJdqbCxDeMc8VN1/RzCabw9BIL/z
73Auh8eFTww/sup26yn8NWUkfbckeDYr1BrXa+rPyLfHpg06kwR8rBKyrs5mHwJx
bvOzXD/5WTdgreB+2Fb7mXNvWhenYuji1MF+q1R2DXV3I05zWHteKX6Dajmx+Uuq
Yq78oaCBSV48hMxWlp8fm40ANCL1+gzQ122xweMFN09FmNYFhwuW+Ao+Vv90ZfQG
PYwTvN4n/gegw2TYcifGZC2PNX74q3DH03DXe5fvNgRW5plgz/7f+9mS+YHd5qa9
tYTPUvoRbi169ou6jicsMKUKPORHWhiTpSCWR1FMMIbsAcsyrvtIsuaGCQ==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIID/jCCAuagAwIBAgIQdOCSuA9psBpQd8EI368/0DANBgkqhkiG9w0BAQsFADCB
lzELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu
Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdB
bWF6b24gUkRTIHNhLWVhc3QtMSBSb290IENBIFJTQTIwNDggRzExEDAOBgNVBAcM
B1NlYXR0bGUwIBcNMjEwNTE5MTgwNjI2WhgPMjA2MTA1MTkxOTA2MjZaMIGXMQsw
CQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjET
MBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMDAuBgNVBAMMJ0FtYXpv
biBSRFMgc2EtZWFzdC0xIFJvb3QgQ0EgUlNBMjA0OCBHMTEQMA4GA1UEBwwHU2Vh
dHRsZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAN6ftL6w8v3dB2yW
LjCxSP1D7ZsOTeLZOSCz1Zv0Gkd0XLhil5MdHOHBvwH/DrXqFU2oGzCRuAy+aZis
DardJU6ChyIQIciXCO37f0K23edhtpXuruTLLwUwzeEPdcnLPCX+sWEn9Y5FPnVm
pCd6J8edH2IfSGoa9LdErkpuESXdidLym/w0tWG/O2By4TabkNSmpdrCL00cqI+c
prA8Bx1jX8/9sY0gpAovtuFaRN+Ivg3PAnWuhqiSYyQ5nC2qDparOWuDiOhpY56E
EgmTvjwqMMjNtExfYx6Rv2Ndu50TriiNKEZBzEtkekwXInTupmYTvc7U83P/959V
UiQ+WSMCAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU4uYHdH0+
bUeh81Eq2l5/RJbW+vswDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEBCwUAA4IB
AQBhxcExJ+w74bvDknrPZDRgTeMLYgbVJjx2ExH7/Ac5FZZWcpUpFwWMIJJxtewI
AnhryzM3tQYYd4CG9O+Iu0+h/VVfW7e4O3joWVkxNMb820kQSEwvZfA78aItGwOY
WSaFNVRyloVicZRNJSyb1UL9EiJ9ldhxm4LTT0ax+4ontI7zTx6n6h8Sr6r/UOvX
d9T5aUUENWeo6M9jGupHNn3BobtL7BZm2oS8wX8IVYj4tl0q5T89zDi2x0MxbsIV
5ZjwqBQ5JWKv7ASGPb+z286RjPA9R2knF4lJVZrYuNV90rHvI/ECyt/JrDqeljGL
BLl1W/UsvZo6ldLIpoMbbrb5
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEBDCCAuygAwIBAgIQUfVbqapkLYpUqcLajpTJWzANBgkqhkiG9w0BAQsFADCB
mjELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu
Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTMwMQYDVQQDDCpB
bWF6b24gUkRTIG1lLWNlbnRyYWwtMSBSb290IENBIFJTQTIwNDggRzExEDAOBgNV
BAcMB1NlYXR0bGUwIBcNMjIwNTA2MjMyMDA5WhgPMjA2MjA1MDcwMDIwMDlaMIGa
MQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5j
LjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMzAxBgNVBAMMKkFt
YXpvbiBSRFMgbWUtY2VudHJhbC0xIFJvb3QgQ0EgUlNBMjA0OCBHMTEQMA4GA1UE
BwwHU2VhdHRsZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJIeovu3
ewI9FVitXMQzvkh34aQ6WyI4NO3YepfJaePiv3cnyFGYHN2S1cR3UQcLWgypP5va
j6bfroqwGbCbZZcb+6cyOB4ceKO9Ws1UkcaGHnNDcy5gXR7aCW2OGTUfinUuhd2d
5bOGgV7JsPbpw0bwJ156+MwfOK40OLCWVbzy8B1kITs4RUPNa/ZJnvIbiMu9rdj4
8y7GSFJLnKCjlOFUkNI5LcaYvI1+ybuNgphT3nuu5ZirvTswGakGUT/Q0J3dxP0J
pDfg5Sj/2G4gXiaM0LppVOoU5yEwVewhQ250l0eQAqSrwPqAkdTg9ng360zqCFPE
JPPcgI1tdGUgneECAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU
/2AJVxWdZxc8eJgdpbwpW7b0f7IwDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEB
CwUAA4IBAQBYm63jTu2qYKJ94gKnqc+oUgqmb1mTXmgmp/lXDbxonjszJDOXFbri
3CCO7xB2sg9bd5YWY8sGKHaWmENj3FZpCmoefbUx++8D7Mny95Cz8R32rNcwsPTl
ebpd9A/Oaw5ug6M0x/cNr0qzF8Wk9Dx+nFEimp8RYQdKvLDfNFZHjPa1itnTiD8M
TorAqj+VwnUGHOYBsT/0NY12tnwXdD+ATWfpEHdOXV+kTMqFFwDyhfgRVNpTc+os
ygr8SwhnSCpJPB/EYl2S7r+tgAbJOkuwUvGT4pTqrzDQEhwE7swgepnHC87zhf6l
qN6mVpSnQKQLm6Ob5TeCEFgcyElsF5bH
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICrjCCAjSgAwIBAgIRAOxu0I1QuMAhIeszB3fJIlkwCgYIKoZIzj0EAwMwgZYx
CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu
MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEvMC0GA1UEAwwmQW1h
em9uIFJEUyB1cy13ZXN0LTIgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcMB1Nl
YXR0bGUwIBcNMjEwNTI0MjIwNjU5WhgPMjEyMTA1MjQyMzA2NTlaMIGWMQswCQYD
VQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjETMBEG
A1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExLzAtBgNVBAMMJkFtYXpvbiBS
RFMgdXMtd2VzdC0yIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQHDAdTZWF0dGxl
MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEz4bylRcGqqDWdP7gQIIoTHdBK6FNtKH1
4SkEIXRXkYDmRvL9Bci1MuGrwuvrka5TDj4b7e+csY0llEzHpKfq6nJPFljoYYP9
uqHFkv77nOpJJ633KOr8IxmeHW5RXgrZo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0G
A1UdDgQWBBQQikVz8wmjd9eDFRXzBIU8OseiGzAOBgNVHQ8BAf8EBAMCAYYwCgYI
KoZIzj0EAwMDaAAwZQIwf06Mcrpw1O0EBLBBrp84m37NYtOkE/0Z0O+C7D41wnXi
EQdn6PXUVgdD23Gj82SrAjEAklhKs+liO1PtN15yeZR1Io98nFve+lLptaLakZcH
+hfFuUtCqMbaI8CdvJlKnPqT
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIGCTCCA/GgAwIBAgIRALyWMTyCebLZOGcZZQmkmfcwDQYJKoZIhvcNAQEMBQAw
gZwxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE1MDMGA1UEAwws
QW1hem9uIFJEUyBhcC1ub3J0aGVhc3QtMyBSb290IENBIFJTQTQwOTYgRzExEDAO
BgNVBAcMB1NlYXR0bGUwIBcNMjEwNTI0MjAyODAzWhgPMjEyMTA1MjQyMTI4MDNa
MIGcMQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywg
SW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExNTAzBgNVBAMM
LEFtYXpvbiBSRFMgYXAtbm9ydGhlYXN0LTMgUm9vdCBDQSBSU0E0MDk2IEcxMRAw
DgYDVQQHDAdTZWF0dGxlMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA
wGFiyDyCrGqgdn4fXG12cxKAAfVvhMea1mw5h9CVRoavkPqhzQpAitSOuMB9DeiP
wQyqcsiGl/cTEau4L+AUBG8b9v26RlY48exUYBXj8CieYntOT9iNw5WtdYJa3kF/
JxgI+HDMzE9cmHDs5DOO3S0uwZVyra/xE1ymfSlpOeUIOTpHRJv97CBUEpaZMUW5
Sr6GruuOwFVpO5FX3A/jQlcS+UN4GjSRgDUJuqg6RRQldEZGCVCCmodbByvI2fGm
reGpsPJD54KkmAX08nOR8e5hkGoHxq0m2DLD4SrOFmt65vG47qnuwplWJjtk9B3Z
9wDoopwZLBOtlkPIkUllWm1P8EuHC1IKOA+wSP6XdT7cy8S77wgyHzR0ynxv7q/l
vlZtH30wnNqFI0y9FeogD0TGMCHcnGqfBSicJXPy9T4fU6f0r1HwqKwPp2GArwe7
dnqLTj2D7M9MyVtFjEs6gfGWXmu1y5uDrf+CszurE8Cycoma+OfjjuVQgWOCy7Nd
jJswPxAroTzVfpgoxXza4ShUY10woZu0/J+HmNmqK7lh4NS75q1tz75in8uTZDkV
be7GK+SEusTrRgcf3tlgPjSTWG3veNzFDF2Vn1GLJXmuZfhdlVQDBNXW4MNREExS
dG57kJjICpT+r8X+si+5j51gRzkSnMYs7VHulpxfcwECAwEAAaNCMEAwDwYDVR0T
AQH/BAUwAwEB/zAdBgNVHQ4EFgQU4JWOpDBmUBuWKvGPZelw87ezhL8wDgYDVR0P
AQH/BAQDAgGGMA0GCSqGSIb3DQEBDAUAA4ICAQBRNLMql7itvXSEFQRAnyOjivHz
l5IlWVQjAbOUr6ogZcwvK6YpxNAFW5zQr8F+fdkiypLz1kk5irx9TIpff0BWC9hQ
/odMPO8Gxn8+COlSvc+dLsF2Dax3Hvz0zLeKMo+cYisJOzpdR/eKd0/AmFdkvQoM
AOK9n0yYvVJU2IrSgeJBiiCarpKSeAktEVQ4rvyacQGr+QAPkkjRwm+5LHZKK43W
nNnggRli9N/27qYtc5bgr3AaQEhEXMI4RxPRXCLsod0ehMGWyRRK728a+6PMMJAJ
WHOU0x7LCEMPP/bvpLj3BdvSGqNor4ZtyXEbwREry1uzsgODeRRns5acPwTM6ff+
CmxO2NZ0OktIUSYRmf6H/ZFlZrIhV8uWaIwEJDz71qvj7buhQ+RFDZ9CNL64C0X6
mf0zJGEpddjANHaaVky+F4gYMtEy2K2Lcm4JGTdyIzUoIe+atzCnRp0QeIcuWtF+
s8AjDYCVFNypcMmqbRmNpITSnOoCHSRuVkY3gutVoYyMLbp8Jm9SJnCIlEWTA6Rm
wADOMGZJVn5/XRTRuetVOB3KlQDjs9OO01XN5NzGSZO2KT9ngAUfh9Eqhf1iRWSP
nZlRbQ2NRCuY/oJ5N59mLGxnNJSE7giEKEBRhTQ/XEPIUYAUPD5fca0arKRJwbol
l9Se1Hsq0ZU5f+OZKQ==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIGATCCA+mgAwIBAgIRAK7vlRrGVEePJpW1VHMXdlIwDQYJKoZIhvcNAQEMBQAw
gZgxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTExMC8GA1UEAwwo
QW1hem9uIFJEUyBhZi1zb3V0aC0xIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4GA1UE
BwwHU2VhdHRsZTAgFw0yMTA1MTkxOTI4NDNaGA8yMTIxMDUxOTIwMjg0M1owgZgx
CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu
MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTExMC8GA1UEAwwoQW1h
em9uIFJEUyBhZi1zb3V0aC0xIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4GA1UEBwwH
U2VhdHRsZTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMZiHOQC6x4o
eC7vVOMCGiN5EuLqPYHdceFPm4h5k/ZejXTf7kryk6aoKZKsDIYihkaZwXVS7Y/y
7Ig1F1ABi2jD+CYprj7WxXbhpysmN+CKG7YC3uE4jSvfvUnpzionkQbjJsRJcrPO
cZJM4FVaVp3mlHHtvnM+K3T+ni4a38nAd8xrv1na4+B8ZzZwWZXarfg8lJoGskSn
ou+3rbGQ0r+XlUP03zWujHoNlVK85qUIQvDfTB7n3O4s1XNGvkfv3GNBhYRWJYlB
4p8T+PFN8wG+UOByp1gV7BD64RnpuZ8V3dRAlO6YVAmINyG5UGrPzkIbLtErUNHO
4iSp4UqYvztDqJWWHR/rA84ef+I9RVwwZ8FQbjKq96OTnPrsr63A5mXTC9dXKtbw
XNJPQY//FEdyM3K8sqM0IdCzxCA1MXZ8+QapWVjwyTjUwFvL69HYky9H8eAER59K
5I7u/CWWeCy2R1SYUBINc3xxLr0CGGukcWPEZW2aPo5ibW5kepU1P/pzdMTaTfao
F42jSFXbc7gplLcSqUgWwzBnn35HLTbiZOFBPKf6vRRu8aRX9atgHw/EjCebi2xP
xIYr5Ub8u0QVHIqcnF1/hVzO/Xz0chj3E6VF/yTXnsakm+W1aM2QkZbFGpga+LMy
mFCtdPrELjea2CfxgibaJX1Q4rdEpc8DAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMB
Af8wHQYDVR0OBBYEFDSaycEyuspo/NOuzlzblui8KotFMA4GA1UdDwEB/wQEAwIB
hjANBgkqhkiG9w0BAQwFAAOCAgEAbosemjeTRsL9o4v0KadBUNS3V7gdAH+X4vH2
Ee1Jc91VOGLdd/s1L9UX6bhe37b9WjUD69ur657wDW0RzxMYgQdZ27SUl0tEgGGp
cCmVs1ky3zEN+Hwnhkz+OTmIg1ufq0W2hJgJiluAx2r1ib1GB+YI3Mo3rXSaBYUk
bgQuujYPctf0PA153RkeICE5GI3OaJ7u6j0caYEixBS3PDHt2MJWexITvXGwHWwc
CcrC05RIrTUNOJaetQw8smVKYOfRImEzLLPZ5kf/H3Cbj8BNAFNsa10wgvlPuGOW
XLXqzNXzrG4V3sjQU5YtisDMagwYaN3a6bBf1wFwFIHQoAPIgt8q5zaQ9WI+SBns
Il6rd4zfvjq/BPmt0uI7rVg/cgbaEg/JDL2neuM9CJAzmKxYxLQuHSX2i3Fy4Y1B
cnxnRQETCRZNPGd00ADyxPKVoYBC45/t+yVusArFt+2SVLEGiFBr23eG2CEZu+HS
nDEgIfQ4V3YOTUNa86wvbAss1gbbnT/v1XCnNGClEWCWNCSRjwV2ZmQ/IVTmNHPo
7axTTBBJbKJbKzFndCnuxnDXyytdYRgFU7Ly3sa27WS2KFyFEDebLFRHQEfoYqCu
IupSqBSbXsR3U10OTjc9z6EPo1nuV6bdz+gEDthmxKa1NI+Qb1kvyliXQHL2lfhr
5zT5+Bs=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIF/zCCA+egAwIBAgIRAOLV6zZcL4IV2xmEneN1GwswDQYJKoZIhvcNAQEMBQAw
gZcxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEwMC4GA1UEAwwn
QW1hem9uIFJEUyB1cy13ZXN0LTEgUm9vdCBDQSBSU0E0MDk2IEcxMRAwDgYDVQQH
DAdTZWF0dGxlMCAXDTIxMDUxOTE5MDg1OFoYDzIxMjEwNTE5MjAwODU4WjCBlzEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdBbWF6
b24gUkRTIHVzLXdlc3QtMSBSb290IENBIFJTQTQwOTYgRzExEDAOBgNVBAcMB1Nl
YXR0bGUwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC7koAKGXXlLixN
fVjhuqvz0WxDeTQfhthPK60ekRpftkfE5QtnYGzeovaUAiS58MYVzqnnTACDwcJs
IGTFE6Wd7sB6r8eI/3CwI1pyJfxepubiQNVAQG0zJETOVkoYKe/5KnteKtnEER3X
tCBRdV/rfbxEDG9ZAsYfMl6zzhEWKF88G6xhs2+VZpDqwJNNALvQuzmTx8BNbl5W
RUWGq9CQ9GK9GPF570YPCuURW7kl35skofudE9bhURNz51pNoNtk2Z3aEeRx3ouT
ifFJlzh+xGJRHqBG7nt5NhX8xbg+vw4xHCeq1aAe6aVFJ3Uf9E2HzLB4SfIT9bRp
P7c9c0ySGt+3n+KLSHFf/iQ3E4nft75JdPjeSt0dnyChi1sEKDi0tnWGiXaIg+J+
r1ZtcHiyYpCB7l29QYMAdD0TjfDwwPayLmq//c20cPmnSzw271VwqjUT0jYdrNAm
gV+JfW9t4ixtE3xF2jaUh/NzL3bAmN5v8+9k/aqPXlU1BgE3uPwMCjrfn7V0I7I1
WLpHyd9jF3U/Ysci6H6i8YKgaPiOfySimQiDu1idmPld659qerutUSemQWmPD3bE
dcjZolmzS9U0Ujq/jDF1YayN3G3xvry1qWkTci0qMRMu2dZu30Herugh9vsdTYkf
00EqngPbqtIVLDrDjEQLqPcb8QvWFQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/
MB0GA1UdDgQWBBQBqg8Za/L0YMHURGExHfvPyfLbOTAOBgNVHQ8BAf8EBAMCAYYw
DQYJKoZIhvcNAQEMBQADggIBACAGPMa1QL7P/FIO7jEtMelJ0hQlQepKnGtbKz4r
Xq1bUX1jnLvnAieR9KZmeQVuKi3g3CDU6b0mDgygS+FL1KDDcGRCSPh238Ou8KcG
HIxtt3CMwMHMa9gmdcMlR5fJF9vhR0C56KM2zvyelUY51B/HJqHwGvWuexryXUKa
wq1/iK2/d9mNeOcjDvEIj0RCMI8dFQCJv3PRCTC36XS36Tzr6F47TcTw1c3mgKcs
xpcwt7ezrXMUunzHS4qWAA5OGdzhYlcv+P5GW7iAA7TDNrBF+3W4a/6s9v2nQAnX
UvXd9ul0ob71377UhZbJ6SOMY56+I9cJOOfF5QvaL83Sz29Ij1EKYw/s8TYdVqAq
+dCyQZBkMSnDFLVe3J1KH2SUSfm3O98jdPORQrUlORQVYCHPls19l2F6lCmU7ICK
hRt8EVSpXm4sAIA7zcnR2nU00UH8YmMQLnx5ok9YGhuh3Ehk6QlTQLJux6LYLskd
9YHOLGW/t6knVtV78DgPqDeEx/Wu/5A8R0q7HunpWxr8LCPBK6hksZnOoUhhb8IP
vl46Ve5Tv/FlkyYr1RTVjETmg7lb16a8J0At14iLtpZWmwmuv4agss/1iBVMXfFk
+ZGtx5vytWU5XJmsfKA51KLsMQnhrLxb3X3zC+JRCyJoyc8++F3YEcRi2pkRYE3q
Hing
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIID/zCCAuegAwIBAgIRAI+asxQA/MB1cGyyrC0MPpkwDQYJKoZIhvcNAQELBQAw
gZcxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEwMC4GA1UEAwwn
QW1hem9uIFJEUyBjYS13ZXN0LTEgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYDVQQH
DAdTZWF0dGxlMCAXDTIzMDkxMzIwMjEzNFoYDzIwNjMwOTEzMjEyMTMzWjCBlzEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdBbWF6
b24gUkRTIGNhLXdlc3QtMSBSb290IENBIFJTQTIwNDggRzExEDAOBgNVBAcMB1Nl
YXR0bGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDMHvQITTZcfl2O
yfzRIAPKwzzlc8eXWdXef7VUsbezg3lm9RC+vArO4JuAzta/aLw1D94wPSRm9JXX
NkP3obO6Ql80/0doooU6BAPceD0xmEWC4aCFT/5KWsD6Sy2/Rjwq3NKBTwzxLwYK
GqVsBp8AdrzDTmdRETC+Dg2czEo32mTDAA1uMgqrz6xxeTYroj8NTSTp6jfE6C0n
YgzYmVQCEIjHqI49j7k3jfT3P2skCVKGJwQzoZnerFacKzXsDB18uIqU7NaMc2cX
kOd0gRqpyKOzAHU2m5/S4jw4UHdkoI3E7nkayuen8ZPKH2YqWtTXUrXGhSTT34nX
yiFgu+vTAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFHzz1NTd
TOm9zAv4d8l6XCFKSdJfMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsFAAOC
AQEAodBvd0cvXQYhFBef2evnuI9XA+AC/Q9P1nYtbp5MPA4aFhy5v9rjW8wwJX14
l+ltd2o3tz8PFDBZ1NX2ooiWVlZthQxKn1/xDVKsTXHbYUXItPQ3jI5IscB5IML8
oCzAbkoLXsSPNOVFP5P4l4cZEMqHGRnBag7hLJZvmvzZSBnz+ioC2jpjVluF8kDX
fQGNjqPECik68CqbSV0SaQ0cgEoYTDjwON5ZLBeS8sxR2abE/gsj4VFYl5w/uEBd
w3Tt9uGfIy+wd2tNj6isGC6PcbPMjA31jd+ifs2yNzigqkcYTTWFtnvh4a8xiecm
GHu2EgH0Jqzz500N7L3uQdPkdg==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIECTCCAvGgAwIBAgIRANxgyBbnxgTEOpDul2ZnC0UwDQYJKoZIhvcNAQELBQAw
gZwxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE1MDMGA1UEAwws
QW1hem9uIFJEUyBhcC1zb3V0aGVhc3QtMyBSb290IENBIFJTQTIwNDggRzExEDAO
BgNVBAcMB1NlYXR0bGUwIBcNMjEwNjEwMTgxOTA3WhgPMjA2MTA2MTAxOTE5MDda
MIGcMQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywg
SW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExNTAzBgNVBAMM
LEFtYXpvbiBSRFMgYXAtc291dGhlYXN0LTMgUm9vdCBDQSBSU0EyMDQ4IEcxMRAw
DgYDVQQHDAdTZWF0dGxlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA
xnwSDAChrMkfk5TA4Dk8hKzStDlSlONzmd3fTG0Wqr5+x3EmFT6Ksiu/WIwEl9J2
K98UI7vYyuZfCxUKb1iMPeBdVGqk0zb92GpURd+Iz/+K1ps9ZLeGBkzR8mBmAi1S
OfpwKiTBzIv6E8twhEn4IUpHsdcuX/2Y78uESpJyM8O5CpkG0JaV9FNEbDkJeBUQ
Ao2qqNcH4R0Qcr5pyeqA9Zto1RswgL06BQMI9dTpfwSP5VvkvcNUaLl7Zv5WzLQE
JzORWePvdPzzvWEkY/3FPjxBypuYwssKaERW0fkPDmPtykktP9W/oJolKUFI6pXp
y+Y6p6/AVdnQD2zZjW5FhQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1Ud
DgQWBBT+jEKs96LC+/X4BZkUYUkzPfXdqTAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZI
hvcNAQELBQADggEBAIGQqgqcQ6XSGkmNebzR6DhadTbfDmbYeN5N0Vuzv+Tdmufb
tMGjdjnYMg4B+IVnTKQb+Ox3pL9gbX6KglGK8HupobmIRtwKVth+gYYz3m0SL/Nk
haWPYzOm0x3tJm8jSdufJcEob4/ATce9JwseLl76pSWdl5A4lLjnhPPKudUDfH+1
BLNUi3lxpp6GkC8aWUPtupnhZuXddolTLOuA3GwTZySI44NfaFRm+o83N1jp+EwD
6e94M4cTRzjUv6J3MZmSbdtQP/Tk1uz2K4bQZGP0PZC3bVpqiesdE/xr+wbu8uHr
cM1JXH0AmXf1yIkTgyWzmvt0k1/vgcw5ixAqvvE=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEATCCAumgAwIBAgIRAMhw98EQU18mIji+unM2YH8wDQYJKoZIhvcNAQELBQAw
gZgxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTExMC8GA1UEAwwo
QW1hem9uIFJEUyBhcC1zb3V0aC0yIFJvb3QgQ0EgUlNBMjA0OCBHMTEQMA4GA1UE
BwwHU2VhdHRsZTAgFw0yMjA2MDYyMTQyMjJaGA8yMDYyMDYwNjIyNDIyMlowgZgx
CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu
MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTExMC8GA1UEAwwoQW1h
em9uIFJEUyBhcC1zb3V0aC0yIFJvb3QgQ0EgUlNBMjA0OCBHMTEQMA4GA1UEBwwH
U2VhdHRsZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAIeeRoLfTm+7
vqm7ZlFSx+1/CGYHyYrOOryM4/Z3dqYVHFMgWTR7V3ziO8RZ6yUanrRcWVX3PZbF
AfX0KFE8OgLsXEZIX8odSrq86+/Th5eZOchB2fDBsUB7GuN2rvFBbM8lTI9ivVOU
lbuTnYyb55nOXN7TpmH2bK+z5c1y9RVC5iQsNAl6IJNvSN8VCqXh31eK5MlKB4DT
+Y3OivCrSGsjM+UR59uZmwuFB1h+icE+U0p9Ct3Mjq3MzSX5tQb6ElTNGlfmyGpW
Kh7GQ5XU1KaKNZXoJ37H53woNSlq56bpVrKI4uv7ATpdpFubOnSLtpsKlpLdR3sy
Ws245200pC8CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUp0ki
6+eWvsnBjQhMxwMW5pwn7DgwDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEBCwUA
A4IBAQB2V8lv0aqbYQpj/bmVv/83QfE4vOxKCJAHv7DQ35cJsTyBdF+8pBczzi3t
3VNL5IUgW6WkyuUOWnE0eqAFOUVj0yTS1jSAtfl3vOOzGJZmWBbqm9BKEdu1D8O6
sB8bnomwiab2tNDHPmUslpdDqdabbkWwNWzLJ97oGFZ7KNODMEPXWKWNxg33iHfS
/nlmnrTVI3XgaNK9qLZiUrxu9Yz5gxi/1K+sG9/Dajd32ZxjRwDipOLiZbiXQrsd
qzIMY4GcWf3g1gHL5mCTfk7dG22h/rhPyGV0svaDnsb+hOt6sv1McMN6Y3Ou0mtM
/UaAXojREmJmTSCNvs2aBny3/2sy
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICrjCCAjSgAwIBAgIRAMnRxsKLYscJV8Qv5pWbL7swCgYIKoZIzj0EAwMwgZYx
CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu
MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEvMC0GA1UEAwwmQW1h
em9uIFJEUyBzYS1lYXN0LTEgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcMB1Nl
YXR0bGUwIBcNMjEwNTE5MTgxNjAxWhgPMjEyMTA1MTkxOTE2MDFaMIGWMQswCQYD
VQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjETMBEG
A1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExLzAtBgNVBAMMJkFtYXpvbiBS
RFMgc2EtZWFzdC0xIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQHDAdTZWF0dGxl
MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEjFOCZgTNVKxLKhUxffiDEvTLFhrmIqdO
dKqVdgDoELEzIHWDdC+19aDPitbCYtBVHl65ITu/9pn6mMUl5hhUNtfZuc6A+Iw1
sBe0v0qI3y9Q9HdQYrGgeHDh8M5P7E2ho0IwQDAPBgNVHRMBAf8EBTADAQH/MB0G
A1UdDgQWBBS5L7/8M0TzoBZk39Ps7BkfTB4yJTAOBgNVHQ8BAf8EBAMCAYYwCgYI
KoZIzj0EAwMDaAAwZQIwI43O0NtWKTgnVv9z0LO5UMZYgSve7GvGTwqktZYCMObE
rUI4QerXM9D6JwLy09mqAjEAypfkdLyVWtaElVDUyHFkihAS1I1oUxaaDrynLNQK
Ou/Ay+ns+J+GyvyDUjBpVVW1
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIF/jCCA+agAwIBAgIQR71Z8lTO5Sj+as2jB7IWXzANBgkqhkiG9w0BAQwFADCB
lzELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu
Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdB
bWF6b24gUkRTIHVzLXdlc3QtMiBSb290IENBIFJTQTQwOTYgRzExEDAOBgNVBAcM
B1NlYXR0bGUwIBcNMjEwNTI0MjIwMzIwWhgPMjEyMTA1MjQyMzAzMjBaMIGXMQsw
CQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjET
MBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMDAuBgNVBAMMJ0FtYXpv
biBSRFMgdXMtd2VzdC0yIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4GA1UEBwwHU2Vh
dHRsZTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAM977bHIs1WJijrS
XQMfUOhmlJjr2v0K0UjPl52sE1TJ76H8umo1yR4T7Whkd9IwBHNGKXCJtJmMr9zp
fB38eLTu+5ydUAXdFuZpRMKBWwPVe37AdJRKqn5beS8HQjd3JXAgGKUNNuE92iqF
qi2fIqFMpnJXWo0FIW6s2Dl2zkORd7tH0DygcRi7lgVxCsw1BJQhFJon3y+IV8/F
bnbUXSNSDUnDW2EhvWSD8L+t4eiXYsozhDAzhBvojpxhPH9OB7vqFYw5qxFx+G0t
lSLX5iWi1jzzc3XyGnB6WInZDVbvnvJ4BGZ+dTRpOCvsoMIn9bz4EQTvu243c7aU
HbS/kvnCASNt+zk7C6lbmaq0AGNztwNj85Opn2enFciWZVnnJ/4OeefUWQxD0EPp
SjEd9Cn2IHzkBZrHCg+lWZJQBKbUVS0lLIMSsLQQ6WvR38jY7D2nxM1A93xWxwpt
ZtQnYRCVXH6zt2OwDAFePInWwxUjR5t/wu3XxPgpSfrmTi3WYtr1wFypAJ811e/P
yBtswWUQ6BNJQvy+KnOEeGfOwmtdDFYR+GOCfvCihzrKJrxOtHIieehR5Iw3cbXG
sm4pDzfMUVvDDz6C2M6PRlJhhClbatHCjik9hxFYEsAlqtVVK9pxaz9i8hOqSFQq
kJSQsgWw+oM/B2CyjcSqkSQEu8RLAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8w
HQYDVR0OBBYEFPmrdxpRRgu3IcaB5BTqlprcKdTsMA4GA1UdDwEB/wQEAwIBhjAN
BgkqhkiG9w0BAQwFAAOCAgEAVdlxWjPvVKky3kn8ZizeM4D+EsLw9dWLau2UD/ls
zwDCFoT6euagVeCknrn+YEl7g20CRYT9iaonGoMUPuMR/cdtPL1W/Rf40PSrGf9q
QuxavWiHLEXOQTCtCaVZMokkvjuuLNDXyZnstgECuiZECTwhexUF4oiuhyGk9o01
QMaiz4HX4lgk0ozALUvEzaNd9gWEwD2qe+rq9cQMTVq3IArUkvTIftZUaVUMzr0O
ed1+zAsNa9nJhURJ/6anJPJjbQgb5qA1asFcp9UaMT1ku36U3gnR1T/BdgG2jX3X
Um0UcaGNVPrH1ukInWW743pxWQb7/2sumEEMVh+jWbB18SAyLI4WIh4lkurdifzS
IuTFp8TEx+MouISFhz/vJDWZ84tqoLVjkEcP6oDypq9lFoEzHDJv3V1CYcIgOusT
k1jm9P7BXdTG7TYzUaTb9USb6bkqkD9EwJAOSs7DI94aE6rsSws2yAHavjAMfuMZ
sDAZvkqS2Qg2Z2+CI6wUZn7mzkJXbZoqRjDvChDXEB1mIhzVXhiNW/CR5WKVDvlj
9v1sdGByh2pbxcLQtVaq/5coM4ANgphoNz3pOYUPWHS+JUrIivBZ+JobjXcxr3SN
9iDzcu5/FVVNbq7+KN/nvPMngT+gduEN5m+EBjm8GukJymFG0m6BENRA0QSDqZ7k
zDY=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIECTCCAvGgAwIBAgIRAK5EYG3iHserxMqgg+0EFjgwDQYJKoZIhvcNAQELBQAw
gZwxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE1MDMGA1UEAwws
QW1hem9uIFJEUyBhcC1ub3J0aGVhc3QtMyBSb290IENBIFJTQTIwNDggRzExEDAO
BgNVBAcMB1NlYXR0bGUwIBcNMjEwNTI0MjAyMzE2WhgPMjA2MTA1MjQyMTIzMTZa
MIGcMQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywg
SW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExNTAzBgNVBAMM
LEFtYXpvbiBSRFMgYXAtbm9ydGhlYXN0LTMgUm9vdCBDQSBSU0EyMDQ4IEcxMRAw
DgYDVQQHDAdTZWF0dGxlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA
s1L6TtB84LGraLHVC+rGPhLBW2P0oN/91Rq3AnYwqDOuTom7agANwEjvLq7dSRG/
sIfZsSV/ABTgArZ5sCmLjHFZAo8Kd45yA9byx20RcYtAG8IZl+q1Cri+s0XefzyO
U6mlfXZkVe6lzjlfXBkrlE/+5ifVbJK4dqOS1t9cWIpgKqv5fbE6Qbq4LVT+5/WM
Vd2BOljuBMGMzdZubqFKFq4mzTuIYfnBm7SmHlZfTdfBYPP1ScNuhpjuzw4n3NCR
EdU6dQv04Q6th4r7eiOCwbWI9LkmVbvBe3ylhH63lApC7MiiPYLlB13xBubVHVhV
q1NHoNTi+zA3MN9HWicRxQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1Ud
DgQWBBSuxoqm0/wjNiZLvqv+JlQwsDvTPDAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZI
hvcNAQELBQADggEBAFfTK/j5kv90uIbM8VaFdVbr/6weKTwehafT0pAk1bfLVX+7
uf8oHgYiyKTTl0DFQicXejghXTeyzwoEkWSR8c6XkhD5vYG3oESqmt/RGvvoxz11
rHHy7yHYu7RIUc3VQG60c4qxXv/1mWySGwVwJrnuyNT9KZXPevu3jVaWOVHEILaK
HvzQ2YEcWBPmde/zEseO2QeeGF8FL45Q1d66wqIP4nNUd2pCjeTS5SpB0MMx7yi9
ki1OH1pv8tOuIdimtZ7wkdB8+JSZoaJ81b8sRrydRwJyvB88rftuI3YB4WwGuONT
ZezUPsmaoK69B0RChB0ofDpAaviF9V3xOWvVZfo=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIGDzCCA/egAwIBAgIRAI0sMNG2XhaBMRN3zD7ZyoEwDQYJKoZIhvcNAQEMBQAw
gZ8xCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE4MDYGA1UEAwwv
QW1hem9uIFJEUyBQcmV2aWV3IHVzLWVhc3QtMiBSb290IENBIFJTQTQwOTYgRzEx
EDAOBgNVBAcMB1NlYXR0bGUwIBcNMjEwNTE4MjA1NzUwWhgPMjEyMTA1MTgyMTU3
NTBaMIGfMQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNl
cywgSW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExODA2BgNV
BAMML0FtYXpvbiBSRFMgUHJldmlldyB1cy1lYXN0LTIgUm9vdCBDQSBSU0E0MDk2
IEcxMRAwDgYDVQQHDAdTZWF0dGxlMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIIC
CgKCAgEAh/otSiCu4Uw3hu7OJm0PKgLsLRqBmUS6jihcrkxfN2SHmp2zuRflkweU
BhMkebzL+xnNvC8okzbgPWtUxSmDnIRhE8J7bvSKFlqs/tmEdiI/LMqe/YIKcdsI
20UYmvyLIjtDaJIh598SHHlF9P8DB5jD8snJfhxWY+9AZRN+YVTltgQAAgayxkWp
M1BbvxpOnz4CC00rE0eqkguXIUSuobb1vKqdKIenlYBNxm2AmtgvQfpsBIQ0SB+8
8Zip8Ef5rtjSw5J3s2Rq0aYvZPfCVIsKYepIboVwXtD7E9J31UkB5onLBQlaHaA6
XlH4srsMmrew5d2XejQGy/lGZ1nVWNsKO0x/Az2QzY5Kjd6AlXZ8kq6H68hscA5i
OMbNlXzeEQsZH0YkId3+UsEns35AAjZv4qfFoLOu8vDotWhgVNT5DfdbIWZW3ZL8
qbmra3JnCHuaTwXMnc25QeKgVq7/rG00YB69tCIDwcf1P+tFJWxvaGtV0g2NthtB
a+Xo09eC0L53gfZZ3hZw1pa3SIF5dIZ6RFRUQ+lFOux3Q/I3u+rYstYw7Zxc4Zeo
Y8JiedpQXEAnbw2ECHix/L6mVWgiWCiDzBnNLLdbmXjJRnafNSndSfFtHCnY1SiP
aCrNpzwZIJejoV1zDlWAMO+gyS28EqzuIq3WJK/TFE7acHkdKIcCAwEAAaNCMEAw
DwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUrmV1YASnuudfmqAZP4sKGTvScaEw
DgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEBDAUAA4ICAQBGpEKeQoPvE85tN/25
qHFkys9oHDl93DZ62EnOqAUKLd6v0JpCyEiop4nlrJe+4KrBYVBPyKOJDcIqE2Sp
3cvgJXLhY4i46VM3Qxe8yuYF1ElqBpg3jJVj/sCQnYz9dwoAMWIJFaDWOvmU2E7M
MRaKx+sPXFkIjiDA6Bv0m+VHef7aedSYIY7IDltEQHuXoqNacGrYo3I50R+fZs88
/mB3e/V7967e99D6565yf9Lcjw4oQf2Hy7kl/6P9AuMz0LODnGITwh2TKk/Zo3RU
Vgq25RDrT4xJK6nFHyjUF6+4cOBxVpimmFw/VP1zaXT8DN5r4HyJ9p4YuSK8ha5N
2pJc/exvU8Nv2+vS/efcDZWyuEdZ7eh1IJWQZlOZKIAONfRDRTpeQHJ3zzv3QVYy
t78pYp/eWBHyVIfEE8p2lFKD4279WYe+Uvdb8c4Jm4TJwqkSJV8ifID7Ub80Lsir
lPAU3OCVTBeVRFPXT2zpC4PB4W6KBSuj6OOcEu2y/HgWcoi7Cnjvp0vFTUhDFdus
Wz3ucmJjfVsrkEO6avDKu4SwdbVHsk30TVAwPd6srIdi9U6MOeOQSOSE4EsrrS7l
SVmu2QIDUVFpm8QAHYplkyWIyGkupyl3ashH9mokQhixIU/Pzir0byePxHLHrwLu
1axqeKpI0F5SBUPsaVNYY2uNFg==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIECDCCAvCgAwIBAgIQCREfzzVyDTMcNME+gWnTCTANBgkqhkiG9w0BAQsFADCB
nDELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu
Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTUwMwYDVQQDDCxB
bWF6b24gUkRTIGFwLXNvdXRoZWFzdC0yIFJvb3QgQ0EgUlNBMjA0OCBHMTEQMA4G
A1UEBwwHU2VhdHRsZTAgFw0yMTA1MjQyMDQyMzNaGA8yMDYxMDUyNDIxNDIzM1ow
gZwxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE1MDMGA1UEAwws
QW1hem9uIFJEUyBhcC1zb3V0aGVhc3QtMiBSb290IENBIFJTQTIwNDggRzExEDAO
BgNVBAcMB1NlYXR0bGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDL
1MT6br3L/4Pq87DPXtcjlXN3cnbNk2YqRAZHJayStTz8VtsFcGPJOpk14geRVeVk
e9uKFHRbcyr/RM4owrJTj5X4qcEuATYZbo6ou/rW2kYzuWFZpFp7lqm0vasV4Z9F
fChlhwkNks0UbM3G+psCSMNSoF19ERunj7w2c4E62LwujkeYLvKGNepjnaH10TJL
2krpERd+ZQ4jIpObtRcMH++bTrvklc+ei8W9lqrVOJL+89v2piN3Ecdd389uphst
qQdb1BBVXbhUrtuGHgVf7zKqN1SkCoktoWxVuOprVWhSvr7akaWeq0UmlvbEsujU
vADqxGMcJFyCzxx3CkJjAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0O
BBYEFFk8UJmlhoxFT3PP12PvhvazHjT4MA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG
9w0BAQsFAAOCAQEAfFtr2lGoWVXmWAsIo2NYre7kzL8Xb9Tx7desKxCCz5HOOvIr
8JMB1YK6A7IOvQsLJQ/f1UnKRh3X3mJZjKIywfrMSh0FiDf+rjcEzXxw2dGtUem4
A+WMvIA3jwxnJ90OQj5rQ8bg3iPtE6eojzo9vWQGw/Vu48Dtw1DJo9210Lq/6hze
hPhNkFh8fMXNT7Q1Wz/TJqJElyAQGNOXhyGpHKeb0jHMMhsy5UNoW5hLeMS5ffao
TBFWEJ1gVfxIU9QRxSh+62m46JIg+dwDlWv8Aww14KgepspRbMqDuaM2cinoejv6
t3dyOyHHrsOyv3ffZUKtQhQbQr+sUcL89lARsg==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIID/zCCAuegAwIBAgIRAIJLTMpzGNxqHZ4t+c1MlCIwDQYJKoZIhvcNAQELBQAw
gZcxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEwMC4GA1UEAwwn
QW1hem9uIFJEUyBhcC1lYXN0LTEgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYDVQQH
DAdTZWF0dGxlMCAXDTIxMDUyNTIxMzAzM1oYDzIwNjEwNTI1MjIzMDMzWjCBlzEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdBbWF6
b24gUkRTIGFwLWVhc3QtMSBSb290IENBIFJTQTIwNDggRzExEDAOBgNVBAcMB1Nl
YXR0bGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDtdHut0ZhJ9Nn2
MpVafFcwHdoEzx06okmmhjJsNy4l9QYVeh0UUoek0SufRNMRF4d5ibzpgZol0Y92
/qKWNe0jNxhEj6sXyHsHPeYtNBPuDMzThfbvsLK8z7pBP7vVyGPGuppqW/6m4ZBB
lcc9fsf7xpZ689iSgoyjiT6J5wlVgmCx8hFYc/uvcRtfd8jAHvheug7QJ3zZmIye
V4htOW+fRVWnBjf40Q+7uTv790UAqs0Zboj4Yil+hER0ibG62y1g71XcCyvcVpto
2/XW7Y9NCgMNqQ7fGN3wR1gjtSYPd7DO32LTzYhutyvfbpAZjsAHnoObmoljcgXI
QjfBcCFpAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFJI3aWLg
CS5xqU5WYVaeT5s8lpO0MA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsFAAOC
AQEAUwATpJOcGVOs3hZAgJwznWOoTzOVJKfrqBum7lvkVH1vBwxBl9CahaKj3ZOt
YYp2qJzhDUWludL164DL4ZjS6eRedLRviyy5cRy0581l1MxPWTThs27z+lCC14RL
PJZNVYYdl7Jy9Q5NsQ0RBINUKYlRY6OqGDySWyuMPgno2GPbE8aynMdKP+f6G/uE
YHOf08gFDqTsbyfa70ztgVEJaRooVf5JJq4UQtpDvVswW2reT96qi6tXPKHN5qp3
3wI0I1Mp4ePmiBKku2dwYzPfrJK/pQlvu0Gu5lKOQ65QdotwLAAoaFqrf9za1yYs
INUkHLWIxDds+4OHNYcerGp5Dw==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIGCTCCA/GgAwIBAgIRAIO6ldra1KZvNWJ0TA1ihXEwDQYJKoZIhvcNAQEMBQAw
gZwxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE1MDMGA1UEAwws
QW1hem9uIFJEUyBhcC1zb3V0aGVhc3QtMSBSb290IENBIFJTQTQwOTYgRzExEDAO
BgNVBAcMB1NlYXR0bGUwIBcNMjEwNTIxMjE0NTA1WhgPMjEyMTA1MjEyMjQ1MDVa
MIGcMQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywg
SW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExNTAzBgNVBAMM
LEFtYXpvbiBSRFMgYXAtc291dGhlYXN0LTEgUm9vdCBDQSBSU0E0MDk2IEcxMRAw
DgYDVQQHDAdTZWF0dGxlMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA
sDN52Si9pFSyZ1ruh3xAN0nVqEs960o2IK5CPu/ZfshFmzAwnx/MM8EHt/jMeZtj
SM58LADAsNDL01ELpFZATjgZQ6xNAyXRXE7RiTRUvNkK7O3o2qAGbLnJq/UqF7Sw
LRnB8V6hYOv+2EjVnohtGCn9SUFGZtYDjWXsLd4ML4Zpxv0a5LK7oEC7AHzbUR7R
jsjkrXqSv7GE7bvhSOhMkmgxgj1F3J0b0jdQdtyyj109aO0ATUmIvf+Bzadg5AI2
A9UA+TUcGeebhpHu8AP1Hf56XIlzPpaQv3ZJ4vzoLaVNUC7XKzAl1dlvCl7Klg/C
84qmbD/tjZ6GHtzpLKgg7kQEV7mRoXq8X4wDX2AFPPQl2fv+Kbe+JODqm5ZjGegm
uskABBi8IFv1hYx9jEulZPxC6uD/09W2+niFm3pirnlWS83BwVDTUBzF+CooUIMT
jhWkIIZGDDgMJTzouBHfoSJtS1KpUZi99m2WyVs21MNKHeWAbs+zmI6TO5iiMC+T
uB8spaOiHFO1573Fmeer4sy3YA6qVoqVl6jjTQqOdy3frAMbCkwH22/crV8YA+08
hLeHXrMK+6XUvU+EtHAM3VzcrLbuYJUI2XJbzTj5g0Eb8I8JWsHvWHR5K7Z7gceR
78AzxQmoGEfV6KABNWKsgoCQnfb1BidDJIe3BsI0A6UCAwEAAaNCMEAwDwYDVR0T
AQH/BAUwAwEB/zAdBgNVHQ4EFgQUABp0MlB14MSHgAcuNSOhs3MOlUcwDgYDVR0P
AQH/BAQDAgGGMA0GCSqGSIb3DQEBDAUAA4ICAQCv4CIOBSQi/QR9NxdRgVAG/pAh
tFJhV7OWb/wqwsNKFDtg6tTxwaahdCfWpGWId15OUe7G9LoPiKiwM9C92n0ZeHRz
4ewbrQVo7Eu1JI1wf0rnZJISL72hVYKmlvaWaacHhWxvsbKLrB7vt6Cknxa+S993
Kf8i2Psw8j5886gaxhiUtzMTBwoDWak8ZaK7m3Y6C6hXQk08+3pnIornVSFJ9dlS
PAqt5UPwWmrEfF+0uIDORlT+cvrAwgSp7nUF1q8iasledycZ/BxFgQqzNwnkBDwQ
Z/aM52ArGsTzfMhkZRz9HIEhz1/0mJw8gZtDVQroD8778h8zsx2SrIz7eWQ6uWsD
QEeSWXpcheiUtEfzkDImjr2DLbwbA23c9LoexUD10nwohhoiQQg77LmvBVxeu7WU
E63JqaYUlOLOzEmNJp85zekIgR8UTkO7Gc+5BD7P4noYscI7pPOL5rP7YLg15ZFi
ega+G53NTckRXz4metsd8XFWloDjZJJq4FfD60VuxgXzoMNT9wpFTNSH42PR2s9L
I1vcl3w8yNccs9se2utM2nLsItZ3J0m/+QSRiw9hbrTYTcM9sXki0DtH2kyIOwYf
lOrGJDiYOIrXSQK36H0gQ+8omlrUTvUj4msvkXuQjlfgx6sgp2duOAfnGxE7uHnc
UhnJzzoe6M+LfGHkVQ==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICuDCCAj2gAwIBAgIQSAG6j2WHtWUUuLGJTPb1nTAKBggqhkjOPQQDAzCBmzEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTQwMgYDVQQDDCtBbWF6
b24gUkRTIGFwLW5vcnRoZWFzdC0yIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQH
DAdTZWF0dGxlMCAXDTIxMDUyMDE2MzgyNloYDzIxMjEwNTIwMTczODI2WjCBmzEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTQwMgYDVQQDDCtBbWF6
b24gUkRTIGFwLW5vcnRoZWFzdC0yIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQH
DAdTZWF0dGxlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE2eqwU4FOzW8RV1W381Bd
olhDOrqoMqzWli21oDUt7y8OnXM/lmAuOS6sr8Nt61BLVbONdbr+jgCYw75KabrK
ZGg3siqvMOgabIKkKuXO14wtrGyGDt7dnKXg5ERGYOZlo0IwQDAPBgNVHRMBAf8E
BTADAQH/MB0GA1UdDgQWBBS1Acp2WYxOcblv5ikZ3ZIbRCCW+zAOBgNVHQ8BAf8E
BAMCAYYwCgYIKoZIzj0EAwMDaQAwZgIxAJL84J08PBprxmsAKPTotBuVI3MyW1r8
xQ0i8lgCQUf8GcmYjQ0jI4oZyv+TuYJAcwIxAP9Xpzq0Docxb+4N1qVhpiOfWt1O
FnemFiy9m1l+wv6p3riQMPV7mBVpklmijkIv3Q==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIECTCCAvGgAwIBAgIRALZLcqCVIJ25maDPE3sbPCIwDQYJKoZIhvcNAQELBQAw
gZwxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE1MDMGA1UEAwws
QW1hem9uIFJEUyBhcC1zb3V0aGVhc3QtMSBSb290IENBIFJTQTIwNDggRzExEDAO
BgNVBAcMB1NlYXR0bGUwIBcNMjEwNTIxMjEzOTM5WhgPMjA2MTA1MjEyMjM5Mzla
MIGcMQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywg
SW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExNTAzBgNVBAMM
LEFtYXpvbiBSRFMgYXAtc291dGhlYXN0LTEgUm9vdCBDQSBSU0EyMDQ4IEcxMRAw
DgYDVQQHDAdTZWF0dGxlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA
ypKc+6FfGx6Gl6fQ78WYS29QoKgQiur58oxR3zltWeg5fqh9Z85K5S3UbRSTqWWu
Xcfnkz0/FS07qHX+nWAGU27JiQb4YYqhjZNOAq8q0+ptFHJ6V7lyOqXBq5xOzO8f
+0DlbJSsy7GEtJp7d7QCM3M5KVY9dENVZUKeJwa8PC5StvwPx4jcLeZRJC2rAVDG
SW7NAInbATvr9ssSh03JqjXb+HDyywiqoQ7EVLtmtXWimX+0b3/2vhqcH5jgcKC9
IGFydrjPbv4kwMrKnm6XlPZ9L0/3FMzanXPGd64LQVy51SI4d5Xymn0Mw2kMX8s6
Nf05OsWcDzJ1n6/Q1qHSxQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1Ud
DgQWBBRmaIc8eNwGP7i6P7AJrNQuK6OpFzAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZI
hvcNAQELBQADggEBAIBeHfGwz3S2zwIUIpqEEI5/sMySDeS+3nJR+woWAHeO0C8i
BJdDh+kzzkP0JkWpr/4NWz84/IdYo1lqASd1Kopz9aT1+iROXaWr43CtbzjXb7/X
Zv7eZZFC8/lS5SROq42pPWl4ekbR0w8XGQElmHYcWS41LBfKeHCUwv83ATF0XQ6I
4t+9YSqZHzj4vvedrvcRInzmwWJaal9s7Z6GuwTGmnMsN3LkhZ+/GD6oW3pU/Pyh
EtWqffjsLhfcdCs3gG8x9BbkcJPH5aPAVkPn4wc8wuXg6xxb9YGsQuY930GWTYRf
schbgjsuqznW4HHakq4WNhs1UdTSTKkRdZz7FUQ=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEDzCCAvegAwIBAgIRAM2zAbhyckaqRim63b+Tib8wDQYJKoZIhvcNAQELBQAw
gZ8xCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE4MDYGA1UEAwwv
QW1hem9uIFJEUyBQcmV2aWV3IHVzLWVhc3QtMiBSb290IENBIFJTQTIwNDggRzEx
EDAOBgNVBAcMB1NlYXR0bGUwIBcNMjEwNTE4MjA0OTQ1WhgPMjA2MTA1MTgyMTQ5
NDVaMIGfMQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNl
cywgSW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExODA2BgNV
BAMML0FtYXpvbiBSRFMgUHJldmlldyB1cy1lYXN0LTIgUm9vdCBDQSBSU0EyMDQ4
IEcxMRAwDgYDVQQHDAdTZWF0dGxlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB
CgKCAQEA1ybjQMH1MkbvfKsWJaCTXeCSN1SG5UYid+Twe+TjuSqaXWonyp4WRR5z
tlkqq+L2MWUeQQAX3S17ivo/t84mpZ3Rla0cx39SJtP3BiA2BwfUKRjhPwOjmk7j
3zrcJjV5k1vSeLNOfFFSlwyDiVyLAE61lO6onBx+cRjelu0egMGq6WyFVidTdCmT
Q9Zw3W6LTrnPvPmEyjHy2yCHzH3E50KSd/5k4MliV4QTujnxYexI2eR8F8YQC4m3
DYjXt/MicbqA366SOoJA50JbgpuVv62+LSBu56FpzY12wubmDZsdn4lsfYKiWxUy
uc83a2fRXsJZ1d3whxrl20VFtLFHFQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/
MB0GA1UdDgQWBBRC0ytKmDYbfz0Bz0Psd4lRQV3aNTAOBgNVHQ8BAf8EBAMCAYYw
DQYJKoZIhvcNAQELBQADggEBAGv8qZu4uaeoF6zsbumauz6ea6tdcWt+hGFuwGrb
tRbI85ucAmVSX06x59DJClsb4MPhL1XmqO3RxVMIVVfRwRHWOsZQPnXm8OYQ2sny
rYuFln1COOz1U/KflZjgJmxbn8x4lYiTPZRLarG0V/OsCmnLkQLPtEl/spMu8Un7
r3K8SkbWN80gg17Q8EV5mnFwycUx9xsTAaFItuG0en9bGsMgMmy+ZsDmTRbL+lcX
Fq8r4LT4QjrFz0shrzCwuuM4GmcYtBSxlacl+HxYEtAs5k10tmzRf6OYlY33tGf6
1tkYvKryxDPF/EDgGp/LiBwx6ixYMBfISoYASt4V/ylAlHA=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICtTCCAjqgAwIBAgIRAK9BSZU6nIe6jqfODmuVctYwCgYIKoZIzj0EAwMwgZkx
CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu
MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEyMDAGA1UEAwwpQW1h
em9uIFJEUyBjYS1jZW50cmFsLTEgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcM
B1NlYXR0bGUwIBcNMjEwNTIxMjIxMzA5WhgPMjEyMTA1MjEyMzEzMDlaMIGZMQsw
CQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjET
MBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMjAwBgNVBAMMKUFtYXpv
biBSRFMgY2EtY2VudHJhbC0xIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQHDAdT
ZWF0dGxlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEUkEERcgxneT5H+P+fERcbGmf
bVx+M7rNWtgWUr6w+OBENebQA9ozTkeSg4c4M+qdYSObFqjxITdYxT1z/nHz1gyx
OKAhLjWu+nkbRefqy3RwXaWT680uUaAP6ccnkZOMo0IwQDAPBgNVHRMBAf8EBTAD
AQH/MB0GA1UdDgQWBBSN6fxlg0s5Wny08uRBYZcQ3TUoyzAOBgNVHQ8BAf8EBAMC
AYYwCgYIKoZIzj0EAwMDaQAwZgIxAORaz+MBVoFBTmZ93j2G2vYTwA6T5hWzBWrx
CrI54pKn5g6At56DBrkjrwZF5T1enAIxAJe/LZ9xpDkAdxDgGJFN8gZYLRWc0NRy
Rb4hihy5vj9L+w9uKc9VfEBIFuhT7Z3ljg==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEADCCAuigAwIBAgIQB/57HSuaqUkLaasdjxUdPjANBgkqhkiG9w0BAQsFADCB
mDELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu
Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTEwLwYDVQQDDChB
bWF6b24gUkRTIGFwLXNvdXRoLTEgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYDVQQH
DAdTZWF0dGxlMCAXDTIxMDUxOTE3NDAzNFoYDzIwNjEwNTE5MTg0MDM0WjCBmDEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTEwLwYDVQQDDChBbWF6
b24gUkRTIGFwLXNvdXRoLTEgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYDVQQHDAdT
ZWF0dGxlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtbkaoVsUS76o
TgLFmcnaB8cswBk1M3Bf4IVRcwWT3a1HeJSnaJUqWHCJ+u3ip/zGVOYl0gN1MgBb
MuQRIJiB95zGVcIa6HZtx00VezDTr3jgGWRHmRjNVCCHGmxOZWvJjsIE1xavT/1j
QYV/ph4EZEIZ/qPq7e3rHohJaHDe23Z7QM9kbyqp2hANG2JtU/iUhCxqgqUHNozV
Zd0l5K6KnltZQoBhhekKgyiHqdTrH8fWajYl5seD71bs0Axowb+Oh0rwmrws3Db2
Dh+oc2PwREnjHeca9/1C6J2vhY+V0LGaJmnnIuOANrslx2+bgMlyhf9j0Bv8AwSi
dSWsobOhNQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBQb7vJT
VciLN72yJGhaRKLn6Krn2TAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZIhvcNAQELBQAD
ggEBAAxEj8N9GslReAQnNOBpGl8SLgCMTejQ6AW/bapQvzxrZrfVOZOYwp/5oV0f
9S1jcGysDM+DrmfUJNzWxq2Y586R94WtpH4UpJDGqZp+FuOVJL313te4609kopzO
lDdmd+8z61+0Au93wB1rMiEfnIMkOEyt7D2eTFJfJRKNmnPrd8RjimRDlFgcLWJA
3E8wca67Lz/G0eAeLhRHIXv429y8RRXDtKNNz0wA2RwURWIxyPjn1fHjA9SPDkeW
E1Bq7gZj+tBnrqz+ra3yjZ2blss6Ds3/uRY6NYqseFTZWmQWT7FolZEnT9vMUitW
I0VynUbShVpGf6946e0vgaaKw20=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIID/jCCAuagAwIBAgIQGyUVTaVjYJvWhroVEiHPpDANBgkqhkiG9w0BAQsFADCB
lzELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu
Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdB
bWF6b24gUkRTIHVzLXdlc3QtMSBSb290IENBIFJTQTIwNDggRzExEDAOBgNVBAcM
B1NlYXR0bGUwIBcNMjEwNTE5MTkwNDA2WhgPMjA2MTA1MTkyMDA0MDZaMIGXMQsw
CQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjET
MBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMDAuBgNVBAMMJ0FtYXpv
biBSRFMgdXMtd2VzdC0xIFJvb3QgQ0EgUlNBMjA0OCBHMTEQMA4GA1UEBwwHU2Vh
dHRsZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANhyXpJ0t4nigRDZ
EwNtFOem1rM1k8k5XmziHKDvDk831p7QsX9ZOxl/BT59Pu/P+6W6SvasIyKls1sW
FJIjFF+6xRQcpoE5L5evMgN/JXahpKGeQJPOX9UEXVW5B8yi+/dyUitFT7YK5LZA
MqWBN/LtHVPa8UmE88RCDLiKkqiv229tmwZtWT7nlMTTCqiAHMFcryZHx0pf9VPh
x/iPV8p2gBJnuPwcz7z1kRKNmJ8/cWaY+9w4q7AYlAMaq/rzEqDaN2XXevdpsYAK
TMMj2kji4x1oZO50+VPNfBl5ZgJc92qz1ocF95SAwMfOUsP8AIRZkf0CILJYlgzk
/6u6qZECAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUm5jfcS9o
+LwL517HpB6hG+PmpBswDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEBCwUAA4IB
AQAcQ6lsqxi63MtpGk9XK8mCxGRLCad51+MF6gcNz6i6PAqhPOoKCoFqdj4cEQTF
F8dCfa3pvfJhxV6RIh+t5FCk/y6bWT8Ls/fYKVo6FhHj57bcemWsw/Z0XnROdVfK
Yqbc7zvjCPmwPHEqYBhjU34NcY4UF9yPmlLOL8uO1JKXa3CAR0htIoW4Pbmo6sA4
6P0co/clW+3zzsQ92yUCjYmRNeSbdXbPfz3K/RtFfZ8jMtriRGuO7KNxp8MqrUho
HK8O0mlSUxGXBZMNicfo7qY8FD21GIPH9w5fp5oiAl7lqFzt3E3sCLD3IiVJmxbf
fUwpGd1XZBBSdIxysRLM6j48
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICrTCCAjOgAwIBAgIQU+PAILXGkpoTcpF200VD/jAKBggqhkjOPQQDAzCBljEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMS8wLQYDVQQDDCZBbWF6
b24gUkRTIGFwLWVhc3QtMSBSb290IENBIEVDQzM4NCBHMTEQMA4GA1UEBwwHU2Vh
dHRsZTAgFw0yMTA1MjUyMTQ1MTFaGA8yMTIxMDUyNTIyNDUxMVowgZYxCzAJBgNV
BAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMuMRMwEQYD
VQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEvMC0GA1UEAwwmQW1hem9uIFJE
UyBhcC1lYXN0LTEgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcMB1NlYXR0bGUw
djAQBgcqhkjOPQIBBgUrgQQAIgNiAAT3tFKE8Kw1sGQAvNLlLhd8OcGhlc7MiW/s
NXm3pOiCT4vZpawKvHBzD76Kcv+ZZzHRxQEmG1/muDzZGlKR32h8AAj+NNO2Wy3d
CKTtYMiVF6Z2zjtuSkZQdjuQbe4eQ7qjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYD
VR0OBBYEFAiSQOp16Vv0Ohpvqcbd2j5RmhYNMA4GA1UdDwEB/wQEAwIBhjAKBggq
hkjOPQQDAwNoADBlAjBVsi+5Ape0kOhMt/WFkANkslD4qXA5uqhrfAtH29Xzz2NV
tR7akiA771OaIGB/6xsCMQCZt2egCtbX7J0WkuZ2KivTh66jecJr5DHvAP4X2xtS
F/5pS+AUhcKTEGjI9jDH3ew=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICuDCCAj2gAwIBAgIQT5mGlavQzFHsB7hV6Mmy6TAKBggqhkjOPQQDAzCBmzEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTQwMgYDVQQDDCtBbWF6
b24gUkRTIGFwLXNvdXRoZWFzdC0yIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQH
DAdTZWF0dGxlMCAXDTIxMDUyNDIwNTAxNVoYDzIxMjEwNTI0MjE1MDE1WjCBmzEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTQwMgYDVQQDDCtBbWF6
b24gUkRTIGFwLXNvdXRoZWFzdC0yIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQH
DAdTZWF0dGxlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEcm4BBBjYK7clwm0HJRWS
flt3iYwoJbIXiXn9c1y3E+Vb7bmuyKhS4eO8mwO4GefUcXObRfoHY2TZLhMJLVBQ
7MN2xDc0RtZNj07BbGD3VAIFRTDX0mH9UNYd0JQM3t/Oo0IwQDAPBgNVHRMBAf8E
BTADAQH/MB0GA1UdDgQWBBRrd5ITedfAwrGo4FA9UaDaGFK3rjAOBgNVHQ8BAf8E
BAMCAYYwCgYIKoZIzj0EAwMDaQAwZgIxAPBNqmVv1IIA3EZyQ6XuVf4gj79/DMO8
bkicNS1EcBpUqbSuU4Zwt2BYc8c/t7KVOQIxAOHoWkoKZPiKyCxfMtJpCZySUG+n
sXgB/LOyWE5BJcXUfm+T1ckeNoWeUUMOLmnJjg==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIECTCCAvGgAwIBAgIRAJcDeinvdNrDQBeJ8+t38WQwDQYJKoZIhvcNAQELBQAw
gZwxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE1MDMGA1UEAwws
QW1hem9uIFJEUyBhcC1zb3V0aGVhc3QtNCBSb290IENBIFJTQTIwNDggRzExEDAO
BgNVBAcMB1NlYXR0bGUwIBcNMjIwNTI1MTY0OTE2WhgPMjA2MjA1MjUxNzQ5MTZa
MIGcMQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywg
SW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExNTAzBgNVBAMM
LEFtYXpvbiBSRFMgYXAtc291dGhlYXN0LTQgUm9vdCBDQSBSU0EyMDQ4IEcxMRAw
DgYDVQQHDAdTZWF0dGxlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA
k8DBNkr9tMoIM0NHoFiO7cQfSX0cOMhEuk/CHt0fFx95IBytx7GHCnNzpM27O5z6
x6iRhfNnx+B6CrGyCzOjxvPizneY+h+9zfvNz9jj7L1I2uYMuiNyOKR6FkHR46CT
1CiArfVLLPaTqgD/rQjS0GL2sLHS/0dmYipzynnZcs613XT0rAWdYDYgxDq7r/Yi
Xge5AkWQFkMUq3nOYDLCyGGfQqWKkwv6lZUHLCDKf+Y0Uvsrj8YGCI1O8mF0qPCQ
lmlfaDvbuBu1AV+aabmkvyFj3b8KRIlNLEtQ4N8KGYR2Jdb82S4YUGIOAt4wuuFt
1B7AUDLk3V/u+HTWiwfoLQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1Ud
DgQWBBSNpcjz6ArWBtAA+Gz6kyyZxrrgdDAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZI
hvcNAQELBQADggEBAGJEd7UgOzHYIcQRSF7nSYyjLROyalaIV9AX4WXW/Cqlul1c
MblP5etDZm7A/thliZIWAuyqv2bNicmS3xKvNy6/QYi1YgxZyy/qwJ3NdFl067W0
t8nGo29B+EVK94IPjzFHWShuoktIgp+dmpijB7wkTIk8SmIoe9yuY4+hzgqk+bo4
ms2SOXSN1DoQ75Xv+YmztbnZM8MuWhL1T7hA4AMorzTQLJ9Pof8SpSdMHeDsHp0R
01jogNFkwy25nw7cL62nufSuH2fPYGWXyNDg+y42wKsKWYXLRgUQuDVEJ2OmTFMB
T0Vf7VuNijfIA9hkN2d3K53m/9z5WjGPSdOjGhg=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIID/jCCAuagAwIBAgIQRiwspKyrO0xoxDgSkqLZczANBgkqhkiG9w0BAQsFADCB
lzELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu
Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdB
bWF6b24gUkRTIHVzLXdlc3QtMiBSb290IENBIFJTQTIwNDggRzExEDAOBgNVBAcM
B1NlYXR0bGUwIBcNMjEwNTI0MjE1OTAwWhgPMjA2MTA1MjQyMjU5MDBaMIGXMQsw
CQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjET
MBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMDAuBgNVBAMMJ0FtYXpv
biBSRFMgdXMtd2VzdC0yIFJvb3QgQ0EgUlNBMjA0OCBHMTEQMA4GA1UEBwwHU2Vh
dHRsZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL53Jk3GsKiu+4bx
jDfsevWbwPCNJ3H08Zp7GWhvI3Tgi39opfHYv2ku2BKFjK8N2L6RvNPSR8yplv5j
Y0tK0U+XVNl8o0ibhqRDhbTuh6KL8CFINWYzAajuxFS+CF0U6c1Q3tXLBdALxA7l
FlXJ71QrP06W31kRe7kvgrvO7qWU3/OzUf9qYw4LSiR1/VkvvRCTqcVNw09clw/M
Jbw6FSgweN65M9j7zPbjGAXSHkXyxH1Erin2fa+B9PE4ZDgX9cp2C1DHewYJQL/g
SepwwcudVNRN1ibKH7kpMrgPnaNIVNx5sXVsTjk6q2ZqYw3SVHegltJpLy/cZReP
mlivF2kCAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUmTcQd6o1
CuS65MjBrMwQ9JJjmBwwDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEBCwUAA4IB
AQAKSDSIzl956wVddPThf2VAzI8syw9ngSwsEHZvxVGHBvu5gg618rDyguVCYX9L
4Kw/xJrk6S3qxOS2ZDyBcOpsrBskgahDFIunzoRP3a18ARQVq55LVgfwSDQiunch
Bd05cnFGLoiLkR5rrkgYaP2ftn3gRBRaf0y0S3JXZ2XB3sMZxGxavYq9mfiEcwB0
LMTMQ1NYzahIeG6Jm3LqRqR8HkzP/Ztq4dT2AtSLvFebbNMiWqeqT7OcYp94HTYT
zqrtaVdUg9bwyAUCDgy0GV9RHDIdNAOInU/4LEETovrtuBU7Z1q4tcHXvN6Hd1H8
gMb0mCG5I393qW5hFsA/diFb
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIECTCCAvGgAwIBAgIRAPQAvihfjBg/JDbj6U64K98wDQYJKoZIhvcNAQELBQAw
gZwxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE1MDMGA1UEAwws
QW1hem9uIFJEUyBhcC1ub3J0aGVhc3QtMiBSb290IENBIFJTQTIwNDggRzExEDAO
BgNVBAcMB1NlYXR0bGUwIBcNMjEwNTIwMTYyODQxWhgPMjA2MTA1MjAxNzI4NDFa
MIGcMQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywg
SW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExNTAzBgNVBAMM
LEFtYXpvbiBSRFMgYXAtbm9ydGhlYXN0LTIgUm9vdCBDQSBSU0EyMDQ4IEcxMRAw
DgYDVQQHDAdTZWF0dGxlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA
vJ9lgyksCxkBlY40qOzI1TCj/Q0FVGuPL/Z1Mw2YN0l+41BDv0FHApjTUkIKOeIP
nwDwpXTa3NjYbk3cOZ/fpH2rYJ++Fte6PNDGPgKppVCUh6x3jiVZ1L7wOgnTdK1Q
Trw8440IDS5eLykRHvz8OmwvYDl0iIrt832V0QyOlHTGt6ZJ/aTQKl12Fy3QBLv7
stClPzvHTrgWqVU6uidSYoDtzHbU7Vda7YH0wD9IUoMBf7Tu0rqcE4uH47s2XYkc
SdLEoOg/Ngs7Y9B1y1GCyj3Ux7hnyvCoRTw014QyNB7dTatFMDvYlrRDGG14KeiU
UL7Vo/+EejWI31eXNLw84wIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1Ud
DgQWBBQkgTWFsNg6wA3HbbihDQ4vpt1E2zAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZI
hvcNAQELBQADggEBAGz1Asiw7hn5WYUj8RpOCzpE0h/oBZcnxP8wulzZ5Xd0YxWO
0jYUcUk3tTQy1QvoY+Q5aCjg6vFv+oFBAxkib/SmZzp4xLisZIGlzpJQuAgRkwWA
6BVMgRS+AaOMQ6wKPgz1x4v6T0cIELZEPq3piGxvvqkcLZKdCaeC3wCS6sxuafzZ
4qA3zMwWuLOzRftgX2hQto7d/2YkRXga7jSvQl3id/EI+xrYoH6zIWgjdU1AUaNq
NGT7DIo47vVMfnd9HFZNhREsd4GJE83I+JhTqIxiKPNxrKgESzyADmNPt0gXDnHo
tbV1pMZz5HpJtjnP/qVZhEK5oB0tqlKPv9yx074=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICuTCCAj6gAwIBAgIRAKp1Rn3aL/g/6oiHVIXtCq8wCgYIKoZIzj0EAwMwgZsx
CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu
MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE0MDIGA1UEAwwrQW1h
em9uIFJEUyBhcC1ub3J0aGVhc3QtMyBSb290IENBIEVDQzM4NCBHMTEQMA4GA1UE
BwwHU2VhdHRsZTAgFw0yMTA1MjQyMDMyMTdaGA8yMTIxMDUyNDIxMzIxN1owgZsx
CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu
MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE0MDIGA1UEAwwrQW1h
em9uIFJEUyBhcC1ub3J0aGVhc3QtMyBSb290IENBIEVDQzM4NCBHMTEQMA4GA1UE
BwwHU2VhdHRsZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABGTYWPILeBJXfcL3Dz4z
EWMUq78xB1HpjBwHoTURYfcMd5r96BTVG6yaUBWnAVCMeeD6yTG9a1eVGNhG14Hk
ZAEjgLiNB7RRbEG5JZ/XV7W/vODh09WCst2y9SLKsdgeAaNCMEAwDwYDVR0TAQH/
BAUwAwEB/zAdBgNVHQ4EFgQUoE0qZHmDCDB+Bnm8GUa/evpfPwgwDgYDVR0PAQH/
BAQDAgGGMAoGCCqGSM49BAMDA2kAMGYCMQCnil5MMwhY3qoXv0xvcKZGxGPaBV15
0CCssCKn0oVtdJQfJQ3Jrf3RSaEyijXIJsoCMQC35iJi4cWoNX3N/qfgnHohW52O
B5dg0DYMqy5cNZ40+UcAanRMyqNQ6P7fy3umGco=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICtzCCAj2gAwIBAgIQPXnDTPegvJrI98qz8WxrMjAKBggqhkjOPQQDAzCBmzEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTQwMgYDVQQDDCtBbWF6
b24gUkRTIEJldGEgdXMtZWFzdC0xIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQH
DAdTZWF0dGxlMCAXDTIxMDUxODIxNDAxMloYDzIxMjEwNTE4MjI0MDEyWjCBmzEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTQwMgYDVQQDDCtBbWF6
b24gUkRTIEJldGEgdXMtZWFzdC0xIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQH
DAdTZWF0dGxlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEI0sR7gwutK5AB46hM761
gcLTGBIYlURSEoM1jcBwy56CL+3CJKZwLLyJ7qoOKfWbu5GsVLUTWS8MV6Nw33cx
2KQD2svb694wi+Px2f4n9+XHkEFQw8BbiodDD7RZA70fo0IwQDAPBgNVHRMBAf8E
BTADAQH/MB0GA1UdDgQWBBTQSioOvnVLEMXwNSDg+zgln/vAkjAOBgNVHQ8BAf8E
BAMCAYYwCgYIKoZIzj0EAwMDaAAwZQIxAMwu1hqm5Bc98uE/E0B5iMYbBQ4kpMxO
tP8FTfz5UR37HUn26nXE0puj6S/Ffj4oJgIwXI7s2c26tFQeqzq6u3lrNJHp5jC9
Uxlo/hEJOLoDj5jnpxo8dMAtCNoQPaHdfL0P
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIF/jCCA+agAwIBAgIQEM1pS+bWfBJeu/6j1yIIFzANBgkqhkiG9w0BAQwFADCB
lzELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu
Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdB
bWF6b24gUkRTIGNhLXdlc3QtMSBSb290IENBIFJTQTQwOTYgRzExEDAOBgNVBAcM
B1NlYXR0bGUwIBcNMjMwOTE5MjIwMTM5WhgPMjEyMzA5MTkyMzAxMzlaMIGXMQsw
CQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjET
MBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMDAuBgNVBAMMJ0FtYXpv
biBSRFMgY2Etd2VzdC0xIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4GA1UEBwwHU2Vh
dHRsZTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK2Pyp8p5z6HnlGB
daOj78gZ3ABufxnBFiu5NdFiGoMrS+eY//xxr2iKbnynJAzjmn5A6VKMNxtbuYIZ
WKAzDb/HrWlIYD2w7ZVBXpylfPhiz3jLNsl03WdPNnEruCcivhY2QMewEVtzjPU0
ofdbZlO2KpF3biv1gjPuIuE7AUyQAbWnWTlrzETAVWLboJJRRqxASSkFUHNLXod7
ow02FwlAhcnCp9gSe1SKRDrpvvEvYQBAFB7owfnoQzOGDdd87RGyYfyuW8aFI2Z0
LHNvsA0dTafO4Rh986c72kDL7ijICQdr5OTgZR2OnuESLk1DSK4xYJ4fA6jb5dJ5
+xsI6tCPykWCW98aO/pha35OsrVNifL/5cH5pdv/ecgQGdffJB+Vdj6f/ZMwR6s/
Rm37cQ9l3tU8eu/qpzsFjLq1ZUzDaVDWgMW9t49+q/zjhdmbPOabZDao7nHXrVRw
rwPHWCmEY4OmH6ikEKQW3AChFjOdSg4me/J0Jr5l5jKggLPHWbNLRO8qTTK6N8qk
ui3aJDi+XQfsTPARXIw4UFErArNImTsoZVyqfX7I4shp0qZbEhP6kRAbfPljw5kW
Yat7ZlXqDanjsreqbLTaOU10P0rC0/4Ctv5cLSKCrzRLWtpXxhKa2wJTQ74G6fAZ
1oUA79qg3F8nyM+ZzDsfNI854+PNAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8w
HQYDVR0OBBYEFLRWiDabEQZNkzEPUCr1ZVJV6xpwMA4GA1UdDwEB/wQEAwIBhjAN
BgkqhkiG9w0BAQwFAAOCAgEATkVVzkkGBjEtLGDtERi+fSpIV0MxwAsA4PAeBBmb
myxo90jz6kWkKM1Wm4BkZM8/mq5VbxPef1kxHfb5CHksCL6SgG5KujfIvht+KT2a
MRJB+III3CbcTy0HtwCX5AlPIbXWydhQFoJTW/OkpecUWoyFM6SqYeYZx1itJpxl
sXshLjYOvw+QgvxRsDxqUfkcaC/N2yhu/30Zo2P8msJfAFry2UmA/TBrWOQKVQxl
Ee/yWgp4U/bC/GZnjWnWDTwkRFGQtI4wjxbVuX6V4FTLCT7kIoHBhG+zOSduJRn3
Axej7gkEXEVc/PAnwp/kSJ/b0/JONLWdjGUFkyiMn1yJlhJ2sg39vepBN5r6yVYU
nJWoZAuupRpoIKfmC3/cZanXqYbYl4yxzX/PMB4kAACfdxGxLawjnnBjSzaWokXs
YVh2TjWpUMwLOi0RB2mtPUjHdDLKtjOTZ1zHZnR/wVp9BmVI1BXYnz5PAqU5XqeD
EmanyaAuFCeyol1EtbQhgtysThQ+vwYAXMm2iKzJxq0hik8wyG8X55FhnGEOGV3u
xxq7odd3/8BXkc3dGdBPQtH+k5glaQyPnAsLVAIUvyzTmy58saL+nJnQY4mmRrwV
1jJA7nnkaklI/L5fvfCg0W+TMinCOAGd+GQ4hK2SAsJLtcqiBgPf2wJHO8wiwUh9
Luw=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICrjCCAjWgAwIBAgIQGKVv+5VuzEZEBzJ+bVfx2zAKBggqhkjOPQQDAzCBlzEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdBbWF6
b24gUkRTIGFwLXNvdXRoLTEgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcMB1Nl
YXR0bGUwIBcNMjEwNTE5MTc1MDU5WhgPMjEyMTA1MTkxODUwNTlaMIGXMQswCQYD
VQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjETMBEG
A1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMDAuBgNVBAMMJ0FtYXpvbiBS
RFMgYXAtc291dGgtMSBSb290IENBIEVDQzM4NCBHMTEQMA4GA1UEBwwHU2VhdHRs
ZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABMqdLJ0tZF/DGFZTKZDrGRJZID8ivC2I
JRCYTWweZKCKSCAzoiuGGHzJhr5RlLHQf/QgmFcgXsdmO2n3CggzhA4tOD9Ip7Lk
P05eHd2UPInyPCHRgmGjGb0Z+RdQ6zkitKNCMEAwDwYDVR0TAQH/BAUwAwEB/zAd
BgNVHQ4EFgQUC1yhRgVqU5bR8cGzOUCIxRpl4EYwDgYDVR0PAQH/BAQDAgGGMAoG
CCqGSM49BAMDA2cAMGQCMG0c/zLGECRPzGKJvYCkpFTCUvdP4J74YP0v/dPvKojL
t/BrR1Tg4xlfhaib7hPc7wIwFvgqHes20CubQnZmswbTKLUrgSUW4/lcKFpouFd2
t2/ewfi/0VhkeUW+IiHhOMdU
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIGCTCCA/GgAwIBAgIRAOXxJuyXVkbfhZCkS/dOpfEwDQYJKoZIhvcNAQEMBQAw
gZwxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE1MDMGA1UEAwws
QW1hem9uIFJEUyBhcC1ub3J0aGVhc3QtMSBSb290IENBIFJTQTQwOTYgRzExEDAO
BgNVBAcMB1NlYXR0bGUwIBcNMjEwNTI1MjE1OTEwWhgPMjEyMTA1MjUyMjU5MTBa
MIGcMQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywg
SW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExNTAzBgNVBAMM
LEFtYXpvbiBSRFMgYXAtbm9ydGhlYXN0LTEgUm9vdCBDQSBSU0E0MDk2IEcxMRAw
DgYDVQQHDAdTZWF0dGxlMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA
xiP4RDYm4tIS12hGgn1csfO8onQDmK5SZDswUpl0HIKXOUVVWkHNlINkVxbdqpqH
FhbyZmNN6F/EWopotMDKe1B+NLrjNQf4zefv2vyKvPHJXhxoKmfyuTd5Wk8k1F7I
lNwLQzznB+ElhrLIDJl9Ro8t31YBBNFRGAGEnxyACFGcdkjlsa52UwfYrwreEg2l
gW5AzqHgjFfj9QRLydeU/n4bHm0F1adMsV7P3rVwilcUlqsENDwXnWyPEyv3sw6F
wNemLEs1129mB77fwvySb+lLNGsnzr8w4wdioZ74co+T9z2ca+eUiP+EQccVw1Is
D4Fh57IjPa6Wuc4mwiUYKkKY63+38aCfEWb0Qoi+zW+mE9nek6MOQ914cN12u5LX
dBoYopphRO5YmubSN4xcBy405nIdSdbrAVWwxXnVVyjqjknmNeqQsPZaxAhdoKhV
AqxNr8AUAdOAO6Sz3MslmcLlDXFihrEEOeUbpg/m1mSUUHGbu966ajTG1FuEHHwS
7WB52yxoJo/tHvt9nAWnh3uH5BHmS8zn6s6CGweWKbX5yICnZ1QFR1e4pogxX39v
XD6YcNOO+Vn+HY4nXmjgSYVC7l+eeP8eduMg1xJujzjrbmrXU+d+cBObgdTOAlpa
JFHaGwYw1osAwPCo9cZ2f04yitBfj9aPFia8ASKldakCAwEAAaNCMEAwDwYDVR0T
AQH/BAUwAwEB/zAdBgNVHQ4EFgQUqKS+ltlior0SyZKYAkJ/efv55towDgYDVR0P
AQH/BAQDAgGGMA0GCSqGSIb3DQEBDAUAA4ICAQAdElvp8bW4B+Cv+1WSN87dg6TN
wGyIjJ14/QYURgyrZiYpUmZpj+/pJmprSWXu4KNyqHftmaidu7cdjL5nCAvAfnY5
/6eDDbX4j8Gt9fb/6H9y0O0dn3mUPSEKG0crR+JRFAtPhn/2FNvst2P82yguWLv0
pHjHVUVcq+HqDMtUIJsTPYjSh9Iy77Q6TOZKln9dyDOWJpCSkiUWQtMAKbCSlvzd
zTs/ahqpT+zLfGR1SR+T3snZHgQnbnemmz/XtlKl52NxccARwfcEEKaCRQyGq/pR
0PVZasyJS9JY4JfQs4YOdeOt4UMZ8BmW1+BQWGSkkb0QIRl8CszoKofucAlqdPcO
IT/ZaMVhI580LFGWiQIizWFskX6lqbCyHqJB3LDl8gJISB5vNTHOHpvpMOMs5PYt
cRl5Mrksx5MKMqG7y5R734nMlZxQIHjL5FOoOxTBp9KeWIL/Ib89T2QDaLw1SQ+w
ihqWBJ4ZdrIMWYpP3WqM+MXWk7WAem+xsFJdR+MDgOOuobVQTy5dGBlPks/6gpjm
rO9TjfQ36ppJ3b7LdKUPeRfnYmlR5RU4oyYJ//uLbClI443RZAgxaCXX/nyc12lr
eVLUMNF2abLX4/VF63m2/Z9ACgMRfqGshPssn1NN33OonrotQoj4S3N9ZrjvzKt8
iHcaqd60QKpfiH2A3A==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICuDCCAj2gAwIBAgIQPaVGRuu86nh/ylZVCLB0MzAKBggqhkjOPQQDAzCBmzEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTQwMgYDVQQDDCtBbWF6
b24gUkRTIGFwLW5vcnRoZWFzdC0xIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQH
DAdTZWF0dGxlMCAXDTIxMDUyNTIyMDMxNloYDzIxMjEwNTI1MjMwMzE2WjCBmzEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTQwMgYDVQQDDCtBbWF6
b24gUkRTIGFwLW5vcnRoZWFzdC0xIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQH
DAdTZWF0dGxlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEexNURoB9KE93MEtEAlJG
obz4LS/pD2hc8Gczix1WhVvpJ8bN5zCDXaKdnDMCebetyRQsmQ2LYlfmCwpZwSDu
0zowB11Pt3I5Avu2EEcuKTlKIDMBeZ1WWuOd3Tf7MEAMo0IwQDAPBgNVHRMBAf8E
BTADAQH/MB0GA1UdDgQWBBSaYbZPBvFLikSAjpa8mRJvyArMxzAOBgNVHQ8BAf8E
BAMCAYYwCgYIKoZIzj0EAwMDaQAwZgIxAOEJkuh3Zjb7Ih/zuNRd1RBqmIYcnyw0
nwUZczKXry+9XebYj3VQxSRNadrarPWVqgIxAMg1dyGoDAYjY/L/9YElyMnvHltO
PwpJShmqHvCLc/mXMgjjYb/akK7yGthvW6j/uQ==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIGCDCCA/CgAwIBAgIQChu3v5W1Doil3v6pgRIcVzANBgkqhkiG9w0BAQwFADCB
nDELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu
Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTUwMwYDVQQDDCxB
bWF6b24gUkRTIEJldGEgdXMtZWFzdC0xIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4G
A1UEBwwHU2VhdHRsZTAgFw0yMTA1MTgyMTM0MTVaGA8yMTIxMDUxODIyMzQxNVow
gZwxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE1MDMGA1UEAwws
QW1hem9uIFJEUyBCZXRhIHVzLWVhc3QtMSBSb290IENBIFJTQTQwOTYgRzExEDAO
BgNVBAcMB1NlYXR0bGUwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC1
FUGQ5tf3OwpDR6hGBxhUcrkwKZhaXP+1St1lSOQvjG8wXT3RkKzRGMvb7Ee0kzqI
mzKKe4ASIhtV3UUWdlNmP0EA3XKnif6N79MismTeGkDj75Yzp5A6tSvqByCgxIjK
JqpJrch3Dszoyn8+XhwDxMZtkUa5nQVdJgPzJ6ltsQ8E4SWLyLtTu0S63jJDkqYY
S7cQblk7y7fel+Vn+LS5dGTdRRhMvSzEnb6mkVBaVzRyVX90FNUED06e8q+gU8Ob
htvQlf9/kRzHwRAdls2YBhH40ZeyhpUC7vdtPwlmIyvW5CZ/QiG0yglixnL6xahL
pbmTuTSA/Oqz4UGQZv2WzHe1lD2gRHhtFX2poQZeNQX8wO9IcUhrH5XurW/G9Xwl
Sat9CMPERQn4KC3HSkat4ir2xaEUrjfg6c4XsGyh2Pk/LZ0gLKum0dyWYpWP4JmM
RQNjrInXPbMhzQObozCyFT7jYegS/3cppdyy+K1K7434wzQGLU1gYXDKFnXwkX8R
bRKgx2pHNbH5lUddjnNt75+e8m83ygSq/ZNBUz2Ur6W2s0pl6aBjwaDES4VfWYlI
jokcmrGvJNDfQWygb1k00eF2bzNeNCHwgWsuo3HSxVgc/WGsbcGrTlDKfz+g3ich
bXUeUidPhRiv5UQIVCLIHpHuin3bj9lQO/0t6p+tAQIDAQABo0IwQDAPBgNVHRMB
Af8EBTADAQH/MB0GA1UdDgQWBBSFmMBgm5IsRv3hLrvDPIhcPweXYTAOBgNVHQ8B
Af8EBAMCAYYwDQYJKoZIhvcNAQEMBQADggIBAAa2EuozymOsQDJlEi7TqnyA2OhT
GXPfYqCyMJVkfrqNgcnsNpCAiNEiZbb+8sIPXnT8Ay8hrwJYEObJ5b7MHXpLuyft
z0Pu1oFLKnQxKjNxrIsCvaB4CRRdYjm1q7EqGhMGv76se9stOxkOqO9it31w/LoU
ENDk7GLsSqsV1OzYLhaH8t+MaNP6rZTSNuPrHwbV3CtBFl2TAZ7iKgKOhdFz1Hh9
Pez0lG+oKi4mHZ7ajov6PD0W7njn5KqzCAkJR6OYmlNVPjir+c/vUtEs0j+owsMl
g7KE5g4ZpTRShyh5BjCFRK2tv0tkqafzNtxrKC5XNpEkqqVTCnLcKG+OplIEadtr
C7UWf4HyhCiR+xIyxFyR05p3uY/QQU/5uza7GlK0J+U1sBUytx7BZ+Fo8KQfPPqV
CqDCaYUksoJcnJE/KeoksyqNQys7sDGJhkd0NeUGDrFLKHSLhIwAMbEWnqGxvhli
E7sP2E5rI/I9Y9zTbLIiI8pfeZlFF8DBdoP/Hzg8pqsiE/yiXSFTKByDwKzGwNqz
F0VoFdIZcIbLdDbzlQitgGpJtvEL7HseB0WH7B2PMMD8KPJlYvPveO3/6OLzCsav
+CAkvk47NQViKMsUTKOA0JDCW+u981YRozxa3K081snhSiSe83zIPBz1ikldXxO9
6YYLNPRrj3mi9T/f
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICrjCCAjSgAwIBAgIRAMkvdFnVDb0mWWFiXqnKH68wCgYIKoZIzj0EAwMwgZYx
CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu
MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEvMC0GA1UEAwwmQW1h
em9uIFJEUyB1cy13ZXN0LTEgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcMB1Nl
YXR0bGUwIBcNMjEwNTE5MTkxMzI0WhgPMjEyMTA1MTkyMDEzMjRaMIGWMQswCQYD
VQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjETMBEG
A1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExLzAtBgNVBAMMJkFtYXpvbiBS
RFMgdXMtd2VzdC0xIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQHDAdTZWF0dGxl
MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEy86DB+9th/0A5VcWqMSWDxIUblWTt/R0
ao6Z2l3vf2YDF2wt1A2NIOGpfQ5+WAOJO/IQmnV9LhYo+kacB8sOnXdQa6biZZkR
IyouUfikVQAKWEJnh1Cuo5YMM4E2sUt5o0IwQDAPBgNVHRMBAf8EBTADAQH/MB0G
A1UdDgQWBBQ8u3OnecANmG8OoT7KLWDuFzZwBTAOBgNVHQ8BAf8EBAMCAYYwCgYI
KoZIzj0EAwMDaAAwZQIwQ817qkb7mWJFnieRAN+m9W3E0FLVKaV3zC5aYJUk2fcZ
TaUx3oLp3jPLGvY5+wgeAjEA6wAicAki4ZiDfxvAIuYiIe1OS/7H5RA++R8BH6qG
iRzUBM/FItFpnkus7u/eTkvo
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICrzCCAjWgAwIBAgIQS/+Ryfgb/IOVEa1pWoe8oTAKBggqhkjOPQQDAzCBlzEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdBbWF6
b24gUkRTIGFwLXNvdXRoLTIgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcMB1Nl
YXR0bGUwIBcNMjIwNjA2MjE1NDQyWhgPMjEyMjA2MDYyMjU0NDJaMIGXMQswCQYD
VQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjETMBEG
A1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMDAuBgNVBAMMJ0FtYXpvbiBS
RFMgYXAtc291dGgtMiBSb290IENBIEVDQzM4NCBHMTEQMA4GA1UEBwwHU2VhdHRs
ZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABDsX6fhdUWBQpYTdseBD/P3s96Dtw2Iw
OrXKNToCnmX5nMkUGdRn9qKNiz1pw3EPzaPxShbYwQ7LYP09ENK/JN4QQjxMihxC
jLFxS85nhBQQQGRCWikDAe38mD8fSvREQKNCMEAwDwYDVR0TAQH/BAUwAwEB/zAd
BgNVHQ4EFgQUIh1xZiseQYFjPYKJmGbruAgRH+AwDgYDVR0PAQH/BAQDAgGGMAoG
CCqGSM49BAMDA2gAMGUCMFudS4zLy+UUGrtgNLtRMcu/DZ9BUzV4NdHxo0bkG44O
thnjl4+wTKI6VbyAbj2rkgIxAOHps8NMITU5DpyiMnKTxV8ubb/WGHrLl0BjB8Lw
ETVJk5DNuZvsIIcm7ykk6iL4Tw==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIGBDCCA+ygAwIBAgIQDcEmNIAVrDpUw5cH5ynutDANBgkqhkiG9w0BAQwFADCB
mjELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu
Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTMwMQYDVQQDDCpB
bWF6b24gUkRTIG1lLWNlbnRyYWwtMSBSb290IENBIFJTQTQwOTYgRzExEDAOBgNV
BAcMB1NlYXR0bGUwIBcNMjIwNTA3MDA0MDIzWhgPMjEyMjA1MDcwMTQwMjNaMIGa
MQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5j
LjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMzAxBgNVBAMMKkFt
YXpvbiBSRFMgbWUtY2VudHJhbC0xIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4GA1UE
BwwHU2VhdHRsZTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAKvADk8t
Fl9bFlU5sajLPPDSOUpPAkKs6iPlz+27o1GJC88THcOvf3x0nVAcu9WYe9Qaas+4
j4a0vv51agqyODRD/SNi2HnqW7DbtLPAm6KBHe4twl28ItB/JD5g7u1oPAHFoXMS
cH1CZEAs5RtlZGzJhcBXLFsHNv/7+SCLyZ7+2XFh9OrtgU4wMzkHoRNndhfwV5bu
17bPTwuH+VxH37zXf1mQ/KjhuJos0C9dL0FpjYBAuyZTAWhZKs8dpSe4DI544z4w
gkwUB4bC2nA1TBzsywEAHyNuZ/xRjNpWvx0ToWAA2iFJqC3VO3iKcnBplMvaUuMt
jwzVSNBnKcoabXCZL2XDLt4YTZR8FSwz05IvsmwcPB7uNTBXq3T9sjejW8QQK3vT
tzyfLq4jKmQE7PoS6cqYm+hEPm2hDaC/WP9bp3FdEJxZlPH26fq1b7BWYWhQ9pBA
Nv9zTnzdR1xohTyOJBUFQ81ybEzabqXqVXUIANqIOaNcTB09/sLJ7+zuMhp3mwBu
LtjfJv8PLuT1r63bU3seROhKA98b5KfzjvbvPSg3vws78JQyoYGbqNyDfyjVjg3U
v//AdVuPie6PNtdrW3upZY4Qti5IjP9e3kimaJ+KAtTgMRG56W0WxD3SP7+YGGbG
KhntDOkKsN39hLpn9UOafTIqFu7kIaueEy/NAgMBAAGjQjBAMA8GA1UdEwEB/wQF
MAMBAf8wHQYDVR0OBBYEFHAems86dTwdZbLe8AaPy3kfIUVoMA4GA1UdDwEB/wQE
AwIBhjANBgkqhkiG9w0BAQwFAAOCAgEAOBHpp0ICx81kmeoBcZTrMdJs2gnhcd85
FoSCjXx9H5XE5rmN/lQcxxOgj8hr3uPuLdLHu+i6THAyzjrl2NA1FWiqpfeECGmy
0jm7iZsYORgGQYp/VKnDrwnKNSqlZvOuRr0kfUexwFlr34Y4VmupvEOK/RdGsd3S
+3hiemcHse9ST/sJLHx962AWMkN86UHPscJEe4+eT3f2Wyzg6La8ARwdWZSNS+WH
ZfybrncMmuiXuUdHv9XspPsqhKgtHhcYeXOGUtrwQPLe3+VJZ0LVxhlTWr9951GZ
GfmWwTV/9VsyKVaCFIXeQ6L+gjcKyEzYF8wpMtQlSc7FFqwgC4bKxvMBSaRy88Nr
lV2+tJD/fr8zGUeBK44Emon0HKDBWGX+/Hq1ZIv0Da0S+j6LbA4fusWxtGfuGha+
luhHgVInCpALIOamiBEdGhILkoTtx7JrYppt3/Raqg9gUNCOOYlCvGhqX7DXeEfL
DGabooiY2FNWot6h04JE9nqGj5QqT8D6t/TL1nzxhRPzbcSDIHUd/b5R+a0bAA+7
YTU6JqzEVCWKEIEynYmqikgLMGB/OzWsgyEL6822QW6hJAQ78XpbNeCzrICF4+GC
7KShLnwuWoWpAb26268lvOEvCTFM47VC6jNQl97md+2SA9Ma81C9wflid2M83Wle
cuLMVcQZceE=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEADCCAuigAwIBAgIQAhAteLRCvizAElaWORFU2zANBgkqhkiG9w0BAQsFADCB
mDELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu
Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTEwLwYDVQQDDChB
bWF6b24gUkRTIG1lLXNvdXRoLTEgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYDVQQH
DAdTZWF0dGxlMCAXDTIxMDUyMDE3MDkxNloYDzIwNjEwNTIwMTgwOTE2WjCBmDEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTEwLwYDVQQDDChBbWF6
b24gUkRTIG1lLXNvdXRoLTEgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYDVQQHDAdT
ZWF0dGxlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA+qg7JAcOVKjh
N83SACnBFZPyB63EusfDr/0V9ZdL8lKcmZX9sv/CqoBo3N0EvBqHQqUUX6JvFb7F
XrMUZ740kr28gSRALfXTFgNODjXeDsCtEkKRTkac/UM8xXHn+hR7UFRPHS3e0GzI
iLiwQWDkr0Op74W8aM0CfaVKvh2bp4BI1jJbdDnQ9OKXpOxNHGUf0ZGb7TkNPkgI
b2CBAc8J5o3H9lfw4uiyvl6Fz5JoP+A+zPELAioYBXDrbE7wJeqQDJrETWqR9VEK
BXURCkVnHeaJy123MpAX2ozf4pqk0V0LOEOZRS29I+USF5DcWr7QIXR/w2I8ws1Q
7ys+qbE+kQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBQFJ16n
1EcCMOIhoZs/F9sR+Jy++zAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZIhvcNAQELBQAD
ggEBAOc5nXbT3XTDEZsxX2iD15YrQvmL5m13B3ImZWpx/pqmObsgx3/dg75rF2nQ
qS+Vl+f/HLh516pj2BPP/yWCq12TRYigGav8UH0qdT3CAClYy2o+zAzUJHm84oiB
ud+6pFVGkbqpsY+QMpJUbZWu52KViBpJMYsUEy+9cnPSFRVuRAHjYynSiLk2ZEjb
Wkdc4x0nOZR5tP0FgrX0Ve2KcjFwVQJVZLgOUqmFYQ/G0TIIGTNh9tcmR7yp+xJR
A2tbPV2Z6m9Yxx4E8lLEPNuoeouJ/GR4CkMEmF8cLwM310t174o3lKKUXJ4Vs2HO
Wj2uN6R9oI+jGLMSswTzCNV1vgc=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICuDCCAj6gAwIBAgIRAOocLeZWjYkG/EbHmscuy8gwCgYIKoZIzj0EAwMwgZsx
CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu
MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE0MDIGA1UEAwwrQW1h
em9uIFJEUyBhcC1zb3V0aGVhc3QtMSBSb290IENBIEVDQzM4NCBHMTEQMA4GA1UE
BwwHU2VhdHRsZTAgFw0yMTA1MjEyMTUwMDFaGA8yMTIxMDUyMTIyNTAwMVowgZsx
CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu
MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE0MDIGA1UEAwwrQW1h
em9uIFJEUyBhcC1zb3V0aGVhc3QtMSBSb290IENBIEVDQzM4NCBHMTEQMA4GA1UE
BwwHU2VhdHRsZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABCEr3jq1KtRncnZfK5cq
btY0nW6ZG3FMbh7XwBIR6Ca0f8llGZ4vJEC1pXgiM/4Dh045B9ZIzNrR54rYOIfa
2NcYZ7mk06DjIQML64hbAxbQzOAuNzLPx268MrlL2uW2XaNCMEAwDwYDVR0TAQH/
BAUwAwEB/zAdBgNVHQ4EFgQUln75pChychwN4RfHl+tOinMrfVowDgYDVR0PAQH/
BAQDAgGGMAoGCCqGSM49BAMDA2gAMGUCMGiyPINRU1mwZ4Crw01vpuPvxZxb2IOr
yX3RNlOIu4We1H+5dQk5tIvH8KGYFbWEpAIxAO9NZ6/j9osMhLgZ0yj0WVjb+uZx
YlZR9fyFisY/jNfX7QhSk+nrc3SFLRUNtpXrng==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEBTCCAu2gAwIBAgIRAKiaRZatN8eiz9p0s0lu0rQwDQYJKoZIhvcNAQELBQAw
gZoxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEzMDEGA1UEAwwq
QW1hem9uIFJEUyBjYS1jZW50cmFsLTEgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYD
VQQHDAdTZWF0dGxlMCAXDTIxMDUyMTIyMDIzNVoYDzIwNjEwNTIxMjMwMjM1WjCB
mjELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu
Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTMwMQYDVQQDDCpB
bWF6b24gUkRTIGNhLWNlbnRyYWwtMSBSb290IENBIFJTQTIwNDggRzExEDAOBgNV
BAcMB1NlYXR0bGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCygVMf
qB865IR9qYRBRFHn4eAqGJOCFx+UbraQZmjr/mnRqSkY+nhbM7Pn/DWOrRnxoh+w
q5F9ZxdZ5D5T1v6kljVwxyfFgHItyyyIL0YS7e2h7cRRscCM+75kMedAP7icb4YN
LfWBqfKHbHIOqvvQK8T6+Emu/QlG2B5LvuErrop9K0KinhITekpVIO4HCN61cuOe
CADBKF/5uUJHwS9pWw3uUbpGUwsLBuhJzCY/OpJlDqC8Y9aToi2Ivl5u3/Q/sKjr
6AZb9lx4q3J2z7tJDrm5MHYwV74elGSXoeoG8nODUqjgklIWAPrt6lQ3WJpO2kug
8RhCdSbWkcXHfX95AgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYE
FOIxhqTPkKVqKBZvMWtKewKWDvDBMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0B
AQsFAAOCAQEAqoItII89lOl4TKvg0I1EinxafZLXIheLcdGCxpjRxlZ9QMQUN3yb
y/8uFKBL0otbQgJEoGhxm4h0tp54g28M6TN1U0332dwkjYxUNwvzrMaV5Na55I2Z
1hq4GB3NMXW+PvdtsgVOZbEN+zOyOZ5MvJHEQVkT3YRnf6avsdntltcRzHJ16pJc
Y8rR7yWwPXh1lPaPkxddrCtwayyGxNbNmRybjR48uHRhwu7v2WuAMdChL8H8bp89
TQLMrMHgSbZfee9hKhO4Zebelf1/cslRSrhkG0ESq6G5MUINj6lMg2g6F0F7Xz2v
ncD/vuRN5P+vT8th/oZ0Q2Gc68Pun0cn/g==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIID/zCCAuegAwIBAgIRAJYlnmkGRj4ju/2jBQsnXJYwDQYJKoZIhvcNAQELBQAw
gZcxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEwMC4GA1UEAwwn
QW1hem9uIFJEUyB1cy1lYXN0LTIgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYDVQQH
DAdTZWF0dGxlMCAXDTIxMDUyMTIzMDQ0NFoYDzIwNjEwNTIyMDAwNDQ0WjCBlzEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdBbWF6
b24gUkRTIHVzLWVhc3QtMiBSb290IENBIFJTQTIwNDggRzExEDAOBgNVBAcMB1Nl
YXR0bGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC74V3eigv+pCj5
nqDBqplY0Jp16pTeNB06IKbzb4MOTvNde6QjsZxrE1xUmprT8LxQqN9tI3aDYEYk
b9v4F99WtQVgCv3Y34tYKX9NwWQgwS1vQwnIR8zOFBYqsAsHEkeJuSqAB12AYUSd
Zv2RVFjiFmYJho2X30IrSLQfS/IE3KV7fCyMMm154+/K1Z2IJlcissydEAwgsUHw
edrE6CxJVkkJ3EvIgG4ugK/suxd8eEMztaQYJwSdN8TdfT59LFuSPl7zmF3fIBdJ
//WexcQmGabaJ7Xnx+6o2HTfkP8Zzzzaq8fvjAcvA7gyFH5EP26G2ZqMG+0y4pTx
SPVTrQEXAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFIWWuNEF
sUMOC82XlfJeqazzrkPDMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsFAAOC
AQEAgClmxcJaQTGpEZmjElL8G2Zc8lGc+ylGjiNlSIw8X25/bcLRptbDA90nuP+q
zXAMhEf0ccbdpwxG/P5a8JipmHgqQLHfpkvaXx+0CuP++3k+chAJ3Gk5XtY587jX
+MJfrPgjFt7vmMaKmynndf+NaIJAYczjhJj6xjPWmGrjM3MlTa9XesmelMwP3jep
bApIWAvCYVjGndbK9byyMq1nyj0TUzB8oJZQooaR3MMjHTmADuVBylWzkRMxbKPl
4Nlsk4Ef1JvIWBCzsMt+X17nuKfEatRfp3c9tbpGlAE/DSP0W2/Lnayxr4RpE9ds
ICF35uSis/7ZlsftODUe8wtpkQ==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICrjCCAjOgAwIBAgIQS7vMpOTVq2Jw457NdZ2ffjAKBggqhkjOPQQDAzCBljEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMS8wLQYDVQQDDCZBbWF6
b24gUkRTIGNhLXdlc3QtMSBSb290IENBIEVDQzM4NCBHMTEQMA4GA1UEBwwHU2Vh
dHRsZTAgFw0yMzA5MTkyMjExNDNaGA8yMTIzMDkxOTIzMTE0M1owgZYxCzAJBgNV
BAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMuMRMwEQYD
VQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEvMC0GA1UEAwwmQW1hem9uIFJE
UyBjYS13ZXN0LTEgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcMB1NlYXR0bGUw
djAQBgcqhkjOPQIBBgUrgQQAIgNiAARdgGSs/F2lpWKqS1ZpcmatFED1JurmNbXG
Sqhv1A/geHrKCS15MPwjtnfZiujYKY4fNkCCUseoGDwkC4281nwkokvnfWR1/cXy
LxfACoXNxsI4b+37CezSUBl48/5p1/OjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYD
VR0OBBYEFFhLokGBuJGwKJhZcYSYKyZIitJtMA4GA1UdDwEB/wQEAwIBhjAKBggq
hkjOPQQDAwNpADBmAjEA8aQQlzJRHbqFsRY4O3u/cN0T8dzjcqnYn4NV1w+jvhzt
QPJLB+ggGyQhoFR6G2UrAjEA0be8OP5MWXD8d01KKbo5Dpy6TwukF5qoJmkFJKS3
bKfEMvFWxXoV06HNZFWdI80u
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIF/zCCA+egAwIBAgIRAPvvd+MCcp8E36lHziv0xhMwDQYJKoZIhvcNAQEMBQAw
gZcxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEwMC4GA1UEAwwn
QW1hem9uIFJEUyB1cy1lYXN0LTIgUm9vdCBDQSBSU0E0MDk2IEcxMRAwDgYDVQQH
DAdTZWF0dGxlMCAXDTIxMDUyMTIzMTEwNloYDzIxMjEwNTIyMDAxMTA2WjCBlzEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdBbWF6
b24gUkRTIHVzLWVhc3QtMiBSb290IENBIFJTQTQwOTYgRzExEDAOBgNVBAcMB1Nl
YXR0bGUwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDbvwekKIKGcV/s
lDU96a71ZdN2pTYkev1X2e2/ICb765fw/i1jP9MwCzs8/xHBEQBJSxdfO4hPeNx3
ENi0zbM+TrMKliS1kFVe1trTTEaHYjF8BMK9yTY0VgSpWiGxGwg4tshezIA5lpu8
sF6XMRxosCEVCxD/44CFqGZTzZaREIvvFPDTXKJ6yOYnuEkhH3OcoOajHN2GEMMQ
ShuyRFDQvYkqOC/Q5icqFbKg7eGwfl4PmimdV7gOVsxSlw2s/0EeeIILXtHx22z3
8QBhX25Lrq2rMuaGcD3IOMBeBo2d//YuEtd9J+LGXL9AeOXHAwpvInywJKAtXTMq
Wsy3LjhuANFrzMlzjR2YdjkGVzeQVx3dKUzJ2//Qf7IXPSPaEGmcgbxuatxjnvfT
H85oeKr3udKnXm0Kh7CLXeqJB5ITsvxI+Qq2iXtYCc+goHNR01QJwtGDSzuIMj3K
f+YMrqBXZgYBwU2J/kCNTH31nfw96WTbOfNGwLwmVRDgguzFa+QzmQsJW4FTDMwc
7cIjwdElQQVA+Gqa67uWmyDKAnoTkudmgAP+OTBkhnmc6NJuZDcy6f/iWUdl0X0u
/tsfgXXR6ZovnHonM13ANiN7VmEVqFlEMa0VVmc09m+2FYjjlk8F9sC7Rc4wt214
7u5YvCiCsFZwx44baP5viyRZgkJVpQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/
MB0GA1UdDgQWBBQgCZCsc34nVTRbWsniXBPjnUTQ2DAOBgNVHQ8BAf8EBAMCAYYw
DQYJKoZIhvcNAQEMBQADggIBAAQas3x1G6OpsIvQeMS9BbiHG3+kU9P/ba6Rrg+E
lUz8TmL04Bcd+I+R0IyMBww4NznT+K60cFdk+1iSmT8Q55bpqRekyhcdWda1Qu0r
JiTi7zz+3w2v66akofOnGevDpo/ilXGvCUJiLOBnHIF0izUqzvfczaMZGJT6xzKq
PcEVRyAN1IHHf5KnGzUlVFv9SGy47xJ9I1vTk24JU0LWkSLzMMoxiUudVmHSqJtN
u0h+n/x3Q6XguZi1/C1KOntH56ewRh8n5AF7c+9LJJSRM9wunb0Dzl7BEy21Xe9q
03xRYjf5wn8eDELB8FZPa1PrNKXIOLYM9egdctbKEcpSsse060+tkyBrl507+SJT
04lvJ4tcKjZFqxn+bUkDQvXYj0D3WK+iJ7a8kZJPRvz8BDHfIqancY8Tgw+69SUn
WqIb+HNZqFuRs16WFSzlMksqzXv6wcDSyI7aZOmCGGEcYW9NHk8EuOnOQ+1UMT9C
Qb1GJcipjRzry3M4KN/t5vN3hIetB+/PhmgTO4gKhBETTEyPC3HC1QbdVfRndB6e
U/NF2U/t8U2GvD26TTFLK4pScW7gyw4FQyXWs8g8FS8f+R2yWajhtS9++VDJQKom
fAUISoCH+PlPRJpu/nHd1Zrddeiiis53rBaLbXu2J1Q3VqjWOmtj0HjxJJxWnYmz
Pqj2
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIGATCCA+mgAwIBAgIRAI/U4z6+GF8/znpHM8Dq8G0wDQYJKoZIhvcNAQEMBQAw
gZgxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTExMC8GA1UEAwwo
QW1hem9uIFJEUyBhcC1zb3V0aC0yIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4GA1UE
BwwHU2VhdHRsZTAgFw0yMjA2MDYyMTQ4MThaGA8yMTIyMDYwNjIyNDgxOFowgZgx
CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu
MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTExMC8GA1UEAwwoQW1h
em9uIFJEUyBhcC1zb3V0aC0yIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4GA1UEBwwH
U2VhdHRsZTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK5WqMvyq888
3uuOtEj1FcP6iZhqO5kJurdJF59Otp2WCg+zv6I+QwaAspEWHQsKD405XfFsTGKV
SKTCwoMxwBniuChSmyhlagQGKSnRY9+znOWq0v7hgmJRwp6FqclTbubmr+K6lzPy
hs86mEp68O5TcOTYWUlPZDqfKwfNTbtCl5YDRr8Gxb5buHmkp6gUSgDkRsXiZ5VV
b3GBmXRqbnwo5ZRNAzQeM6ylXCn4jKs310lQGUrFbrJqlyxUdfxzqdlaIRn2X+HY
xRSYbHox3LVNPpJxYSBRvpQVFSy9xbX8d1v6OM8+xluB31cbLBtm08KqPFuqx+cO
I2H5F0CYqYzhyOSKJsiOEJT6/uH4ewryskZzncx9ae62SC+bB5n3aJLmOSTkKLFY
YS5IsmDT2m3iMgzsJNUKVoCx2zihAzgBanFFBsG+Xmoq0aKseZUI6vd2qpd5tUST
/wS1sNk0Ph7teWB2ACgbFE6etnJ6stwjHFZOj/iTYhlnR2zDRU8akunFdGb6CB4/
hMxGJxaqXSJeGtHm7FpadlUTf+2ESbYcVW+ui/F8sdBJseQdKZf3VdZZMgM0bcaX
NE47cauDTy72WdU9YJX/YXKYMLDE0iFHTnGpfVGsuWGPYhlwZ3dFIO07mWnCRM6X
u5JXRB1oy5n5HRluMsmpSN/R92MeBxKFAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMB
Af8wHQYDVR0OBBYEFNtH0F0xfijSLHEyIkRGD9gW6NazMA4GA1UdDwEB/wQEAwIB
hjANBgkqhkiG9w0BAQwFAAOCAgEACo+5jFeY3ygxoDDzL3xpfe5M0U1WxdKk+az4
/OfjZvkoma7WfChi3IIMtwtKLYC2/seKWA4KjlB3rlTsCVNPnK6D+gAnybcfTKk/
IRSPk92zagwQkSUWtAk80HpVfWJzpkSU16ejiajhedzOBRtg6BwsbSqLCDXb8hXr
eXWC1S9ZceGc+LcKRHewGWPu31JDhHE9bNcl9BFSAS0lYVZqxIRWxivZ+45j5uQv
wPrC8ggqsdU3K8quV6dblUQzzA8gKbXJpCzXZihkPrYpQHTH0szvXvgebh+CNUAG
rUxm8+yTS0NFI3U+RLbcLFVzSvjMOnEwCX0SPj5XZRYYXs5ajtQCoZhTUkkwpDV8
RxXk8qGKiXwUxDO8GRvmvM82IOiXz5w2jy/h7b7soyIgdYiUydMq4Ja4ogB/xPZa
gf4y0o+bremO15HFf1MkaU2UxPK5FFVUds05pKvpSIaQWbF5lw4LHHj4ZtVup7zF
CLjPWs4Hs/oUkxLMqQDw0FBwlqa4uot8ItT8uq5BFpz196ZZ+4WXw5PVzfSxZibI
C/nwcj0AS6qharXOs8yPnPFLPSZ7BbmWzFDgo3tpglRqo3LbSPsiZR+sLeivqydr
0w4RK1btRda5Ws88uZMmW7+2aufposMKcbAdrApDEAVzHijbB/nolS5nsnFPHZoA
KDPtFEk=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICtzCCAj2gAwIBAgIQVZ5Y/KqjR4XLou8MCD5pOjAKBggqhkjOPQQDAzCBmzEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTQwMgYDVQQDDCtBbWF6
b24gUkRTIGFwLXNvdXRoZWFzdC00IFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQH
DAdTZWF0dGxlMCAXDTIyMDUyNTE2NTgzM1oYDzIxMjIwNTI1MTc1ODMzWjCBmzEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTQwMgYDVQQDDCtBbWF6
b24gUkRTIGFwLXNvdXRoZWFzdC00IFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQH
DAdTZWF0dGxlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEbo473OmpD5vkckdJajXg
brhmNFyoSa0WCY1njuZC2zMFp3zP6rX4I1r3imrYnJd9pFH/aSiV/r6L5ACE5RPx
4qdg5SQ7JJUaZc3DWsTOiOed7BCZSzM+KTYK/2QzDMApo0IwQDAPBgNVHRMBAf8E
BTADAQH/MB0GA1UdDgQWBBTmogc06+1knsej1ltKUOdWFvwgsjAOBgNVHQ8BAf8E
BAMCAYYwCgYIKoZIzj0EAwMDaAAwZQIxAIs7TlLMbGTWNXpGiKf9DxaM07d/iDHe
F/Vv/wyWSTGdobxBL6iArQNVXz0Gr4dvPAIwd0rsoa6R0x5mtvhdRPtM37FYrbHJ
pbV+OMusQqcSLseunLBoCHenvJW0QOCQ8EDY
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIGBTCCA+2gAwIBAgIRAO9dVdiLTEGO8kjUFExJmgowDQYJKoZIhvcNAQEMBQAw
gZoxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEzMDEGA1UEAwwq
QW1hem9uIFJEUyBpbC1jZW50cmFsLTEgUm9vdCBDQSBSU0E0MDk2IEcxMRAwDgYD
VQQHDAdTZWF0dGxlMCAXDTIyMTIwMjIwMjYwOFoYDzIxMjIxMjAyMjEyNjA4WjCB
mjELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu
Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTMwMQYDVQQDDCpB
bWF6b24gUkRTIGlsLWNlbnRyYWwtMSBSb290IENBIFJTQTQwOTYgRzExEDAOBgNV
BAcMB1NlYXR0bGUwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDkVHmJ
bUc8CNDGBcgPmXHSHj5dS1PDnnpk3doCu6pahyYXW8tqAOmOqsDuNz48exY7YVy4
u9I9OPBeTYB9ZUKwxq+1ZNLsr1cwVz5DdOyDREVFOjlU4rvw0eTgzhP5yw/d+Ai/
+WmPebZG0irwPKN2f60W/KJ45UNtR+30MT8ugfnPuSHWjjV+dqCOCp/mj8nOCckn
k8GoREwjuTFJMKInpQUC0BaVVX6LiIdgtoLY4wdx00EqNBuROoRTAvrked0jvm7J
UI39CSYxhNZJ9F6LdESZXjI4u2apfNQeSoy6WptxFHr+kh2yss1B2KT6lbwGjwWm
l9HODk9kbBNSy2NeewAms36q+p8wSLPavL28IRfK0UaBAiN1hr2a/2RDGCwOJmw6
5erRC5IIX5kCStyXPEGhVPp18EvMuBd37eLIxjZBBO8AIDf4Ue8QmxSeZH0cT204
3/Bd6XR6+Up9iMTxkHr1URcL1AR8Zd62lg/lbEfxePNMK9mQGxKP8eTMG5AjtW9G
TatEoRclgE0wZQalXHmKpBNshyYdGqQZhzL1MxCxWzfHNgZkTKIsdzxrjnP7RiBR
jdRH0YhXn6Y906QfLwMCaufwfQ5J8+nj/tu7nG138kSxsu6VUkhnQJhUcUsxuHD/
NnBx0KGVEldtZiZf7ccgtRVp1lA0OrVtq3ZLMQIDAQABo0IwQDAPBgNVHRMBAf8E
BTADAQH/MB0GA1UdDgQWBBQ2WC3p8rWeE2N0S4Om01KsNLpk/jAOBgNVHQ8BAf8E
BAMCAYYwDQYJKoZIhvcNAQEMBQADggIBAFFEVDt45Obr6Ax9E4RMgsKjj4QjMFB9
wHev1jL7hezl/ULrHuWxjIusaIZEIcKfn+v2aWtqOq13P3ht7jV5KsV29CmFuCdQ
q3PWiAXVs+hnMskTOmGMDnptqd6/UuSIha8mlOKKAvnmRQJvfX9hIfb/b/mVyKWD
uvTTmcy3cOTJY5ZIWGyzuvmcqA0YNcb7rkJt/iaLq4RX3/ofq4y4w36hefbcvj++
pXHOmXk3dAej3y6SMBOUcGMyCJcCluRPNYKDTLn+fitcPxPC3JG7fI5bxQ0D6Hpa
qbyGBQu96sfahQyMc+//H8EYlo4b0vPeS5RFFXJS/VBf0AyNT4vVc7H17Q6KjeNp
wEARqsIa7UalHx9MnxrQ/LSTTxiC8qmDkIFuQtw8iQMN0SoL5S0eCZNRD31awgaY
y1PvY8JMN549ugIUjOXnown/OxharLW1evWUraU5rArq3JfeFpPXl4K/u10T5SCL
iJRoxFilGPMFE3hvnmbi5rEy8wRUn7TpLb4I4s/CB/lT2qZTPqvQHwxKCnMm9BKF
NHb4rLL5dCvUi5NJ6fQ/exOoGdOVSfT7jqFeq2TtNunERSz9vpriweliB6iIe1Al
Thj8aEs1GqA764rLVGA+vUe18NhjJm9EemrdIzjSQFy/NdbN/DMaHqEzJogWloAI
izQWYnCS19TJ
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICvTCCAkOgAwIBAgIQCIY7E/bFvFN2lK9Kckb0dTAKBggqhkjOPQQDAzCBnjEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTcwNQYDVQQDDC5BbWF6
b24gUkRTIFByZXZpZXcgdXMtZWFzdC0yIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYD
VQQHDAdTZWF0dGxlMCAXDTIxMDUxODIxMDUxMFoYDzIxMjEwNTE4MjIwNTEwWjCB
njELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu
Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTcwNQYDVQQDDC5B
bWF6b24gUkRTIFByZXZpZXcgdXMtZWFzdC0yIFJvb3QgQ0EgRUNDMzg0IEcxMRAw
DgYDVQQHDAdTZWF0dGxlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEMI0hzf1JCEOI
Eue4+DmcNnSs2i2UaJxHMrNGGfU7b42a7vwP53F7045ffHPBGP4jb9q02/bStZzd
VHqfcgqkSRI7beBKjD2mfz82hF/wJSITTgCLs+NRpS6zKMFOFHUNo0IwQDAPBgNV
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBS8uF/6hk5mPLH4qaWv9NVZaMmyTjAOBgNV
HQ8BAf8EBAMCAYYwCgYIKoZIzj0EAwMDaAAwZQIxAO7Pu9wzLyM0X7Q08uLIL+vL
qaxe3UFuzFTWjM16MLJHbzLf1i9IDFKz+Q4hXCSiJwIwClMBsqT49BPUxVsJnjGr
EbyEk6aOOVfY1p2yQL649zh3M4h8okLnwf+bYIb1YpeU
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEADCCAuigAwIBAgIQY+JhwFEQTe36qyRlUlF8ozANBgkqhkiG9w0BAQsFADCB
mDELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu
Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTEwLwYDVQQDDChB
bWF6b24gUkRTIGFmLXNvdXRoLTEgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYDVQQH
DAdTZWF0dGxlMCAXDTIxMDUxOTE5MjQxNloYDzIwNjEwNTE5MjAyNDE2WjCBmDEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTEwLwYDVQQDDChBbWF6
b24gUkRTIGFmLXNvdXRoLTEgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYDVQQHDAdT
ZWF0dGxlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnIye77j6ev40
8wRPyN2OdKFSUfI9jB20Or2RLO+RDoL43+USXdrze0Wv4HMRLqaen9BcmCfaKMp0
E4SFo47bXK/O17r6G8eyq1sqnHE+v288mWtYH9lAlSamNFRF6YwA7zncmE/iKL8J
0vePHMHP/B6svw8LULZCk+nZk3tgxQn2+r0B4FOz+RmpkoVddfqqUPMbKUxhM2wf
fO7F6bJaUXDNMBPhCn/3ayKCjYr49ErmnpYV2ZVs1i34S+LFq39J7kyv6zAgbHv9
+/MtRMoRB1CjpqW0jIOZkHBdYcd1o9p1zFn591Do1wPkmMsWdjIYj+6e7UXcHvOB
2+ScIRAcnwIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBQGtq2W
YSyMMxpdQ3IZvcGE+nyZqTAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZIhvcNAQELBQAD
ggEBAEgoP3ixJsKSD5FN8dQ01RNHERl/IFbA7TRXfwC+L1yFocKnQh4Mp/msPRSV
+OeHIvemPW/wtZDJzLTOFJ6eTolGekHK1GRTQ6ZqsWiU2fmiOP8ks4oSpI+tQ9Lw
VrfZqTiEcS5wEIqyfUAZZfKDo7W1xp+dQWzfczSBuZJZwI5iaha7+ILM0r8Ckden
TVTapc5pLSoO15v0ziRuQ2bT3V3nwu/U0MRK44z+VWOJdSiKxdnOYDs8hFNnKhfe
klbTZF7kW7WbiNYB43OaAQBJ6BALZsIskEaqfeZT8FD71uN928TcEQyBDXdZpRN+
iGQZDGhht0r0URGMDSs9waJtTfA=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIF/jCCA+agAwIBAgIQXY/dmS+72lZPranO2JM9jjANBgkqhkiG9w0BAQwFADCB
lzELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu
Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdB
bWF6b24gUkRTIGFwLWVhc3QtMSBSb290IENBIFJTQTQwOTYgRzExEDAOBgNVBAcM
B1NlYXR0bGUwIBcNMjEwNTI1MjEzNDUxWhgPMjEyMTA1MjUyMjM0NTFaMIGXMQsw
CQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjET
MBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMDAuBgNVBAMMJ0FtYXpv
biBSRFMgYXAtZWFzdC0xIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4GA1UEBwwHU2Vh
dHRsZTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMyW9kBJjD/hx8e8
b5E1sF42bp8TXsz1htSYE3Tl3T1Aq379DfEhB+xa/ASDZxt7/vwa81BkNo4M6HYq
okYIXeE7cu5SnSgjWXqcERhgPevtAwgmhdE3yREe8oz2DyOi2qKKZqah+1gpPaIQ
fK0uAqoeQlyHosye3KZZKkDHBatjBsQ5kf8lhuf7wVulEZVRHY2bP2X7N98PfbpL
QdH7mWXzDtJJ0LiwFwds47BrkgK1pkHx2p1mTo+HMkfX0P6Fq1atkVC2RHHtbB/X
iYyH7paaHBzviFrhr679zNqwXIOKlbf74w3mS11P76rFn9rS1BAH2Qm6eY5S/Fxe
HEKXm4kjPN63Zy0p3yE5EjPt54yPkvumOnT+RqDGJ2HCI9k8Ehcbve0ogfdRKNqQ
VHWYTy8V33ndQRHZlx/CuU1yN61TH4WSoMly1+q1ihTX9sApmlQ14B2pJi/9DnKW
cwECrPy1jAowC2UJ45RtC8UC05CbP9yrIy/7Noj8gQDiDOepm+6w1g6aNlWoiuQS
kyI6nzz1983GcnOHya73ga7otXo0Qfg9jPghlYiMomrgshlSLDHZG0Ib/3hb8cnR
1OcN9FpzNmVK2Ll1SmTMLrIhuCkyNYX9O/bOknbcf706XeESxGduSkHEjIw/k1+2
Atteoq5dT6cwjnJ9hyhiueVlVkiDAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8w
HQYDVR0OBBYEFLUI+DD7RJs+0nRnjcwIVWzzYSsFMA4GA1UdDwEB/wQEAwIBhjAN
BgkqhkiG9w0BAQwFAAOCAgEAb1mcCHv4qMQetLGTBH9IxsB2YUUhr5dda0D2BcHr
UtDbfd0VQs4tux6h/6iKwHPx0Ew8fuuYj99WknG0ffgJfNc5/fMspxR/pc1jpdyU
5zMQ+B9wi0lOZPO9uH7/pr+d2odcNEy8zAwqdv/ihsTwLmGP54is9fVbsgzNW1cm
HKAVL2t/Ope+3QnRiRilKCN1lzhav4HHdLlN401TcWRWKbEuxF/FgxSO2Hmx86pj
e726lweCTMmnq/cTsPOVY0WMjs0or3eHDVlyLgVeV5ldyN+ptg3Oit60T05SRa58
AJPTaVKIcGQ/gKkKZConpu7GDofT67P/ox0YNY57LRbhsx9r5UY4ROgz7WMQ1yoS
Y+19xizm+mBm2PyjMUbfwZUyCxsdKMwVdOq5/UmTmdms+TR8+m1uBHPOTQ2vKR0s
Pd/THSzPuu+d3dbzRyDSLQbHFFneG760CUlD/ZmzFlQjJ89/HmAmz8IyENq+Sjhx
Jgzy+FjVZb8aRUoYLlnffpUpej1n87Ynlr1GrvC4GsRpNpOHlwuf6WD4W0qUTsC/
C9JO+fBzUj/aWlJzNcLEW6pte1SB+EdkR2sZvWH+F88TxemeDrV0jKJw5R89CDf8
ZQNfkxJYjhns+YeV0moYjqQdc7tq4i04uggEQEtVzEhRLU5PE83nlh/K2NZZm8Kj
dIA=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIID/zCCAuegAwIBAgIRAPVSMfFitmM5PhmbaOFoGfUwDQYJKoZIhvcNAQELBQAw
gZcxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEwMC4GA1UEAwwn
QW1hem9uIFJEUyB1cy1lYXN0LTEgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYDVQQH
DAdTZWF0dGxlMCAXDTIxMDUyNTIyMzQ1N1oYDzIwNjEwNTI1MjMzNDU3WjCBlzEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdBbWF6
b24gUkRTIHVzLWVhc3QtMSBSb290IENBIFJTQTIwNDggRzExEDAOBgNVBAcMB1Nl
YXR0bGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDu9H7TBeGoDzMr
dxN6H8COntJX4IR6dbyhnj5qMD4xl/IWvp50lt0VpmMd+z2PNZzx8RazeGC5IniV
5nrLg0AKWRQ2A/lGGXbUrGXCSe09brMQCxWBSIYe1WZZ1iU1IJ/6Bp4D2YEHpXrW
bPkOq5x3YPcsoitgm1Xh8ygz6vb7PsvJvPbvRMnkDg5IqEThapPjmKb8ZJWyEFEE
QRrkCIRueB1EqQtJw0fvP4PKDlCJAKBEs/y049FoOqYpT3pRy0WKqPhWve+hScMd
6obq8kxTFy1IHACjHc51nrGII5Bt76/MpTWhnJIJrCnq1/Uc3Qs8IVeb+sLaFC8K
DI69Sw6bAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFE7PCopt
lyOgtXX0Y1lObBUxuKaCMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsFAAOC
AQEAFj+bX8gLmMNefr5jRJfHjrL3iuZCjf7YEZgn89pS4z8408mjj9z6Q5D1H7yS
jNETVV8QaJip1qyhh5gRzRaArgGAYvi2/r0zPsy+Tgf7v1KGL5Lh8NT8iCEGGXwF
g3Ir+Nl3e+9XUp0eyyzBIjHtjLBm6yy8rGk9p6OtFDQnKF5OxwbAgip42CD75r/q
p421maEDDvvRFR4D+99JZxgAYDBGqRRceUoe16qDzbMvlz0A9paCZFclxeftAxv6
QlR5rItMz/XdzpBJUpYhdzM0gCzAzdQuVO5tjJxmXhkSMcDP+8Q+Uv6FA9k2VpUV
E/O5jgpqUJJ2Hc/5rs9VkAPXeA==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICrzCCAjWgAwIBAgIQW0yuFCle3uj4vWiGU0SaGzAKBggqhkjOPQQDAzCBlzEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdBbWF6
b24gUkRTIGFmLXNvdXRoLTEgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcMB1Nl
YXR0bGUwIBcNMjEwNTE5MTkzNTE2WhgPMjEyMTA1MTkyMDM1MTZaMIGXMQswCQYD
VQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjETMBEG
A1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMDAuBgNVBAMMJ0FtYXpvbiBS
RFMgYWYtc291dGgtMSBSb290IENBIEVDQzM4NCBHMTEQMA4GA1UEBwwHU2VhdHRs
ZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABDPiKNZSaXs3Un/J/v+LTsFDANHpi7en
oL2qh0u0DoqNzEBTbBjvO23bLN3k599zh6CY3HKW0r2k1yaIdbWqt4upMCRCcUFi
I4iedAmubgzh56wJdoMZztjXZRwDthTkJKNCMEAwDwYDVR0TAQH/BAUwAwEB/zAd
BgNVHQ4EFgQUWbYkcrvVSnAWPR5PJhIzppcAnZIwDgYDVR0PAQH/BAQDAgGGMAoG
CCqGSM49BAMDA2gAMGUCMCESGqpat93CjrSEjE7z+Hbvz0psZTHwqaxuiH64GKUm
mYynIiwpKHyBrzjKBmeDoQIxANGrjIo6/b8Jl6sdIZQI18V0pAyLfLiZjlHVOnhM
MOTVgr82ZuPoEHTX78MxeMnYlw==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIECTCCAvGgAwIBAgIRAIbsx8XOl0sgTNiCN4O+18QwDQYJKoZIhvcNAQELBQAw
gZwxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE1MDMGA1UEAwws
QW1hem9uIFJEUyBhcC1ub3J0aGVhc3QtMSBSb290IENBIFJTQTIwNDggRzExEDAO
BgNVBAcMB1NlYXR0bGUwIBcNMjEwNTI1MjE1NDU4WhgPMjA2MTA1MjUyMjU0NTha
MIGcMQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywg
SW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExNTAzBgNVBAMM
LEFtYXpvbiBSRFMgYXAtbm9ydGhlYXN0LTEgUm9vdCBDQSBSU0EyMDQ4IEcxMRAw
DgYDVQQHDAdTZWF0dGxlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA
tROxwXWCgn5R9gI/2Ivjzaxc0g95ysBjoJsnhPdJEHQb7w3y2kWrVWU3Y9fOitgb
CEsnEC3PrhRnzNVW0fPsK6kbvOeCmjvY30rdbxbc8h+bjXfGmIOgAkmoULEr6Hc7
G1Q/+tvv4lEwIs7bEaf+abSZxRJbZ0MBxhbHn7UHHDiMZYvzK+SV1MGCxx7JVhrm
xWu3GC1zZCsGDhB9YqY9eR6PmjbqA5wy8vqbC57dZZa1QVtWIQn3JaRXn+faIzHx
nLMN5CEWihsdmHBXhnRboXprE/OS4MFv1UrQF/XM/h5RBeCywpHePpC+Oe1T3LNC
iP8KzRFrjC1MX/WXJnmOVQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1Ud
DgQWBBS33XbXAUMs1znyZo4B0+B3D68WFTAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZI
hvcNAQELBQADggEBADuadd2EmlpueY2VlrIIPC30QkoA1EOSoCmZgN6124apkoY1
HiV4r+QNPljN4WP8gmcARnNkS7ZeR4fvWi8xPh5AxQCpiaBMw4gcbTMCuKDV68Pw
P2dZCTMspvR3CDfM35oXCufdtFnxyU6PAyINUqF/wyTHguO3owRFPz64+sk3r2pT
WHmJjG9E7V+KOh0s6REgD17Gqn6C5ijLchSrPUHB0wOIkeLJZndHxN/76h7+zhMt
fFeNxPWHY2MfpcaLjz4UREzZPSB2U9k+y3pW1omCIcl6MQU9itGx/LpQE+H3ZeX2
M2bdYd5L+ow+bdbGtsVKOuN+R9Dm17YpswF+vyQ=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIGATCCA+mgAwIBAgIRAKlQ+3JX9yHXyjP/Ja6kZhkwDQYJKoZIhvcNAQEMBQAw
gZgxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTExMC8GA1UEAwwo
QW1hem9uIFJEUyBhcC1zb3V0aC0xIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4GA1UE
BwwHU2VhdHRsZTAgFw0yMTA1MTkxNzQ1MjBaGA8yMTIxMDUxOTE4NDUyMFowgZgx
CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu
MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTExMC8GA1UEAwwoQW1h
em9uIFJEUyBhcC1zb3V0aC0xIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4GA1UEBwwH
U2VhdHRsZTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAKtahBrpUjQ6
H2mni05BAKU6Z5USPZeSKmBBJN3YgD17rJ93ikJxSgzJ+CupGy5rvYQ0xznJyiV0
91QeQN4P+G2MjGQR0RGeUuZcfcZitJro7iAg3UBvw8WIGkcDUg+MGVpRv/B7ry88
7E4OxKb8CPNoa+a9j6ABjOaaxaI22Bb7j3OJ+JyMICs6CU2bgkJaj3VUV9FCNUOc
h9PxD4jzT9yyGYm/sK9BAT1WOTPG8XQUkpcFqy/IerZDfiQkf1koiSd4s5VhBkUn
aQHOdri/stldT7a+HJFVyz2AXDGPDj+UBMOuLq0K6GAT6ThpkXCb2RIf4mdTy7ox
N5BaJ+ih+Ro3ZwPkok60egnt/RN98jgbm+WstgjJWuLqSNInnMUgkuqjyBWwePqX
Kib+wdpyx/LOzhKPEFpeMIvHQ3A0sjlulIjnh+j+itezD+dp0UNxMERlW4Bn/IlS
sYQVNfYutWkRPRLErXOZXtlxxkI98JWQtLjvGzQr+jywxTiw644FSLWdhKa6DtfU
2JWBHqQPJicMElfZpmfaHZjtXuCZNdZQXWg7onZYohe281ZrdFPOqC4rUq7gYamL
T+ZB+2P+YCPOLJ60bj/XSvcB7mesAdg8P0DNddPhHUFWx2dFqOs1HxIVB4FZVA9U
Ppbv4a484yxjTgG7zFZNqXHKTqze6rBBAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMB
Af8wHQYDVR0OBBYEFCEAqjighncv/UnWzBjqu1Ka2Yb4MA4GA1UdDwEB/wQEAwIB
hjANBgkqhkiG9w0BAQwFAAOCAgEAYyvumblckIXlohzi3QiShkZhqFzZultbFIu9
GhA5CDar1IFMhJ9vJpO9nUK/camKs1VQRs8ZsBbXa0GFUM2p8y2cgUfLwFULAiC/
sWETyW5lcX/xc4Pyf6dONhqFJt/ovVBxNZtcmMEWv/1D6Tf0nLeEb0P2i/pnSRR4
Oq99LVFjossXtyvtaq06OSiUUZ1zLPvV6AQINg8dWeBOWRcQYhYcEcC2wQ06KShZ
0ahuu7ar5Gym3vuLK6nH+eQrkUievVomN/LpASrYhK32joQ5ypIJej3sICIgJUEP
UoeswJ+Z16f3ECoL1OSnq4A0riiLj1ZGmVHNhM6m/gotKaHNMxsK9zsbqmuU6IT/
P6cR0S+vdigQG8ZNFf5vEyVNXhl8KcaJn6lMD/gMB2rY0qpaeTg4gPfU5wcg8S4Y
C9V//tw3hv0f2n+8kGNmqZrylOQDQWSSo8j8M2SRSXiwOHDoTASd1fyBEIqBAwzn
LvXVg8wQd1WlmM3b0Vrsbzltyh6y4SuKSkmgufYYvC07NknQO5vqvZcNoYbLNea3
76NkFaMHUekSbwVejZgG5HGwbaYBgNdJEdpbWlA3X4yGRVxknQSUyt4dZRnw/HrX
k8x6/wvtw7wht0/DOqz1li7baSsMazqxx+jDdSr1h9xML416Q4loFCLgqQhil8Jq
Em4Hy3A=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEBDCCAuygAwIBAgIQFn6AJ+uxaPDpNVx7174CpjANBgkqhkiG9w0BAQsFADCB
mjELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu
Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTMwMQYDVQQDDCpB
bWF6b24gUkRTIGlsLWNlbnRyYWwtMSBSb290IENBIFJTQTIwNDggRzExEDAOBgNV
BAcMB1NlYXR0bGUwIBcNMjIxMjAyMjAxNDA4WhgPMjA2MjEyMDIyMTE0MDhaMIGa
MQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5j
LjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMzAxBgNVBAMMKkFt
YXpvbiBSRFMgaWwtY2VudHJhbC0xIFJvb3QgQ0EgUlNBMjA0OCBHMTEQMA4GA1UE
BwwHU2VhdHRsZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL2xGTSJ
fXorki/dkkTqdLyv4U1neeFYEyUCPN/HJ7ZloNwhj8RBrHYhZ4qtvUAvN+rs8fUm
L0wmaL69ye61S+CSfDzNwBDGwOzUm/cc1NEJOHCm8XA0unBNBvpJTjsFk2LQ+rz8
oU0lVV4mjnfGektrTDeADonO1adJvUTYmF6v1wMnykSkp8AnW9EG/6nwcAJuAJ7d
BfaLThm6lfxPdsBNG81DLKi2me2TLQ4yl+vgRKJi2fJWwA77NaDqQuD5upRIcQwt
5noJt2kFFmeiro98ZMMRaDTHAHhJfWkwkw5f2QNIww7T4r85IwbQCgJVRo4m4ZTC
W/1eiEccU2407mECAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU
DNhVvGHzKXv0Yh6asK0apP9jJlUwDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEB
CwUAA4IBAQCoEVTUY/rF9Zrlpb1Y1hptEguw0i2pCLakcmv3YNj6thsubbGeGx8Z
RjUA/gPKirpoae2HU1y64WEu7akwr6pdTRtXXjbe9NReT6OW/0xAwceSXCOiStqS
cMsWWTGg6BA3uHqad5clqITjDZr1baQ8X8en4SXRBxXyhJXbOkB60HOQeFR9CNeh
pJdrWLeNYXwU0Z59juqdVMGwvDAYdugWUhW2rhafVUXszfRA5c8Izc+E31kq90aY
LmxFXUHUfG0eQOmxmg+Z/nG7yLUdHIFA3id8MRh22hye3KvRdQ7ZVGFni0hG2vQQ
Q01AvD/rhzyjg0czzJKLK9U/RttwdMaV
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIGBTCCA+2gAwIBAgIRAJfKe4Zh4aWNt3bv6ZjQwogwDQYJKoZIhvcNAQEMBQAw
gZoxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEzMDEGA1UEAwwq
QW1hem9uIFJEUyBjYS1jZW50cmFsLTEgUm9vdCBDQSBSU0E0MDk2IEcxMRAwDgYD
VQQHDAdTZWF0dGxlMCAXDTIxMDUyMTIyMDg1M1oYDzIxMjEwNTIxMjMwODUzWjCB
mjELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu
Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTMwMQYDVQQDDCpB
bWF6b24gUkRTIGNhLWNlbnRyYWwtMSBSb290IENBIFJTQTQwOTYgRzExEDAOBgNV
BAcMB1NlYXR0bGUwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCpgUH6
Crzd8cOw9prAh2rkQqAOx2vtuI7xX4tmBG4I/um28eBjyVmgwQ1fpq0Zg2nCKS54
Nn0pCmT7f3h6Bvopxn0J45AzXEtajFqXf92NQ3iPth95GVfAJSD7gk2LWMhpmID9
JGQyoGuDPg+hYyr292X6d0madzEktVVGO4mKTF989qEg+tY8+oN0U2fRTrqa2tZp
iYsmg350ynNopvntsJAfpCO/srwpsqHHLNFZ9jvhTU8uW90wgaKO9i31j/mHggCE
+CAOaJCM3g+L8DPl/2QKsb6UkBgaaIwKyRgKSj1IlgrK+OdCBCOgM9jjId4Tqo2j
ZIrrPBGl6fbn1+etZX+2/tf6tegz+yV0HHQRAcKCpaH8AXF44bny9andslBoNjGx
H6R/3ib4FhPrnBMElzZ5i4+eM/cuPC2huZMBXb/jKgRC/QN1Wm3/nah5FWq+yn+N
tiAF10Ga0BYzVhHDEwZzN7gn38bcY5yi/CjDUNpY0OzEe2+dpaBKPlXTaFfn9Nba
CBmXPRF0lLGGtPeTAgjcju+NEcVa82Ht1pqxyu2sDtbu3J5bxp4RKtj+ShwN8nut
Tkf5Ea9rSmHEY13fzgibZlQhXaiFSKA2ASUwgJP19Putm0XKlBCNSGCoECemewxL
+7Y8FszS4Uu4eaIwvXVqUEE2yf+4ex0hqQ1acQIDAQABo0IwQDAPBgNVHRMBAf8E
BTADAQH/MB0GA1UdDgQWBBSeUnXIRxNbYsZLtKomIz4Y1nOZEzAOBgNVHQ8BAf8E
BAMCAYYwDQYJKoZIhvcNAQEMBQADggIBAIpRvxVS0dzoosBh/qw65ghPUGSbP2D4
dm6oYCv5g/zJr4fR7NzEbHOXX5aOQnHbQL4M/7veuOCLNPOW1uXwywMg6gY+dbKe
YtPVA1as8G9sUyadeXyGh2uXGsziMFXyaESwiAXZyiYyKChS3+g26/7jwECFo5vC
XGhWpIO7Hp35Yglp8AnwnEAo/PnuXgyt2nvyTSrxlEYa0jus6GZEZd77pa82U1JH
qFhIgmKPWWdvELA3+ra1nKnvpWM/xX0pnMznMej5B3RT3Y+k61+kWghJE81Ix78T
+tG4jSotgbaL53BhtQWBD1yzbbilqsGE1/DXPXzHVf9yD73fwh2tGWSaVInKYinr
a4tcrB3KDN/PFq0/w5/21lpZjVFyu/eiPj6DmWDuHW73XnRwZpHo/2OFkei5R7cT
rn/YdDD6c1dYtSw5YNnS6hdCQ3sOiB/xbPRN9VWJa6se79uZ9NLz6RMOr73DNnb2
bhIR9Gf7XAA5lYKqQk+A+stoKbIT0F65RnkxrXi/6vSiXfCh/bV6B41cf7MY/6YW
ehserSdjhQamv35rTFdM+foJwUKz1QN9n9KZhPxeRmwqPitAV79PloksOnX25ElN
SlyxdndIoA1wia1HRd26EFm2pqfZ2vtD2EjU3wD42CXX4H8fKVDna30nNFSYF0yn
jGKc3k6UNxpg
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIF/jCCA+agAwIBAgIQaRHaEqqacXN20e8zZJtmDDANBgkqhkiG9w0BAQwFADCB
lzELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu
Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdB
bWF6b24gUkRTIHVzLWVhc3QtMSBSb290IENBIFJTQTQwOTYgRzExEDAOBgNVBAcM
B1NlYXR0bGUwIBcNMjEwNTI1MjIzODM1WhgPMjEyMTA1MjUyMzM4MzVaMIGXMQsw
CQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjET
MBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMDAuBgNVBAMMJ0FtYXpv
biBSRFMgdXMtZWFzdC0xIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4GA1UEBwwHU2Vh
dHRsZTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAInfBCaHuvj6Rb5c
L5Wmn1jv2PHtEGMHm+7Z8dYosdwouG8VG2A+BCYCZfij9lIGszrTXkY4O7vnXgru
JUNdxh0Q3M83p4X+bg+gODUs3jf+Z3Oeq7nTOk/2UYvQLcxP4FEXILxDInbQFcIx
yen1ESHggGrjEodgn6nbKQNRfIhjhW+TKYaewfsVWH7EF2pfj+cjbJ6njjgZ0/M9
VZifJFBgat6XUTOf3jwHwkCBh7T6rDpgy19A61laImJCQhdTnHKvzTpxcxiLRh69
ZObypR7W04OAUmFS88V7IotlPmCL8xf7kwxG+gQfvx31+A9IDMsiTqJ1Cc4fYEKg
bL+Vo+2Ii4W2esCTGVYmHm73drznfeKwL+kmIC/Bq+DrZ+veTqKFYwSkpHRyJCEe
U4Zym6POqQ/4LBSKwDUhWLJIlq99bjKX+hNTJykB+Lbcx0ScOP4IAZQoxmDxGWxN
S+lQj+Cx2pwU3S/7+OxlRndZAX/FKgk7xSMkg88HykUZaZ/ozIiqJqSnGpgXCtED
oQ4OJw5ozAr+/wudOawaMwUWQl5asD8fuy/hl5S1nv9XxIc842QJOtJFxhyeMIXt
LVECVw/dPekhMjS3Zo3wwRgYbnKG7YXXT5WMxJEnHu8+cYpMiRClzq2BEP6/MtI2
AZQQUFu2yFjRGL2OZA6IYjxnXYiRAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8w
HQYDVR0OBBYEFADCcQCPX2HmkqQcmuHfiQ2jjqnrMA4GA1UdDwEB/wQEAwIBhjAN
BgkqhkiG9w0BAQwFAAOCAgEASXkGQ2eUmudIKPeOIF7RBryCoPmMOsqP0+1qxF8l
pGkwmrgNDGpmd9s0ArfIVBTc1jmpgB3oiRW9c6n2OmwBKL4UPuQ8O3KwSP0iD2sZ
KMXoMEyphCEzW1I2GRvYDugL3Z9MWrnHkoaoH2l8YyTYvszTvdgxBPpM2x4pSkp+
76d4/eRpJ5mVuQ93nC+YG0wXCxSq63hX4kyZgPxgCdAA+qgFfKIGyNqUIqWgeyTP
n5OgKaboYk2141Rf2hGMD3/hsGm0rrJh7g3C0ZirPws3eeJfulvAOIy2IZzqHUSY
jkFzraz6LEH3IlArT3jUPvWKqvh2lJWnnp56aqxBR7qHH5voD49UpJWY1K0BjGnS
OHcurpp0Yt/BIs4VZeWdCZwI7JaSeDcPMaMDBvND3Ia5Fga0thgYQTG6dE+N5fgF
z+hRaujXO2nb0LmddVyvE8prYlWRMuYFv+Co8hcMdJ0lEZlfVNu0jbm9/GmwAZ+l
9umeYO9yz/uC7edC8XJBglMAKUmVK9wNtOckUWAcCfnPWYLbYa/PqtXBYcxrso5j
iaS/A7iEW51uteHBGrViCy1afGG+hiUWwFlesli+Rq4dNstX3h6h2baWABaAxEVJ
y1RnTQSz6mROT1VmZSgSVO37rgIyY0Hf0872ogcTS+FfvXgBxCxsNWEbiQ/XXva4
0Ws=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICtDCCAjqgAwIBAgIRAMyaTlVLN0ndGp4ffwKAfoMwCgYIKoZIzj0EAwMwgZkx
CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu
MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEyMDAGA1UEAwwpQW1h
em9uIFJEUyBtZS1jZW50cmFsLTEgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcM
B1NlYXR0bGUwIBcNMjIwNTA3MDA0NDM3WhgPMjEyMjA1MDcwMTQ0MzdaMIGZMQsw
CQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjET
MBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMjAwBgNVBAMMKUFtYXpv
biBSRFMgbWUtY2VudHJhbC0xIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQHDAdT
ZWF0dGxlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE19nCV1nsI6CohSor13+B25cr
zg+IHdi9Y3L7ziQnHWI6yjBazvnKD+oC71aRRlR8b5YXsYGUQxWzPLHN7EGPcSGv
bzA9SLG1KQYCJaQ0m9Eg/iGrwKWOgylbhVw0bCxoo0IwQDAPBgNVHRMBAf8EBTAD
AQH/MB0GA1UdDgQWBBS4KsknsJXM9+QPEkBdZxUPaLr11zAOBgNVHQ8BAf8EBAMC
AYYwCgYIKoZIzj0EAwMDaAAwZQIxAJaRgrYIEfXQMZQQDxMTYS0azpyWSseQooXo
L3nYq4OHGBgYyQ9gVjvRYWU85PXbfgIwdi82DtANQFkCu+j+BU0JBY/uRKPEeYzo
JG92igKIcXPqCoxIJ7lJbbzmuf73gQu5
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIGATCCA+mgAwIBAgIRAJwCobx0Os8F7ihbJngxrR8wDQYJKoZIhvcNAQEMBQAw
gZgxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTExMC8GA1UEAwwo
QW1hem9uIFJEUyBtZS1zb3V0aC0xIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4GA1UE
BwwHU2VhdHRsZTAgFw0yMTA1MjAxNzE1MzNaGA8yMTIxMDUyMDE4MTUzM1owgZgx
CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu
MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTExMC8GA1UEAwwoQW1h
em9uIFJEUyBtZS1zb3V0aC0xIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4GA1UEBwwH
U2VhdHRsZTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANukKwlm+ZaI
Y5MkWGbEVLApEyLmlrHLEg8PfiiEa9ts7jssQcin3bzEPdTqGr5jo91ONoZ3ccWq
xJgg1W3bLu5CAO2CqIOXTXHRyCO/u0Ch1FGgWB8xETPSi3UHt/Vn1ltdO6DYdbDU
mYgwzYrvLBdRCwxsb9o+BuYQHVFzUYonqk/y9ujz3gotzFq7r55UwDTA1ita3vb4
eDKjIb4b1M4Wr81M23WHonpje+9qkkrAkdQcHrkgvSCV046xsq/6NctzwCUUNsgF
7Q1a8ut5qJEYpz5ta8vI1rqFqAMBqCbFjRYlmAoTTpFPOmzAVxV+YoqTrW5A16su
/2SXlMYfJ/n/ad/QfBNPPAAQMpyOr2RCL/YiL/PFZPs7NxYjnZHNWxMLSPgFyI+/
t2klnn5jR76KJK2qimmaXedB90EtFsMRUU1e4NxH9gDuyrihKPJ3aVnZ35mSipvR
/1KB8t8gtFXp/VQaz2sg8+uxPMKB81O37fL4zz6Mg5K8+aq3ejBiyHucpFGnsnVB
3kQWeD36ONkybngmgWoyPceuSWm1hQ0Z7VRAQX+KlxxSaHmSaIk1XxZu9h9riQHx
fMuev6KXjRn/CjCoUTn+7eFrt0dT5GryQEIZP+nA0oq0LKxogigHNZlwAT4flrqb
JUfZJrqgoce5HjZSXl10APbtPjJi0fW9AgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMB
Af8wHQYDVR0OBBYEFEfV+LztI29OVDRm0tqClP3NrmEWMA4GA1UdDwEB/wQEAwIB
hjANBgkqhkiG9w0BAQwFAAOCAgEAvSNe+0wuk53KhWlRlRf2x/97H2Q76X3anzF0
5fOSVm022ldALzXMzqOfdnoKIhAu2oVKiHHKs7mMas+T6TL+Mkphx0CYEVxFE3PG
061q3CqJU+wMm9W9xsB79oB2XG47r1fIEywZZ3GaRsatAbjcNOT8uBaATPQAfJFN
zjFe4XyN+rA4cFrYNvfHTeu5ftrYmvks7JlRaJgEGWsz+qXux7uvaEEVPqEumd2H
uYeaRNOZ2V23R009X5lbgBFx9tq5VDTnKhQiTQ2SeT0rc1W3Dz5ik6SbQQNP3nSR
0Ywy7r/sZ3fcDyfFiqnrVY4Ympfvb4YW2PZ6OsQJbzH6xjdnTG2HtzEU30ngxdp1
WUEF4zt6rjJCp7QBUqXgdlHvJqYu6949qtWjEPiFN9uSsRV2i1YDjJqN52dLjAPn
AipJKo8x1PHTwUzuITqnB9BdP+5TlTl8biJfkEf/+08eWDTLlDHr2VrZLOLompTh
bS5OrhDmqA2Q+O+EWrTIhMflwwlCpR9QYM/Xwvlbad9H0FUHbJsCVNaru3wGOgWo
tt3dNSK9Lqnv/Ej9K9v6CRr36in4ylJKivhJ5B9E7ABHg7EpBJ1xi7O5eNDkNoJG
+pFyphJq3AkBR2U4ni2tUaTAtSW2tks7IaiDV+UMtqZyGabT5ISQfWLLtLHSWn2F
Tspdjbg=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIECTCCAvGgAwIBAgIRAJZFh4s9aZGzKaTMLrSb4acwDQYJKoZIhvcNAQELBQAw
gZwxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE1MDMGA1UEAwws
QW1hem9uIFJEUyBCZXRhIHVzLWVhc3QtMSBSb290IENBIFJTQTIwNDggRzExEDAO
BgNVBAcMB1NlYXR0bGUwIBcNMjEwNTE4MjEyODQxWhgPMjA2MTA1MTgyMjI4NDFa
MIGcMQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywg
SW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExNTAzBgNVBAMM
LEFtYXpvbiBSRFMgQmV0YSB1cy1lYXN0LTEgUm9vdCBDQSBSU0EyMDQ4IEcxMRAw
DgYDVQQHDAdTZWF0dGxlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA
17i2yoU6diep+WrqxIn2CrDEO2NdJVwWTSckx4WMZlLpkQDoymSmkNHjq9ADIApD
A31Cx+843apL7wub8QkFZD0Tk7/ThdHWJOzcAM3ov98QBPQfOC1W5zYIIRP2F+vQ
TRETHQnLcW3rLv0NMk5oQvIKpJoC9ett6aeVrzu+4cU4DZVWYlJUoC/ljWzCluau
8blfW0Vwin6OB7s0HCG5/wijQWJBU5SrP/KAIPeQi1GqG5efbqAXDr/ple0Ipwyo
Xjjl73LenGUgqpANlC9EAT4i7FkJcllLPeK3NcOHjuUG0AccLv1lGsHAxZLgjk/x
z9ZcnVV9UFWZiyJTKxeKPwIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1Ud
DgQWBBRWyMuZUo4gxCR3Luf9/bd2AqZ7CjAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZI
hvcNAQELBQADggEBAIqN2DlIKlvDFPO0QUZQVFbsi/tLdYM98/vvzBpttlTGVMyD
gJuQeHVz+MnhGIwoCGOlGU3OOUoIlLAut0+WG74qYczn43oA2gbMd7HoD7oL/IGg
njorBwJVcuuLv2G//SqM3nxGcLRtkRnQ+lvqPxMz9+0fKFUn6QcIDuF0QSfthLs2
WSiGEPKO9c9RSXdRQ4pXA7c3hXng8P4A2ZmdciPne5Nu4I4qLDGZYRrRLRkNTrOi
TyS6r2HNGUfgF7eOSeKt3NWL+mNChcYj71/Vycf5edeczpUgfnWy9WbPrK1svKyl
aAs2xg+X6O8qB+Mnj2dNBzm+lZIS3sIlm+nO9sg=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICrjCCAjSgAwIBAgIRAPAlEk8VJPmEzVRRaWvTh2AwCgYIKoZIzj0EAwMwgZYx
CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu
MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEvMC0GA1UEAwwmQW1h
em9uIFJEUyB1cy1lYXN0LTEgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcMB1Nl
YXR0bGUwIBcNMjEwNTI1MjI0MTU1WhgPMjEyMTA1MjUyMzQxNTVaMIGWMQswCQYD
VQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjETMBEG
A1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExLzAtBgNVBAMMJkFtYXpvbiBS
RFMgdXMtZWFzdC0xIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQHDAdTZWF0dGxl
MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEx5xjrup8II4HOJw15NTnS3H5yMrQGlbj
EDA5MMGnE9DmHp5dACIxmPXPMe/99nO7wNdl7G71OYPCgEvWm0FhdvVUeTb3LVnV
BnaXt32Ek7/oxGk1T+Df03C+W0vmuJ+wo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0G
A1UdDgQWBBTGXmqBWN/1tkSea4pNw0oHrjk2UDAOBgNVHQ8BAf8EBAMCAYYwCgYI
KoZIzj0EAwMDaAAwZQIxAIqqZWCSrIkZ7zsv/FygtAusW6yvlL935YAWYPVXU30m
jkMFLM+/RJ9GMvnO8jHfCgIwB+whlkcItzE9CRQ6CsMo/d5cEHDUu/QW6jSIh9BR
OGh9pTYPVkUbBiKPA7lVVhre
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIF/zCCA+egAwIBAgIRAJGY9kZITwfSRaAS/bSBOw8wDQYJKoZIhvcNAQEMBQAw
gZcxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEwMC4GA1UEAwwn
QW1hem9uIFJEUyBzYS1lYXN0LTEgUm9vdCBDQSBSU0E0MDk2IEcxMRAwDgYDVQQH
DAdTZWF0dGxlMCAXDTIxMDUxOTE4MTEyMFoYDzIxMjEwNTE5MTkxMTIwWjCBlzEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdBbWF6
b24gUkRTIHNhLWVhc3QtMSBSb290IENBIFJTQTQwOTYgRzExEDAOBgNVBAcMB1Nl
YXR0bGUwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDe2vlDp6Eo4WQi
Wi32YJOgdXHhxTFrLjB9SRy22DYoMaWfginJIwJcSR8yse8ZDQuoNhERB9LRggAE
eng23mhrfvtL1yQkMlZfBu4vG1nOb22XiPFzk7X2wqz/WigdYNBCqa1kK3jrLqPx
YUy7jk2oZle4GLVRTNGuMfcid6S2hs3UCdXfkJuM2z2wc3WUlvHoVNk37v2/jzR/
hSCHZv5YHAtzL/kLb/e64QkqxKll5QmKhyI6d7vt6Lr1C0zb+DmwxUoJhseAS0hI
dRk5DklMb4Aqpj6KN0ss0HAYqYERGRIQM7KKA4+hxDMUkJmt8KqWKZkAlCZgflzl
m8NZ31o2cvBzf6g+VFHx+6iVrSkohVQydkCxx7NJ743iPKsh8BytSM4qU7xx4OnD
H2yNXcypu+D5bZnVZr4Pywq0w0WqbTM2bpYthG9IC4JeVUvZ2mDc01lqOlbMeyfT
og5BRPLDXdZK8lapo7se2teh64cIfXtCmM2lDSwm1wnH2iSK+AWZVIM3iE45WSGc
vZ+drHfVgjJJ5u1YrMCWNL5C2utFbyF9Obw9ZAwm61MSbPQL9JwznhNlCh7F2ANW
ZHWQPNcOAJqzE4uVcJB1ZeVl28ORYY1668lx+s9yYeMXk3QQdj4xmdnvoBFggqRB
ZR6Z0D7ZohADXe024RzEo1TukrQgKQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/
MB0GA1UdDgQWBBT7Vs4Y5uG/9aXnYGNMEs6ycPUT3jAOBgNVHQ8BAf8EBAMCAYYw
DQYJKoZIhvcNAQEMBQADggIBACN4Htp2PvGcQA0/sAS+qUVWWJoAXSsu8Pgc6Gar
7tKVlNJ/4W/a6pUV2Xo/Tz3msg4yiE8sMESp2k+USosD5n9Alai5s5qpWDQjrqrh
76AGyF2nzve4kIN19GArYhm4Mz/EKEG1QHYvBDGgXi3kNvL/a2Zbybp+3LevG+q7
xtx4Sz9yIyMzuT/6Y7ijtiMZ9XbuxGf5wab8UtwT3Xq1UradJy0KCkzRJAz/Wy/X
HbTkEvKSaYKExH6sLo0jqdIjV/d2Io31gt4e0Ly1ER2wPyFa+pc/swu7HCzrN+iz
A2ZM4+KX9nBvFyfkHLix4rALg+WTYJa/dIsObXkdZ3z8qPf5A9PXlULiaa1mcP4+
rokw74IyLEYooQ8iSOjxumXhnkTS69MAdGzXYE5gnHokABtGD+BB5qLhtLt4fqAp
8AyHpQWMyV42M9SJLzQ+iOz7kAgJOBOaVtJI3FV/iAg/eqWVm3yLuUTWDxSHrKuL
N19+pSjF6TNvUSFXwEa2LJkfDqIOCE32iOuy85QY//3NsgrSQF6UkSPa95eJrSGI
3hTRYYh3Up2GhBGl1KUy7/o0k3KRZTk4s38fylY8bZ3TakUOH5iIGoHyFVVcp361
Pyy25SzFSmNalWoQd9wZVc/Cps2ldxhcttM+WLkFNzprd0VJa8qTz8vYtHP0ouDN
nWS0
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICtDCCAjmgAwIBAgIQKKqVZvk6NsLET+uYv5myCzAKBggqhkjOPQQDAzCBmTEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTIwMAYDVQQDDClBbWF6
b24gUkRTIGlsLWNlbnRyYWwtMSBSb290IENBIEVDQzM4NCBHMTEQMA4GA1UEBwwH
U2VhdHRsZTAgFw0yMjEyMDIyMDMyMjBaGA8yMTIyMTIwMjIxMzIyMFowgZkxCzAJ
BgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMuMRMw
EQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEyMDAGA1UEAwwpQW1hem9u
IFJEUyBpbC1jZW50cmFsLTEgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcMB1Nl
YXR0bGUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAASYwfvj8BmvLAP6UkNQ4X4dXBB/
webBO7swW+8HnFN2DAu+Cn/lpcDpu+dys1JmkVX435lrCH3oZjol0kCDIM1lF4Cv
+78yoY1Jr/YMat22E4iz4AZd9q0NToS7+ZA0r2yjQjBAMA8GA1UdEwEB/wQFMAMB
Af8wHQYDVR0OBBYEFO/8Py16qPr7J2GWpvxlTMB+op7XMA4GA1UdDwEB/wQEAwIB
hjAKBggqhkjOPQQDAwNpADBmAjEAwk+rg788+u8JL6sdix7l57WTo8E/M+o3TO5x
uRuPdShrBFm4ArGR2PPs4zCQuKgqAjEAi0TA3PVqAxKpoz+Ps8/054p9WTgDfBFZ
i/lm2yTaPs0xjY6FNWoy7fsVw5oEKxOn
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIGCTCCA/GgAwIBAgIRAOY7gfcBZgR2tqfBzMbFQCUwDQYJKoZIhvcNAQEMBQAw
gZwxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE1MDMGA1UEAwws
QW1hem9uIFJEUyBhcC1zb3V0aGVhc3QtNCBSb290IENBIFJTQTQwOTYgRzExEDAO
BgNVBAcMB1NlYXR0bGUwIBcNMjIwNTI1MTY1NDU5WhgPMjEyMjA1MjUxNzU0NTla
MIGcMQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywg
SW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExNTAzBgNVBAMM
LEFtYXpvbiBSRFMgYXAtc291dGhlYXN0LTQgUm9vdCBDQSBSU0E0MDk2IEcxMRAw
DgYDVQQHDAdTZWF0dGxlMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA
lfxER43FuLRdL08bddF0YhbCP+XXKj1A/TFMXmd2My8XDei8rPXFYyyjMig9+xZw
uAsIxLwz8uiA26CKA8bCZKg5VG2kTeOJAfvBJaLv1CZefs3Z4Uf1Sjvm6MF2yqEj
GoORfyfL9HiZFTDuF/hcjWoKYCfMuG6M/wO8IbdICrX3n+BiYQJu/pFO660Mg3h/
8YBBWYDbHoCiH/vkqqJugQ5BM3OI5nsElW51P1icEEqti4AZ7JmtSv9t7fIFBVyR
oaEyOgpp0sm193F/cDJQdssvjoOnaubsSYm1ep3awZAUyGN/X8MBrPY95d0hLhfH
Ehc5Icyg+hsosBljlAyksmt4hFQ9iBnWIz/ZTfGMck+6p3HVL9RDgvluez+rWv59
8q7omUGsiPApy5PDdwI/Wt/KtC34/2sjslIJfvgifdAtkRPkhff1WEwER00ADrN9
eGGInaCpJfb1Rq8cV2n00jxg7DcEd65VR3dmIRb0bL+jWK62ni/WdEyomAOMfmGj
aWf78S/4rasHllWJ+QwnaUYY3u6N8Cgio0/ep4i34FxMXqMV3V0/qXdfhyabi/LM
wCxNo1Dwt+s6OtPJbwO92JL+829QAxydfmaMTeHBsgMPkG7RwAekeuatKGHNsc2Z
x2Q4C2wVvOGAhcHwxfM8JfZs3nDSZJndtVVnFlUY0UECAwEAAaNCMEAwDwYDVR0T
AQH/BAUwAwEB/zAdBgNVHQ4EFgQUpnG7mWazy6k97/tb5iduRB3RXgQwDgYDVR0P
AQH/BAQDAgGGMA0GCSqGSIb3DQEBDAUAA4ICAQCDLqq1Wwa9Tkuv7vxBnIeVvvFF
ecTn+P+wJxl9Qa2ortzqTHZsBDyJO62d04AgBwiDXkJ9a+bthgG0H1J7Xee8xqv1
xyX2yKj24ygHjspLotKP4eDMdDi5TYq+gdkbPmm9Q69B1+W6e049JVGXvWG8/7kU
igxeuCYwtCCdUPRLf6D8y+1XMGgVv3/DSOHWvTg3MJ1wJ3n3+eve3rjGdRYWZeJu
k21HLSZYzVrCtUsh2YAeLnUbSxVuT2Xr4JehYe9zW5HEQ8Je/OUfnCy9vzoN/ITw
osAH+EBJQey7RxEDqMwCaRefH0yeHFcnOll0OXg/urnQmwbEYzQ1uutJaBPsjU0J
Qf06sMxI7GiB5nPE+CnI2sM6A9AW9kvwexGXpNJiLxF8dvPQthpOKGcYu6BFvRmt
6ctfXd9b7JJoVqMWuf5cCY6ihpk1e9JTlAqu4Eb/7JNyGiGCR40iSLvV28un9wiE
plrdYxwcNYq851BEu3r3AyYWw/UW1AKJ5tM+/Gtok+AphMC9ywT66o/Kfu44mOWm
L3nSLSWEcgfUVgrikpnyGbUnGtgCmHiMlUtNVexcE7OtCIZoVAlCGKNu7tyuJf10
Qlk8oIIzfSIlcbHpOYoN79FkLoDNc2er4Gd+7w1oPQmdAB0jBJnA6t0OUBPKdDdE
Ufff2jrbfbzECn1ELg==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIGCDCCA/CgAwIBAgIQIuO1A8LOnmc7zZ/vMm3TrDANBgkqhkiG9w0BAQwFADCB
nDELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu
Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTUwMwYDVQQDDCxB
bWF6b24gUkRTIGFwLXNvdXRoZWFzdC0yIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4G
A1UEBwwHU2VhdHRsZTAgFw0yMTA1MjQyMDQ2MThaGA8yMTIxMDUyNDIxNDYxOFow
gZwxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE1MDMGA1UEAwws
QW1hem9uIFJEUyBhcC1zb3V0aGVhc3QtMiBSb290IENBIFJTQTQwOTYgRzExEDAO
BgNVBAcMB1NlYXR0bGUwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDq
qRHKbG8ZK6/GkGm2cenznEF06yHwI1gD5sdsHjTgekDZ2Dl9RwtDmUH2zFuIQwGj
SeC7E2iKwrJRA5wYzL9/Vk8NOILEKQOP8OIKUHbc7q8rEtjs401KcU6pFBBEdO9G
CTiRhogq+8mhC13AM/UriZJbKhwgM2UaDOzAneGMhQAGjH8z83NsNcPxpYVE7tqM
sch5yLtIJLkJRusrmQQTeHUev16YNqyUa+LuFclFL0FzFCimkcxUhXlbfEKXbssS
yPzjiv8wokGyo7+gA0SueceMO2UjfGfute3HlXZDcNvBbkSY+ver41jPydyRD6Qq
oEkh0tyIbPoa3oU74kwipJtz6KBEA3u3iq61OUR0ENhR2NeP7CSKrC24SnQJZ/92
qxusrbyV/0w+U4m62ug/o4hWNK1lUcc2AqiBOvCSJ7qpdteTFxcEIzDwYfERDx6a
d9+3IPvzMb0ZCxBIIUFMxLTF7yAxI9s6KZBBXSZ6tDcCCYIgEysEPRWMRAcG+ye/
fZVn9Vnzsj4/2wchC2eQrYpb1QvG4eMXA4M5tFHKi+/8cOPiUzJRgwS222J8YuDj
yEBval874OzXk8H8Mj0JXJ/jH66WuxcBbh5K7Rp5oJn7yju9yqX6qubY8gVeMZ1i
u4oXCopefDqa35JplQNUXbWwSebi0qJ4EK0V8F9Q+QIDAQABo0IwQDAPBgNVHRMB
Af8EBTADAQH/MB0GA1UdDgQWBBT4ysqCxaPe7y+g1KUIAenqu8PAgzAOBgNVHQ8B
Af8EBAMCAYYwDQYJKoZIhvcNAQEMBQADggIBALU8WN35KAjPZEX65tobtCDQFkIO
uJjv0alD7qLB0i9eY80C+kD87HKqdMDJv50a5fZdqOta8BrHutgFtDm+xo5F/1M3
u5/Vva5lV4xy5DqPajcF4Mw52czYBmeiLRTnyPJsU93EQIC2Bp4Egvb6LI4cMOgm
4pY2hL8DojOC5PXt4B1/7c1DNcJX3CMzHDm4SMwiv2MAxSuC/cbHXcWMk+qXdrVx
+ayLUSh8acaAOy3KLs1MVExJ6j9iFIGsDVsO4vr4ZNsYQiyHjp+L8ops6YVBO5AT
k/pI+axHIVsO5qiD4cFWvkGqmZ0gsVtgGUchZaacboyFsVmo6QPrl28l6LwxkIEv
GGJYvIBW8sfqtGRspjfX5TlNy5IgW/VOwGBdHHsvg/xpRo31PR3HOFw7uPBi7cAr
FiZRLJut7af98EB2UvovZnOh7uIEGPeecQWeOTQfJeWet2FqTzFYd0NUMgqPuJx1
vLKferP+ajAZLJvVnW1J7Vccx/pm0rMiUJEf0LRb/6XFxx7T2RGjJTi0EzXODTYI
gnLfBBjnolQqw+emf4pJ4pAtly0Gq1KoxTG2QN+wTd4lsCMjnelklFDjejwnl7Uy
vtxzRBAu/hi/AqDkDFf94m6j+edIrjbi9/JDFtQ9EDlyeqPgw0qwi2fwtJyMD45V
fejbXelUSJSzDIdY
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIGCTCCA/GgAwIBAgIRAN7Y9G9i4I+ZaslPobE7VL4wDQYJKoZIhvcNAQEMBQAw
gZwxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE1MDMGA1UEAwws
QW1hem9uIFJEUyBhcC1ub3J0aGVhc3QtMiBSb290IENBIFJTQTQwOTYgRzExEDAO
BgNVBAcMB1NlYXR0bGUwIBcNMjEwNTIwMTYzMzIzWhgPMjEyMTA1MjAxNzMzMjNa
MIGcMQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywg
SW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExNTAzBgNVBAMM
LEFtYXpvbiBSRFMgYXAtbm9ydGhlYXN0LTIgUm9vdCBDQSBSU0E0MDk2IEcxMRAw
DgYDVQQHDAdTZWF0dGxlMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA
4BEPCiIfiK66Q/qa8k+eqf1Q3qsa6Xuu/fPkpuStXVBShhtXd3eqrM0iT4Xxs420
Va0vSB3oZ7l86P9zYfa60n6PzRxdYFckYX330aI7L/oFIdaodB/C9szvROI0oLG+
6RwmIF2zcprH0cTby8MiM7G3v9ykpq27g4WhDC1if2j8giOQL3oHpUaByekZNIHF
dIllsI3RkXmR3xmmxoOxJM1B9MZi7e1CvuVtTGOnSGpNCQiqofehTGwxCN2wFSK8
xysaWlw48G0VzZs7cbxoXMH9QbMpb4tpk0d+T8JfAPu6uWO9UwCLWWydf0CkmA/+
D50/xd1t33X9P4FEaPSg5lYbHXzSLWn7oLbrN2UqMLaQrkoEBg/VGvzmfN0mbflw
+T87bJ/VEOVNlG+gepyCTf89qIQVWOjuYMox4sK0PjzZGsYEuYiq1+OUT3vk/e5K
ag1fCcq2Isy4/iwB2xcXrsQ6ljwdk1fc+EmOnjGKrhuOHJY3S+RFv4ToQBsVyYhC
XGaC3EkqIX0xaCpDimxYhFjWhpDXAjG/zJ+hRLDAMCMhl/LPGRk/D1kzSbPmdjpl
lEMK5695PeBvEBTQdBQdOiYgOU3vWU6tzwwHfiM2/wgvess/q0FDAHfJhppbgbb9
3vgsIUcsvoC5o29JvMsUxsDRvsAfEmMSDGkJoA/X6GECAwEAAaNCMEAwDwYDVR0T
AQH/BAUwAwEB/zAdBgNVHQ4EFgQUgEWm1mZCbGD6ytbwk2UU1aLaOUUwDgYDVR0P
AQH/BAQDAgGGMA0GCSqGSIb3DQEBDAUAA4ICAQBb4+ABTGBGwxK1U/q4g8JDqTQM
1Wh8Oz8yAk4XtPJMAmCctxbd81cRnSnePWw/hxViLVtkZ/GsemvXfqAQyOn1coN7
QeYSw+ZOlu0j2jEJVynmgsR7nIRqE7QkCyZAU+d2FTJUfmee+IiBiGyFGgxz9n7A
JhBZ/eahBbiuoOik/APW2JWLh0xp0W0GznfJ8lAlaQTyDa8iDXmVtbJg9P9qzkvl
FgPXQttzEOyooF8Pb2LCZO4kUz+1sbU7tHdr2YE+SXxt6D3SBv+Yf0FlvyWLiqVk
GDEOlPPTDSjAWgKnqST8UJ0RDcZK/v1ixs7ayqQJU0GUQm1I7LGTErWXHMnCuHKe
UKYuiSZwmTcJ06NgdhcCnGZgPq13ryMDqxPeltQc3n5eO7f1cL9ERYLDLOzm6A9P
oQ3MfcVOsbHgGHZWaPSeNrQRN9xefqBXH0ZPasgcH9WJdsLlEjVUXoultaHOKx3b
UCCb+d3EfqF6pRT488ippOL6bk7zNubwhRa/+y4wjZtwe3kAX78ACJVcjPobH9jZ
ErySads5zdQeaoee5wRKdp3TOfvuCe4bwLRdhOLCHWzEcXzY3g/6+ppLvNom8o+h
Bh5X26G6KSfr9tqhQ3O9IcbARjnuPbvtJnoPY0gz3EHHGPhy0RNW8i2gl3nUp0ah
PtjwbKW0hYAhIttT0Q==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICtzCCAj2gAwIBAgIQQRBQTs6Y3H1DDbpHGta3lzAKBggqhkjOPQQDAzCBmzEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTQwMgYDVQQDDCtBbWF6
b24gUkRTIGFwLXNvdXRoZWFzdC0zIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQH
DAdTZWF0dGxlMCAXDTIxMDYxMTAwMTI0M1oYDzIxMjEwNjExMDExMjQzWjCBmzEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTQwMgYDVQQDDCtBbWF6
b24gUkRTIGFwLXNvdXRoZWFzdC0zIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQH
DAdTZWF0dGxlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEs0942Xj4m/gKA+WA6F5h
AHYuek9eGpzTRoLJddM4rEV1T3eSueytMVKOSlS3Ub9IhyQrH2D8EHsLYk9ktnGR
pATk0kCYTqFbB7onNo070lmMJmGT/Q7NgwC8cySChFxbo0IwQDAPBgNVHRMBAf8E
BTADAQH/MB0GA1UdDgQWBBQ20iKBKiNkcbIZRu0y1uoF1yJTEzAOBgNVHQ8BAf8E
BAMCAYYwCgYIKoZIzj0EAwMDaAAwZQIwYv0wTSrpQTaPaarfLN8Xcqrqu3hzl07n
FrESIoRw6Cx77ZscFi2/MV6AFyjCV/TlAjEAhpwJ3tpzPXpThRML8DMJYZ3YgMh3
CMuLqhPpla3cL0PhybrD27hJWl29C4el6aMO
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICrDCCAjOgAwIBAgIQGcztRyV40pyMKbNeSN+vXTAKBggqhkjOPQQDAzCBljEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMS8wLQYDVQQDDCZBbWF6
b24gUkRTIHVzLWVhc3QtMiBSb290IENBIEVDQzM4NCBHMTEQMA4GA1UEBwwHU2Vh
dHRsZTAgFw0yMTA1MjEyMzE1NTZaGA8yMTIxMDUyMjAwMTU1NlowgZYxCzAJBgNV
BAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMuMRMwEQYD
VQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEvMC0GA1UEAwwmQW1hem9uIFJE
UyB1cy1lYXN0LTIgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcMB1NlYXR0bGUw
djAQBgcqhkjOPQIBBgUrgQQAIgNiAAQfDcv+GGRESD9wT+I5YIPRsD3L+/jsiIis
Tr7t9RSbFl+gYpO7ZbDXvNbV5UGOC5lMJo/SnqFRTC6vL06NF7qOHfig3XO8QnQz
6T5uhhrhnX2RSY3/10d2kTyHq3ZZg3+jQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYD
VR0OBBYEFLDyD3PRyNXpvKHPYYxjHXWOgfPnMA4GA1UdDwEB/wQEAwIBhjAKBggq
hkjOPQQDAwNnADBkAjB20HQp6YL7CqYD82KaLGzgw305aUKw2aMrdkBR29J183jY
6Ocj9+Wcif9xnRMS+7oCMAvrt03rbh4SU9BohpRUcQ2Pjkh7RoY0jDR4Xq4qzjNr
5UFr3BXpFvACxXF51BksGQ==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICrjCCAjWgAwIBAgIQeKbS5zvtqDvRtwr5H48cAjAKBggqhkjOPQQDAzCBlzEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdBbWF6
b24gUkRTIG1lLXNvdXRoLTEgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcMB1Nl
YXR0bGUwIBcNMjEwNTIwMTcxOTU1WhgPMjEyMTA1MjAxODE5NTVaMIGXMQswCQYD
VQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjETMBEG
A1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMDAuBgNVBAMMJ0FtYXpvbiBS
RFMgbWUtc291dGgtMSBSb290IENBIEVDQzM4NCBHMTEQMA4GA1UEBwwHU2VhdHRs
ZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABEKjgUaAPmUlRMEQdBC7BScAGosJ1zRV
LDd38qTBjzgmwBfQJ5ZfGIvyEK5unB09MB4e/3qqK5I/L6Qn5Px/n5g4dq0c7MQZ
u7G9GBYm90U3WRJBf7lQrPStXaRnS4A/O6NCMEAwDwYDVR0TAQH/BAUwAwEB/zAd
BgNVHQ4EFgQUNKcAbGEIn03/vkwd8g6jNyiRdD4wDgYDVR0PAQH/BAQDAgGGMAoG
CCqGSM49BAMDA2cAMGQCMHIeTrjenCSYuGC6txuBt/0ZwnM/ciO9kHGWVCoK8QLs
jGghb5/YSFGZbmQ6qpGlSAIwVOQgdFfTpEfe5i+Vs9frLJ4QKAfc27cTNYzRIM0I
E+AJgK4C4+DiyyMzOpiCfmvq
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIGCDCCA/CgAwIBAgIQSFkEUzu9FYgC5dW+5lnTgjANBgkqhkiG9w0BAQwFADCB
nDELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu
Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTUwMwYDVQQDDCxB
bWF6b24gUkRTIGFwLXNvdXRoZWFzdC0zIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4G
A1UEBwwHU2VhdHRsZTAgFw0yMTA2MTEwMDA4MzZaGA8yMTIxMDYxMTAxMDgzNlow
gZwxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE1MDMGA1UEAwws
QW1hem9uIFJEUyBhcC1zb3V0aGVhc3QtMyBSb290IENBIFJTQTQwOTYgRzExEDAO
BgNVBAcMB1NlYXR0bGUwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDx
my5Qmd8zdwaI/KOKV9Xar9oNbhJP5ED0JCiigkuvCkg5qM36klszE8JhsUj40xpp
vQw9wkYW4y+C8twBpzKGBvakqMnoaVUV7lOCKx0RofrnNwkZCboTBB4X/GCZ3fIl
YTybS7Ehi1UuiaZspIT5A2jidoA8HiBPk+mTg1UUkoWS9h+MEAPa8L4DY6fGf4pO
J1Gk2cdePuNzzIrpm2yPto+I8MRROwZ3ha7ooyymOXKtz2c7jEHHJ314boCXAv9G
cdo27WiebewZkHHH7Zx9iTIVuuk2abyVSzvLVeGv7Nuy4lmSqa5clWYqWsGXxvZ2
0fZC5Gd+BDUMW1eSpW7QDTk3top6x/coNoWuLSfXiC5ZrJkIKimSp9iguULgpK7G
abMMN4PR+O+vhcB8E879hcwmS2yd3IwcPTl3QXxufqeSV58/h2ibkqb/W4Bvggf6
5JMHQPlPHOqMCVFIHP1IffIo+Of7clb30g9FD2j3F4qgV3OLwEDNg/zuO1DiAvH1
L+OnmGHkfbtYz+AVApkAZrxMWwoYrwpauyBusvSzwRE24vLTd2i80ZDH422QBLXG
rN7Zas8rwIiBKacJLYtBYETw8mfsNt8gb72aIQX6cZOsphqp6hUtKaiMTVgGazl7
tBXqbB+sIv3S9X6bM4cZJKkMJOXbnyCCLZFYv8TurwIDAQABo0IwQDAPBgNVHRMB
Af8EBTADAQH/MB0GA1UdDgQWBBTOVtaS1b/lz6yJDvNk65vEastbQTAOBgNVHQ8B
Af8EBAMCAYYwDQYJKoZIhvcNAQEMBQADggIBABEONg+TmMZM/PrYGNAfB4S41zp1
3CVjslZswh/pC4kgXSf8cPJiUOzMwUevuFQj7tCqxQtJEygJM2IFg4ViInIah2kh
xlRakEGGw2dEVlxZAmmLWxlL1s1lN1565t5kgVwM0GVfwYM2xEvUaby6KDVJIkD3
aM6sFDBshvVA70qOggM6kU6mwTbivOROzfoIQDnVaT+LQjHqY/T+ok6IN0YXXCWl
Favai8RDjzLDFwXSRvgIK+1c49vlFFY4W9Efp7Z9tPSZU1TvWUcKdAtV8P2fPHAS
vAZ+g9JuNfeawhEibjXkwg6Z/yFUueQCQOs9TRXYogzp5CMMkfdNJF8byKYqHscs
UosIcETnHwqwban99u35sWcoDZPr6aBIrz7LGKTJrL8Nis8qHqnqQBXu/fsQEN8u
zJ2LBi8sievnzd0qI0kaWmg8GzZmYH1JCt1GXSqOFkI8FMy2bahP7TUQR1LBUKQ3
hrOSqldkhN+cSAOnvbQcFzLr+iEYEk34+NhcMIFVE+51KJ1n6+zISOinr6mI3ckX
6p2tmiCD4Shk2Xx/VTY/KGvQWKFcQApWezBSvDNlGe0yV71LtLf3dr1pr4ofo7cE
rYucCJ40bfxEU/fmzYdBF32xP7AOD9U0FbOR3Mcthc6Z6w20WFC+zru8FGY08gPf
WT1QcNdw7ntUJP/w
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICrzCCAjWgAwIBAgIQARky6+5PNFRkFVOp3Ob1CTAKBggqhkjOPQQDAzCBlzEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdBbWF6
b24gUkRTIGV1LXNvdXRoLTIgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcMB1Nl
YXR0bGUwIBcNMjIwNTIzMTg0MTI4WhgPMjEyMjA1MjMxOTQxMjdaMIGXMQswCQYD
VQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjETMBEG
A1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMDAuBgNVBAMMJ0FtYXpvbiBS
RFMgZXUtc291dGgtMiBSb290IENBIEVDQzM4NCBHMTEQMA4GA1UEBwwHU2VhdHRs
ZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABNVGL5oF7cfIBxKyWd2PVK/S5yQfaJY3
QFHWvEdt6951n9JhiiPrHzfVHsxZp1CBjILRMzjgRbYWmc8qRoLkgGE7htGdwudJ
Fa/WuKzO574Prv4iZXUnVGTboC7JdvKbh6NCMEAwDwYDVR0TAQH/BAUwAwEB/zAd
BgNVHQ4EFgQUgDeIIEKynwUbNXApdIPnmRWieZwwDgYDVR0PAQH/BAQDAgGGMAoG
CCqGSM49BAMDA2gAMGUCMEOOJfucrST+FxuqJkMZyCM3gWGZaB+/w6+XUAJC6hFM
uSTY0F44/bERkA4XhH+YGAIxAIpJQBakCA1/mXjsTnQ+0El9ty+LODp8ibkn031c
8DKDS7pR9UK7ZYdR6zFg3ZCjQw==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICrjCCAjOgAwIBAgIQJvkWUcYLbnxtuwnyjMmntDAKBggqhkjOPQQDAzCBljEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMS8wLQYDVQQDDCZBbWF6
b24gUkRTIGV1LXdlc3QtMyBSb290IENBIEVDQzM4NCBHMTEQMA4GA1UEBwwHU2Vh
dHRsZTAgFw0yMTA1MjUyMjI2MTJaGA8yMTIxMDUyNTIzMjYxMlowgZYxCzAJBgNV
BAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMuMRMwEQYD
VQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEvMC0GA1UEAwwmQW1hem9uIFJE
UyBldS13ZXN0LTMgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcMB1NlYXR0bGUw
djAQBgcqhkjOPQIBBgUrgQQAIgNiAARENn8uHCyjn1dFax4OeXxvbV861qsXFD9G
DshumTmFzWWHN/69WN/AOsxy9XN5S7Cgad4gQgeYYYgZ5taw+tFo/jQvCLY//uR5
uihcLuLJ78opvRPvD9kbWZ6oXfBtFkWjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYD
VR0OBBYEFKiK3LpoF+gDnqPldGSwChBPCYciMA4GA1UdDwEB/wQEAwIBhjAKBggq
hkjOPQQDAwNpADBmAjEA+7qfvRlnvF1Aosyp9HzxxCbN7VKu+QXXPhLEBWa5oeWW
UOcifunf/IVLC4/FGCsLAjEAte1AYp+iJyOHDB8UYkhBE/1sxnFaTiEPbvQBU0wZ
SuwWVLhu2wWDuSW+K7tTuL8p
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIID/zCCAuegAwIBAgIRAKeDpqX5WFCGNo94M4v69sUwDQYJKoZIhvcNAQELBQAw
gZcxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEwMC4GA1UEAwwn
QW1hem9uIFJEUyBldS13ZXN0LTMgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYDVQQH
DAdTZWF0dGxlMCAXDTIxMDUyNTIyMTgzM1oYDzIwNjEwNTI1MjMxODMzWjCBlzEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdBbWF6
b24gUkRTIGV1LXdlc3QtMyBSb290IENBIFJTQTIwNDggRzExEDAOBgNVBAcMB1Nl
YXR0bGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCcKOTEMTfzvs4H
WtJR8gI7GXN6xesulWtZPv21oT+fLGwJ+9Bv8ADCGDDrDxfeH/HxJmzG9hgVAzVn
4g97Bn7q07tGZM5pVi96/aNp11velZT7spOJKfJDZTlGns6DPdHmx48whpdO+dOb
6+eR0VwCIv+Vl1fWXgoACXYCoKjhxJs+R+fwY//0JJ1YG8yjZ+ghLCJmvlkOJmE1
TCPUyIENaEONd6T+FHGLVYRRxC2cPO65Jc4yQjsXvvQypoGgx7FwD5voNJnFMdyY
754JGPOOe/SZdepN7Tz7UEq8kn7NQSbhmCsgA/Hkjkchz96qN/YJ+H/okiQUTNB0
eG9ogiVFAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFFjayw9Y
MjbxfF14XAhMM2VPl0PfMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsFAAOC
AQEAAtmx6d9+9CWlMoU0JCirtp4dSS41bBfb9Oor6GQ8WIr2LdfZLL6uES/ubJPE
1Sh5Vu/Zon5/MbqLMVrfniv3UpQIof37jKXsjZJFE1JVD/qQfRzG8AlBkYgHNEiS
VtD4lFxERmaCkY1tjKB4Dbd5hfhdrDy29618ZjbSP7NwAfnwb96jobCmMKgxVGiH
UqsLSiEBZ33b2hI7PJ6iTJnYBWGuiDnsWzKRmheA4nxwbmcQSfjbrNwa93w3caL2
v/4u54Kcasvcu3yFsUwJygt8z43jsGAemNZsS7GWESxVVlW93MJRn6M+MMakkl9L
tWaXdHZ+KUV7LhfYLb0ajvb40w==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEBDCCAuygAwIBAgIQJ5oxPEjefCsaESSwrxk68DANBgkqhkiG9w0BAQsFADCB
mjELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu
Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTMwMQYDVQQDDCpB
bWF6b24gUkRTIGV1LWNlbnRyYWwtMiBSb290IENBIFJTQTIwNDggRzExEDAOBgNV
BAcMB1NlYXR0bGUwIBcNMjIwNjA2MjExNzA1WhgPMjA2MjA2MDYyMjE3MDVaMIGa
MQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5j
LjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMzAxBgNVBAMMKkFt
YXpvbiBSRFMgZXUtY2VudHJhbC0yIFJvb3QgQ0EgUlNBMjA0OCBHMTEQMA4GA1UE
BwwHU2VhdHRsZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALTQt5eX
g+VP3BjO9VBkWJhE0GfLrU/QIk32I6WvrnejayTrlup9H1z4QWlXF7GNJrqScRMY
KhJHlcP05aPsx1lYco6pdFOf42ybXyWHHJdShj4A5glU81GTT+VrXGzHSarLmtua
eozkQgPpDsSlPt0RefyTyel7r3Cq+5K/4vyjCTcIqbfgaGwTU36ffjM1LaPCuE4O
nINMeD6YuImt2hU/mFl20FZ+IZQUIFZZU7pxGLqTRz/PWcH8tDDxnkYg7tNuXOeN
JbTpXrw7St50/E9ZQ0llGS+MxJD8jGRAa/oL4G/cwnV8P2OEPVVkgN9xDDQeieo0
3xkzolkDkmeKOnUCAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU
bwu8635iQGQMRanekesORM8Hkm4wDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEB
CwUAA4IBAQAgN6LE9mUgjsj6xGCX1afYE69fnmCjjb0rC6eEe1mb/QZNcyw4XBIW
6+zTXo4mjZ4ffoxb//R0/+vdTE7IvaLgfAZgFsLKJCtYDDstXZj8ujQnGR9Pig3R
W+LpNacvOOSJSawNQq0Xrlcu55AU4buyD5VjcICnfF1dqBMnGTnh27m/scd/ZMx/
kapHZ/fMoK2mAgSX/NvUKF3UkhT85vSSM2BTtET33DzCPDQTZQYxFBa4rFRmFi4c
BLlmIReiCGyh3eJhuUUuYAbK6wLaRyPsyEcIOLMQmZe1+gAFm1+1/q5Ke9ugBmjf
PbTWjsi/lfZ5CdVAhc5lmZj/l5aKqwaS
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICrjCCAjSgAwIBAgIRAKKPTYKln9L4NTx9dpZGUjowCgYIKoZIzj0EAwMwgZYx
CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu
MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEvMC0GA1UEAwwmQW1h
em9uIFJEUyBldS13ZXN0LTIgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcMB1Nl
YXR0bGUwIBcNMjEwNTIxMjI1NTIxWhgPMjEyMTA1MjEyMzU1MjFaMIGWMQswCQYD
VQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjETMBEG
A1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExLzAtBgNVBAMMJkFtYXpvbiBS
RFMgZXUtd2VzdC0yIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQHDAdTZWF0dGxl
MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE/owTReDvaRqdmbtTzXbyRmEpKCETNj6O
hZMKH0F8oU9Tmn8RU7kQQj6xUKEyjLPrFBN7c+26TvrVO1KmJAvbc8bVliiJZMbc
C0yV5PtJTalvlMZA1NnciZuhxaxrzlK1o0IwQDAPBgNVHRMBAf8EBTADAQH/MB0G
A1UdDgQWBBT4i5HaoHtrs7Mi8auLhMbKM1XevDAOBgNVHQ8BAf8EBAMCAYYwCgYI
KoZIzj0EAwMDaAAwZQIxAK9A+8/lFdX4XJKgfP+ZLy5ySXC2E0Spoy12Gv2GdUEZ
p1G7c1KbWVlyb1d6subzkQIwKyH0Naf/3usWfftkmq8SzagicKz5cGcEUaULq4tO
GzA/AMpr63IDBAqkZbMDTCmH
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICrzCCAjWgAwIBAgIQTgIvwTDuNWQo0Oe1sOPQEzAKBggqhkjOPQQDAzCBlzEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdBbWF6
b24gUkRTIGV1LW5vcnRoLTEgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcMB1Nl
YXR0bGUwIBcNMjEwNTI0MjEwNjM4WhgPMjEyMTA1MjQyMjA2MzhaMIGXMQswCQYD
VQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjETMBEG
A1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMDAuBgNVBAMMJ0FtYXpvbiBS
RFMgZXUtbm9ydGgtMSBSb290IENBIEVDQzM4NCBHMTEQMA4GA1UEBwwHU2VhdHRs
ZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABJuzXLU8q6WwSKXBvx8BbdIi3mPhb7Xo
rNJBfuMW1XRj5BcKH1ZoGaDGw+BIIwyBJg8qNmCK8kqIb4cH8/Hbo3Y+xBJyoXq/
cuk8aPrxiNoRsKWwiDHCsVxaK9L7GhHHAqNCMEAwDwYDVR0TAQH/BAUwAwEB/zAd
BgNVHQ4EFgQUYgcsdU4fm5xtuqLNppkfTHM2QMYwDgYDVR0PAQH/BAQDAgGGMAoG
CCqGSM49BAMDA2gAMGUCMQDz/Rm89+QJOWJecYAmYcBWCcETASyoK1kbr4vw7Hsg
7Ew3LpLeq4IRmTyuiTMl0gMCMAa0QSjfAnxBKGhAnYxcNJSntUyyMpaXzur43ec0
3D8npJghwC4DuICtKEkQiI5cSg==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIGATCCA+mgAwIBAgIRAORIGqQXLTcbbYT2upIsSnQwDQYJKoZIhvcNAQEMBQAw
gZgxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTExMC8GA1UEAwwo
QW1hem9uIFJEUyBldS1zb3V0aC0yIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4GA1UE
BwwHU2VhdHRsZTAgFw0yMjA1MjMxODM0MjJaGA8yMTIyMDUyMzE5MzQyMlowgZgx
CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu
MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTExMC8GA1UEAwwoQW1h
em9uIFJEUyBldS1zb3V0aC0yIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4GA1UEBwwH
U2VhdHRsZTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAPKukwsW2s/h
1k+Hf65pOP0knVBnOnMQyT1mopp2XHGdXznj9xS49S30jYoUnWccyXgD983A1bzu
w4fuJRHg4MFdz/NWTgXvy+zy0Roe83OPIJjUmXnnzwUHQcBa9vl6XUO65iQ3pbSi
fQfNDFXD8cvuXbkezeADoy+iFAlzhXTzV9MD44GTuo9Z3qAXNGHQCrgRSCL7uRYt
t1nfwboCbsVRnElopn2cTigyVXE62HzBUmAw1GTbAZeFAqCn5giBWYAfHwTUldRL
6eEa6atfsS2oPNus4ZENa1iQxXq7ft+pMdNt0qKXTCZiiCZjmLkY0V9kWwHTRRF8
r+75oSL//3di43QnuSCgjwMRIeWNtMud5jf3eQzSBci+9njb6DrrSUbx7blP0srg
94/C/fYOp/0/EHH34w99Th14VVuGWgDgKahT9/COychLOubXUT6vD1As47S9KxTv
yYleVKwJnF9cVjepODN72fNlEf74BwzgSIhUmhksmZSeJBabrjSUj3pdyo/iRZN/
CiYz9YPQ29eXHPQjBZVIUqWbOVfdwsx0/Xu5T1e7yyXByQ3/oDulahtcoKPAFQ3J
ee6NJK655MdS7pM9hJnU2Rzu3qZ/GkM6YK7xTlMXVouPUZov/VbiaCKbqYDs8Dg+
UKdeNXAT6+BMleGQzly1X7vjhgeA8ugVAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMB
Af8wHQYDVR0OBBYEFJdaPwpCf78UolFTEn6GO85/QwUIMA4GA1UdDwEB/wQEAwIB
hjANBgkqhkiG9w0BAQwFAAOCAgEAWkxHIT3mers5YnZRSVjmpxCLivGj1jMB9VYC
iKqTAeIvD0940L0YaZgivQll5pue8UUcQ6M2uCdVVAsNJdmQ5XHIYiGOknYPtxzO
aO+bnZp7VIZw/vJ49hvH6RreA2bbxYMZO/ossYdcWsWbOKHFrRmAw0AhtK/my51g
obV7eQg+WmlE5Iqc75ycUsoZdc3NimkjBi7LQoNP1HMvlLHlF71UZhQDdq+/WdV7
0zmg+epkki1LjgMmuPyb+xWuYkFKT1/faX+Xs62hIm5BY+aI4if4RuQ+J//0pOSs
UajrjTo+jLGB8A96jAe8HaFQenbwMjlaHRDAF0wvbkYrMr5a6EbneAB37V05QD0Y
Rh4L4RrSs9DX2hbSmS6iLDuPEjanHKzglF5ePEvnItbRvGGkynqDVlwF+Bqfnw8l
0i8Hr1f1/LP1c075UjkvsHlUnGgPbLqA0rDdcxF8Fdlv1BunUjX0pVlz10Ha5M6P
AdyWUOneOfaA5G7jjv7i9qg3r99JNs1/Lmyg/tV++gnWTAsSPFSSEte81kmPhlK3
2UtAO47nOdTtk+q4VIRAwY1MaOR7wTFZPfer1mWs4RhKNu/odp8urEY87iIzbMWT
QYO/4I6BGj9rEWNGncvR5XTowwIthMCj2KWKM3Z/JxvjVFylSf+s+FFfO1bNIm6h
u3UBpZI=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICtDCCAjmgAwIBAgIQenQbcP/Zbj9JxvZ+jXbRnTAKBggqhkjOPQQDAzCBmTEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTIwMAYDVQQDDClBbWF6
b24gUkRTIGV1LWNlbnRyYWwtMSBSb290IENBIEVDQzM4NCBHMTEQMA4GA1UEBwwH
U2VhdHRsZTAgFw0yMTA1MjEyMjMzMjRaGA8yMTIxMDUyMTIzMzMyNFowgZkxCzAJ
BgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMuMRMw
EQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEyMDAGA1UEAwwpQW1hem9u
IFJEUyBldS1jZW50cmFsLTEgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcMB1Nl
YXR0bGUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATlBHiEM9LoEb1Hdnd5j2VpCDOU
5nGuFoBD8ROUCkFLFh5mHrHfPXwBc63heW9WrP3qnDEm+UZEUvW7ROvtWCTPZdLz
Z4XaqgAlSqeE2VfUyZOZzBSgUUJk7OlznXfkCMOjQjBAMA8GA1UdEwEB/wQFMAMB
Af8wHQYDVR0OBBYEFDT/ThjQZl42Nv/4Z/7JYaPNMly2MA4GA1UdDwEB/wQEAwIB
hjAKBggqhkjOPQQDAwNpADBmAjEAnZWmSgpEbmq+oiCa13l5aGmxSlfp9h12Orvw
Dq/W5cENJz891QD0ufOsic5oGq1JAjEAp5kSJj0MxJBTHQze1Aa9gG4sjHBxXn98
4MP1VGsQuhfndNHQb4V0Au7OWnOeiobq
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIID/zCCAuegAwIBAgIRAMgnyikWz46xY6yRgiYwZ3swDQYJKoZIhvcNAQELBQAw
gZcxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEwMC4GA1UEAwwn
QW1hem9uIFJEUyBldS13ZXN0LTEgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYDVQQH
DAdTZWF0dGxlMCAXDTIxMDUyMDE2NDkxMloYDzIwNjEwNTIwMTc0OTEyWjCBlzEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdBbWF6
b24gUkRTIGV1LXdlc3QtMSBSb290IENBIFJTQTIwNDggRzExEDAOBgNVBAcMB1Nl
YXR0bGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCi8JYOc9cYSgZH
gYPxLk6Xcc7HqzamvsnjYU98Dcb98y6iDqS46Ra2Ne02MITtU5MDL+qjxb8WGDZV
RUA9ZS69tkTO3gldW8QdiSh3J6hVNJQW81F0M7ZWgV0gB3n76WCmfT4IWos0AXHM
5v7M/M4tqVmCPViQnZb2kdVlM3/Xc9GInfSMCgNfwHPTXl+PXX+xCdNBePaP/A5C
5S0oK3HiXaKGQAy3K7VnaQaYdiv32XUatlM4K2WS4AMKt+2cw3hTCjlmqKRHvYFQ
veWCXAuc+U5PQDJ9SuxB1buFJZhT4VP3JagOuZbh5NWpIbOTxlAJOb5pGEDuJTKi
1gQQQVEFAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFNXm+N87
OFxK9Af/bjSxDCiulGUzMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsFAAOC
AQEAkqIbkgZ45spvrgRQ6n9VKzDLvNg+WciLtmVrqyohwwJbj4pYvWwnKQCkVc7c
hUOSBmlSBa5REAPbH5o8bdt00FPRrD6BdXLXhaECKgjsHe1WW08nsequRKD8xVmc
8bEX6sw/utBeBV3mB+3Zv7ejYAbDFM4vnRsWtO+XqgReOgrl+cwdA6SNQT9oW3e5
rSQ+VaXgJtl9NhkiIysq9BeYigxqS/A13pHQp0COMwS8nz+kBPHhJTsajHCDc8F4
HfLi6cgs9G0gaRhT8FCH66OdGSqn196sE7Y3bPFFFs/3U+vxvmQgoZC6jegQXAg5
Prxd+VNXtNI/azitTysQPumH7A==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEBTCCAu2gAwIBAgIRAO8bekN7rUReuNPG8pSTKtEwDQYJKoZIhvcNAQELBQAw
gZoxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEzMDEGA1UEAwwq
QW1hem9uIFJEUyBldS1jZW50cmFsLTEgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYD
VQQHDAdTZWF0dGxlMCAXDTIxMDUyMTIyMjM0N1oYDzIwNjEwNTIxMjMyMzQ3WjCB
mjELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu
Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTMwMQYDVQQDDCpB
bWF6b24gUkRTIGV1LWNlbnRyYWwtMSBSb290IENBIFJTQTIwNDggRzExEDAOBgNV
BAcMB1NlYXR0bGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCTTYds
Tray+Q9VA5j5jTh5TunHKFQzn68ZbOzdqaoi/Rq4ohfC0xdLrxCpfqn2TGDHN6Zi
2qGK1tWJZEd1H0trhzd9d1CtGK+3cjabUmz/TjSW/qBar7e9MA67/iJ74Gc+Ww43
A0xPNIWcL4aLrHaLm7sHgAO2UCKsrBUpxErOAACERScVYwPAfu79xeFcX7DmcX+e
lIqY16pQAvK2RIzrekSYfLFxwFq2hnlgKHaVgZ3keKP+nmXcXmRSHQYUUr72oYNZ
HcNYl2+gxCc9ccPEHM7xncVEKmb5cWEWvVoaysgQ+osi5f5aQdzgC2X2g2daKbyA
XL/z5FM9GHpS5BJjAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYE
FBDAiJ7Py9/A9etNa/ebOnx5l5MGMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0B
AQsFAAOCAQEALMh/+81fFPdJV/RrJUeoUvFCGMp8iaANu97NpeJyKitNOv7RoeVP
WjivS0KcCqZaDBs+p6IZ0sLI5ZH098LDzzytcfZg0PsGqUAb8a0MiU/LfgDCI9Ee
jsOiwaFB8k0tfUJK32NPcIoQYApTMT2e26lPzYORSkfuntme2PTHUnuC7ikiQrZk
P+SZjWgRuMcp09JfRXyAYWIuix4Gy0eZ4rpRuaTK6mjAb1/LYoNK/iZ/gTeIqrNt
l70OWRsWW8jEmSyNTIubGK/gGGyfuZGSyqoRX6OKHESkP6SSulbIZHyJ5VZkgtXo
2XvyRyJ7w5pFyoofrL3Wv0UF8yt/GDszmg==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIF/zCCA+egAwIBAgIRAMDk/F+rrhdn42SfE+ghPC8wDQYJKoZIhvcNAQEMBQAw
gZcxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEwMC4GA1UEAwwn
QW1hem9uIFJEUyBldS13ZXN0LTIgUm9vdCBDQSBSU0E0MDk2IEcxMRAwDgYDVQQH
DAdTZWF0dGxlMCAXDTIxMDUyMTIyNTEyMloYDzIxMjEwNTIxMjM1MTIyWjCBlzEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdBbWF6
b24gUkRTIGV1LXdlc3QtMiBSb290IENBIFJTQTQwOTYgRzExEDAOBgNVBAcMB1Nl
YXR0bGUwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC2twMALVg9vRVu
VNqsr6N8thmp3Dy8jEGTsm3GCQ+C5P2YcGlD/T/5icfWW84uF7Sx3ezcGlvsqFMf
Ukj9sQyqtz7qfFFugyy7pa/eH9f48kWFHLbQYm9GEgbYBIrWMp1cy3vyxuMCwQN4
DCncqU+yNpy0CprQJEha3PzY+3yJOjDQtc3zr99lyECCFJTDUucxHzyQvX89eL74
uh8la0lKH3v9wPpnEoftbrwmm5jHNFdzj7uXUHUJ41N7af7z7QUfghIRhlBDiKtx
5lYZemPCXajTc3ryDKUZC/b+B6ViXZmAeMdmQoPE0jwyEp/uaUcdp+FlUQwCfsBk
ayPFEApTWgPiku2isjdeTVmEgL8bJTDUZ6FYFR7ZHcYAsDzcwHgIu3GGEMVRS3Uf
ILmioiyly9vcK4Sa01ondARmsi/I0s7pWpKflaekyv5boJKD/xqwz9lGejmJHelf
8Od2TyqJScMpB7Q8c2ROxBwqwB72jMCEvYigB+Wnbb8RipliqNflIGx938FRCzKL
UQUBmNAznR/yRRL0wHf9UAE/8v9a09uZABeiznzOFAl/frHpgdAbC00LkFlnwwgX
g8YfEFlkp4fLx5B7LtoO6uVNFVimLxtwirpyKoj3G4M/kvSTux8bTw0heBCmWmKR
57MS6k7ODzbv+Kpeht2hqVZCNFMxoQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/
MB0GA1UdDgQWBBRuMnDhJjoj7DcKALj+HbxEqj3r6jAOBgNVHQ8BAf8EBAMCAYYw
DQYJKoZIhvcNAQEMBQADggIBALSnXfx72C3ldhBP5kY4Mo2DDaGQ8FGpTOOiD95d
0rf7I9LrsBGVqu/Nir+kqqP80PB70+Jy9fHFFigXwcPBX3MpKGxK8Cel7kVf8t1B
4YD6A6bqlzP+OUL0uGWfZpdpDxwMDI2Flt4NEldHgXWPjvN1VblEKs0+kPnKowyg
jhRMgBbD/y+8yg0fIcjXUDTAw/+INcp21gWaMukKQr/8HswqC1yoqW9in2ijQkpK
2RB9vcQ0/gXR0oJUbZQx0jn0OH8Agt7yfMAnJAdnHO4M3gjvlJLzIC5/4aGrRXZl
JoZKfJ2fZRnrFMi0nhAYDeInoS+Rwx+QzaBk6fX5VPyCj8foZ0nmqvuYoydzD8W5
mMlycgxFqS+DUmO+liWllQC4/MnVBlHGB1Cu3wTj5kgOvNs/k+FW3GXGzD3+rpv0
QTLuwSbMr+MbEThxrSZRSXTCQzKfehyC+WZejgLb+8ylLJUA10e62o7H9PvCrwj+
ZDVmN7qj6amzvndCP98sZfX7CFZPLfcBd4wVIjHsFjSNEwWHOiFyLPPG7cdolGKA
lOFvonvo4A1uRc13/zFeP0Xi5n5OZ2go8aOOeGYdI2vB2sgH9R2IASH/jHmr0gvY
0dfBCcfXNgrS0toq0LX/y+5KkKOxh52vEYsJLdhqrveuZhQnsFEm/mFwjRXkyO7c
2jpC
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIGADCCA+igAwIBAgIQYe0HgSuFFP9ivYM2vONTrTANBgkqhkiG9w0BAQwFADCB
mDELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu
Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTEwLwYDVQQDDChB
bWF6b24gUkRTIGV1LXNvdXRoLTEgUm9vdCBDQSBSU0E0MDk2IEcxMRAwDgYDVQQH
DAdTZWF0dGxlMCAXDTIxMDUxOTE4MzMyMVoYDzIxMjEwNTE5MTkzMzIxWjCBmDEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTEwLwYDVQQDDChBbWF6
b24gUkRTIGV1LXNvdXRoLTEgUm9vdCBDQSBSU0E0MDk2IEcxMRAwDgYDVQQHDAdT
ZWF0dGxlMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAuO7QPKfPMTo2
POQWvzDLwi5f++X98hGjORI1zkN9kotCYH5pAzSBwBPoMNaIfedgmsIxGHj2fq5G
4oXagNhNuGP79Zl6uKW5H7S74W7aWM8C0s8zuxMOI4GZy5h2IfQk3m/3AzZEX5w8
UtNPkzo2feDVOkerHT+j+vjXgAxZ4wHnuMDcRT+K4r9EXlAH6X9b/RO0JlfEwmNz
xlqqGxocq9qRC66N6W0HF2fNEAKP84n8H80xcZBOBthQORRi8HSmKcPdmrvwCuPz
M+L+j18q6RAVaA0ABbD0jMWcTf0UvjUfBStn5mvu/wGlLjmmRkZsppUTRukfwqXK
yltUsTq0tOIgCIpne5zA4v+MebbR5JBnsvd4gdh5BI01QH470yB7BkUefZ9bobOm
OseAAVXcYFJKe4DAA6uLDrqOfFSxV+CzVvEp3IhLRaik4G5MwI/h2c/jEYDqkg2J
HMflxc2gcSMdk7E5ByLz5f6QrFfSDFk02ZJTs4ssbbUEYohht9znPMQEaWVqATWE
3n0VspqZyoBNkH/agE5GiGZ/k/QyeqzMNj+c9kr43Upu8DpLrz8v2uAp5xNj3YVg
ihaeD6GW8+PQoEjZ3mrCmH7uGLmHxh7Am59LfEyNrDn+8Rq95WvkmbyHSVxZnBmo
h/6O3Jk+0/QhIXZ2hryMflPcYWeRGH0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB
/zAdBgNVHQ4EFgQU2eFK7+R3x/me8roIBNxBrplkM6EwDgYDVR0PAQH/BAQDAgGG
MA0GCSqGSIb3DQEBDAUAA4ICAQB5gWFe5s7ObQFj1fTO9L6gYgtFhnwdmxU0q8Ke
HWCrdFmyXdC39qdAFOwM5/7fa9zKmiMrZvy9HNvCXEp4Z7z9mHhBmuqPZQx0qPgU
uLdP8wGRuWryzp3g2oqkX9t31Z0JnkbIdp7kfRT6ME4I4VQsaY5Y3mh+hIHOUvcy
p+98i3UuEIcwJnVAV9wTTzrWusZl9iaQ1nSYbmkX9bBssJ2GmtW+T+VS/1hJ/Q4f
AlE3dOQkLFoPPb3YRWBHr2n1LPIqMVwDNAuWavRA2dSfaLl+kzbn/dua7HTQU5D4
b2Fu2vLhGirwRJe+V7zdef+tI7sngXqjgObyOeG5O2BY3s+um6D4fS0Th3QchMO7
0+GwcIgSgcjIjlrt6/xJwJLE8cRkUUieYKq1C4McpZWTF30WnzOPUzRzLHkcNzNA
0A7sKMK6QoYWo5Rmo8zewUxUqzc9oQSrYADP7PEwGncLtFe+dlRFx+PA1a+lcIgo
1ZGfXigYtQ3VKkcknyYlJ+hN4eCMBHtD81xDy9iP2MLE41JhLnoB2rVEtewO5diF
7o95Mwl84VMkLhhHPeGKSKzEbBtYYBifHNct+Bst8dru8UumTltgfX6urH3DN+/8
JF+5h3U8oR2LL5y76cyeb+GWDXXy9zoQe2QvTyTy88LwZq1JzujYi2k8QiLLhFIf
FEv9Bg==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICsDCCAjagAwIBAgIRAMgApnfGYPpK/fD0dbN2U4YwCgYIKoZIzj0EAwMwgZcx
CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu
MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEwMC4GA1UEAwwnQW1h
em9uIFJEUyBldS1zb3V0aC0xIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQHDAdT
ZWF0dGxlMCAXDTIxMDUxOTE4MzgxMVoYDzIxMjEwNTE5MTkzODExWjCBlzELMAkG
A1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4xEzAR
BgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdBbWF6b24g
UkRTIGV1LXNvdXRoLTEgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcMB1NlYXR0
bGUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQfEWl6d4qSuIoECdZPp+39LaKsfsX7
THs3/RrtT0+h/jl3bjZ7Qc68k16x+HGcHbaayHfqD0LPdzH/kKtNSfQKqemdxDQh
Z4pwkixJu8T1VpXZ5zzCvBXCl75UqgEFS92jQjBAMA8GA1UdEwEB/wQFMAMBAf8w
HQYDVR0OBBYEFFPrSNtWS5JU+Tvi6ABV231XbjbEMA4GA1UdDwEB/wQEAwIBhjAK
BggqhkjOPQQDAwNoADBlAjEA+a7hF1IrNkBd2N/l7IQYAQw8chnRZDzh4wiGsZsC
6A83maaKFWUKIb3qZYXFSi02AjAbp3wxH3myAmF8WekDHhKcC2zDvyOiKLkg9Y6v
ZVmyMR043dscQbcsVoacOYv198c=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICtDCCAjqgAwIBAgIRAPhVkIsQ51JFhD2kjFK5uAkwCgYIKoZIzj0EAwMwgZkx
CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu
MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEyMDAGA1UEAwwpQW1h
em9uIFJEUyBldS1jZW50cmFsLTIgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcM
B1NlYXR0bGUwIBcNMjIwNjA2MjEyOTE3WhgPMjEyMjA2MDYyMjI5MTdaMIGZMQsw
CQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjET
MBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMjAwBgNVBAMMKUFtYXpv
biBSRFMgZXUtY2VudHJhbC0yIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQHDAdT
ZWF0dGxlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEA5xnIEBtG5b2nmbj49UEwQza
yX0844fXjccYzZ8xCDUe9dS2XOUi0aZlGblgSe/3lwjg8fMcKXLObGGQfgIx1+5h
AIBjORis/dlyN5q/yH4U5sjS8tcR0GDGVHrsRUZCo0IwQDAPBgNVHRMBAf8EBTAD
AQH/MB0GA1UdDgQWBBRK+lSGutXf4DkTjR3WNfv4+KeNFTAOBgNVHQ8BAf8EBAMC
AYYwCgYIKoZIzj0EAwMDaAAwZQIxAJ4NxQ1Gerqr70ZrnUqc62Vl8NNqTzInamCG
Kce3FTsMWbS9qkgrjZkO9QqOcGIw/gIwSLrwUT+PKr9+H9eHyGvpq9/3AIYSnFkb
Cf3dyWPiLKoAtLFwjzB/CkJlsAS1c8dS
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIF/jCCA+agAwIBAgIQGZH12Q7x41qIh9vDu9ikTjANBgkqhkiG9w0BAQwFADCB
lzELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu
Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdB
bWF6b24gUkRTIGV1LXdlc3QtMyBSb290IENBIFJTQTQwOTYgRzExEDAOBgNVBAcM
B1NlYXR0bGUwIBcNMjEwNTI1MjIyMjMzWhgPMjEyMTA1MjUyMzIyMzNaMIGXMQsw
CQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjET
MBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMDAuBgNVBAMMJ0FtYXpv
biBSRFMgZXUtd2VzdC0zIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4GA1UEBwwHU2Vh
dHRsZTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMqE47sHXWzdpuqj
JHb+6jM9tDbQLDFnYjDWpq4VpLPZhb7xPNh9gnYYTPKG4avG421EblAHqzy9D2pN
1z90yKbIfUb/Sy2MhQbmZomsObhONEra06fJ0Dydyjswf1iYRp2kwpx5AgkVoNo7
3dlws73zFjD7ImKvUx2C7B75bhnw2pJWkFnGcswl8fZt9B5Yt95sFOKEz2MSJE91
kZlHtya19OUxZ/cSGci4MlOySzqzbGwUqGxEIDlY8I39VMwXaYQ8uXUN4G780VcL
u46FeyRGxZGz2n3hMc805WAA1V5uir87vuirTvoSVREET97HVRGVVNJJ/FM6GXr1
VKtptybbo81nefYJg9KBysxAa2Ao2x2ry/2ZxwhS6VZ6v1+90bpZA1BIYFEDXXn/
dW07HSCFnYSlgPtSc+Muh15mdr94LspYeDqNIierK9i4tB6ep7llJAnq0BU91fM2
JPeqyoTtc3m06QhLf68ccSxO4l8Hmq9kLSHO7UXgtdjfRVaffngopTNk8qK7bIb7
LrgkqhiQw/PRCZjUdyXL153/fUcsj9nFNe25gM4vcFYwH6c5trd2tUl31NTi1MfG
Mgp3d2dqxQBIYANkEjtBDMy3SqQLIo9EymqmVP8xx2A/gCBgaxvMAsI6FSWRoC7+
hqJ8XH4mFnXSHKtYMe6WPY+/XZgtAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8w
HQYDVR0OBBYEFIkXqTnllT/VJnI2NqipA4XV8rh1MA4GA1UdDwEB/wQEAwIBhjAN
BgkqhkiG9w0BAQwFAAOCAgEAKjSle8eenGeHgT8pltWCw/HzWyQruVKhfYIBfKJd
MhV4EnH5BK7LxBIvpXGsFUrb0ThzSw0fn0zoA9jBs3i/Sj6KyeZ9qUF6b8ycDXd+
wHonmJiQ7nk7UuMefaYAfs06vosgl1rI7eBHC0itexIQmKh0aX+821l4GEgEoSMf
loMFTLXv2w36fPHHCsZ67ODldgcZbKNnpCTX0YrCwEYO3Pz/L398btiRcWGrewrK
jdxAAyietra8DRno1Zl87685tfqc6HsL9v8rVw58clAo9XAQvT+fmSOFw/PogRZ7
OMHUat3gu/uQ1M5S64nkLLFsKu7jzudBuoNmcJysPlzIbqJ7vYc82OUGe9ucF3wi
3tbKQ983hdJiTExVRBLX/fYjPsGbG3JtPTv89eg2tjWHlPhCDMMxyRKl6isu2RTq
6VT489Z2zQrC33MYF8ZqO1NKjtyMAMIZwxVu4cGLkVsqFmEV2ScDHa5RadDyD3Ok
m+mqybhvEVm5tPgY6p0ILPMN3yvJsMSPSvuBXhO/X5ppNnpw9gnxpwbjQKNhkFaG
M5pkADZ14uRguOLM4VthSwUSEAr5VQYCFZhEwK+UOyJAGiB/nJz6IxL5XBNUXmRM
Hl8Xvz4riq48LMQbjcVQj0XvH941yPh+P8xOi00SGaQRaWp55Vyr4YKGbV0mEDz1
r1o=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIF/zCCA+egAwIBAgIRAKwYju1QWxUZpn6D1gOtwgQwDQYJKoZIhvcNAQEMBQAw
gZcxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEwMC4GA1UEAwwn
QW1hem9uIFJEUyBldS13ZXN0LTEgUm9vdCBDQSBSU0E0MDk2IEcxMRAwDgYDVQQH
DAdTZWF0dGxlMCAXDTIxMDUyMDE2NTM1NFoYDzIxMjEwNTIwMTc1MzU0WjCBlzEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdBbWF6
b24gUkRTIGV1LXdlc3QtMSBSb290IENBIFJTQTQwOTYgRzExEDAOBgNVBAcMB1Nl
YXR0bGUwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCKdBP1U4lqWWkc
Cb25/BKRTsvNVnISiKocva8GAzJyKfcGRa85gmgu41U+Hz6+39K+XkRfM0YS4BvQ
F1XxWT0bNyypuvwCvmYShSTjN1TY0ltncDddahTajE/4MdSOZb/c98u0yt03cH+G
hVwRyT50h0v/UEol50VfwcVAEZEgcQQYhf1IFUFlIvKpmDOqLuFakOnc7c9akK+i
ivST+JO1tgowbnNkn2iLlSSgUWgb1gjaOsNfysagv1RXdlyPw3EyfwkFifAQvF2P
Q0ayYZfYS640cccv7efM1MSVyFHR9PrrDsF/zr2S2sGPbeHr7R/HwLl+S5J/l9N9
y0rk6IHAWV4dEkOvgpnuJKURwA48iu1Hhi9e4moNS6eqoK2KmY3VFpuiyWcA73nH
GSmyaH+YuMrF7Fnuu7GEHZL/o6+F5cL3mj2SJJhL7sz0ryf5Cs5R4yN9BIEj/f49
wh84pM6nexoI0Q4wiSFCxWiBpjSmOK6h7z6+2utaB5p20XDZHhxAlmlx4vMuWtjh
XckgRFxc+ZpVMU3cAHUpVEoO49e/+qKEpPzp8Xg4cToKw2+AfTk3cmyyXQfGwXMQ
ZUHNZ3w9ILMWihGCM2aGUsLcGDRennvNmnmin/SENsOQ8Ku0/a3teEzwV9cmmdYz
5iYs1YtgPvKFobY6+T2RXXh+A5kprwIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/
MB0GA1UdDgQWBBSyUrsQVnKmA8z6/2Ech0rCvqpNmTAOBgNVHQ8BAf8EBAMCAYYw
DQYJKoZIhvcNAQEMBQADggIBAFlj3IFmgiFz5lvTzFTRizhVofhTJsGr14Yfkuc7
UrXPuXOwJomd4uot2d/VIeGJpfnuS84qGdmQyGewGTJ9inatHsGZgHl9NHNWRwKZ
lTKTbBiq7aqgtUSFa06v202wpzU+1kadxJJePrbABxiXVfOmIW/a1a4hPNcT3syH
FIEg1+CGsp71UNjBuwg3JTKWna0sLSKcxLOSOvX1fzxK5djzVpEsvQMB4PSAzXca
vENgg2ErTwgTA+4s6rRtiBF9pAusN1QVuBahYP3ftrY6f3ycS4K65GnqscyfvKt5
YgjtEKO3ZeeX8NpubMbzC+0Z6tVKfPFk/9TXuJtwvVeqow0YMrLLyRiYvK7EzJ97
rrkxoKnHYQSZ+rH2tZ5SE392/rfk1PJL0cdHnkpDkUDO+8cKsFjjYKAQSNC52sKX
74AVh6wMwxYwVZZJf2/2XxkjMWWhKNejsZhUkTISSmiLs+qPe3L67IM7GyKm9/m6
R3r8x6NGjhTsKH64iYJg7AeKeax4b2e4hBb6GXFftyOs7unpEOIVkJJgM6gh3mwn
R7v4gwFbLKADKt1vHuerSZMiTuNTGhSfCeDM53XI/mjZl2HeuCKP1mCDLlaO+gZR
Q/G+E0sBKgEX4xTkAc3kgkuQGfExdGtnN2U2ehF80lBHB8+2y2E+xWWXih/ZyIcW
wOx+
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIGBDCCA+ygAwIBAgIQM4C8g5iFRucSWdC8EdqHeDANBgkqhkiG9w0BAQwFADCB
mjELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu
Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTMwMQYDVQQDDCpB
bWF6b24gUkRTIGV1LWNlbnRyYWwtMSBSb290IENBIFJTQTQwOTYgRzExEDAOBgNV
BAcMB1NlYXR0bGUwIBcNMjEwNTIxMjIyODI2WhgPMjEyMTA1MjEyMzI4MjZaMIGa
MQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5j
LjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMzAxBgNVBAMMKkFt
YXpvbiBSRFMgZXUtY2VudHJhbC0xIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4GA1UE
BwwHU2VhdHRsZTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANeTsD/u
6saPiY4Sg0GlJlMXMBltnrcGAEkwq34OKQ0bCXqcoNJ2rcAMmuFC5x9Ho1Y3YzB7
NO2GpIh6bZaO76GzSv4cnimcv9n/sQSYXsGbPD+bAtnN/RvNW1avt4C0q0/ghgF1
VFS8JihIrgPYIArAmDtGNEdl5PUrdi9y6QGggbRfidMDdxlRdZBe1C18ZdgERSEv
UgSTPRlVczONG5qcQkUGCH83MMqL5MKQiby/Br5ZyPq6rxQMwRnQ7tROuElzyYzL
7d6kke+PNzG1mYy4cbYdjebwANCtZ2qYRSUHAQsOgybRcSoarv2xqcjO9cEsDiRU
l97ToadGYa4VVERuTaNZxQwrld4mvzpyKuirqZltOqg0eoy8VUsaRPL3dc5aChR0
dSrBgRYmSAClcR2/2ZCWpXemikwgt031Dsc0A/+TmVurrsqszwbr0e5xqMow9LzO
MI/JtLd0VFtoOkL/7GG2tN8a+7gnLFxpv+AQ0DH5n4k/BY/IyS+H1erqSJhOTQ11
vDOFTM5YplB9hWV9fp5PRs54ILlHTlZLpWGs3I2BrJwzRtg/rOlvsosqcge9ryai
AKm2j+JBg5wJ19R8oxRy8cfrNTftZePpISaLTyV2B16w/GsSjqixjTQe9LRN2DHk
cC+HPqYyzW2a3pUVyTGHhW6a7YsPBs9yzt6hAgMBAAGjQjBAMA8GA1UdEwEB/wQF
MAMBAf8wHQYDVR0OBBYEFIqA8QkOs2cSirOpCuKuOh9VDfJfMA4GA1UdDwEB/wQE
AwIBhjANBgkqhkiG9w0BAQwFAAOCAgEAOUI90mEIsa+vNJku0iUwdBMnHiO4gm7E
5JloP7JG0xUr7d0hypDorMM3zVDAL+aZRHsq8n934Cywj7qEp1304UF6538ByGdz
tkfacJsUSYfdlNJE9KbA4T+U+7SNhj9jvePpVjdQbhgzxITE9f8CxY/eM40yluJJ
PhbaWvOiRagzo74wttlcDerzLT6Y/JrVpWhnB7IY8HvzK+BwAdaCsBUPC3HF+kth
CIqLq7J3YArTToejWZAp5OOI6DLPM1MEudyoejL02w0jq0CChmZ5i55ElEMnapRX
7GQTARHmjgAOqa95FjbHEZzRPqZ72AtZAWKFcYFNk+grXSeWiDgPFOsq6mDg8DDB
0kfbYwKLFFCC9YFmYzR2YrWw2NxAScccUc2chOWAoSNHiqBbHR8ofrlJSWrtmKqd
YRCXzn8wqXnTS3NNHNccqJ6dN+iMr9NGnytw8zwwSchiev53Fpc1mGrJ7BKTWH0t
ZrA6m32wzpMymtKozlOPYoE5mtZEzrzHEXfa44Rns7XIHxVQSXVWyBHLtIsZOrvW
U5F41rQaFEpEeUQ7sQvqUoISfTUVRNDn6GK6YaccEhCji14APLFIvhRQUDyYMIiM
4vll0F/xgVRHTgDVQ8b8sxdhSYlqB4Wc2Ym41YRz+X2yPqk3typEZBpc4P5Tt1/N
89cEIGdbjsA=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEADCCAuigAwIBAgIQYjbPSg4+RNRD3zNxO1fuKDANBgkqhkiG9w0BAQsFADCB
mDELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu
Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTEwLwYDVQQDDChB
bWF6b24gUkRTIGV1LW5vcnRoLTEgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYDVQQH
DAdTZWF0dGxlMCAXDTIxMDUyNDIwNTkyMVoYDzIwNjEwNTI0MjE1OTIxWjCBmDEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTEwLwYDVQQDDChBbWF6
b24gUkRTIGV1LW5vcnRoLTEgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYDVQQHDAdT
ZWF0dGxlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA179eQHxcV0YL
XMkqEmhSBazHhnRVd8yICbMq82PitE3BZcnv1Z5Zs/oOgNmMkOKae4tCXO/41JCX
wAgbs/eWWi+nnCfpQ/FqbLPg0h3dqzAgeszQyNl9IzTzX4Nd7JFRBVJXPIIKzlRf
+GmFsAhi3rYgDgO27pz3ciahVSN+CuACIRYnA0K0s9lhYdddmrW/SYeWyoB7jPa2
LmWpAs7bDOgS4LlP2H3eFepBPgNufRytSQUVA8f58lsE5w25vNiUSnrdlvDrIU5n
Qwzc7NIZCx4qJpRbSKWrUtbyJriWfAkGU7i0IoainHLn0eHp9bWkwb9D+C/tMk1X
ERZw2PDGkwIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBSFmR7s
dAblusFN+xhf1ae0KUqhWTAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZIhvcNAQELBQAD
ggEBAHsXOpjPMyH9lDhPM61zYdja1ebcMVgfUvsDvt+w0xKMKPhBzYDMs/cFOi1N
Q8LV79VNNfI2NuvFmGygcvTIR+4h0pqqZ+wjWl3Kk5jVxCrbHg3RBX02QLumKd/i
kwGcEtTUvTssn3SM8bgM0/1BDXgImZPC567ciLvWDo0s/Fe9dJJC3E0G7d/4s09n
OMdextcxFuWBZrBm/KK3QF0ByA8MG3//VXaGO9OIeeOJCpWn1G1PjT1UklYhkg61
EbsTiZVA2DLd1BGzfU4o4M5mo68l0msse/ndR1nEY6IywwpgIFue7+rEleDh6b9d
PYkG1rHVw2I0XDG4o17aOn5E94I=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEADCCAuigAwIBAgIQC6W4HFghUkkgyQw14a6JljANBgkqhkiG9w0BAQsFADCB
mDELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu
Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTEwLwYDVQQDDChB
bWF6b24gUkRTIGV1LXNvdXRoLTIgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYDVQQH
DAdTZWF0dGxlMCAXDTIyMDUyMzE4MTYzMloYDzIwNjIwNTIzMTkxNjMyWjCBmDEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTEwLwYDVQQDDChBbWF6
b24gUkRTIGV1LXNvdXRoLTIgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYDVQQHDAdT
ZWF0dGxlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAiM/t4FV2R9Nx
UQG203UY83jInTa/6TMq0SPyg617FqYZxvz2kkx09x3dmxepUg9ttGMlPgjsRZM5
LCFEi1FWk+hxHzt7vAdhHES5tdjwds3aIkgNEillmRDVrUsbrDwufLaa+MMDO2E1
wQ/JYFXw16WBCCi2g1EtyQ2Xp+tZDX5IWOTnvhZpW8vVDptZ2AcJ5rMhfOYO3OsK
5EF0GGA5ldzuezP+BkrBYGJ4wVKGxeaq9+5AT8iVZrypjwRkD7Y5CurywK3+aBwm
s9Q5Nd8t45JCOUzYp92rFKsCriD86n/JnEvgDfdP6Hvtm0/DkwXK40Wz2q0Zrd0k
mjP054NRPwIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRR7yqd
SfKcX2Q8GzhcVucReIpewTAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZIhvcNAQELBQAD
ggEBAEszBRDwXcZyNm07VcFwI1Im94oKwKccuKYeJEsizTBsVon8VpEiMwDs+yGu
3p8kBhvkLwWybkD/vv6McH7T5b9jDX2DoOudqYnnaYeypsPH/00Vh3LvKagqzQza
orWLx+0tLo8xW4BtU+Wrn3JId8LvAhxyYXTn9bm+EwPcStp8xGLwu53OPD1RXYuy
uu+3ps/2piP7GVfou7H6PRaqbFHNfiGg6Y+WA0HGHiJzn8uLmrRJ5YRdIOOG9/xi
qTmAZloUNM7VNuurcMM2hWF494tQpsQ6ysg2qPjbBqzlGoOt3GfBTOZmqmwmqtam
K7juWM/mdMQAJ3SMlE5wI8nVdx4=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICrjCCAjSgAwIBAgIRAL9SdzVPcpq7GOpvdGoM80IwCgYIKoZIzj0EAwMwgZYx
CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu
MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEvMC0GA1UEAwwmQW1h
em9uIFJEUyBldS13ZXN0LTEgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcMB1Nl
YXR0bGUwIBcNMjEwNTIwMTY1ODA3WhgPMjEyMTA1MjAxNzU4MDdaMIGWMQswCQYD
VQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjETMBEG
A1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExLzAtBgNVBAMMJkFtYXpvbiBS
RFMgZXUtd2VzdC0xIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQHDAdTZWF0dGxl
MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEJWDgXebvwjR+Ce+hxKOLbnsfN5W5dOlP
Zn8kwWnD+SLkU81Eac/BDJsXGrMk6jFD1vg16PEkoSevsuYWlC8xR6FmT6F6pmeh
fsMGOyJpfK4fyoEPhKeQoT23lFIc5Orjo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0G
A1UdDgQWBBSVNAN1CHAz0eZ77qz2adeqjm31TzAOBgNVHQ8BAf8EBAMCAYYwCgYI
KoZIzj0EAwMDaAAwZQIxAMlQeHbcjor49jqmcJ9gRLWdEWpXG8thIf6zfYQ/OEAg
d7GDh4fR/OUk0VfjsBUN/gIwZB0bGdXvK38s6AAE/9IT051cz/wMe9GIrX1MnL1T
1F5OqnXJdiwfZRRTHsRQ/L00
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIGBDCCA+ygAwIBAgIQalr16vDfX4Rsr+gfQ4iVFDANBgkqhkiG9w0BAQwFADCB
mjELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu
Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTMwMQYDVQQDDCpB
bWF6b24gUkRTIGV1LWNlbnRyYWwtMiBSb290IENBIFJTQTQwOTYgRzExEDAOBgNV
BAcMB1NlYXR0bGUwIBcNMjIwNjA2MjEyNTIzWhgPMjEyMjA2MDYyMjI1MjNaMIGa
MQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5j
LjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMzAxBgNVBAMMKkFt
YXpvbiBSRFMgZXUtY2VudHJhbC0yIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4GA1UE
BwwHU2VhdHRsZTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANbHbFg7
2VhZor1YNtez0VlNFaobS3PwOMcEn45BE3y7HONnElIIWXGQa0811M8V2FnyqnE8
Z5aO1EuvijvWf/3D8DPZkdmAkIfh5hlZYY6Aatr65kEOckwIAm7ZZzrwFogYuaFC
z/q0CW+8gxNK+98H/zeFx+IxiVoPPPX6UlrLvn+R6XYNERyHMLNgoZbbS5gGHk43
KhENVv3AWCCcCc85O4rVd+DGb2vMVt6IzXdTQt6Kih28+RGph+WDwYmf+3txTYr8
xMcCBt1+whyCPlMbC+Yn/ivtCO4LRf0MPZDRQrqTTrFf0h/V0BGEUmMGwuKgmzf5
Kl9ILdWv6S956ioZin2WgAxhcn7+z//sN++zkqLreSf90Vgv+A7xPRqIpTdJ/nWG
JaAOUofBfsDsk4X4SUFE7xJa1FZAiu2lqB/E+y7jnWOvFRalzxVJ2Y+D/ZfUfrnK
4pfKtyD1C6ni1celrZrAwLrJ3PoXPSg4aJKh8+CHex477SRsGj8KP19FG8r0P5AG
8lS1V+enFCNvT5KqEBpDZ/Y5SQAhAYFUX+zH4/n4ql0l/emS+x23kSRrF+yMkB9q
lhC/fMk6Pi3tICBjrDQ8XAxv56hfud9w6+/ljYB2uQ1iUYtlE3JdIiuE+3ws26O8
i7PLMD9zQmo+sVi12pLHfBHQ6RRHtdVRXbXRAgMBAAGjQjBAMA8GA1UdEwEB/wQF
MAMBAf8wHQYDVR0OBBYEFBFot08ipEL9ZUXCG4lagmF53C0/MA4GA1UdDwEB/wQE
AwIBhjANBgkqhkiG9w0BAQwFAAOCAgEAi2mcZi6cpaeqJ10xzMY0F3L2eOKYnlEQ
h6QyhmNKCUF05q5u+cok5KtznzqMwy7TFOZtbVHl8uUX+xvgq/MQCxqFAnuStBXm
gr2dg1h509ZwvTdk7TDxGdftvPCfnPNJBFbMSq4CZtNcOFBg9Rj8c3Yj+Qvwd56V
zWs65BUkDNJrXmxdvhJZjUkMa9vi/oFN+M84xXeZTaC5YDYNZZeW9706QqDbAVES
5ulvKLavB8waLI/lhRBK5/k0YykCMl0A8Togt8D1QsQ0eWWbIM8/HYJMPVFhJ8Wj
vT1p/YVeDA3Bo1iKDOttgC5vILf5Rw1ZEeDxjf/r8A7VS13D3OLjBmc31zxRTs3n
XvHKP9MieQHn9GE44tEYPjK3/yC6BDFzCBlvccYHmqGb+jvDEXEBXKzimdC9mcDl
f4BBQWGJBH5jkbU9p6iti19L/zHhz7qU6UJWbxY40w92L9jS9Utljh4A0LCTjlnR
NQUgjnGC6K+jkw8hj0LTC5Ip87oqoT9w7Av5EJ3VJ4hcnmNMXJJ1DkWYdnytcGpO
DMVITQzzDZRwhbitCVPHagTN2wdi9TEuYE33J0VmFeTc6FSI50wP2aOAZ0Q1/8Aj
bxeM5jS25eaHc2CQAuhrc/7GLnxOcPwdWQb2XWT8eHudhMnoRikVv/KSK3mf6om4
1YfpdH2jp30=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIID/jCCAuagAwIBAgIQTDc+UgTRtYO7ZGTQ8UWKDDANBgkqhkiG9w0BAQsFADCB
lzELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu
Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdB
bWF6b24gUkRTIGV1LXdlc3QtMiBSb290IENBIFJTQTIwNDggRzExEDAOBgNVBAcM
B1NlYXR0bGUwIBcNMjEwNTIxMjI0NjI0WhgPMjA2MTA1MjEyMzQ2MjRaMIGXMQsw
CQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjET
MBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMDAuBgNVBAMMJ0FtYXpv
biBSRFMgZXUtd2VzdC0yIFJvb3QgQ0EgUlNBMjA0OCBHMTEQMA4GA1UEBwwHU2Vh
dHRsZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAM1oGtthQ1YiVIC2
i4u4swMAGxAjc/BZp0yq0eP5ZQFaxnxs7zFAPabEWsrjeDzrRhdVO0h7zskrertP
gblGhfD20JfjvCHdP1RUhy/nzG+T+hn6Takan/GIgs8grlBMRHMgBYHW7tklhjaH
3F7LujhceAHhhgp6IOrpb6YTaTTaJbF3GTmkqxSJ3l1LtEoWz8Al/nL/Ftzxrtez
Vs6ebpvd7sw37sxmXBWX2OlvUrPCTmladw9OrllGXtCFw4YyLe3zozBlZ3cHzQ0q
lINhpRcajTMfZrsiGCkQtoJT+AqVJPS2sHjqsEH8yiySW9Jbq4zyMbM1yqQ2vnnx
MJgoYMcCAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUaQG88UnV
JPTI+Pcti1P+q3H7pGYwDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEBCwUAA4IB
AQBAkgr75V0sEJimC6QRiTVWEuj2Khy7unjSfudbM6zumhXEU2/sUaVLiYy6cA/x
3v0laDle6T07x9g64j5YastE/4jbzrGgIINFlY0JnaYmR3KZEjgi1s1fkRRf3llL
PJm9u4Q1mbwAMQK/ZjLuuRcL3uRIHJek18nRqT5h43GB26qXyvJqeYYpYfIjL9+/
YiZAbSRRZG+Li23cmPWrbA1CJY121SB+WybCbysbOXzhD3Sl2KSZRwSw4p2HrFtV
1Prk0dOBtZxCG9luf87ultuDZpfS0w6oNBAMXocgswk24ylcADkkFxBWW+7BETn1
EpK+t1Lm37mU4sxtuha00XAi
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEADCCAuigAwIBAgIQcY44/8NUvBwr6LlHfRy7KjANBgkqhkiG9w0BAQsFADCB
mDELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu
Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTEwLwYDVQQDDChB
bWF6b24gUkRTIGV1LXNvdXRoLTEgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYDVQQH
DAdTZWF0dGxlMCAXDTIxMDUxOTE4MjcxOFoYDzIwNjEwNTE5MTkyNzE4WjCBmDEL
MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x
EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTEwLwYDVQQDDChBbWF6
b24gUkRTIGV1LXNvdXRoLTEgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYDVQQHDAdT
ZWF0dGxlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0UaBeC+Usalu
EtXnV7+PnH+gi7/71tI/jkKVGKuhD2JDVvqLVoqbMHRh3+wGMvqKCjbHPcC2XMWv
566fpAj4UZ9CLB5fVzss+QVNTl+FH2XhEzigopp+872ajsNzcZxrMkifxGb4i0U+
t0Zi+UrbL5tsfP2JonKR1crOrbS6/DlzHBjIiJazGOQcMsJjNuTOItLbMohLpraA
/nApa3kOvI7Ufool1/34MG0+wL3UUA4YkZ6oBJVxjZvvs6tI7Lzz/SnhK2widGdc
snbLqBpHNIZQSorVoiwcFaRBGYX/uzYkiw44Yfa4cK2V/B5zgu1Fbr0gbI2am4eh
yVYyg4jPawIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBS9gM1m
IIjyh9O5H/7Vj0R/akI7UzAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZIhvcNAQELBQAD
ggEBAF0Sm9HC2AUyedBVnwgkVXMibnYChOzz7T+0Y+fOLXYAEXex2s8oqGeZdGYX
JHkjBn7JXu7LM+TpTbPbFFDoc1sgMguD/ls+8XsqAl1CssW+amryIL+jfcfbgQ+P
ICwEUD9hGdjBgJ5WcuS+qqxHsEIlFNci3HxcxfBa9VsWs5TjI7Vsl4meL5lf7ZyL
wDV7dHRuU+cImqG1MIvPRIlvPnT7EghrCYi2VCPhP2pM/UvShuwVnkz4MJ29ebIk
WR9kpblFxFdE92D5UUvMCjC2kmtgzNiErvTcwIvOO9YCbBHzRB1fFiWrXUHhJWq9
IkaxR5icb/IpAV0A1lYZEWMVsfQ=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIGATCCA+mgAwIBAgIRAMa0TPL+QgbWfUPpYXQkf8wwDQYJKoZIhvcNAQEMBQAw
gZgxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ
bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTExMC8GA1UEAwwo
QW1hem9uIFJEUyBldS1ub3J0aC0xIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4GA1UE
BwwHU2VhdHRsZTAgFw0yMTA1MjQyMTAzMjBaGA8yMTIxMDUyNDIyMDMyMFowgZgx
CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu
MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTExMC8GA1UEAwwoQW1h
em9uIFJEUyBldS1ub3J0aC0xIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4GA1UEBwwH
U2VhdHRsZTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANhS9LJVJyWp
6Rudy9t47y6kzvgnFYDrvJVtgEK0vFn5ifdlHE7xqMz4LZqWBFTnS+3oidwVRqo7
tqsuuElsouStO8m315/YUzKZEPmkw8h5ufWt/lg3NTCoUZNkB4p4skr7TspyMUwE
VdlKQuWTCOLtofwmWT+BnFF3To6xTh3XPlT3ssancw27Gob8kJegD7E0TSMVsecP
B8je65+3b8CGwcD3QB3kCTGLy87tXuS2+07pncHvjMRMBdDQQQqhXWsRSeUNg0IP
xdHTWcuwMldYPWK5zus9M4dCNBDlmZjKdcZZVUOKeBBAm7Uo7CbJCk8r/Fvfr6mw
nXXDtuWhqn/WhJiI/y0QU27M+Hy5CQMxBwFsfAjJkByBpdXmyYxUgTmMpLf43p7H
oWfH1xN0cT0OQEVmAQjMakauow4AQLNkilV+X6uAAu3STQVFRSrpvMen9Xx3EPC3
G9flHueTa71bU65Xe8ZmEmFhGeFYHY0GrNPAFhq9RThPRY0IPyCZe0Th8uGejkek
jQjm0FHPOqs5jc8CD8eJs4jSEFt9lasFLVDcAhx0FkacLKQjGHvKAnnbRwhN/dF3
xt4oL8Z4JGPCLau056gKnYaEyviN7PgO+IFIVOVIdKEBu2ASGE8/+QJB5bcHefNj
04hEkDW0UYJbSfPpVbGAR0gFI/QpycKnAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMB
Af8wHQYDVR0OBBYEFFMXvvjoaGGUcul8GA3FT05DLbZcMA4GA1UdDwEB/wQEAwIB
hjANBgkqhkiG9w0BAQwFAAOCAgEAQLwFhd2JKn4K/6salLyIA4mP58qbA/9BTB/r
D9l0bEwDlVPSdY7R3gZCe6v7SWLfA9RjE5tdWDrQMi5IU6W2OVrVsZS/yGJfwnwe
a/9iUAYprA5QYKDg37h12XhVsDKlYCekHdC+qa5WwB1SL3YUprDLPWeaIQdg+Uh2
+LxvpZGoxoEbca0fc7flwq9ke/3sXt/3V4wJDyY6AL2YNdjFzC+FtYjHHx8rYxHs
aesP7yunuN17KcfOZBBnSFRrx96k+Xm95VReTEEpwiBqAECqEpMbd+R0mFAayMb1
cE77GaK5yeC2f67NLYGpkpIoPbO9p9rzoXLE5GpSizMjimnz6QCbXPFAFBDfSzim
u6azp40kEUO6kWd7rBhqRwLc43D3TtNWQYxMve5mTRG4Od+eMKwYZmQz89BQCeqm
aZiJP9y9uwJw4p/A5V3lYHTDQqzmbOyhGUk6OdpdE8HXs/1ep1xTT20QDYOx3Ekt
r4mmNYfH/8v9nHNRlYJOqFhmoh1i85IUl5IHhg6OT5ZTTwsGTSxvgQQXrmmHVrgZ
rZIqyBKllCgVeB9sMEsntn4bGLig7CS/N1y2mYdW/745yCLZv2gj0NXhPqgEIdVV
f9DhFD4ohE1C63XP0kOQee+LYg/MY5vH8swpCSWxQgX5icv5jVDz8YTdCKgUc5u8
rM2p0kk=
-----END CERTIFICATE-----
================================================
FILE: redash/query_runner/files/redshift-ca-bundle.crt
================================================
-----BEGIN CERTIFICATE-----
MIIDeDCCAuGgAwIBAgIJALPHPDcjk979MA0GCSqGSIb3DQEBBQUAMIGFMQswCQYD
VQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHU2VhdHRsZTET
MBEGA1UEChMKQW1hem9uLmNvbTELMAkGA1UECxMCQ00xLTArBgkqhkiG9w0BCQEW
HmNvb2tpZS1tb25zdGVyLWNvcmVAYW1hem9uLmNvbTAeFw0xMjExMDIyMzI0NDda
Fw0xODExMDEyMzI0NDdaMIGFMQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGlu
Z3RvbjEQMA4GA1UEBxMHU2VhdHRsZTETMBEGA1UEChMKQW1hem9uLmNvbTELMAkG
A1UECxMCQ00xLTArBgkqhkiG9w0BCQEWHmNvb2tpZS1tb25zdGVyLWNvcmVAYW1h
em9uLmNvbTCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAw949t4UZ+9n1K8vj
PVkyehoV2kWepDmJ8YKl358nkmNwrSAGkslVttdpZS+FrgIcb44UbfVbB4bOSq0J
qd39GYVRzSazCwr2tpibFvH87PyAX4VVUBDlCizJToEYsXkAKecs+IRqCDWG2ht/
pibO2+T5Wp8jaxUBvDmoHY3BSgkCAwEAAaOB7TCB6jAdBgNVHQ4EFgQUE5KUaWSM
Uml+6MZQia7DjmfjvLgwgboGA1UdIwSBsjCBr4AUE5KUaWSMUml+6MZQia7Djmfj
vLihgYukgYgwgYUxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAw
DgYDVQQHEwdTZWF0dGxlMRMwEQYDVQQKEwpBbWF6b24uY29tMQswCQYDVQQLEwJD
TTEtMCsGCSqGSIb3DQEJARYeY29va2llLW1vbnN0ZXItY29yZUBhbWF6b24uY29t
ggkAs8c8NyOT3v0wDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQUFAAOBgQC9l5+L
7PaPiF9tsZ20CkyBNEdcM3dWrGT2KR0UBQLWYgPDoBKKkqV56c361kWInOtZ2ucf
JHjJpT1Np8j673LRbTrZiFiITMg7CcScq5u2ntMa3BNVCeVYlqVLH3RZ7RiQIBXR
M5hUZ03/aJqN3fQKamd3MfGHft42AXFOwvh9xg==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEDzCCAvegAwIBAgIBADANBgkqhkiG9w0BAQUFADBoMQswCQYDVQQGEwJVUzEl
MCMGA1UEChMcU3RhcmZpZWxkIFRlY2hub2xvZ2llcywgSW5jLjEyMDAGA1UECxMp
U3RhcmZpZWxkIENsYXNzIDIgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMDQw
NjI5MTczOTE2WhcNMzQwNjI5MTczOTE2WjBoMQswCQYDVQQGEwJVUzElMCMGA1UE
ChMcU3RhcmZpZWxkIFRlY2hub2xvZ2llcywgSW5jLjEyMDAGA1UECxMpU3RhcmZp
ZWxkIENsYXNzIDIgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwggEgMA0GCSqGSIb3
DQEBAQUAA4IBDQAwggEIAoIBAQC3Msj+6XGmBIWtDBFk385N78gDGIc/oav7PKaf
8MOh2tTYbitTkPskpD6E8J7oX+zlJ0T1KKY/e97gKvDIr1MvnsoFAZMej2YcOadN
+lq2cwQlZut3f+dZxkqZJRRU6ybH838Z1TBwj6+wRir/resp7defqgSHo9T5iaU0
X9tDkYI22WY8sbi5gv2cOj4QyDvvBmVmepsZGD3/cVE8MC5fvj13c7JdBmzDI1aa
K4UmkhynArPkPw2vCHmCuDY96pzTNbO8acr1zJ3o/WSNF4Azbl5KXZnJHoe0nRrA
1W4TNSNe35tfPe/W93bC6j67eA0cQmdrBNj41tpvi/JEoAGrAgEDo4HFMIHCMB0G
A1UdDgQWBBS/X7fRzt0fhvRbVazc1xDCDqmI5zCBkgYDVR0jBIGKMIGHgBS/X7fR
zt0fhvRbVazc1xDCDqmI56FspGowaDELMAkGA1UEBhMCVVMxJTAjBgNVBAoTHFN0
YXJmaWVsZCBUZWNobm9sb2dpZXMsIEluYy4xMjAwBgNVBAsTKVN0YXJmaWVsZCBD
bGFzcyAyIENlcnRpZmljYXRpb24gQXV0aG9yaXR5ggEAMAwGA1UdEwQFMAMBAf8w
DQYJKoZIhvcNAQEFBQADggEBAAWdP4id0ckaVaGsafPzWdqbAYcaT1epoXkJKtv3
L7IezMdeatiDh6GX70k1PncGQVhiv45YuApnP+yz3SFmH8lU+nLMPUxA2IGvd56D
eruix/U0F47ZEUD0/CwqTRV/p2JdLiXTAAsgGh1o+Re49L2L7ShZ3U0WixeDyLJl
xy16paq8U4Zt3VekyvggQQto8PT7dL5WXXp59fkdheMtlb71cZBDzI0fmgAKhynp
VSJYACPq4xJDKVtHCN2MQWplBqjlIapBtJUhlbl90TSrE9atvNziPTnNvT51cKEY
WQPJIrSPnNVeKtelttQKbfi3QBFGmh95DmK/D5fs4C8fF5Q=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIID7zCCAtegAwIBAgIBADANBgkqhkiG9w0BAQsFADCBmDELMAkGA1UEBhMCVVMx
EDAOBgNVBAgTB0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxJTAjBgNVBAoT
HFN0YXJmaWVsZCBUZWNobm9sb2dpZXMsIEluYy4xOzA5BgNVBAMTMlN0YXJmaWVs
ZCBTZXJ2aWNlcyBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eSAtIEcyMB4XDTA5
MDkwMTAwMDAwMFoXDTM3MTIzMTIzNTk1OVowgZgxCzAJBgNVBAYTAlVTMRAwDgYD
VQQIEwdBcml6b25hMRMwEQYDVQQHEwpTY290dHNkYWxlMSUwIwYDVQQKExxTdGFy
ZmllbGQgVGVjaG5vbG9naWVzLCBJbmMuMTswOQYDVQQDEzJTdGFyZmllbGQgU2Vy
dmljZXMgUm9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgLSBHMjCCASIwDQYJKoZI
hvcNAQEBBQADggEPADCCAQoCggEBANUMOsQq+U7i9b4Zl1+OiFOxHz/Lz58gE20p
OsgPfTz3a3Y4Y9k2YKibXlwAgLIvWX/2h/klQ4bnaRtSmpDhcePYLQ1Ob/bISdm2
8xpWriu2dBTrz/sm4xq6HZYuajtYlIlHVv8loJNwU4PahHQUw2eeBGg6345AWh1K
Ts9DkTvnVtYAcMtS7nt9rjrnvDH5RfbCYM8TWQIrgMw0R9+53pBlbQLPLJGmpufe
hRhJfGZOozptqbXuNC66DQO4M99H67FrjSXZm86B0UVGMpZwh94CDklDhbZsc7tk
6mFBrMnUVN+HL8cisibMn1lUaJ/8viovxFUcdUBgF4UCVTmLfwUCAwEAAaNCMEAw
DwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFJxfAN+q
AdcwKziIorhtSpzyEZGDMA0GCSqGSIb3DQEBCwUAA4IBAQBLNqaEd2ndOxmfZyMI
bw5hyf2E3F/YNoHN2BtBLZ9g3ccaaNnRbobhiCPPE95Dz+I0swSdHynVv/heyNXB
ve6SbzJ08pGCL72CQnqtKrcgfU28elUSwhXqvfdqlS5sdJ/PHLTyxQGjhdByPq1z
qwubdQxtRbeOlKyWN7Wg0I8VRw7j6IPdj/3vQQF3zCepYoUz8jcI73HPdwbeyBkd
iEDPfUYd/x7H4c7/I9vG+o1VTqkC50cRRj70/b17KSa7qWFiNyi2LSr2EIZkyXCn
0q23KXB56jzaYyWf/Wi3MOxw+3WKt21gZ7IeyLnp2KhvAotnDU0mV3HaIPzBSlCN
sSi6
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDQTCCAimgAwIBAgITBmyfz5m/jAo54vB4ikPmljZbyjANBgkqhkiG9w0BAQsF
ADA5MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6
b24gUm9vdCBDQSAxMB4XDTE1MDUyNjAwMDAwMFoXDTM4MDExNzAwMDAwMFowOTEL
MAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJv
b3QgQ0EgMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALJ4gHHKeNXj
ca9HgFB0fW7Y14h29Jlo91ghYPl0hAEvrAIthtOgQ3pOsqTQNroBvo3bSMgHFzZM
9O6II8c+6zf1tRn4SWiw3te5djgdYZ6k/oI2peVKVuRF4fn9tBb6dNqcmzU5L/qw
IFAGbHrQgLKm+a/sRxmPUDgH3KKHOVj4utWp+UhnMJbulHheb4mjUcAwhmahRWa6
VOujw5H5SNz/0egwLX0tdHA114gk957EWW67c4cX8jJGKLhD+rcdqsq08p8kDi1L
93FcXmn/6pUCyziKrlA4b9v7LWIbxcceVOF34GfID5yHI9Y/QCB/IIDEgEw+OyQm
jgSubJrIqg0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC
AYYwHQYDVR0OBBYEFIQYzIU07LwMlJQuCFmcx7IQTgoIMA0GCSqGSIb3DQEBCwUA
A4IBAQCY8jdaQZChGsV2USggNiMOruYou6r4lK5IpDB/G/wkjUu0yKGX9rbxenDI
U5PMCCjjmCXPI6T53iHTfIUJrU6adTrCC2qJeHZERxhlbI1Bjjt/msv0tadQ1wUs
N+gDS63pYaACbvXy8MWy7Vu33PqUXHeeE6V/Uq2V8viTO96LXFvKWlJbYK8U90vv
o/ufQJVtMVT8QtPHRh8jrdkPSHCa2XV4cdFyQzR1bldZwgJcJmApzyMZFo6IQ6XU
5MsI+yMRQ+hDKXJioaldXgjUkK642M4UwtBV8ob2xJNDd2ZhwLnoQdeXeGADbkpy
rqXRfboQnoZsG4q5WTP468SQvvG5
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIFQTCCAymgAwIBAgITBmyf0pY1hp8KD+WGePhbJruKNzANBgkqhkiG9w0BAQwF
ADA5MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6
b24gUm9vdCBDQSAyMB4XDTE1MDUyNjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTEL
MAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJv
b3QgQ0EgMjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK2Wny2cSkxK
gXlRmeyKy2tgURO8TW0G/LAIjd0ZEGrHJgw12MBvIITplLGbhQPDW9tK6Mj4kHbZ
W0/jTOgGNk3Mmqw9DJArktQGGWCsN0R5hYGCrVo34A3MnaZMUnbqQ523BNFQ9lXg
1dKmSYXpN+nKfq5clU1Imj+uIFptiJXZNLhSGkOQsL9sBbm2eLfq0OQ6PBJTYv9K
8nu+NQWpEjTj82R0Yiw9AElaKP4yRLuH3WUnAnE72kr3H9rN9yFVkE8P7K6C4Z9r
2UXTu/Bfh+08LDmG2j/e7HJV63mjrdvdfLC6HM783k81ds8P+HgfajZRRidhW+me
z/CiVX18JYpvL7TFz4QuK/0NURBs+18bvBt+xa47mAExkv8LV/SasrlX6avvDXbR
8O70zoan4G7ptGmh32n2M8ZpLpcTnqWHsFcQgTfJU7O7f/aS0ZzQGPSSbtqDT6Zj
mUyl+17vIWR6IF9sZIUVyzfpYgwLKhbcAS4y2j5L9Z469hdAlO+ekQiG+r5jqFoz
7Mt0Q5X5bGlSNscpb/xVA1wf+5+9R+vnSUeVC06JIglJ4PVhHvG/LopyboBZ/1c6
+XUyo05f7O0oYtlNc/LMgRdg7c3r3NunysV+Ar3yVAhU/bQtCSwXVEqY0VThUWcI
0u1ufm8/0i2BWSlmy5A5lREedCf+3euvAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMB
Af8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBSwDPBMMPQFWAJI/TPlUq9LhONm
UjANBgkqhkiG9w0BAQwFAAOCAgEAqqiAjw54o+Ci1M3m9Zh6O+oAA7CXDpO8Wqj2
LIxyh6mx/H9z/WNxeKWHWc8w4Q0QshNabYL1auaAn6AFC2jkR2vHat+2/XcycuUY
+gn0oJMsXdKMdYV2ZZAMA3m3MSNjrXiDCYZohMr/+c8mmpJ5581LxedhpxfL86kS
k5Nrp+gvU5LEYFiwzAJRGFuFjWJZY7attN6a+yb3ACfAXVU3dJnJUH/jWS5E4ywl
7uxMMne0nxrpS10gxdr9HIcWxkPo1LsmmkVwXqkLN1PiRnsn/eBG8om3zEK2yygm
btmlyTrIQRNg91CMFa6ybRoVGld45pIq2WWQgj9sAq+uEjonljYE1x2igGOpm/Hl
urR8FLBOybEfdF849lHqm/osohHUqS0nGkWxr7JOcQ3AWEbWaQbLU8uz/mtBzUF+
fUwPfHJ5elnNXkoOrJupmHN5fLT0zLm4BwyydFy4x2+IoZCn9Kr5v2c69BoVYh63
n749sSmvZ6ES8lgQGVMDMBu4Gon2nL2XA46jCfMdiyHxtN/kHNGfZQIG6lzWE7OE
76KlXIx3KadowGuuQNKotOrN8I1LOJwZmhsoVLiJkO/KdYE+HvJkJMcYr07/R54H
9jVlpNMKVv/1F2Rs76giJUmTtt8AF9pYfl3uxRuw0dFfIRDH+fO6AgonB8Xx1sfT
4PsJYGw=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIBtjCCAVugAwIBAgITBmyf1XSXNmY/Owua2eiedgPySjAKBggqhkjOPQQDAjA5
MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24g
Um9vdCBDQSAzMB4XDTE1MDUyNjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTELMAkG
A1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJvb3Qg
Q0EgMzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABCmXp8ZBf8ANm+gBG1bG8lKl
ui2yEujSLtf6ycXYqm0fc4E7O5hrOXwzpcVOho6AF2hiRVd9RFgdszflZwjrZt6j
QjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBSr
ttvXBp43rDCGB5Fwx5zEGbF4wDAKBggqhkjOPQQDAgNJADBGAiEA4IWSoxe3jfkr
BqWTrBqYaGFy+uGh0PsceGCmQ5nFuMQCIQCcAu/xlJyzlvnrxir4tiz+OpAUFteM
YyRIHN8wfdVoOw==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIB8jCCAXigAwIBAgITBmyf18G7EEwpQ+Vxe3ssyBrBDjAKBggqhkjOPQQDAzA5
MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24g
Um9vdCBDQSA0MB4XDTE1MDUyNjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTELMAkG
A1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJvb3Qg
Q0EgNDB2MBAGByqGSM49AgEGBSuBBAAiA2IABNKrijdPo1MN/sGKe0uoe0ZLY7Bi
9i0b2whxIdIA6GO9mif78DluXeo9pcmBqqNbIJhFXRbb/egQbeOc4OO9X4Ri83Bk
M6DLJC9wuoihKqB1+IGuYgbEgds5bimwHvouXKNCMEAwDwYDVR0TAQH/BAUwAwEB
/zAOBgNVHQ8BAf8EBAMCAYYwHQYDVR0OBBYEFNPsxzplbszh2naaVvuc84ZtV+WB
MAoGCCqGSM49BAMDA2gAMGUCMDqLIfG9fhGt0O9Yli/W651+kI0rz2ZVwyzjKKlw
CkcO8DdZEv8tmZQoTipPNU0zWgIxAOp1AE47xDqUEpHJWEadIRNyp4iciuRMStuW
1KyLa2tJElMzrdfkviT8tQp21KW8EA==
-----END CERTIFICATE-----
================================================
FILE: redash/query_runner/google_analytics.py
================================================
import logging
from base64 import b64decode
from datetime import datetime
from urllib.parse import parse_qs, urlparse
from redash.query_runner import (
TYPE_DATE,
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseSQLQueryRunner,
register,
)
from redash.utils import json_loads
logger = logging.getLogger(__name__)
try:
import google.auth
from apiclient.discovery import build
from apiclient.errors import HttpError
from google.oauth2.service_account import Credentials
enabled = True
except ImportError:
enabled = False
types_conv = dict(
STRING=TYPE_STRING,
INTEGER=TYPE_INTEGER,
FLOAT=TYPE_FLOAT,
DATE=TYPE_DATE,
DATETIME=TYPE_DATETIME,
)
def parse_ga_response(response):
columns = []
for h in response["columnHeaders"]:
if h["name"] in ("ga:date", "mcf:conversionDate"):
h["dataType"] = "DATE"
elif h["name"] == "ga:dateHour":
h["dataType"] = "DATETIME"
columns.append(
{
"name": h["name"],
"friendly_name": h["name"].split(":", 1)[1],
"type": types_conv.get(h["dataType"], "string"),
}
)
rows = []
for r in response.get("rows", []):
d = {}
for c, value in enumerate(r):
column_name = response["columnHeaders"][c]["name"]
column_type = [col for col in columns if col["name"] == column_name][0]["type"]
# mcf results come a bit different than ga results:
if isinstance(value, dict):
if "primitiveValue" in value:
value = value["primitiveValue"]
elif "conversionPathValue" in value:
steps = []
for step in value["conversionPathValue"]:
steps.append("{}:{}".format(step["interactionType"], step["nodeValue"]))
value = ", ".join(steps)
else:
raise Exception("Results format not supported")
if column_type == TYPE_DATE:
value = datetime.strptime(value, "%Y%m%d")
elif column_type == TYPE_DATETIME:
if len(value) == 10:
value = datetime.strptime(value, "%Y%m%d%H")
elif len(value) == 12:
value = datetime.strptime(value, "%Y%m%d%H%M")
else:
raise Exception("Unknown date/time format in results: '{}'".format(value))
d[column_name] = value
rows.append(d)
return {"columns": columns, "rows": rows}
class GoogleAnalytics(BaseSQLQueryRunner):
should_annotate_query = False
@classmethod
def type(cls):
return "google_analytics"
@classmethod
def name(cls):
return "Google Analytics"
@classmethod
def enabled(cls):
return enabled
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {"jsonKeyFile": {"type": "string", "title": "JSON Key File (ADC is used if omitted)"}},
"required": [],
"secret": ["jsonKeyFile"],
}
def __init__(self, configuration):
super(GoogleAnalytics, self).__init__(configuration)
self.syntax = "json"
def _get_analytics_service(self):
scopes = ["https://www.googleapis.com/auth/analytics.readonly"]
try:
key = json_loads(b64decode(self.configuration["jsonKeyFile"]))
creds = Credentials.from_service_account_info(key, scopes=scopes)
except KeyError:
creds = google.auth.default(scopes=scopes)[0]
return build("analytics", "v3", credentials=creds)
def _get_tables(self, schema):
accounts = self._get_analytics_service().management().accounts().list().execute().get("items")
if accounts is None:
raise Exception("Failed getting accounts.")
else:
for account in accounts:
schema[account["name"]] = {"name": account["name"], "columns": []}
properties = (
self._get_analytics_service()
.management()
.webproperties()
.list(accountId=account["id"])
.execute()
.get("items", [])
)
for property_ in properties:
if "defaultProfileId" in property_ and "name" in property_:
schema[account["name"]]["columns"].append(
"{0} (ga:{1})".format(property_["name"], property_["defaultProfileId"])
)
return list(schema.values())
def test_connection(self):
try:
service = self._get_analytics_service()
service.management().accounts().list().execute()
except HttpError as e:
# Make sure we return a more readable error to the end user
raise Exception(e._get_reason())
def run_query(self, query, user):
logger.debug("Analytics is about to execute query: %s", query)
try:
params = json_loads(query)
except Exception:
query_string = parse_qs(urlparse(query).query, keep_blank_values=True)
params = {k.replace("-", "_"): ",".join(v) for k, v in query_string.items()}
if "mcf:" in params["metrics"] and "ga:" in params["metrics"]:
raise Exception("Can't mix mcf: and ga: metrics.")
if "mcf:" in params.get("dimensions", "") and "ga:" in params.get("dimensions", ""):
raise Exception("Can't mix mcf: and ga: dimensions.")
if "mcf:" in params["metrics"]:
api = self._get_analytics_service().data().mcf()
else:
api = self._get_analytics_service().data().ga()
if len(params) > 0:
try:
response = api.get(**params).execute()
data = parse_ga_response(response)
error = None
except HttpError as e:
# Make sure we return a more readable error to the end user
error = e._get_reason()
data = None
else:
error = "Wrong query format."
data = None
return data, error
register(GoogleAnalytics)
================================================
FILE: redash/query_runner/google_analytics4.py
================================================
import datetime
import logging
from base64 import b64decode
import requests
from redash.query_runner import (
TYPE_DATE,
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseQueryRunner,
register,
)
from redash.utils import json_loads
logger = logging.getLogger(__name__)
try:
import google.auth
import google.auth.transport.requests
from google.oauth2.service_account import Credentials
enabled = True
except ImportError:
enabled = False
types_conv = dict(
STRING=TYPE_STRING,
INTEGER=TYPE_INTEGER,
FLOAT=TYPE_FLOAT,
DATE=TYPE_DATE,
DATETIME=TYPE_DATETIME,
)
ga_report_endpoint = "https://analyticsdata.googleapis.com/v1beta/properties/{propertyId}:runReport"
ga_metadata_endpoint = "https://analyticsdata.googleapis.com/v1beta/properties/{propertyId}/metadata"
def format_column_value(column_name, value, columns):
column_type = [col for col in columns if col["name"] == column_name][0]["type"]
if column_type == TYPE_DATE:
value = datetime.datetime.strptime(value, "%Y%m%d")
elif column_type == TYPE_DATETIME:
if len(value) == 10:
value = datetime.datetime.strptime(value, "%Y%m%d%H")
elif len(value) == 12:
value = datetime.datetime.strptime(value, "%Y%m%d%H%M")
else:
raise Exception("Unknown date/time format in results: '{}'".format(value))
return value
def get_formatted_column_json(column_name):
data_type = None
if column_name == "date":
data_type = "DATE"
elif column_name == "dateHour":
data_type = "DATETIME"
result = {
"name": column_name,
"friendly_name": column_name,
"type": types_conv.get(data_type, "string"),
}
return result
def parse_ga_response(response):
columns = []
for dim_header in response["dimensionHeaders"]:
columns.append(get_formatted_column_json(dim_header["name"]))
for met_header in response["metricHeaders"]:
columns.append(get_formatted_column_json(met_header["name"]))
rows = []
for r in response["rows"]:
counter = 0
d = {}
for item in r["dimensionValues"]:
column_name = columns[counter]["name"]
value = item["value"]
d[column_name] = format_column_value(column_name, value, columns)
counter = counter + 1
for item in r["metricValues"]:
column_name = columns[counter]["name"]
value = item["value"]
d[column_name] = format_column_value(column_name, value, columns)
counter = counter + 1
rows.append(d)
return {"columns": columns, "rows": rows}
class GoogleAnalytics4(BaseQueryRunner):
should_annotate_query = False
@classmethod
def type(cls):
return "google_analytics4"
@classmethod
def name(cls):
return "Google Analytics 4"
@classmethod
def enabled(cls):
return enabled
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"propertyId": {"type": "number", "title": "Property Id"},
"jsonKeyFile": {"type": "string", "title": "JSON Key File (ADC is used if omitted)"},
},
"required": ["propertyId"],
"secret": ["jsonKeyFile"],
}
def _get_access_token(self):
scopes = ["https://www.googleapis.com/auth/analytics.readonly"]
try:
key = json_loads(b64decode(self.configuration["jsonKeyFile"]))
creds = Credentials.from_service_account_info(key, scopes=scopes)
except KeyError:
creds = google.auth.default(scopes=scopes)[0]
creds.refresh(google.auth.transport.requests.Request())
return creds.token
def run_query(self, query, user):
access_token = self._get_access_token()
params = json_loads(query)
property_id = self.configuration["propertyId"]
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {access_token}"}
url = ga_report_endpoint.replace("{propertyId}", str(property_id))
r = requests.post(url, json=params, headers=headers)
r.raise_for_status()
raw_result = r.json()
data = parse_ga_response(raw_result)
error = None
return data, error
def test_connection(self):
try:
access_token = self._get_access_token()
property_id = self.configuration["propertyId"]
url = ga_metadata_endpoint.replace("{propertyId}", str(property_id))
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {access_token}"}
r = requests.get(url, headers=headers)
r.raise_for_status()
except Exception as e:
raise Exception(e)
register(GoogleAnalytics4)
================================================
FILE: redash/query_runner/google_search_console.py
================================================
import logging
from base64 import b64decode
from datetime import datetime
from redash.query_runner import (
TYPE_DATE,
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseSQLQueryRunner,
register,
)
from redash.utils import json_loads
logger = logging.getLogger(__name__)
try:
import google.auth
from apiclient.discovery import build
from apiclient.errors import HttpError
from google.oauth2.service_account import Credentials
enabled = True
except ImportError:
enabled = False
types_conv = dict(
STRING=TYPE_STRING,
INTEGER=TYPE_INTEGER,
FLOAT=TYPE_FLOAT,
DATE=TYPE_DATE,
DATETIME=TYPE_DATETIME,
)
def parse_ga_response(response, dimensions):
columns = []
for item in dimensions:
if item == "date":
data_type = "date"
else:
data_type = "string"
columns.append(
{
"name": item,
"friendly_name": item,
"type": data_type,
}
)
default_items = ["clicks", "impressions", "ctr", "position"]
for item in default_items:
columns.append({"name": item, "friendly_name": item, "type": "number"})
rows = []
for r in response.get("rows", []):
d = {}
for k, value in r.items():
if k == "keys":
for index, val in enumerate(value):
column_name = columns[index]["name"]
column_type = columns[index]["type"]
val = get_formatted_value(column_type, val)
d[column_name] = val
else:
column_name = k
column_type = [col for col in columns if col["name"] == column_name][0]["type"]
value = get_formatted_value(column_type, value)
d[column_name] = value
rows.append(d)
return {"columns": columns, "rows": rows}
def get_formatted_value(column_type, value):
if column_type == "number":
value = round(value, 2)
elif column_type == TYPE_DATE:
value = datetime.strptime(value, "%Y-%m-%d")
elif column_type == TYPE_DATETIME:
if len(value) == 10:
value = datetime.strptime(value, "%Y%m%d%H")
elif len(value) == 12:
value = datetime.strptime(value, "%Y%m%d%H%M")
else:
raise Exception("Unknown date/time format in results: '{}'".format(value))
return value
class GoogleSearchConsole(BaseSQLQueryRunner):
should_annotate_query = False
@classmethod
def type(cls):
return "google_search_console"
@classmethod
def name(cls):
return "Google Search Console"
@classmethod
def enabled(cls):
return enabled
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"siteURL": {"type": "string", "title": "Site URL"},
"jsonKeyFile": {"type": "string", "title": "JSON Key File (ADC is used if omitted)"},
},
"required": [],
"secret": ["jsonKeyFile"],
}
def __init__(self, configuration):
super(GoogleSearchConsole, self).__init__(configuration)
self.syntax = "json"
def _get_search_service(self):
scopes = ["https://www.googleapis.com/auth/webmasters.readonly"]
try:
key = json_loads(b64decode(self.configuration["jsonKeyFile"]))
creds = Credentials.from_service_account_info(key, scopes=scopes)
except KeyError:
creds = google.auth.default(scopes=scopes)[0]
return build("searchconsole", "v1", credentials=creds)
def test_connection(self):
try:
service = self._get_search_service()
service.sites().list().execute()
except HttpError as e:
# Make sure we return a more readable error to the end user
raise Exception(e._get_reason())
def run_query(self, query, user):
logger.debug("Search Analytics is about to execute query: %s", query)
params = json_loads(query)
site_url = self.configuration["siteURL"]
api = self._get_search_service()
if len(params) > 0:
try:
response = api.searchanalytics().query(siteUrl=site_url, body=params).execute()
data = parse_ga_response(response, params["dimensions"])
error = None
except HttpError as e:
# Make sure we return a more readable error to the end user
error = e._get_reason()
data = None
else:
error = "Wrong query format."
data = None
return data, error
register(GoogleSearchConsole)
================================================
FILE: redash/query_runner/google_spanner.py
================================================
================================================
FILE: redash/query_runner/google_spreadsheets.py
================================================
import logging
import re
from base64 import b64decode
from dateutil import parser
from requests import Session
from xlsxwriter.utility import xl_col_to_name
from redash.query_runner import (
TYPE_BOOLEAN,
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseQueryRunner,
guess_type,
register,
)
from redash.utils import json_loads
logger = logging.getLogger(__name__)
try:
import google.auth
import gspread
from google.auth.exceptions import GoogleAuthError
from google.oauth2.service_account import Credentials
from gspread.exceptions import APIError
from gspread.exceptions import WorksheetNotFound as GSWorksheetNotFound
enabled = True
except ImportError:
enabled = False
def _load_key(filename):
with open(filename, "rb") as f:
return json_loads(f.read())
def _get_columns_and_column_names(row):
column_names = []
columns = []
duplicate_counter = 1
for i, column_name in enumerate(row):
if not column_name:
column_name = "column_{}".format(xl_col_to_name(i))
if column_name in column_names:
column_name = "{}{}".format(column_name, duplicate_counter)
duplicate_counter += 1
column_names.append(column_name)
columns.append({"name": column_name, "friendly_name": column_name, "type": TYPE_STRING})
return columns, column_names
def _value_eval_list(row_values, col_types):
value_list = []
raw_values = zip(col_types, row_values)
for typ, rval in raw_values:
try:
if rval is None or rval == "":
val = None
elif typ == TYPE_BOOLEAN:
val = True if str(rval).lower() == "true" else False
elif typ == TYPE_DATETIME:
val = parser.parse(rval)
elif typ == TYPE_FLOAT:
val = float(rval)
elif typ == TYPE_INTEGER:
val = int(rval)
else:
# for TYPE_STRING and default
val = str(rval)
value_list.append(val)
except (ValueError, OverflowError):
value_list.append(rval)
return value_list
HEADER_INDEX = 0
class WorksheetNotFoundError(Exception):
def __init__(self, worksheet_num, worksheet_count):
message = "Worksheet number {} not found. Spreadsheet has {} worksheets. Note that the worksheet count is zero based.".format(
worksheet_num, worksheet_count
)
super(WorksheetNotFoundError, self).__init__(message)
class WorksheetNotFoundByTitleError(Exception):
def __init__(self, worksheet_title):
message = "Worksheet title '{}' not found.".format(worksheet_title)
super(WorksheetNotFoundByTitleError, self).__init__(message)
def parse_query(query):
values = query.split("|")
key = values[0] # key of the spreadsheet
worksheet_num_or_title = 0 # A default value for when a number of inputs is invalid
if len(values) == 2:
s = values[1].strip()
if len(s) > 0:
if re.match(r"^\"(.*?)\"$", s):
# A string quoted by " means a title of worksheet
worksheet_num_or_title = s[1:-1]
else:
# if spreadsheet contains more than one worksheet - this is the number of it
worksheet_num_or_title = int(s)
return key, worksheet_num_or_title
def parse_worksheet(worksheet):
if not worksheet:
return {"columns": [], "rows": []}
columns, column_names = _get_columns_and_column_names(worksheet[HEADER_INDEX])
if len(worksheet) > 1:
for j, value in enumerate(worksheet[HEADER_INDEX + 1]):
columns[j]["type"] = guess_type(value)
column_types = [c["type"] for c in columns]
rows = [dict(zip(column_names, _value_eval_list(row, column_types))) for row in worksheet[HEADER_INDEX + 1 :]]
data = {"columns": columns, "rows": rows}
return data
def parse_spreadsheet(spreadsheet, worksheet_num_or_title):
worksheet = None
if isinstance(worksheet_num_or_title, int):
worksheet = spreadsheet.get_worksheet_by_index(worksheet_num_or_title)
if worksheet is None:
worksheet_count = len(spreadsheet.worksheets())
raise WorksheetNotFoundError(worksheet_num_or_title, worksheet_count)
elif isinstance(worksheet_num_or_title, str):
worksheet = spreadsheet.get_worksheet_by_title(worksheet_num_or_title)
if worksheet is None:
raise WorksheetNotFoundByTitleError(worksheet_num_or_title)
worksheet_values = worksheet.get_all_values()
return parse_worksheet(worksheet_values)
def is_url_key(key):
return key.startswith("https://")
def parse_api_error(error):
error_data = error.response.json()
if "error" in error_data and "message" in error_data["error"]:
message = error_data["error"]["message"]
else:
message = str(error)
return message
class SpreadsheetWrapper:
def __init__(self, spreadsheet):
self.spreadsheet = spreadsheet
def worksheets(self):
return self.spreadsheet.worksheets()
def get_worksheet_by_index(self, index):
return self.spreadsheet.get_worksheet(index)
def get_worksheet_by_title(self, title):
try:
return self.spreadsheet.worksheet(title)
except GSWorksheetNotFound:
return None
class TimeoutSession(Session):
def request(self, *args, **kwargs):
kwargs.setdefault("timeout", 300)
return super(TimeoutSession, self).request(*args, **kwargs)
class GoogleSpreadsheet(BaseQueryRunner):
should_annotate_query = False
def __init__(self, configuration):
super(GoogleSpreadsheet, self).__init__(configuration)
self.syntax = "custom"
@classmethod
def name(cls):
return "Google Sheets"
@classmethod
def type(cls):
return "google_spreadsheets"
@classmethod
def enabled(cls):
return enabled
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {"jsonKeyFile": {"type": "string", "title": "JSON Key File (ADC is used if omitted)"}},
"required": [],
"secret": ["jsonKeyFile"],
}
def _get_spreadsheet_service(self):
scopes = ["https://spreadsheets.google.com/feeds"]
try:
key = json_loads(b64decode(self.configuration["jsonKeyFile"]))
creds = Credentials.from_service_account_info(key, scopes=scopes)
except KeyError:
creds = google.auth.default(scopes=scopes)[0]
timeout_session = Session()
timeout_session.requests_session = TimeoutSession()
spreadsheetservice = gspread.Client(auth=creds, session=timeout_session)
spreadsheetservice.login()
return spreadsheetservice
def test_connection(self):
test_spreadsheet_key = "1S0mld7LMbUad8LYlo13Os9f7eNjw57MqVC0YiCd1Jis"
try:
service = self._get_spreadsheet_service()
service.open_by_key(test_spreadsheet_key).worksheets()
except APIError as e:
logger.exception(e)
message = parse_api_error(e)
raise Exception(message)
except GoogleAuthError as e:
logger.exception(e)
raise Exception(str(e))
def run_query(self, query, user):
logger.debug("Spreadsheet is about to execute query: %s", query)
key, worksheet_num_or_title = parse_query(query)
try:
spreadsheet_service = self._get_spreadsheet_service()
if is_url_key(key):
spreadsheet = spreadsheet_service.open_by_url(key)
else:
spreadsheet = spreadsheet_service.open_by_key(key)
data = parse_spreadsheet(SpreadsheetWrapper(spreadsheet), worksheet_num_or_title)
return data, None
except gspread.SpreadsheetNotFound:
return (
None,
"Spreadsheet ({}) not found. Make sure you used correct id.".format(key),
)
except APIError as e:
return None, parse_api_error(e)
register(GoogleSpreadsheet)
================================================
FILE: redash/query_runner/graphite.py
================================================
import datetime
import logging
import requests
from redash.query_runner import (
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_STRING,
BaseQueryRunner,
register,
)
logger = logging.getLogger(__name__)
def _transform_result(response):
columns = (
{"name": "Time::x", "type": TYPE_DATETIME},
{"name": "value::y", "type": TYPE_FLOAT},
{"name": "name::series", "type": TYPE_STRING},
)
rows = []
for series in response.json():
for values in series["datapoints"]:
timestamp = datetime.datetime.fromtimestamp(int(values[1]))
rows.append(
{
"Time::x": timestamp,
"name::series": series["target"],
"value::y": values[0],
}
)
return {"columns": columns, "rows": rows}
class Graphite(BaseQueryRunner):
should_annotate_query = False
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"url": {"type": "string"},
"username": {"type": "string"},
"password": {"type": "string"},
"verify": {"type": "boolean", "title": "Verify SSL certificate"},
},
"required": ["url"],
"secret": ["password"],
}
def __init__(self, configuration):
super(Graphite, self).__init__(configuration)
self.syntax = "custom"
if "username" in self.configuration and self.configuration["username"]:
self.auth = (self.configuration["username"], self.configuration["password"])
else:
self.auth = None
self.verify = self.configuration.get("verify", True)
self.base_url = "%s/render?format=json&" % self.configuration["url"]
def test_connection(self):
r = requests.get(
"{}/render".format(self.configuration["url"]),
auth=self.auth,
verify=self.verify,
)
if r.status_code != 200:
raise Exception("Got invalid response from Graphite (http status code: {0}).".format(r.status_code))
def run_query(self, query, user):
url = "%s%s" % (self.base_url, "&".join(query.split("\n")))
error = None
data = None
try:
response = requests.get(url, auth=self.auth, verify=self.verify)
if response.status_code == 200:
data = _transform_result(response)
else:
error = "Failed getting results (%d)" % response.status_code
except Exception as ex:
data = None
error = str(ex)
return data, error
register(Graphite)
================================================
FILE: redash/query_runner/hive_ds.py
================================================
import base64
import logging
from redash.query_runner import (
TYPE_BOOLEAN,
TYPE_DATE,
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseSQLQueryRunner,
JobTimeoutException,
register,
)
logger = logging.getLogger(__name__)
try:
from pyhive import hive
from pyhive.exc import DatabaseError
from thrift.transport import THttpClient
enabled = True
except ImportError:
enabled = False
COLUMN_NAME = 0
COLUMN_TYPE = 1
types_map = {
"BIGINT_TYPE": TYPE_INTEGER,
"TINYINT_TYPE": TYPE_INTEGER,
"SMALLINT_TYPE": TYPE_INTEGER,
"INT_TYPE": TYPE_INTEGER,
"DOUBLE_TYPE": TYPE_FLOAT,
"DECIMAL_TYPE": TYPE_FLOAT,
"FLOAT_TYPE": TYPE_FLOAT,
"REAL_TYPE": TYPE_FLOAT,
"BOOLEAN_TYPE": TYPE_BOOLEAN,
"TIMESTAMP_TYPE": TYPE_DATETIME,
"DATE_TYPE": TYPE_DATE,
"CHAR_TYPE": TYPE_STRING,
"STRING_TYPE": TYPE_STRING,
"VARCHAR_TYPE": TYPE_STRING,
}
class Hive(BaseSQLQueryRunner):
should_annotate_query = False
noop_query = "SELECT 1"
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"host": {"type": "string"},
"port": {"type": "number"},
"database": {"type": "string"},
"username": {"type": "string"},
},
"order": ["host", "port", "database", "username"],
"required": ["host"],
}
@classmethod
def type(cls):
return "hive"
@classmethod
def enabled(cls):
return enabled
def _get_tables(self, schema):
schemas_query = "show schemas"
tables_query = "show tables in %s"
columns_query = "show columns in %s.%s"
for schema_name in [
a for a in [str(a["database_name"]) for a in self._run_query_internal(schemas_query)] if len(a) > 0
]:
for table_name in [
a
for a in [str(a["tab_name"]) for a in self._run_query_internal(tables_query % schema_name)]
if len(a) > 0
]:
columns = [
a
for a in [
str(a["field"]) for a in self._run_query_internal(columns_query % (schema_name, table_name))
]
if len(a) > 0
]
if schema_name != "default":
table_name = "{}.{}".format(schema_name, table_name)
schema[table_name] = {"name": table_name, "columns": columns}
return list(schema.values())
def _get_connection(self):
host = self.configuration["host"]
connection = hive.connect(
host=host,
port=self.configuration.get("port", None),
database=self.configuration.get("database", "default"),
username=self.configuration.get("username", None),
)
return connection
def run_query(self, query, user):
connection = None
try:
connection = self._get_connection()
cursor = connection.cursor()
cursor.execute(query)
column_names = []
columns = []
for column in cursor.description:
column_name = column[COLUMN_NAME]
column_names.append(column_name)
columns.append(
{
"name": column_name,
"friendly_name": column_name,
"type": types_map.get(column[COLUMN_TYPE], None),
}
)
rows = [dict(zip(column_names, row)) for row in cursor]
data = {"columns": columns, "rows": rows}
error = None
except (KeyboardInterrupt, JobTimeoutException):
if connection:
connection.cancel()
raise
except DatabaseError as e:
try:
error = e.args[0].status.errorMessage
except AttributeError:
error = str(e)
data = None
finally:
if connection:
connection.close()
return data, error
class HiveHttp(Hive):
@classmethod
def name(cls):
return "Hive (HTTP)"
@classmethod
def type(cls):
return "hive_http"
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"host": {"type": "string"},
"port": {"type": "number"},
"database": {"type": "string"},
"username": {"type": "string"},
"http_scheme": {
"type": "string",
"title": "HTTP Scheme (http or https)",
"default": "https",
},
"http_path": {"type": "string", "title": "HTTP Path"},
"http_password": {"type": "string", "title": "Password"},
},
"order": [
"host",
"port",
"http_path",
"username",
"http_password",
"database",
"http_scheme",
],
"secret": ["http_password"],
"required": ["host", "http_path"],
}
def _get_connection(self):
host = self.configuration["host"]
scheme = self.configuration.get("http_scheme", "https")
# if path is set but is missing initial slash, append it
path = self.configuration.get("http_path", "")
if path and path[0] != "/":
path = "/" + path
# if port is set prepend colon
port = self.configuration.get("port", "")
if port:
port = ":" + str(port)
http_uri = "{}://{}{}{}".format(scheme, host, port, path)
# create transport
transport = THttpClient.THttpClient(http_uri)
# if username or password is set, add Authorization header
username = self.configuration.get("username", "")
password = self.configuration.get("http_password", "")
if username or password:
auth = base64.b64encode(username.encode("ascii") + b":" + password.encode("ascii"))
transport.setCustomHeaders({"Authorization": "Basic " + auth.decode()})
# create connection
connection = hive.connect(thrift_transport=transport)
return connection
register(Hive)
register(HiveHttp)
================================================
FILE: redash/query_runner/ignite.py
================================================
import datetime
import importlib.util
import logging
from redash.query_runner import (
TYPE_BOOLEAN,
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseSQLQueryRunner,
JobTimeoutException,
register,
)
ignite_available = importlib.util.find_spec("pyignite") is not None
gridgain_available = importlib.util.find_spec("pygridgain") is not None
logger = logging.getLogger(__name__)
types_map = {
"java.lang.String": TYPE_STRING,
"java.lang.Float": TYPE_FLOAT,
"java.lang.Double": TYPE_FLOAT,
"java.sql.Date": TYPE_DATETIME,
"java.sql.Timestamp": TYPE_DATETIME,
"java.lang.Long": TYPE_INTEGER,
"java.lang.Integer": TYPE_INTEGER,
"java.lang.Short": TYPE_INTEGER,
"java.lang.Boolean": TYPE_BOOLEAN,
"java.lang.Decimal": TYPE_FLOAT,
}
class Ignite(BaseSQLQueryRunner):
should_annotate_query = False
noop_query = "SELECT 1"
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"user": {"type": "string"},
"password": {"type": "string"},
"server": {"type": "string", "default": "127.0.0.1:10800"},
"tls": {"type": "boolean", "default": False, "title": "Use SSL/TLS connection"},
"schema": {"type": "string", "title": "Schema Name", "default": "PUBLIC"},
"distributed_joins": {"type": "boolean", "title": "Allow distributed joins", "default": False},
"enforce_join_order": {"type": "boolean", "title": "Enforce join order", "default": False},
"lazy": {"type": "boolean", "title": "Lazy query execution", "default": True},
"gridgain": {"type": "boolean", "title": "Use GridGain libraries", "default": gridgain_available},
},
"required": ["server"],
"secret": ["password"],
}
@classmethod
def name(cls):
return "Apache Ignite"
@classmethod
def type(cls):
return "ignite"
@classmethod
def enabled(cls):
return ignite_available or gridgain_available
def _get_tables(self, schema):
query = """
SELECT schema_name, table_name, column_name, type
FROM SYS.TABLE_COLUMNS
WHERE schema_name NOT IN ('SYS') and column_name not in ('_KEY','_VAL');
"""
results, error = self.run_query(query, None)
if error is not None:
raise Exception("Failed getting schema.")
for row in results["rows"]:
if row["SCHEMA_NAME"] != self.configuration.get("schema", "PUBLIC"):
table_name = "{}.{}".format(row["SCHEMA_NAME"], row["TABLE_NAME"])
else:
table_name = row["TABLE_NAME"]
if table_name not in schema:
schema[table_name] = {"name": table_name, "columns": []}
col_type = TYPE_STRING
if row["TYPE"] in types_map:
col_type = types_map[row["TYPE"]]
schema[table_name]["columns"].append({"name": row["COLUMN_NAME"], "type": col_type})
return list(schema.values())
def normalise_column(self, col):
# if it's a datetime, just return the milliseconds
if type(col) is tuple and len(col) == 2 and type(col[0]) is datetime.datetime and isinstance(col[1], int):
return col[0]
else:
return col
def normalise_row(self, row):
return [self.normalise_column(col) for col in row]
def server_to_connection(self, s):
st = s.split(":")
if len(st) == 1:
server = s
port = 10800
elif len(st) == 2:
server = st[0]
port = int(st[1])
else:
server = "unknown"
port = 10800
return (server, port)
def _parse_results(self, c):
column_names = next(c)
columns = [{"name": col, "friendly_name": col.lower()} for col in column_names]
rows = [dict(zip(column_names, self.normalise_row(row))) for row in c]
return (columns, rows)
def run_query(self, query, user):
connection = None
try:
server = self.configuration.get("server", "127.0.0.1:10800")
user = self.configuration.get("user", None)
password = self.configuration.get("password", None)
tls = self.configuration.get("tls", False)
distributed_joins = self.configuration.get("distributed_joins", False)
enforce_join_order = self.configuration.get("enforce_join_order", False)
lazy = self.configuration.get("lazy", True)
gridgain = self.configuration.get("gridgain", False)
if gridgain:
from pygridgain import Client
else:
from pyignite import Client
connection = Client(username=user, password=password, use_ssl=tls)
connection.connect([self.server_to_connection(s) for s in server.split(",")])
cursor = connection.sql(
query,
include_field_names=True,
distributed_joins=distributed_joins,
enforce_join_order=enforce_join_order,
lazy=lazy,
)
logger.debug("Ignite running query: %s", query)
result = self._parse_results(cursor)
data = {"columns": result[0], "rows": result[1]}
error = None
except (KeyboardInterrupt, JobTimeoutException):
connection.cancel()
raise
finally:
if connection:
connection.close()
return data, error
register(Ignite)
================================================
FILE: redash/query_runner/impala_ds.py
================================================
import logging
from redash.query_runner import (
TYPE_BOOLEAN,
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseSQLQueryRunner,
JobTimeoutException,
register,
)
logger = logging.getLogger(__name__)
try:
from impala.dbapi import connect
from impala.error import DatabaseError, RPCError
enabled = True
except ImportError:
enabled = False
COLUMN_NAME = 0
COLUMN_TYPE = 1
types_map = {
"BIGINT": TYPE_INTEGER,
"TINYINT": TYPE_INTEGER,
"SMALLINT": TYPE_INTEGER,
"INT": TYPE_INTEGER,
"DOUBLE": TYPE_FLOAT,
"DECIMAL": TYPE_FLOAT,
"FLOAT": TYPE_FLOAT,
"REAL": TYPE_FLOAT,
"BOOLEAN": TYPE_BOOLEAN,
"TIMESTAMP": TYPE_DATETIME,
"CHAR": TYPE_STRING,
"STRING": TYPE_STRING,
"VARCHAR": TYPE_STRING,
}
class Impala(BaseSQLQueryRunner):
noop_query = "show schemas"
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"host": {"type": "string"},
"port": {"type": "number"},
"protocol": {
"type": "string",
"extendedEnum": [
{"value": "beeswax", "name": "Beeswax"},
{"value": "hiveserver2", "name": "Hive Server 2"},
],
"title": "Protocol",
},
"database": {"type": "string"},
"use_ldap": {"type": "boolean"},
"use_ssl": {"type": "boolean"},
"ldap_user": {"type": "string"},
"ldap_password": {"type": "string"},
"timeout": {"type": "number"},
},
"required": ["host"],
"secret": ["ldap_password"],
}
@classmethod
def type(cls):
return "impala"
def _get_tables(self, schema_dict):
schemas_query = "show schemas;"
tables_query = "show tables in `%s`;"
columns_query = "show column stats `%s`.`%s`;"
for schema_name in [str(a["name"]) for a in self._run_query_internal(schemas_query)]:
for table_name in [str(a["name"]) for a in self._run_query_internal(tables_query % schema_name)]:
columns = [
str(a["Column"]) for a in self._run_query_internal(columns_query % (schema_name, table_name))
]
if schema_name != "default":
table_name = "{}.{}".format(schema_name, table_name)
schema_dict[table_name] = {"name": table_name, "columns": columns}
return list(schema_dict.values())
def run_query(self, query, user):
connection = None
try:
connection = connect(**self.configuration.to_dict())
cursor = connection.cursor()
cursor.execute(query)
column_names = []
columns = []
for column in cursor.description:
column_name = column[COLUMN_NAME]
column_names.append(column_name)
columns.append(
{
"name": column_name,
"friendly_name": column_name,
"type": types_map.get(column[COLUMN_TYPE], None),
}
)
rows = [dict(zip(column_names, row)) for row in cursor]
data = {"columns": columns, "rows": rows}
error = None
cursor.close()
except DatabaseError as e:
data = None
error = str(e)
except RPCError as e:
data = None
error = "Metastore Error [%s]" % str(e)
except (KeyboardInterrupt, JobTimeoutException):
connection.cancel()
raise
finally:
if connection:
connection.close()
return data, error
register(Impala)
================================================
FILE: redash/query_runner/influx_db.py
================================================
import logging
from redash.query_runner import (
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseQueryRunner,
register,
)
logger = logging.getLogger(__name__)
try:
from influxdb import InfluxDBClient
enabled = True
except ImportError:
enabled = False
TYPES_MAP = {
str: TYPE_STRING,
int: TYPE_INTEGER,
float: TYPE_FLOAT,
}
def _get_type(value):
return TYPES_MAP.get(type(value), TYPE_STRING)
def _transform_result(results):
column_names = []
result_rows = []
for result in results:
for series in result.raw.get("series", []):
for column in series["columns"]:
if column not in column_names:
column_names.append(column)
tags = series.get("tags", {})
for key in tags.keys():
if key not in column_names:
column_names.append(key)
for result in results:
for series in result.raw.get("series", []):
for point in series["values"]:
result_row = {}
for column in column_names:
tags = series.get("tags", {})
if column in tags:
result_row[column] = tags[column]
elif column in series["columns"]:
index = series["columns"].index(column)
value = point[index]
result_row[column] = value
result_rows.append(result_row)
if len(result_rows) > 0:
result_columns = [{"name": c, "type": _get_type(result_rows[0][c])} for c in result_rows[0].keys()]
else:
result_columns = [{"name": c, "type": TYPE_STRING} for c in column_names]
return {"columns": result_columns, "rows": result_rows}
class InfluxDB(BaseQueryRunner):
should_annotate_query = False
noop_query = "show measurements limit 1"
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {"url": {"type": "string"}},
"required": ["url"],
}
@classmethod
def enabled(cls):
return enabled
@classmethod
def type(cls):
return "influxdb"
def run_query(self, query, user):
client = InfluxDBClient.from_dsn(self.configuration["url"])
logger.debug("influxdb url: %s", self.configuration["url"])
logger.debug("influxdb got query: %s", query)
try:
results = client.query(query)
if not isinstance(results, list):
results = [results]
json_data = _transform_result(results)
error = None
except Exception as ex:
json_data = None
error = str(ex)
return json_data, error
register(InfluxDB)
================================================
FILE: redash/query_runner/influx_db_v2.py
================================================
import logging
import os
from base64 import b64decode
from tempfile import NamedTemporaryFile
from typing import Any, Dict, Optional, Tuple, Type, TypeVar
from redash.query_runner import (
TYPE_BOOLEAN,
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseQueryRunner,
register,
)
try:
from influxdb_client import InfluxDBClient
enabled = True
except ImportError:
enabled = False
logger = logging.getLogger(__name__)
T = TypeVar("T")
TYPES_MAP = {
"integer": TYPE_INTEGER,
"long": TYPE_INTEGER,
"float": TYPE_FLOAT,
"double": TYPE_FLOAT,
"boolean": TYPE_BOOLEAN,
"string": TYPE_STRING,
"datetime:RFC3339": TYPE_DATETIME,
}
class InfluxDBv2(BaseQueryRunner):
"""
Query runner for influxdb version 2.
"""
should_annotate_query = False
def _get_influx_kwargs(self) -> Dict:
"""
Determines additional arguments for influxdb client connection.
:return: An object with additional arguments for influxdb client.
"""
return {
"verify_ssl": self.configuration.get("verify_ssl", None),
"cert_file": self._create_cert_file("cert_File"),
"cert_key_file": self._create_cert_file("cert_key_File"),
"cert_key_password": self.configuration.get("cert_key_password", None),
"ssl_ca_cert": self._create_cert_file("ssl_ca_cert_File"),
}
def _create_cert_file(self, key: str) -> str:
"""
Creates a temporary file from base64 encoded content from stored
configuration in filesystem.
:param key: The key to get the content from configuration object.
:return: The name of temporary file.
"""
cert_file_name = None
if self.configuration.get(key, None) is not None:
with NamedTemporaryFile(mode="w", delete=False) as cert_file:
cert_bytes = b64decode(self.configuration[key])
cert_file.write(cert_bytes.decode("utf-8"))
cert_file_name = cert_file.name
return cert_file_name
def _cleanup_cert_files(self, influx_kwargs: Dict) -> None:
"""
Deletes temporary stored files in filesystem.
"""
for key in ["cert_file", "cert_key_file", "ssl_ca_cert"]:
cert_path = influx_kwargs.get(key, None)
if cert_path is not None and os.path.exists(cert_path):
os.remove(cert_path)
@classmethod
def configuration_schema(cls: Type[T]) -> Dict:
"""
Defines a configuration schema for this query runner.
:param cls: Object of this class.
:return: The defined configuration schema.
"""
# files has to end with "File" in name
return {
"type": "object",
"properties": {
"url": {"type": "string", "title": "URL"},
"org": {"type": "string", "title": "Organization"},
"token": {"type": "string", "title": "Token"},
"verify_ssl": {"type": "boolean", "title": "Verify SSL", "default": False},
"cert_File": {"type": "string", "title": "SSL Client Certificate", "default": None},
"cert_key_File": {"type": "string", "title": "SSL Client Key", "default": None},
"cert_key_password": {"type": "string", "title": "Password for SSL Client Key", "default": None},
"ssl_ca_cert_File": {"type": "string", "title": "SSL Root Certificate", "default": None},
},
"order": ["url", "org", "token", "cert_File", "cert_key_File", "cert_key_password", "ssl_ca_cert_File"],
"required": ["url", "org", "token"],
"secret": ["token", "cert_File", "cert_key_File", "cert_key_password", "ssl_ca_cert_File"],
"extra_options": ["verify_ssl", "cert_File", "cert_key_File", "cert_key_password", "ssl_ca_cert_File"],
}
@classmethod
def enabled(cls: Type[T]) -> bool:
"""
Determines, if this query runner is enabled or not.
:param cls: Object of this class.
:return: True, if this query runner is enabled; otherwise False.
"""
return enabled
def test_connection(self) -> None:
"""
Tests the healthiness of the influxdb instance. If it is not healthy,
it logs an error message and raises an exception with an appropriate
message.
:raises Exception: If the remote influxdb instance is not healthy.
"""
try:
influx_kwargs = self._get_influx_kwargs()
with InfluxDBClient(
url=self.configuration["url"],
token=self.configuration["token"],
org=self.configuration["org"],
**influx_kwargs,
) as client:
healthy = client.health()
if healthy.status == "fail":
logger.error("Connection test failed, due to: " f"{healthy.message!r}.")
raise Exception("InfluxDB is not healthy. Check logs for more " "information.")
except Exception:
raise
finally:
self._cleanup_cert_files(influx_kwargs)
def _get_type(self, type_: str) -> str:
"""
Determines the internal type of a passed data type which the database
uses.
:param type_: The type from the database to map to internal datatype.
:return: The name of the internal datatype.
"""
return TYPES_MAP.get(type_, "string")
def _get_data_from_tables(self, tables: Any) -> Dict:
"""
Determines the data of the given tables in an appropriate schema for
redash ui to render it. It retrieves all available columns and records
from the tables.
:param tables: A list of FluxTable instances.
:return: An object with columns and rows list.
"""
columns = []
rows = []
for table in tables:
for column in table.columns:
column_entry = {
"name": column.label,
"type": self._get_type(column.data_type),
"friendly_name": column.label.title(),
}
if column_entry not in columns:
columns.append(column_entry)
rows.extend([row.values for row in [record for record in table.records]])
return {"columns": columns, "rows": rows}
def run_query(self, query: str, user: str) -> Tuple[Optional[str], Optional[str]]:
"""
Runs a given query against the influxdb instance and returns its
result.
:param query: The query, this runner is executed.
:param user: The user who runs the query.
:return: A 2-tuple:
1. element: The queried result in an appropriate format for redash
ui. If an error occurred, it returns None.
2. element: An error message, if an error occured. None, if no
error occurred.
"""
data = None
error = None
try:
influx_kwargs = self._get_influx_kwargs()
with InfluxDBClient(
url=self.configuration["url"],
token=self.configuration["token"],
org=self.configuration["org"],
**influx_kwargs,
) as client:
logger.debug(f"InfluxDB got query: {query!r}")
tables = client.query_api().query(query)
data = self._get_data_from_tables(tables)
except Exception as ex:
error = str(ex)
finally:
self._cleanup_cert_files(influx_kwargs)
return data, error
register(InfluxDBv2)
================================================
FILE: redash/query_runner/jql.py
================================================
import re
from collections import OrderedDict
from redash.query_runner import TYPE_STRING, BaseHTTPQueryRunner, register
from redash.utils import json_loads
# TODO: make this more general and move into __init__.py
class ResultSet:
def __init__(self):
self.columns = OrderedDict()
self.rows = []
def add_row(self, row):
for key in row.keys():
self.add_column(key)
self.rows.append(row)
def add_column(self, column, column_type=TYPE_STRING):
if column not in self.columns:
self.columns[column] = {
"name": column,
"type": column_type,
"friendly_name": column,
}
def to_json(self):
return {"rows": self.rows, "columns": list(self.columns.values())}
def merge(self, set):
self.rows = self.rows + set.rows
def parse_issue(issue, field_mapping): # noqa: C901
result = OrderedDict()
# Handle API v3 response format: key field may be missing, use id as fallback
result["key"] = issue.get("key", issue.get("id", "unknown"))
# Handle API v3 response format: fields may be missing
fields = issue.get("fields", {})
for k, v in fields.items(): #
output_name = field_mapping.get_output_field_name(k)
member_names = field_mapping.get_dict_members(k)
if isinstance(v, dict):
if len(member_names) > 0:
# if field mapping with dict member mappings defined get value of each member
for member_name in member_names:
if member_name in v:
result[field_mapping.get_dict_output_field_name(k, member_name)] = v[member_name]
else:
# these special mapping rules are kept for backwards compatibility
if "key" in v:
result["{}_key".format(output_name)] = v["key"]
if "name" in v:
result["{}_name".format(output_name)] = v["name"]
if k in v:
result[output_name] = v[k]
if "watchCount" in v:
result[output_name] = v["watchCount"]
elif isinstance(v, list):
if len(member_names) > 0:
# if field mapping with dict member mappings defined get value of each member
for member_name in member_names:
listValues = []
for listItem in v:
if isinstance(listItem, dict):
if member_name in listItem:
listValues.append(listItem[member_name])
if len(listValues) > 0:
result[field_mapping.get_dict_output_field_name(k, member_name)] = ",".join(listValues)
else:
# otherwise support list values only for non-dict items
listValues = []
for listItem in v:
if not isinstance(listItem, dict):
listValues.append(listItem)
if len(listValues) > 0:
result[output_name] = ",".join(listValues)
else:
result[output_name] = v
return result
def parse_issues(data, field_mapping):
results = ResultSet()
for issue in data["issues"]:
results.add_row(parse_issue(issue, field_mapping))
return results
def parse_count(data):
results = ResultSet()
# API v3 may not return 'total' field, fallback to counting issues
count = data.get("total", len(data.get("issues", [])))
results.add_row({"count": count})
return results
class FieldMapping:
def __init__(cls, query_field_mapping):
cls.mapping = []
for k, v in query_field_mapping.items():
field_name = k
member_name = None
# check for member name contained in field name
member_parser = re.search(r"(\w+)\.(\w+)", k)
if member_parser:
field_name = member_parser.group(1)
member_name = member_parser.group(2)
cls.mapping.append(
{
"field_name": field_name,
"member_name": member_name,
"output_field_name": v,
}
)
def get_output_field_name(cls, field_name):
for item in cls.mapping:
if item["field_name"] == field_name and not item["member_name"]:
return item["output_field_name"]
return field_name
def get_dict_members(cls, field_name):
member_names = []
for item in cls.mapping:
if item["field_name"] == field_name and item["member_name"]:
member_names.append(item["member_name"])
return member_names
def get_dict_output_field_name(cls, field_name, member_name):
for item in cls.mapping:
if item["field_name"] == field_name and item["member_name"] == member_name:
return item["output_field_name"]
return None
class JiraJQL(BaseHTTPQueryRunner):
noop_query = '{"queryType": "count"}'
response_error = "JIRA returned unexpected status code"
requires_authentication = True
url_title = "JIRA URL"
username_title = "Username"
password_title = "API Token"
@classmethod
def name(cls):
return "JIRA (JQL)"
def __init__(self, configuration):
super(JiraJQL, self).__init__(configuration)
self.syntax = "json"
def run_query(self, query, user):
# Updated to API v3 endpoint, fix double slash issue
jql_url = "{}/rest/api/3/search/jql".format(self.configuration["url"].rstrip("/"))
query = json_loads(query)
query_type = query.pop("queryType", "select")
field_mapping = FieldMapping(query.pop("fieldMapping", {}))
# API v3 requires mandatory jql parameter with restrictions
if "jql" not in query or not query["jql"]:
query["jql"] = "created >= -30d order by created DESC"
if query_type == "count":
query["maxResults"] = 1
query["fields"] = ""
else:
query["maxResults"] = query.get("maxResults", 1000)
if "fields" not in query:
query["fields"] = "*all"
response, error = self.get_response(jql_url, params=query)
if error is not None:
return None, error
data = response.json()
if query_type == "count":
results = parse_count(data)
else:
results = parse_issues(data, field_mapping)
# API v3 uses token-based pagination instead of startAt/total
while not data.get("isLast", True) and "nextPageToken" in data:
query["nextPageToken"] = data["nextPageToken"]
response, error = self.get_response(jql_url, params=query)
if error is not None:
return None, error
data = response.json()
addl_results = parse_issues(data, field_mapping)
results.merge(addl_results)
return results.to_json(), None
register(JiraJQL)
================================================
FILE: redash/query_runner/json_ds.py
================================================
import datetime
import logging
from urllib.parse import urljoin
import yaml
from funcy import compact, project
from redash.query_runner import (
TYPE_BOOLEAN,
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseHTTPQueryRunner,
register,
)
class QueryParseError(Exception):
pass
def parse_query(query):
# TODO: copy paste from Metrica query runner, we should extract this into a utility
query = query.strip()
if query == "":
raise QueryParseError("Query is empty.")
try:
params = yaml.safe_load(query)
return params
except ValueError as e:
logging.exception(e)
error = str(e)
raise QueryParseError(error)
TYPES_MAP = {
str: TYPE_STRING,
bytes: TYPE_STRING,
int: TYPE_INTEGER,
float: TYPE_FLOAT,
bool: TYPE_BOOLEAN,
datetime.datetime: TYPE_DATETIME,
}
def _get_column_by_name(columns, column_name):
for c in columns:
if "name" in c and c["name"] == column_name:
return c
return None
def _get_type(value):
return TYPES_MAP.get(type(value), TYPE_STRING)
def add_column(columns, column_name, column_type):
if _get_column_by_name(columns, column_name) is None:
columns.append({"name": column_name, "friendly_name": column_name, "type": column_type})
def _apply_path_search(response, path, default=None):
if path is None:
return response
path_parts = path.split(".")
path_parts.reverse()
while len(path_parts) > 0:
current_path = path_parts.pop()
if current_path in response:
response = response[current_path]
elif default is not None:
return default
else:
raise Exception("Couldn't find path {} in response.".format(path))
return response
def _normalize_json(data, path):
if not data:
return None
data = _apply_path_search(data, path)
if isinstance(data, dict):
data = [data]
return data
def _sort_columns_with_fields(columns, fields):
if fields:
columns = compact([_get_column_by_name(columns, field) for field in fields])
return columns
# TODO: merge the logic here with the one in MongoDB's queyr runner
def parse_json(data, fields):
rows = []
columns = []
for row in data:
parsed_row = {}
for key in row:
if isinstance(row[key], dict):
for inner_key in row[key]:
column_name = "{}.{}".format(key, inner_key)
if fields and key not in fields and column_name not in fields:
continue
value = row[key][inner_key]
add_column(columns, column_name, _get_type(value))
parsed_row[column_name] = value
else:
if fields and key not in fields:
continue
value = row[key]
add_column(columns, key, _get_type(value))
parsed_row[key] = row[key]
rows.append(parsed_row)
columns = _sort_columns_with_fields(columns, fields)
return {"rows": rows, "columns": columns}
class JSON(BaseHTTPQueryRunner):
requires_url = False
base_url_title = "Base URL"
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"base_url": {"type": "string", "title": cls.base_url_title},
"username": {"type": "string", "title": cls.username_title},
"password": {"type": "string", "title": cls.password_title},
},
"secret": ["password"],
"order": ["base_url", "username", "password"],
}
def __init__(self, configuration):
super(JSON, self).__init__(configuration)
self.syntax = "yaml"
def test_connection(self):
pass
def run_query(self, query, user):
query = parse_query(query)
data, error = self._run_json_query(query)
if error is not None:
return None, error
if data:
return data, None
return None, "Got empty response from '{}'.".format(query["url"])
def _run_json_query(self, query):
if not isinstance(query, dict):
raise QueryParseError("Query should be a YAML object describing the URL to query.")
if "url" not in query:
raise QueryParseError("Query must include 'url' option.")
method = query.get("method", "get")
request_options = project(query, ("params", "headers", "data", "auth", "json", "verify"))
fields = query.get("fields")
path = query.get("path")
if "pagination" in query:
pagination = RequestPagination.from_config(self.configuration, query["pagination"])
else:
pagination = None
if isinstance(request_options.get("auth", None), list):
request_options["auth"] = tuple(request_options["auth"])
elif self.configuration.get("username") or self.configuration.get("password"):
request_options["auth"] = (self.configuration.get("username"), self.configuration.get("password"))
if method not in ("get", "post"):
raise QueryParseError("Only GET or POST methods are allowed.")
if fields and not isinstance(fields, list):
raise QueryParseError("'fields' needs to be a list.")
results, error = self._get_all_results(query["url"], method, path, pagination, **request_options)
return parse_json(results, fields), error
def _get_all_results(self, url, method, result_path, pagination, **request_options):
"""Get all results from a paginated endpoint."""
base_url = self.configuration.get("base_url")
url = urljoin(base_url, url)
results = []
has_more = True
while has_more:
response, error = self._get_json_response(url, method, **request_options)
has_more = False
result = _normalize_json(response, result_path)
if result:
results.extend(result)
if pagination:
has_more, url, request_options = pagination.next(url, request_options, response)
return results, error
def _get_json_response(self, url, method, **request_options):
response, error = self.get_response(url, http_method=method, **request_options)
result = response.json() if error is None else {}
return result, error
class RequestPagination:
def next(self, url, request_options, response):
"""Checks the response for another page.
Returns:
has_more, next_url, next_request_options
"""
return False, None, request_options
@staticmethod
def from_config(configuration, pagination):
if not isinstance(pagination, dict) or not isinstance(pagination.get("type"), str):
raise QueryParseError("'pagination' should be an object with a `type` property")
if pagination["type"] == "url":
return UrlPagination(pagination)
elif pagination["type"] == "token":
return TokenPagination(pagination)
raise QueryParseError("Unknown 'pagination.type' {}".format(pagination["type"]))
class UrlPagination(RequestPagination):
def __init__(self, pagination):
self.path = pagination.get("path", "_links.next.href")
if not isinstance(self.path, str):
raise QueryParseError("'pagination.path' should be a string")
def next(self, url, request_options, response):
next_url = _apply_path_search(response, self.path, "")
if not next_url:
return False, None, request_options
next_url = urljoin(url, next_url)
return True, next_url, request_options
class TokenPagination(RequestPagination):
def __init__(self, pagination):
self.fields = pagination.get("fields", ["next_page_token", "page_token"])
if not isinstance(self.fields, list) or len(self.fields) != 2:
raise QueryParseError("'pagination.fields' should be a list of 2 field names")
def next(self, url, request_options, response):
next_token = _apply_path_search(response, self.fields[0], "")
if not next_token:
return False, None, request_options
params = request_options.get("params", {})
# prevent infinite loop that can happen if self.fields[1] is wrong
if next_token == params.get(self.fields[1]):
raise Exception("{} did not change; possible misconfiguration".format(self.fields[0]))
params[self.fields[1]] = next_token
request_options["params"] = params
return True, url, request_options
register(JSON)
================================================
FILE: redash/query_runner/kylin.py
================================================
import logging
import os
import requests
from requests.auth import HTTPBasicAuth
from redash import settings
from redash.query_runner import (
TYPE_BOOLEAN,
TYPE_DATE,
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseQueryRunner,
register,
)
logger = logging.getLogger(__name__)
types_map = {
"tinyint": TYPE_INTEGER,
"smallint": TYPE_INTEGER,
"integer": TYPE_INTEGER,
"bigint": TYPE_INTEGER,
"int4": TYPE_INTEGER,
"long8": TYPE_INTEGER,
"int": TYPE_INTEGER,
"short": TYPE_INTEGER,
"long": TYPE_INTEGER,
"byte": TYPE_INTEGER,
"hllc10": TYPE_INTEGER,
"hllc12": TYPE_INTEGER,
"hllc14": TYPE_INTEGER,
"hllc15": TYPE_INTEGER,
"hllc16": TYPE_INTEGER,
"hllc(10)": TYPE_INTEGER,
"hllc(12)": TYPE_INTEGER,
"hllc(14)": TYPE_INTEGER,
"hllc(15)": TYPE_INTEGER,
"hllc(16)": TYPE_INTEGER,
"float": TYPE_FLOAT,
"double": TYPE_FLOAT,
"decimal": TYPE_FLOAT,
"real": TYPE_FLOAT,
"numeric": TYPE_FLOAT,
"boolean": TYPE_BOOLEAN,
"bool": TYPE_BOOLEAN,
"date": TYPE_DATE,
"datetime": TYPE_DATETIME,
"timestamp": TYPE_DATETIME,
"time": TYPE_DATETIME,
"varchar": TYPE_STRING,
"char": TYPE_STRING,
"string": TYPE_STRING,
}
class Kylin(BaseQueryRunner):
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"user": {"type": "string", "title": "Kylin Username"},
"password": {"type": "string", "title": "Kylin Password"},
"url": {
"type": "string",
"title": "Kylin API URL",
"default": "http://kylin.example.com/kylin/",
},
"project": {"type": "string", "title": "Kylin Project"},
},
"order": ["url", "project", "user", "password"],
"required": ["url", "project", "user", "password"],
"secret": ["password"],
}
def run_query(self, query, user):
url = self.configuration["url"]
kylinuser = self.configuration["user"]
kylinpass = self.configuration["password"]
kylinproject = self.configuration["project"]
resp = requests.post(
os.path.join(url, "api/query"),
auth=HTTPBasicAuth(kylinuser, kylinpass),
json={
"sql": query,
"offset": settings.KYLIN_OFFSET,
"limit": settings.KYLIN_LIMIT,
"acceptPartial": settings.KYLIN_ACCEPT_PARTIAL,
"project": kylinproject,
},
)
if not resp.ok:
return {}, resp.text or str(resp.reason)
data = resp.json()
columns = self.get_columns(data["columnMetas"])
rows = self.get_rows(columns, data["results"])
return {"columns": columns, "rows": rows}, None
def get_schema(self, get_stats=False):
url = self.configuration["url"]
kylinuser = self.configuration["user"]
kylinpass = self.configuration["password"]
kylinproject = self.configuration["project"]
resp = requests.get(
os.path.join(url, "api/tables_and_columns"),
params={"project": kylinproject},
auth=HTTPBasicAuth(kylinuser, kylinpass),
)
resp.raise_for_status()
data = resp.json()
return [self.get_table_schema(table) for table in data]
def test_connection(self):
url = self.configuration["url"]
requests.get(url).raise_for_status()
def get_columns(self, colmetas):
return self.fetch_columns(
[
(
meta["name"],
types_map.get(meta["columnTypeName"].lower(), TYPE_STRING),
)
for meta in colmetas
]
)
def get_rows(self, columns, results):
return [dict(zip((column["name"] for column in columns), row)) for row in results]
def get_table_schema(self, table):
name = table["table_NAME"]
columns = [col["column_NAME"].lower() for col in table["columns"]]
return {"name": name, "columns": columns}
register(Kylin)
================================================
FILE: redash/query_runner/memsql_ds.py
================================================
import logging
from redash.query_runner import (
TYPE_BOOLEAN,
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseSQLQueryRunner,
JobTimeoutException,
register,
)
logger = logging.getLogger(__name__)
try:
from memsql.common import database
enabled = True
except ImportError:
enabled = False
COLUMN_NAME = 0
COLUMN_TYPE = 1
types_map = {
"BIGINT": TYPE_INTEGER,
"TINYINT": TYPE_INTEGER,
"SMALLINT": TYPE_INTEGER,
"MEDIUMINT": TYPE_INTEGER,
"INT": TYPE_INTEGER,
"DOUBLE": TYPE_FLOAT,
"DECIMAL": TYPE_FLOAT,
"FLOAT": TYPE_FLOAT,
"REAL": TYPE_FLOAT,
"BOOL": TYPE_BOOLEAN,
"BOOLEAN": TYPE_BOOLEAN,
"TIMESTAMP": TYPE_DATETIME,
"DATETIME": TYPE_DATETIME,
"DATE": TYPE_DATETIME,
"JSON": TYPE_STRING,
"CHAR": TYPE_STRING,
"VARCHAR": TYPE_STRING,
}
class MemSQL(BaseSQLQueryRunner):
should_annotate_query = False
noop_query = "SELECT 1"
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"host": {"type": "string"},
"port": {"type": "number"},
"user": {"type": "string"},
"password": {"type": "string"},
},
"required": ["host", "port"],
"secret": ["password"],
}
@classmethod
def type(cls):
return "memsql"
@classmethod
def enabled(cls):
return enabled
def _get_tables(self, schema):
schemas_query = "show schemas"
tables_query = "show tables in %s"
columns_query = "show columns in %s"
for schema_name in [
a for a in [str(a["Database"]) for a in self._run_query_internal(schemas_query)] if len(a) > 0
]:
for table_name in [
a
for a in [
str(a["Tables_in_%s" % schema_name]) for a in self._run_query_internal(tables_query % schema_name)
]
if len(a) > 0
]:
table_name = ".".join((schema_name, table_name))
columns = [
a
for a in [str(a["Field"]) for a in self._run_query_internal(columns_query % table_name)]
if len(a) > 0
]
schema[table_name] = {"name": table_name, "columns": columns}
return list(schema.values())
def run_query(self, query, user):
cursor = None
try:
cursor = database.connect(**self.configuration.to_dict())
res = cursor.query(query)
# column_names = []
# columns = []
#
# for column in cursor.description:
# column_name = column[COLUMN_NAME]
# column_names.append(column_name)
#
# columns.append({
# 'name': column_name,
# 'friendly_name': column_name,
# 'type': types_map.get(column[COLUMN_TYPE], None)
# })
rows = [dict(zip(row.keys(), row.values())) for row in res]
# ====================================================================================================
# temporary - until https://github.com/memsql/memsql-python/pull/8 gets merged
# ====================================================================================================
columns = []
column_names = rows[0].keys() if rows else None
if column_names:
for column in column_names:
columns.append({"name": column, "friendly_name": column, "type": TYPE_STRING})
data = {"columns": columns, "rows": rows}
error = None
except (KeyboardInterrupt, JobTimeoutException):
cursor.close()
raise
finally:
if cursor:
cursor.close()
return data, error
register(MemSQL)
================================================
FILE: redash/query_runner/mongodb.py
================================================
import datetime
import logging
import re
from dateutil.parser import parse
from redash.query_runner import (
TYPE_BOOLEAN,
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseQueryRunner,
register,
)
from redash.utils import json_loads, parse_human_time
logger = logging.getLogger(__name__)
try:
import pymongo
from bson.decimal128 import Decimal128
from bson.json_util import JSONOptions
from bson.json_util import object_hook as bson_object_hook
from bson.objectid import ObjectId
from bson.son import SON
from bson.timestamp import Timestamp
enabled = True
except ImportError:
enabled = False
TYPES_MAP = {
str: TYPE_STRING,
bytes: TYPE_STRING,
int: TYPE_INTEGER,
float: TYPE_FLOAT,
bool: TYPE_BOOLEAN,
datetime.datetime: TYPE_DATETIME,
}
date_regex = re.compile(r'ISODate\("(.*)"\)', re.IGNORECASE)
def parse_oids(oids):
if not isinstance(oids, list):
raise Exception("$oids takes an array as input.")
return [bson_object_hook({"$oid": oid}) for oid in oids]
def datetime_parser(dct):
for k, v in dct.items():
if isinstance(v, str):
m = date_regex.findall(v)
if len(m) > 0:
dct[k] = parse(m[0], yearfirst=True)
if "$humanTime" in dct:
return parse_human_time(dct["$humanTime"])
if "$oids" in dct:
return parse_oids(dct["$oids"])
opts = JSONOptions(tz_aware=True)
return bson_object_hook(dct, json_options=opts)
def parse_query_json(query: str):
query_data = json_loads(query, object_hook=datetime_parser)
return query_data
def _get_column_by_name(columns, column_name):
for c in columns:
if "name" in c and c["name"] == column_name:
return c
return None
def _parse_dict(dic: dict, flatten: bool = False) -> dict:
res = {}
def _flatten(x, name=""):
if isinstance(x, dict):
for k, v in x.items():
_flatten(v, "{}.{}".format(name, k))
elif isinstance(x, list):
for idx, item in enumerate(x):
_flatten(item, "{}.{}".format(name, idx))
else:
res[name[1:]] = x
if flatten:
_flatten(dic)
else:
for key, value in dic.items():
if isinstance(value, dict):
for tmp_key, tmp_value in _parse_dict(value).items():
new_key = "{}.{}".format(key, tmp_key)
res[new_key] = tmp_value
else:
res[key] = value
return res
def parse_results(results: list, flatten: bool = False) -> list:
rows = []
columns = []
for row in results:
parsed_row = {}
parsed_row = _parse_dict(row, flatten)
for column_name, value in parsed_row.items():
if _get_column_by_name(columns, column_name) is None:
columns.append(
{
"name": column_name,
"friendly_name": column_name,
"type": TYPES_MAP.get(type(value), TYPE_STRING),
}
)
rows.append(parsed_row)
return rows, columns
def _sorted_fields(fields):
ord = {}
for k, v in fields.items():
if isinstance(v, int):
ord[k] = v
else:
ord[k] = len(fields)
return sorted(ord, key=ord.get)
class MongoDB(BaseQueryRunner):
should_annotate_query = False
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"connectionString": {"type": "string", "title": "Connection String"},
"username": {"type": "string"},
"password": {"type": "string"},
"dbName": {"type": "string", "title": "Database Name"},
"replicaSetName": {"type": "string", "title": "Replica Set Name"},
"readPreference": {
"type": "string",
"extendedEnum": [
{"value": "primaryPreferred", "name": "Primary Preferred"},
{"value": "primary", "name": "Primary"},
{"value": "secondary", "name": "Secondary"},
{"value": "secondaryPreferred", "name": "Secondary Preferred"},
{"value": "nearest", "name": "Nearest"},
],
"title": "Replica Set Read Preference",
},
"flatten": {
"type": "string",
"extendedEnum": [
{"value": "False", "name": "False"},
{"value": "True", "name": "True"},
],
"title": "Flatten Results",
},
},
"secret": ["password"],
"required": ["connectionString", "dbName"],
}
@classmethod
def enabled(cls):
return enabled
def __init__(self, configuration):
super(MongoDB, self).__init__(configuration)
self.syntax = "json"
self.db_name = self.configuration.get("dbName", "")
self.is_replica_set = (
True if "replicaSetName" in self.configuration and self.configuration["replicaSetName"] else False
)
self.flatten = self.configuration.get("flatten", "False").upper() in ["TRUE", "YES", "ON", "1", "Y", "T"]
logger.debug("flatten: {}".format(self.flatten))
@classmethod
def custom_json_encoder(cls, dec, o):
if isinstance(o, ObjectId):
return str(o)
elif isinstance(o, Timestamp):
return dec.default(o.as_datetime())
elif isinstance(o, Decimal128):
return o.to_decimal()
return None
def _get_db(self):
kwargs = {}
if self.is_replica_set:
kwargs["replicaSet"] = self.configuration["replicaSetName"]
readPreference = self.configuration.get("readPreference")
if readPreference:
kwargs["readPreference"] = readPreference
if self.configuration.get("username"):
kwargs["username"] = self.configuration["username"]
if self.configuration.get("password"):
kwargs["password"] = self.configuration["password"]
db_connection = pymongo.MongoClient(self.configuration["connectionString"], **kwargs)
return db_connection[self.db_name]
def test_connection(self):
db = self._get_db()
if not db.command("connectionStatus")["ok"]:
raise Exception("MongoDB connection error")
return db
def _merge_property_names(self, columns, document):
for property in document:
if property not in columns:
columns.append(property)
def _is_collection_a_view(self, db, collection_name):
if "viewOn" in db[collection_name].options():
return True
else:
return False
def _get_collection_fields(self, db, collection_name):
# Since MongoDB is a document based database and each document doesn't have
# to have the same fields as another documet in the collection its a bit hard to
# show these attributes as fields in the schema.
#
# For now, the logic is to take the first and last documents (last is determined
# by the Natural Order (http://www.mongodb.org/display/DOCS/Sorting+and+Natural+Order)
# as we don't know the correct order. In most single server installations it would be
# fine. In replicaset when reading from non master it might not return the really last
# document written.
collection_is_a_view = self._is_collection_a_view(db, collection_name)
documents_sample = []
try:
if collection_is_a_view:
for d in db[collection_name].find().limit(2):
documents_sample.append(d)
else:
for d in db[collection_name].find().sort([("$natural", 1)]).limit(1):
documents_sample.append(d)
for d in db[collection_name].find().sort([("$natural", -1)]).limit(1):
documents_sample.append(d)
except Exception as ex:
template = "An exception of type {0} occurred. Arguments:\n{1!r}"
message = template.format(type(ex).__name__, ex.args)
logger.error(message)
return []
columns = []
for d in documents_sample:
self._merge_property_names(columns, d)
return columns
def get_schema(self, get_stats=False):
schema = {}
db = self._get_db()
for collection_name in db.list_collection_names():
if collection_name.startswith("system."):
continue
columns = self._get_collection_fields(db, collection_name)
if columns:
schema[collection_name] = {
"name": collection_name,
"columns": sorted(columns),
}
return list(schema.values())
def run_query(self, query, user): # noqa: C901
db = self._get_db()
logger.debug("mongodb connection string: %s", self.configuration["connectionString"])
logger.debug("mongodb got query: %s", query)
try:
query_data = parse_query_json(query)
except ValueError as error:
return None, f"Invalid JSON format. {error.__str__()}"
if "collection" not in query_data:
return None, "'collection' must have a value to run a query"
else:
collection = query_data["collection"]
q = query_data.get("query", None)
f = None
aggregate = query_data.get("aggregate", None)
if aggregate:
for step in aggregate:
if "$sort" in step:
sort_list = []
for sort_item in step["$sort"]:
if isinstance(sort_item, dict):
sort_list.append((sort_item["name"], sort_item.get("direction", 1)))
elif isinstance(sort_item, list):
sort_list.append(tuple(sort_item))
step["$sort"] = SON(sort_list)
if "fields" in query_data:
f = query_data["fields"]
s = None
if "sort" in query_data and query_data["sort"]:
s = []
for field_data in query_data["sort"]:
if isinstance(field_data, dict):
s.append((field_data["name"], field_data.get("direction", 1)))
elif isinstance(field_data, list):
s.append(tuple(field_data))
columns = []
rows = []
cursor = None
if q or (not q and not aggregate):
if "count" in query_data:
options = {opt: query_data[opt] for opt in ("skip", "limit") if opt in query_data}
cursor = db[collection].count_documents(q, **options)
else:
if s:
cursor = db[collection].find(q, f).sort(s)
else:
cursor = db[collection].find(q, f)
if "skip" in query_data:
cursor = cursor.skip(query_data["skip"])
if "limit" in query_data:
cursor = cursor.limit(query_data["limit"])
elif aggregate:
allow_disk_use = query_data.get("allowDiskUse", False)
r = db[collection].aggregate(aggregate, allowDiskUse=allow_disk_use)
# Backwards compatibility with older pymongo versions.
#
# Older pymongo version would return a dictionary from an aggregate command.
# The dict would contain a "result" key which would hold the cursor.
# Newer ones return pymongo.command_cursor.CommandCursor.
if isinstance(r, dict):
cursor = r["result"]
else:
cursor = r
if "count" in query_data:
columns.append({"name": "count", "friendly_name": "count", "type": TYPE_INTEGER})
rows.append({"count": cursor})
else:
rows, columns = parse_results(cursor, flatten=self.flatten)
if f:
ordered_columns = []
for k in _sorted_fields(f):
column = _get_column_by_name(columns, k)
if column:
ordered_columns.append(column)
columns = ordered_columns
logger.debug("columns: {}".format(columns))
if query_data.get("sortColumns"):
reverse = query_data["sortColumns"] == "desc"
columns = sorted(columns, key=lambda col: col["name"], reverse=reverse)
data = {"columns": columns, "rows": rows}
error = None
return data, error
register(MongoDB)
================================================
FILE: redash/query_runner/mssql.py
================================================
import logging
from redash.query_runner import (
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_STRING,
BaseSQLQueryRunner,
JobTimeoutException,
register,
)
logger = logging.getLogger(__name__)
try:
import pymssql
enabled = True
except ImportError:
enabled = False
# from _mssql.pyx ## DB-API type definitions & http://www.freetds.org/tds.html#types ##
types_map = {
1: TYPE_STRING,
2: TYPE_STRING,
# Type #3 supposed to be an integer, but in some cases decimals are returned
# with this type. To be on safe side, marking it as float.
3: TYPE_FLOAT,
4: TYPE_DATETIME,
5: TYPE_FLOAT,
}
class SqlServer(BaseSQLQueryRunner):
should_annotate_query = False
noop_query = "SELECT 1"
limit_query = " TOP 1000"
limit_keywords = ["TOP"]
limit_after_select = True
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"user": {"type": "string"},
"password": {"type": "string"},
"server": {"type": "string", "default": "127.0.0.1"},
"port": {"type": "number", "default": 1433},
"tds_version": {
"type": "string",
"default": "7.0",
"title": "TDS Version",
},
"charset": {
"type": "string",
"default": "UTF-8",
"title": "Character Set",
},
"db": {"type": "string", "title": "Database Name"},
},
"required": ["db"],
"secret": ["password"],
}
@classmethod
def enabled(cls):
return enabled
@classmethod
def name(cls):
return "Microsoft SQL Server"
@classmethod
def type(cls):
return "mssql"
def _get_tables(self, schema):
query = """
SELECT table_schema, table_name, column_name
FROM INFORMATION_SCHEMA.COLUMNS
WHERE table_schema NOT IN ('guest','INFORMATION_SCHEMA','sys','db_owner','db_accessadmin'
,'db_securityadmin','db_ddladmin','db_backupoperator','db_datareader'
,'db_datawriter','db_denydatareader','db_denydatawriter'
);
"""
results, error = self.run_query(query, None)
if error is not None:
self._handle_run_query_error(error)
for row in results["rows"]:
if row["table_schema"] != self.configuration["db"]:
table_name = "{}.{}".format(row["table_schema"], row["table_name"])
else:
table_name = row["table_name"]
if table_name not in schema:
schema[table_name] = {"name": table_name, "columns": []}
schema[table_name]["columns"].append(row["column_name"])
return list(schema.values())
def run_query(self, query, user):
connection = None
try:
server = self.configuration.get("server", "")
user = self.configuration.get("user", "")
password = self.configuration.get("password", "")
db = self.configuration["db"]
port = self.configuration.get("port", 1433)
tds_version = self.configuration.get("tds_version", "7.0")
charset = self.configuration.get("charset", "UTF-8")
if port != 1433:
server = server + ":" + str(port)
connection = pymssql.connect(
server=server,
user=user,
password=password,
database=db,
tds_version=tds_version,
charset=charset,
)
if isinstance(query, str):
query = query.encode(charset)
cursor = connection.cursor()
logger.debug("SqlServer running query: %s", query)
cursor.execute(query)
data = cursor.fetchall()
if cursor.description is not None:
columns = self.fetch_columns([(i[0], types_map.get(i[1], None)) for i in cursor.description])
rows = [dict(zip((column["name"] for column in columns), row)) for row in data]
data = {"columns": columns, "rows": rows}
error = None
else:
error = "No data was returned."
data = None
cursor.close()
connection.commit()
except pymssql.Error as e:
try:
# Query errors are at `args[1]`
error = e.args[1]
except IndexError:
# Connection errors are `args[0][1]`
error = e.args[0][1]
data = None
except (KeyboardInterrupt, JobTimeoutException):
connection.cancel()
raise
finally:
if connection:
connection.close()
return data, error
register(SqlServer)
================================================
FILE: redash/query_runner/mssql_odbc.py
================================================
import logging
from redash.query_runner import (
BaseSQLQueryRunner,
JobTimeoutException,
register,
)
from redash.query_runner.mssql import types_map
logger = logging.getLogger(__name__)
try:
import pyodbc
enabled = True
except ImportError:
enabled = False
class SQLServerODBC(BaseSQLQueryRunner):
should_annotate_query = False
noop_query = "SELECT 1"
limit_query = " TOP 1000"
limit_keywords = ["TOP"]
limit_after_select = True
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"server": {"type": "string"},
"port": {"type": "number", "default": 1433},
"user": {"type": "string"},
"password": {"type": "string"},
"db": {"type": "string", "title": "Database Name"},
"charset": {
"type": "string",
"default": "UTF-8",
"title": "Character Set",
},
"use_ssl": {
"type": "boolean",
"title": "Use SSL",
"default": False,
},
"verify_ssl": {
"type": "boolean",
"title": "Verify SSL certificate",
"default": False,
},
},
"order": [
"server",
"port",
"user",
"password",
"db",
"charset",
"use_ssl",
"verify_ssl",
],
"required": ["server", "user", "password", "db"],
"secret": ["password"],
"extra_options": ["verify_ssl", "use_ssl"],
}
@classmethod
def enabled(cls):
return enabled
@classmethod
def name(cls):
return "Microsoft SQL Server (ODBC)"
@classmethod
def type(cls):
return "mssql_odbc"
@property
def supports_auto_limit(self):
return False
def _get_tables(self, schema):
query = """
SELECT table_schema, table_name, column_name
FROM INFORMATION_SCHEMA.COLUMNS
WHERE table_schema NOT IN ('guest','INFORMATION_SCHEMA','sys','db_owner','db_accessadmin'
,'db_securityadmin','db_ddladmin','db_backupoperator','db_datareader'
,'db_datawriter','db_denydatareader','db_denydatawriter'
);
"""
results, error = self.run_query(query, None)
if error is not None:
self._handle_run_query_error(error)
for row in results["rows"]:
if row["table_schema"] != self.configuration["db"]:
table_name = "{}.{}".format(row["table_schema"], row["table_name"])
else:
table_name = row["table_name"]
if table_name not in schema:
schema[table_name] = {"name": table_name, "columns": []}
schema[table_name]["columns"].append(row["column_name"])
return list(schema.values())
def run_query(self, query, user):
connection = None
try:
server = self.configuration.get("server")
user = self.configuration.get("user", "")
password = self.configuration.get("password", "")
db = self.configuration["db"]
port = self.configuration.get("port", 1433)
connection_params = {
"Driver": "{ODBC Driver 18 for SQL Server}",
"Server": server,
"Port": port,
"Database": db,
"Uid": user,
"Pwd": password,
}
if self.configuration.get("use_ssl", False):
connection_params["Encrypt"] = "YES"
if not self.configuration.get("verify_ssl"):
connection_params["TrustServerCertificate"] = "YES"
else:
connection_params["TrustServerCertificate"] = "NO"
else:
connection_params["Encrypt"] = "NO"
def fn(k):
return "{}={}".format(k, connection_params[k])
connection_string = ";".join(list(map(fn, connection_params)))
connection = pyodbc.connect(connection_string)
cursor = connection.cursor()
logger.debug("SQLServerODBC running query: %s", query)
cursor.execute(query)
data = cursor.fetchall()
if cursor.description is not None:
columns = self.fetch_columns([(i[0], types_map.get(i[1], None)) for i in cursor.description])
rows = [dict(zip((column["name"] for column in columns), row)) for row in data]
data = {"columns": columns, "rows": rows}
error = None
else:
error = "No data was returned."
data = None
cursor.close()
except pyodbc.Error as e:
try:
# Query errors are at `args[1]`
error = e.args[1]
except IndexError:
# Connection errors are `args[0][1]`
error = e.args[0][1]
data = None
except (KeyboardInterrupt, JobTimeoutException):
connection.cancel()
raise
finally:
if connection:
connection.close()
return data, error
register(SQLServerODBC)
================================================
FILE: redash/query_runner/mysql.py
================================================
import logging
import os
import threading
from redash.query_runner import (
TYPE_DATE,
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseSQLQueryRunner,
InterruptException,
JobTimeoutException,
register,
)
from redash.settings import parse_boolean
try:
import MySQLdb
enabled = True
except ImportError:
enabled = False
logger = logging.getLogger(__name__)
types_map = {
0: TYPE_FLOAT,
1: TYPE_INTEGER,
2: TYPE_INTEGER,
3: TYPE_INTEGER,
4: TYPE_FLOAT,
5: TYPE_FLOAT,
7: TYPE_DATETIME,
8: TYPE_INTEGER,
9: TYPE_INTEGER,
10: TYPE_DATE,
12: TYPE_DATETIME,
15: TYPE_STRING,
16: TYPE_INTEGER,
246: TYPE_FLOAT,
253: TYPE_STRING,
254: TYPE_STRING,
}
class Result:
def __init__(self):
pass
class Mysql(BaseSQLQueryRunner):
noop_query = "SELECT 1"
@classmethod
def configuration_schema(cls):
show_ssl_settings = parse_boolean(os.environ.get("MYSQL_SHOW_SSL_SETTINGS", "true"))
schema = {
"type": "object",
"properties": {
"host": {"type": "string", "default": "127.0.0.1"},
"user": {"type": "string"},
"passwd": {"type": "string", "title": "Password"},
"db": {"type": "string", "title": "Database name"},
"port": {"type": "number", "default": 3306},
"connect_timeout": {"type": "number", "default": 60, "title": "Connection Timeout"},
"charset": {"type": "string", "default": "utf8mb4"},
"use_unicode": {"type": "boolean", "default": True},
"autocommit": {"type": "boolean", "default": False},
},
"order": [
"host",
"port",
"user",
"passwd",
"db",
"connect_timeout",
"charset",
"use_unicode",
"autocommit",
],
"required": ["db"],
"secret": ["passwd"],
}
if show_ssl_settings:
schema["properties"].update(
{
"ssl_mode": {
"type": "string",
"title": "SSL Mode",
"default": "preferred",
"extendedEnum": [
{"value": "disabled", "name": "Disabled"},
{"value": "preferred", "name": "Preferred"},
{"value": "required", "name": "Required"},
{"value": "verify-ca", "name": "Verify CA"},
{"value": "verify-identity", "name": "Verify Identity"},
],
},
"use_ssl": {"type": "boolean", "title": "Use SSL"},
"ssl_cacert": {
"type": "string",
"title": "Path to CA certificate file to verify peer against (SSL)",
},
"ssl_cert": {
"type": "string",
"title": "Path to client certificate file (SSL)",
},
"ssl_key": {
"type": "string",
"title": "Path to private key file (SSL)",
},
}
)
return schema
@classmethod
def name(cls):
return "MySQL"
@classmethod
def enabled(cls):
return enabled
def _connection(self):
params = dict(
host=self.configuration.get("host", ""),
user=self.configuration.get("user", ""),
passwd=self.configuration.get("passwd", ""),
db=self.configuration["db"],
port=self.configuration.get("port", 3306),
charset=self.configuration.get("charset", "utf8mb4"),
use_unicode=self.configuration.get("use_unicode", True),
connect_timeout=self.configuration.get("connect_timeout", 60),
autocommit=self.configuration.get("autocommit", True),
)
ssl_options = self._get_ssl_parameters()
if ssl_options:
params["ssl"] = ssl_options
connection = MySQLdb.connect(**params)
return connection
def _get_tables(self, schema):
query = """
SELECT col.table_schema as table_schema,
col.table_name as table_name,
col.column_name as column_name,
col.data_type as data_type,
col.column_comment as column_comment
FROM `information_schema`.`columns` col
WHERE LOWER(col.table_schema) NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys');
"""
results, error = self.run_query(query, None)
if error is not None:
self._handle_run_query_error(error)
for row in results["rows"]:
if row["table_schema"] != self.configuration["db"]:
table_name = "{}.{}".format(row["table_schema"], row["table_name"])
else:
table_name = row["table_name"]
if table_name not in schema:
schema[table_name] = {"name": table_name, "columns": []}
schema[table_name]["columns"].append(
{
"name": row["column_name"],
"type": row["data_type"],
"description": row["column_comment"],
}
)
table_query = """
SELECT col.table_schema as table_schema,
col.table_name as table_name,
col.table_comment as table_comment
FROM `information_schema`.`tables` col
WHERE LOWER(col.table_schema) NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys'); \
"""
results, error = self.run_query(table_query, None)
if error is not None:
self._handle_run_query_error(error)
for row in results["rows"]:
if row["table_schema"] != self.configuration["db"]:
table_name = "{}.{}".format(row["table_schema"], row["table_name"])
else:
table_name = row["table_name"]
if table_name not in schema:
schema[table_name] = {"name": table_name, "columns": []}
if "table_comment" in row and row["table_comment"]:
schema[table_name]["description"] = row["table_comment"]
return list(schema.values())
def run_query(self, query, user):
ev = threading.Event()
thread_id = ""
r = Result()
t = None
try:
connection = self._connection()
thread_id = connection.thread_id()
t = threading.Thread(target=self._run_query, args=(query, user, connection, r, ev))
t.start()
while not ev.wait(1):
pass
except (KeyboardInterrupt, InterruptException, JobTimeoutException):
self._cancel(thread_id)
t.join()
raise
return r.data, r.error
def _run_query(self, query, user, connection, r, ev):
try:
cursor = connection.cursor()
logger.debug("MySQL running query: %s", query)
cursor.execute(query)
data = cursor.fetchall()
desc = cursor.description
while cursor.nextset():
if cursor.description is not None:
data = cursor.fetchall()
desc = cursor.description
# TODO - very similar to pg.py
if desc is not None:
columns = self.fetch_columns([(i[0], types_map.get(i[1], None)) for i in desc])
rows = [dict(zip((column["name"] for column in columns), row)) for row in data]
data = {"columns": columns, "rows": rows}
r.data = data
r.error = None
else:
r.data = None
r.error = "No data was returned."
cursor.close()
except MySQLdb.Error as e:
if cursor:
cursor.close()
r.data = None
r.error = e.args[1]
finally:
ev.set()
if connection:
connection.close()
def _get_ssl_parameters(self):
if not self.configuration.get("use_ssl"):
return None
ssl_params = {}
if self.configuration.get("use_ssl"):
config_map = {"ssl_mode": "preferred", "ssl_cacert": "ca", "ssl_cert": "cert", "ssl_key": "key"}
for key, cfg in config_map.items():
val = self.configuration.get(key)
if val:
ssl_params[cfg] = val
return ssl_params
def _cancel(self, thread_id):
connection = None
cursor = None
error = None
try:
connection = self._connection()
cursor = connection.cursor()
query = "KILL %d" % (thread_id)
logging.debug(query)
cursor.execute(query)
except MySQLdb.Error as e:
if cursor:
cursor.close()
error = e.args[1]
finally:
if connection:
connection.close()
return error
class RDSMySQL(Mysql):
@classmethod
def name(cls):
return "MySQL (Amazon RDS)"
@classmethod
def type(cls):
return "rds_mysql"
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"host": {"type": "string"},
"user": {"type": "string"},
"passwd": {"type": "string", "title": "Password"},
"db": {"type": "string", "title": "Database name"},
"port": {"type": "number", "default": 3306},
"use_ssl": {"type": "boolean", "title": "Use SSL"},
"charset": {"type": "string", "default": "utf8mb4"},
},
"order": ["host", "port", "user", "passwd", "db"],
"required": ["db", "user", "passwd", "host"],
"secret": ["passwd"],
}
def _get_ssl_parameters(self):
if self.configuration.get("use_ssl"):
ca_path = os.path.join(os.path.dirname(__file__), "./files/rds-combined-ca-bundle.pem")
return {"ca": ca_path}
return None
register(Mysql)
register(RDSMySQL)
================================================
FILE: redash/query_runner/nz.py
================================================
import logging
import traceback
from redash.query_runner import (
TYPE_BOOLEAN,
TYPE_DATE,
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseSQLQueryRunner,
register,
)
logger = logging.getLogger(__name__)
try:
import nzpy
import nzpy.core
_enabled = True
_nztypes = {
nzpy.core.NzTypeInt1: TYPE_INTEGER,
nzpy.core.NzTypeInt2: TYPE_INTEGER,
nzpy.core.NzTypeInt: TYPE_INTEGER,
nzpy.core.NzTypeInt8: TYPE_INTEGER,
nzpy.core.NzTypeBool: TYPE_BOOLEAN,
nzpy.core.NzTypeDate: TYPE_DATE,
nzpy.core.NzTypeTimestamp: TYPE_DATETIME,
nzpy.core.NzTypeDouble: TYPE_FLOAT,
nzpy.core.NzTypeFloat: TYPE_FLOAT,
nzpy.core.NzTypeChar: TYPE_STRING,
nzpy.core.NzTypeNChar: TYPE_STRING,
nzpy.core.NzTypeNVarChar: TYPE_STRING,
nzpy.core.NzTypeVarChar: TYPE_STRING,
nzpy.core.NzTypeVarFixedChar: TYPE_STRING,
nzpy.core.NzTypeNumeric: TYPE_FLOAT,
}
_cat_types = {
16: TYPE_BOOLEAN, # boolean
17: TYPE_STRING, # bytea
19: TYPE_STRING, # name type
20: TYPE_INTEGER, # int8
21: TYPE_INTEGER, # int2
23: TYPE_INTEGER, # int4
25: TYPE_STRING, # TEXT type
26: TYPE_INTEGER, # oid
28: TYPE_INTEGER, # xid
700: TYPE_FLOAT, # float4
701: TYPE_FLOAT, # float8
705: TYPE_STRING, # unknown
829: TYPE_STRING, # MACADDR type
1042: TYPE_STRING, # CHAR type
1043: TYPE_STRING, # VARCHAR type
1082: TYPE_DATE, # date
1083: TYPE_DATETIME,
1114: TYPE_DATETIME, # timestamp w/ tz
1184: TYPE_DATETIME,
1700: TYPE_FLOAT, # NUMERIC
2275: TYPE_STRING, # cstring
2950: TYPE_STRING, # uuid
}
except ImportError:
_enabled = False
_nztypes = {}
_cat_types = {}
class Netezza(BaseSQLQueryRunner):
noop_query = "SELECT 1"
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"user": {"type": "string"},
"password": {"type": "string"},
"host": {"type": "string", "default": "127.0.0.1"},
"port": {"type": "number", "default": 5480},
"database": {"type": "string", "title": "Database Name", "default": "system"},
},
"order": ["host", "port", "user", "password", "database"],
"required": ["user", "password", "database"],
"secret": ["password"],
}
@classmethod
def type(cls):
return "nz"
def __init__(self, configuration):
super().__init__(configuration)
self._conn = None
@property
def connection(self):
if self._conn is None:
self._conn = nzpy.connect(
host=self.configuration.get("host"),
user=self.configuration.get("user"),
password=self.configuration.get("password"),
port=self.configuration.get("port"),
database=self.configuration.get("database"),
)
return self._conn
def get_schema(self, get_stats=False):
qry = """
select
table_schema || '.' || table_name as table_name,
column_name,
data_type
from
columns
where
table_schema not in (^information_schema^, ^definition_schema^) and
table_catalog = current_catalog;
"""
schema = {}
with self.connection.cursor() as cursor:
cursor.execute(qry)
for table_name, column_name, data_type in cursor:
if table_name not in schema:
schema[table_name] = {"name": table_name, "columns": []}
schema[table_name]["columns"].append({"name": column_name, "type": data_type})
return list(schema.values())
@classmethod
def enabled(cls):
global _enabled
return _enabled
def type_map(self, typid, func):
global _nztypes, _cat_types
typ = _nztypes.get(typid)
if typ is None:
return _cat_types.get(typid)
# check for conflicts
if typid == nzpy.core.NzTypeVarChar:
return TYPE_BOOLEAN if "bool" in func.__name__ else typ
if typid == nzpy.core.NzTypeInt2:
return TYPE_STRING if "text" in func.__name__ else typ
if typid in (nzpy.core.NzTypeVarFixedChar, nzpy.core.NzTypeVarBinary, nzpy.core.NzTypeNVarChar):
return TYPE_INTEGER if "int" in func.__name__ else typ
return typ
def run_query(self, query, user):
data, error = None, None
try:
with self.connection.cursor() as cursor:
cursor.execute(query)
if cursor.description is None:
columns = {"columns": [], "rows": []}
else:
columns = self.fetch_columns(
[
(val[0], self.type_map(val[1], cursor.ps["row_desc"][i]["func"]))
for i, val in enumerate(cursor.description)
]
)
rows = [dict(zip((column["name"] for column in columns), row)) for row in cursor]
data = {"columns": columns, "rows": rows}
except Exception:
error = traceback.format_exc()
return data, error
register(Netezza)
================================================
FILE: redash/query_runner/oracle.py
================================================
import logging
import os
from redash.query_runner import (
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseSQLQueryRunner,
JobTimeoutException,
register,
)
try:
import oracledb
TYPES_MAP = {
oracledb.DATETIME: TYPE_DATETIME,
oracledb.CLOB: TYPE_STRING,
oracledb.LOB: TYPE_STRING,
oracledb.FIXED_CHAR: TYPE_STRING,
oracledb.FIXED_NCHAR: TYPE_STRING,
oracledb.INTERVAL: TYPE_DATETIME,
oracledb.LONG_STRING: TYPE_STRING,
oracledb.NATIVE_FLOAT: TYPE_FLOAT,
oracledb.NCHAR: TYPE_STRING,
oracledb.NUMBER: TYPE_FLOAT,
oracledb.ROWID: TYPE_INTEGER,
oracledb.STRING: TYPE_STRING,
oracledb.TIMESTAMP: TYPE_DATETIME,
}
ENABLED = True
except ImportError:
ENABLED = False
logger = logging.getLogger(__name__)
class Oracle(BaseSQLQueryRunner):
should_annotate_query = False
noop_query = "SELECT 1 FROM dual"
limit_query = " FETCH NEXT 1000 ROWS ONLY"
limit_keywords = ["ROW", "ROWS", "ONLY", "TIES"]
@classmethod
def get_col_type(cls, col_type, scale):
if col_type == oracledb.NUMBER:
if scale is None:
return TYPE_INTEGER
if scale > 0:
return TYPE_FLOAT
return TYPE_INTEGER
else:
return TYPES_MAP.get(col_type, None)
@classmethod
def enabled(cls):
return ENABLED
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"user": {"type": "string"},
"password": {"type": "string"},
"host": {
"type": "string",
"title": "Host: To use a DSN Service Name instead, use the text string `_useservicename` in the host name field.",
},
"port": {"type": "number"},
"servicename": {"type": "string", "title": "DSN Service Name"},
"encoding": {"type": "string"},
},
"required": ["servicename", "user", "password", "host", "port"],
"extra_options": ["encoding"],
"secret": ["password"],
}
@classmethod
def type(cls):
return "oracle"
def _get_tables(self, schema):
query = """
SELECT
all_tab_cols.OWNER,
all_tab_cols.TABLE_NAME,
all_tab_cols.COLUMN_NAME
FROM all_tab_cols
WHERE all_tab_cols.OWNER NOT IN('SYS','SYSTEM','ORDSYS','CTXSYS','WMSYS','MDSYS','ORDDATA','XDB','OUTLN','DMSYS','DSSYS','EXFSYS','LBACSYS','TSMSYS')
"""
results, error = self.run_query(query, None)
if error is not None:
self._handle_run_query_error(error)
for row in results["rows"]:
if row["OWNER"] is not None:
table_name = "{}.{}".format(row["OWNER"], row["TABLE_NAME"])
else:
table_name = row["TABLE_NAME"]
if table_name not in schema:
schema[table_name] = {"name": table_name, "columns": []}
schema[table_name]["columns"].append(row["COLUMN_NAME"])
return list(schema.values())
@classmethod
def _convert_number(cls, value):
try:
return int(value)
except BaseException:
return value
@classmethod
def output_handler(cls, cursor, name, default_type, length, precision, scale):
if default_type in (oracledb.CLOB, oracledb.LOB):
return cursor.var(oracledb.LONG_STRING, 80000, cursor.arraysize)
if default_type in (oracledb.STRING, oracledb.FIXED_CHAR):
return cursor.var(str, length, cursor.arraysize)
if default_type == oracledb.NUMBER:
if scale <= 0:
return cursor.var(
oracledb.STRING,
255,
outconverter=Oracle._convert_number,
arraysize=cursor.arraysize,
)
def run_query(self, query, user):
if self.configuration.get("encoding"):
os.environ["NLS_LANG"] = self.configuration["encoding"]
# To use a DSN Service Name instead, use the text string `_useservicename` in the host name field.
if self.configuration["host"].lower() == "_useservicename":
dsn = self.configuration["servicename"]
else:
dsn = oracledb.makedsn(
self.configuration["host"],
self.configuration["port"],
service_name=self.configuration["servicename"],
)
connection = oracledb.connect(
user=self.configuration["user"],
password=self.configuration["password"],
dsn=dsn,
)
connection.outputtypehandler = Oracle.output_handler
cursor = connection.cursor()
try:
cursor.execute(query)
rows_count = cursor.rowcount
if cursor.description is not None:
columns = self.fetch_columns([(i[0], Oracle.get_col_type(i[1], i[5])) for i in cursor.description])
rows = [dict(zip((c["name"] for c in columns), row)) for row in cursor]
data = {"columns": columns, "rows": rows}
error = None
else:
columns = [{"name": "Row(s) Affected", "type": "TYPE_INTEGER"}]
rows = [{"Row(s) Affected": rows_count}]
data = {"columns": columns, "rows": rows}
connection.commit()
except oracledb.DatabaseError as err:
(err_args,) = err.args
line_number = query.count("\n", 0, err_args.offset) + 1
column_number = err_args.offset - query.rfind("\n", 0, err_args.offset) - 1
error = "Query failed at line {}, column {}: {}".format(str(line_number), str(column_number), str(err))
data = None
except (KeyboardInterrupt, JobTimeoutException):
connection.cancel()
raise
finally:
os.environ.pop("NLS_LANG", None)
connection.close()
return data, error
register(Oracle)
================================================
FILE: redash/query_runner/pg.py
================================================
import logging
import os
import select
from base64 import b64decode
from tempfile import NamedTemporaryFile
from uuid import uuid4
import psycopg2
from psycopg2.extras import Range
from redash.query_runner import (
TYPE_BOOLEAN,
TYPE_DATE,
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseSQLQueryRunner,
InterruptException,
JobTimeoutException,
register,
)
logger = logging.getLogger(__name__)
try:
import boto3
IAM_ENABLED = True
except ImportError:
IAM_ENABLED = False
types_map = {
20: TYPE_INTEGER,
21: TYPE_INTEGER,
23: TYPE_INTEGER,
700: TYPE_FLOAT,
1700: TYPE_FLOAT,
701: TYPE_FLOAT,
16: TYPE_BOOLEAN,
1082: TYPE_DATE,
1182: TYPE_DATE,
1114: TYPE_DATETIME,
1184: TYPE_DATETIME,
1115: TYPE_DATETIME,
1185: TYPE_DATETIME,
1014: TYPE_STRING,
1015: TYPE_STRING,
1008: TYPE_STRING,
1009: TYPE_STRING,
2951: TYPE_STRING,
1043: TYPE_STRING,
1002: TYPE_STRING,
1003: TYPE_STRING,
}
def _wait(conn, timeout=None):
while 1:
try:
state = conn.poll()
if state == psycopg2.extensions.POLL_OK:
break
elif state == psycopg2.extensions.POLL_WRITE:
select.select([], [conn.fileno()], [], timeout)
elif state == psycopg2.extensions.POLL_READ:
select.select([conn.fileno()], [], [], timeout)
else:
raise psycopg2.OperationalError("poll() returned %s" % state)
except select.error:
raise psycopg2.OperationalError("select.error received")
def full_table_name(schema, name):
if "." in name:
name = '"{}"'.format(name)
return "{}.{}".format(schema, name)
def build_schema(query_result, schema):
# By default we omit the public schema name from the table name. But there are
# edge cases, where this might cause conflicts. For example:
# * We have a schema named "main" with table "users".
# * We have a table named "main.users" in the public schema.
# (while this feels unlikely, this actually happened)
# In this case if we omit the schema name for the public table, we will have
# a conflict.
table_names = set(
map(
lambda r: full_table_name(r["table_schema"], r["table_name"]),
query_result["rows"],
)
)
for row in query_result["rows"]:
if row["table_schema"] != "public":
table_name = full_table_name(row["table_schema"], row["table_name"])
else:
if row["table_name"] in table_names:
table_name = full_table_name(row["table_schema"], row["table_name"])
else:
table_name = row["table_name"]
if table_name not in schema:
schema[table_name] = {"name": table_name, "columns": []}
column = row["column_name"]
if row.get("data_type") is not None:
column = {"name": row["column_name"], "type": row["data_type"]}
schema[table_name]["columns"].append(column)
def _create_cert_file(configuration, key, ssl_config):
file_key = key + "File"
if file_key in configuration:
with NamedTemporaryFile(mode="w", delete=False) as cert_file:
cert_bytes = b64decode(configuration[file_key])
cert_file.write(cert_bytes.decode("utf-8"))
ssl_config[key] = cert_file.name
def _cleanup_ssl_certs(ssl_config):
for k, v in ssl_config.items():
if k != "sslmode":
os.remove(v)
def _get_ssl_config(configuration):
ssl_config = {"sslmode": configuration.get("sslmode", "prefer")}
_create_cert_file(configuration, "sslrootcert", ssl_config)
_create_cert_file(configuration, "sslcert", ssl_config)
_create_cert_file(configuration, "sslkey", ssl_config)
return ssl_config
def _parse_dsn(configuration):
standard_params = {"user", "password", "host", "port", "dbname"}
params = psycopg2.extensions.parse_dsn(configuration.get("dsn", ""))
overlap = standard_params.intersection(params.keys())
if overlap:
raise ValueError("Extra parameters may not contain {}".format(overlap))
return params
class PostgreSQL(BaseSQLQueryRunner):
noop_query = "SELECT 1"
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"user": {"type": "string"},
"password": {"type": "string"},
"host": {"type": "string", "default": "127.0.0.1"},
"port": {"type": "number", "default": 5432},
"dbname": {"type": "string", "title": "Database Name"},
"dsn": {"type": "string", "default": "application_name=redash", "title": "Parameters"},
"sslmode": {
"type": "string",
"title": "SSL Mode",
"default": "prefer",
"extendedEnum": [
{"value": "disable", "name": "Disable"},
{"value": "allow", "name": "Allow"},
{"value": "prefer", "name": "Prefer"},
{"value": "require", "name": "Require"},
{"value": "verify-ca", "name": "Verify CA"},
{"value": "verify-full", "name": "Verify Full"},
],
},
"sslrootcertFile": {"type": "string", "title": "SSL Root Certificate"},
"sslcertFile": {"type": "string", "title": "SSL Client Certificate"},
"sslkeyFile": {"type": "string", "title": "SSL Client Key"},
},
"order": ["host", "port", "user", "password"],
"required": ["dbname"],
"secret": ["password", "sslrootcertFile", "sslcertFile", "sslkeyFile"],
"extra_options": [
"sslmode",
"sslrootcertFile",
"sslcertFile",
"sslkeyFile",
],
}
@classmethod
def type(cls):
return "pg"
@classmethod
def custom_json_encoder(cls, dec, o):
if isinstance(o, Range):
# From: https://github.com/psycopg/psycopg2/pull/779
if o._bounds is None:
return ""
items = [o._bounds[0], str(o._lower), ", ", str(o._upper), o._bounds[1]]
return "".join(items)
return None
def _get_definitions(self, schema, query):
results, error = self.run_query(query, None)
if error is not None:
self._handle_run_query_error(error)
build_schema(results, schema)
def _get_tables(self, schema):
"""
relkind constants from https://www.postgresql.org/docs/current/catalog-pg-class.html
m = materialized view
"""
query = """
SELECT s.nspname AS table_schema,
c.relname AS table_name,
a.attname AS column_name,
NULL AS data_type
FROM pg_class c
JOIN pg_namespace s
ON c.relnamespace = s.oid
AND s.nspname NOT IN ('pg_catalog', 'information_schema')
JOIN pg_attribute a
ON a.attrelid = c.oid
AND a.attnum > 0
AND NOT a.attisdropped
WHERE c.relkind = 'm'
AND has_table_privilege(quote_ident(s.nspname) || '.' || quote_ident(c.relname), 'select')
AND has_schema_privilege(s.nspname, 'usage')
UNION
SELECT table_schema,
table_name,
column_name,
data_type
FROM information_schema.columns
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
AND has_table_privilege(quote_ident(table_schema) || '.' || quote_ident(table_name), 'select')
AND has_schema_privilege(table_schema, 'usage')
"""
self._get_definitions(schema, query)
return list(schema.values())
def _get_connection(self):
self.ssl_config = _get_ssl_config(self.configuration)
self.dsn = _parse_dsn(self.configuration)
connection = psycopg2.connect(
user=self.configuration.get("user"),
password=self.configuration.get("password"),
host=self.configuration.get("host"),
port=self.configuration.get("port"),
dbname=self.configuration.get("dbname"),
async_=True,
**self.ssl_config,
**self.dsn,
)
return connection
def run_query(self, query, user):
connection = self._get_connection()
_wait(connection, timeout=10)
cursor = connection.cursor()
try:
cursor.execute(query)
_wait(connection)
if cursor.description is not None:
columns = self.fetch_columns([(i[0], types_map.get(i[1], None)) for i in cursor.description])
rows = [dict(zip((column["name"] for column in columns), row)) for row in cursor]
data = {"columns": columns, "rows": rows}
error = None
else:
error = "Query completed but it returned no data."
data = None
except (select.error, OSError):
error = "Query interrupted. Please retry."
data = None
except psycopg2.DatabaseError as e:
error = str(e)
data = None
except (KeyboardInterrupt, InterruptException, JobTimeoutException):
connection.cancel()
raise
finally:
connection.close()
_cleanup_ssl_certs(self.ssl_config)
return data, error
class Redshift(PostgreSQL):
@classmethod
def type(cls):
return "redshift"
@classmethod
def name(cls):
return "Redshift"
def _get_connection(self):
self.ssl_config = {}
sslrootcert_path = os.path.join(os.path.dirname(__file__), "./files/redshift-ca-bundle.crt")
connection = psycopg2.connect(
user=self.configuration.get("user"),
password=self.configuration.get("password"),
host=self.configuration.get("host"),
port=self.configuration.get("port"),
dbname=self.configuration.get("dbname"),
sslmode=self.configuration.get("sslmode", "prefer"),
sslrootcert=sslrootcert_path,
async_=True,
)
return connection
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"user": {"type": "string"},
"password": {"type": "string"},
"host": {"type": "string"},
"port": {"type": "number"},
"dbname": {"type": "string", "title": "Database Name"},
"sslmode": {"type": "string", "title": "SSL Mode", "default": "prefer"},
"adhoc_query_group": {
"type": "string",
"title": "Query Group for Adhoc Queries",
"default": "default",
},
"scheduled_query_group": {
"type": "string",
"title": "Query Group for Scheduled Queries",
"default": "default",
},
},
"order": [
"host",
"port",
"user",
"password",
"dbname",
"sslmode",
"adhoc_query_group",
"scheduled_query_group",
],
"required": ["dbname", "user", "password", "host", "port"],
"secret": ["password"],
}
def annotate_query(self, query, metadata):
annotated = super(Redshift, self).annotate_query(query, metadata)
if metadata.get("Scheduled", False):
query_group = self.configuration.get("scheduled_query_group")
else:
query_group = self.configuration.get("adhoc_query_group")
if query_group:
set_query_group = "set query_group to {};".format(query_group)
annotated = "{}\n{}".format(set_query_group, annotated)
return annotated
def _get_tables(self, schema):
# Use svv_columns to include internal & external (Spectrum) tables and views data for Redshift
# https://docs.aws.amazon.com/redshift/latest/dg/r_SVV_COLUMNS.html
# Use HAS_SCHEMA_PRIVILEGE(), SVV_EXTERNAL_SCHEMAS and HAS_TABLE_PRIVILEGE() to filter
# out tables the current user cannot access.
# https://docs.aws.amazon.com/redshift/latest/dg/r_HAS_SCHEMA_PRIVILEGE.html
# https://docs.aws.amazon.com/redshift/latest/dg/r_SVV_EXTERNAL_SCHEMAS.html
# https://docs.aws.amazon.com/redshift/latest/dg/r_HAS_TABLE_PRIVILEGE.html
query = """
WITH tables AS (
SELECT DISTINCT table_name,
table_schema,
column_name,
data_type,
ordinal_position AS pos
FROM svv_columns
WHERE table_schema NOT IN ('pg_internal','pg_catalog','information_schema')
AND table_schema NOT LIKE 'pg_temp_%'
)
SELECT table_name, table_schema, column_name, data_type
FROM tables
WHERE
HAS_SCHEMA_PRIVILEGE(table_schema, 'USAGE') AND
(
table_schema IN (SELECT schemaname FROM SVV_EXTERNAL_SCHEMAS) OR
HAS_TABLE_PRIVILEGE('"' || table_schema || '"."' || table_name || '"', 'SELECT')
)
ORDER BY table_name, pos
"""
self._get_definitions(schema, query)
return list(schema.values())
class RedshiftIAM(Redshift):
@classmethod
def type(cls):
return "redshift_iam"
@classmethod
def name(cls):
return "Redshift (with IAM User/Role)"
@classmethod
def enabled(cls):
return IAM_ENABLED
def _login_method_selection(self):
if self.configuration.get("rolename"):
if not self.configuration.get("aws_access_key_id") or not self.configuration.get("aws_secret_access_key"):
return "ASSUME_ROLE_NO_KEYS"
else:
return "ASSUME_ROLE_KEYS"
elif self.configuration.get("aws_access_key_id") and self.configuration.get("aws_secret_access_key"):
return "KEYS"
elif not self.configuration.get("password"):
return "ROLE"
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"rolename": {"type": "string", "title": "IAM Role Name"},
"aws_region": {"type": "string", "title": "AWS Region"},
"aws_access_key_id": {"type": "string", "title": "AWS Access Key ID"},
"aws_secret_access_key": {
"type": "string",
"title": "AWS Secret Access Key",
},
"clusterid": {"type": "string", "title": "Redshift Cluster ID"},
"user": {"type": "string"},
"host": {"type": "string"},
"port": {"type": "number"},
"dbname": {"type": "string", "title": "Database Name"},
"sslmode": {"type": "string", "title": "SSL Mode", "default": "prefer"},
"adhoc_query_group": {
"type": "string",
"title": "Query Group for Adhoc Queries",
"default": "default",
},
"scheduled_query_group": {
"type": "string",
"title": "Query Group for Scheduled Queries",
"default": "default",
},
},
"order": [
"rolename",
"aws_region",
"aws_access_key_id",
"aws_secret_access_key",
"clusterid",
"host",
"port",
"user",
"dbname",
"sslmode",
"adhoc_query_group",
"scheduled_query_group",
],
"required": ["dbname", "user", "host", "port", "aws_region"],
"secret": ["aws_secret_access_key"],
}
def _get_connection(self):
self.ssl_config = {}
sslrootcert_path = os.path.join(os.path.dirname(__file__), "./files/redshift-ca-bundle.crt")
login_method = self._login_method_selection()
if login_method == "KEYS":
client = boto3.client(
"redshift",
region_name=self.configuration.get("aws_region"),
aws_access_key_id=self.configuration.get("aws_access_key_id"),
aws_secret_access_key=self.configuration.get("aws_secret_access_key"),
)
elif login_method == "ROLE":
client = boto3.client("redshift", region_name=self.configuration.get("aws_region"))
else:
if login_method == "ASSUME_ROLE_KEYS":
assume_client = client = boto3.client(
"sts",
region_name=self.configuration.get("aws_region"),
aws_access_key_id=self.configuration.get("aws_access_key_id"),
aws_secret_access_key=self.configuration.get("aws_secret_access_key"),
)
else:
assume_client = client = boto3.client("sts", region_name=self.configuration.get("aws_region"))
role_session = f"redash_{uuid4().hex}"
session_keys = assume_client.assume_role(
RoleArn=self.configuration.get("rolename"), RoleSessionName=role_session
)["Credentials"]
client = boto3.client(
"redshift",
region_name=self.configuration.get("aws_region"),
aws_access_key_id=session_keys["AccessKeyId"],
aws_secret_access_key=session_keys["SecretAccessKey"],
aws_session_token=session_keys["SessionToken"],
)
credentials = client.get_cluster_credentials(
DbUser=self.configuration.get("user"),
DbName=self.configuration.get("dbname"),
ClusterIdentifier=self.configuration.get("clusterid"),
)
db_user = credentials["DbUser"]
db_password = credentials["DbPassword"]
connection = psycopg2.connect(
user=db_user,
password=db_password,
host=self.configuration.get("host"),
port=self.configuration.get("port"),
dbname=self.configuration.get("dbname"),
sslmode=self.configuration.get("sslmode", "prefer"),
sslrootcert=sslrootcert_path,
async_=True,
)
return connection
class CockroachDB(PostgreSQL):
@classmethod
def type(cls):
return "cockroach"
register(PostgreSQL)
register(Redshift)
register(RedshiftIAM)
register(CockroachDB)
================================================
FILE: redash/query_runner/phoenix.py
================================================
import logging
from redash.query_runner import (
TYPE_BOOLEAN,
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseQueryRunner,
register,
)
logger = logging.getLogger(__name__)
try:
import phoenixdb
from phoenixdb.errors import Error
enabled = True
except ImportError:
enabled = False
TYPES_MAPPING = {
"VARCHAR": TYPE_STRING,
"CHAR": TYPE_STRING,
"BINARY": TYPE_STRING,
"VARBINARY": TYPE_STRING,
"BOOLEAN": TYPE_BOOLEAN,
"TIME": TYPE_DATETIME,
"DATE": TYPE_DATETIME,
"TIMESTAMP": TYPE_DATETIME,
"UNSIGNED_TIME": TYPE_DATETIME,
"UNSIGNED_DATE": TYPE_DATETIME,
"UNSIGNED_TIMESTAMP": TYPE_DATETIME,
"INTEGER": TYPE_INTEGER,
"UNSIGNED_INT": TYPE_INTEGER,
"BIGINT": TYPE_INTEGER,
"UNSIGNED_LONG": TYPE_INTEGER,
"TINYINT": TYPE_INTEGER,
"UNSIGNED_TINYINT": TYPE_INTEGER,
"SMALLINT": TYPE_INTEGER,
"UNSIGNED_SMALLINT": TYPE_INTEGER,
"FLOAT": TYPE_FLOAT,
"UNSIGNED_FLOAT": TYPE_FLOAT,
"DOUBLE": TYPE_FLOAT,
"UNSIGNED_DOUBLE": TYPE_FLOAT,
"DECIMAL": TYPE_FLOAT,
}
class Phoenix(BaseQueryRunner):
noop_query = "select 1"
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {"url": {"type": "string"}},
"required": ["url"],
}
@classmethod
def enabled(cls):
return enabled
@classmethod
def type(cls):
return "phoenix"
def get_schema(self, get_stats=False):
schema = {}
query = """
SELECT TABLE_SCHEM, TABLE_NAME, COLUMN_NAME
FROM SYSTEM.CATALOG
WHERE TABLE_SCHEM IS NULL OR TABLE_SCHEM != 'SYSTEM' AND COLUMN_NAME IS NOT NULL
"""
results, error = self.run_query(query, None)
if error is not None:
self._handle_run_query_error(error)
for row in results["rows"]:
table_name = "{}.{}".format(row["TABLE_SCHEM"], row["TABLE_NAME"])
if table_name not in schema:
schema[table_name] = {"name": table_name, "columns": []}
schema[table_name]["columns"].append(row["COLUMN_NAME"])
return list(schema.values())
def run_query(self, query, user):
connection = phoenixdb.connect(url=self.configuration.get("url", ""), autocommit=True)
cursor = connection.cursor()
try:
cursor.execute(query)
column_tuples = [(i[0], TYPES_MAPPING.get(i[1], None)) for i in cursor.description]
columns = self.fetch_columns(column_tuples)
rows = [dict(zip(([column["name"] for column in columns]), r)) for i, r in enumerate(cursor.fetchall())]
data = {"columns": columns, "rows": rows}
error = None
cursor.close()
except Error as e:
data = None
error = "code: {}, sql state:{}, message: {}".format(e.code, e.sqlstate, str(e))
finally:
if connection:
connection.close()
return data, error
register(Phoenix)
================================================
FILE: redash/query_runner/pinot.py
================================================
try:
import pinotdb
enabled = True
except ImportError:
enabled = False
import logging
import requests
from requests.auth import HTTPBasicAuth
from redash.query_runner import (
TYPE_BOOLEAN,
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseQueryRunner,
register,
)
logger = logging.getLogger(__name__)
PINOT_TYPES_MAPPING = {
"BOOLEAN": TYPE_BOOLEAN,
"INT": TYPE_INTEGER,
"LONG": TYPE_INTEGER,
"FLOAT": TYPE_FLOAT,
"DOUBLE": TYPE_FLOAT,
"STRING": TYPE_STRING,
"BYTES": TYPE_STRING,
"JSON": TYPE_STRING,
"TIMESTAMP": TYPE_DATETIME,
}
class Pinot(BaseQueryRunner):
noop_query = "SELECT 1"
username = None
password = None
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"brokerHost": {"type": "string", "default": ""},
"brokerPort": {"type": "number", "default": 8099},
"brokerScheme": {"type": "string", "default": "http"},
"controllerURI": {"type": "string", "default": ""},
"username": {"type": "string"},
"password": {"type": "string"},
},
"order": ["brokerScheme", "brokerHost", "brokerPort", "controllerURI", "username", "password"],
"required": ["brokerHost", "controllerURI"],
"secret": ["password"],
}
@classmethod
def enabled(cls):
return enabled
def __init__(self, configuration):
super(Pinot, self).__init__(configuration)
self.controller_uri = self.configuration.get("controllerURI")
self.username = self.configuration.get("username") or None
self.password = self.configuration.get("password") or None
def run_query(self, query, user):
logger.debug("Running query %s with username: %s", query, self.username)
connection = pinotdb.connect(
host=self.configuration["brokerHost"],
port=self.configuration["brokerPort"],
path="/query/sql",
scheme=(self.configuration.get("brokerScheme") or "http"),
verify_ssl=False,
username=self.username,
password=self.password,
)
cursor = connection.cursor()
try:
cursor.execute(query)
logger.debug("cursor.schema = %s", cursor.schema)
columns = self.fetch_columns(
[(i["name"], PINOT_TYPES_MAPPING.get(i["type"], None)) for i in cursor.schema]
)
rows = [dict(zip((column["name"] for column in columns), row)) for row in cursor]
data = {"columns": columns, "rows": rows}
error = None
logger.debug("Pinot execute query [%s]", query)
finally:
connection.close()
return data, error
def get_schema(self, get_stats=False):
schema = {}
for schema_name in self.get_schema_names():
for table_name in self.get_table_names():
schema_table_name = "{}.{}".format(schema_name, table_name)
if table_name not in schema:
schema[schema_table_name] = {"name": schema_table_name, "columns": []}
table_schema = self.get_pinot_table_schema(table_name)
for column in (
table_schema.get("dimensionFieldSpecs", [])
+ table_schema.get("metricFieldSpecs", [])
+ table_schema.get("dateTimeFieldSpecs", [])
):
c = {
"name": column["name"],
"type": PINOT_TYPES_MAPPING[column["dataType"]],
}
schema[schema_table_name]["columns"].append(c)
return list(schema.values())
def get_schema_names(self):
return ["default"]
def get_pinot_table_schema(self, pinot_table_name):
return self.get_metadata_from_controller("/tables/" + pinot_table_name + "/schema")
def get_table_names(self):
return self.get_metadata_from_controller("/tables")["tables"]
def get_metadata_from_controller(self, path):
url = self.controller_uri + path
r = requests.get(url, headers={"Accept": "application/json"}, auth=HTTPBasicAuth(self.username, self.password))
try:
result = r.json()
logger.debug("get_metadata_from_controller from path %s", path)
except ValueError as e:
raise pinotdb.exceptions.DatabaseError(
f"Got invalid json response from {self.controller_uri}:{path}: {r.text}"
) from e
return result
register(Pinot)
================================================
FILE: redash/query_runner/presto.py
================================================
import logging
from redash.query_runner import (
TYPE_BOOLEAN,
TYPE_DATE,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseQueryRunner,
InterruptException,
JobTimeoutException,
register,
)
logger = logging.getLogger(__name__)
try:
from pyhive import presto
from pyhive.exc import DatabaseError
enabled = True
except ImportError:
enabled = False
PRESTO_TYPES_MAPPING = {
"integer": TYPE_INTEGER,
"tinyint": TYPE_INTEGER,
"smallint": TYPE_INTEGER,
"long": TYPE_INTEGER,
"bigint": TYPE_INTEGER,
"float": TYPE_FLOAT,
"double": TYPE_FLOAT,
"boolean": TYPE_BOOLEAN,
"string": TYPE_STRING,
"varchar": TYPE_STRING,
"date": TYPE_DATE,
}
class Presto(BaseQueryRunner):
noop_query = "SHOW TABLES"
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"host": {"type": "string"},
"protocol": {"type": "string", "default": "http"},
"port": {"type": "number"},
"schema": {"type": "string"},
"catalog": {"type": "string"},
"username": {"type": "string"},
"password": {"type": "string"},
},
"order": [
"host",
"protocol",
"port",
"username",
"password",
"schema",
"catalog",
],
"required": ["host"],
}
@classmethod
def enabled(cls):
return enabled
@classmethod
def type(cls):
return "presto"
def get_schema(self, get_stats=False):
schema = {}
query = """
SELECT table_schema, table_name, column_name
FROM information_schema.columns
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
"""
results, error = self.run_query(query, None)
if error is not None:
self._handle_run_query_error(error)
for row in results["rows"]:
table_name = "{}.{}".format(row["table_schema"], row["table_name"])
if table_name not in schema:
schema[table_name] = {"name": table_name, "columns": []}
schema[table_name]["columns"].append(row["column_name"])
return list(schema.values())
def run_query(self, query, user):
connection = presto.connect(
host=self.configuration.get("host", ""),
port=self.configuration.get("port", 8080),
protocol=self.configuration.get("protocol", "http"),
username=self.configuration.get("username", "redash"),
password=(self.configuration.get("password") or None),
catalog=self.configuration.get("catalog", "hive"),
schema=self.configuration.get("schema", "default"),
)
cursor = connection.cursor()
try:
cursor.execute(query)
column_tuples = [(i[0], PRESTO_TYPES_MAPPING.get(i[1], None)) for i in cursor.description]
columns = self.fetch_columns(column_tuples)
rows = [dict(zip(([column["name"] for column in columns]), r)) for i, r in enumerate(cursor.fetchall())]
data = {"columns": columns, "rows": rows}
error = None
except DatabaseError as db:
data = None
default_message = "Unspecified DatabaseError: {0}".format(str(db))
if isinstance(db.args[0], dict):
message = db.args[0].get("failureInfo", {"message", None}).get("message")
else:
message = None
error = default_message if message is None else message
except (KeyboardInterrupt, InterruptException, JobTimeoutException):
cursor.cancel()
raise
return data, error
register(Presto)
================================================
FILE: redash/query_runner/prometheus.py
================================================
import os
import time
from base64 import b64decode
from datetime import datetime
from tempfile import NamedTemporaryFile
from urllib.parse import parse_qs
import requests
from dateutil import parser
from redash.query_runner import (
TYPE_DATETIME,
TYPE_STRING,
BaseQueryRunner,
register,
)
def get_instant_rows(metrics_data):
rows = []
for metric in metrics_data:
row_data = metric["metric"]
timestamp, value = metric["value"]
date_time = datetime.fromtimestamp(timestamp)
row_data.update({"timestamp": date_time, "value": value})
rows.append(row_data)
return rows
def get_range_rows(metrics_data):
rows = []
for metric in metrics_data:
ts_values = metric["values"]
metric_labels = metric["metric"]
for values in ts_values:
row_data = metric_labels.copy()
timestamp, value = values
date_time = datetime.fromtimestamp(timestamp)
row_data.update({"timestamp": date_time, "value": value})
rows.append(row_data)
return rows
# Convert datetime string to timestamp
def convert_query_range(payload):
query_range = {}
for key in ["start", "end"]:
if key not in payload.keys():
continue
value = payload[key][0]
if isinstance(value, str):
# Don't convert timestamp string
try:
int(value)
continue
except ValueError:
pass
value = parser.parse(value)
if type(value) is datetime:
query_range[key] = [int(time.mktime(value.timetuple()))]
payload.update(query_range)
class Prometheus(BaseQueryRunner):
should_annotate_query = False
def _get_datetime_now(self):
return datetime.now()
def _get_prometheus_kwargs(self):
ca_cert_file = self._create_cert_file("ca_cert_File")
if ca_cert_file is not None:
verify = ca_cert_file
else:
verify = self.configuration.get("verify_ssl", True)
cert_file = self._create_cert_file("cert_File")
cert_key_file = self._create_cert_file("cert_key_File")
if cert_file is not None and cert_key_file is not None:
cert = (cert_file, cert_key_file)
else:
cert = ()
return {
"verify": verify,
"cert": cert,
}
def _create_cert_file(self, key):
cert_file_name = None
if self.configuration.get(key, None) is not None:
with NamedTemporaryFile(mode="w", delete=False) as cert_file:
cert_bytes = b64decode(self.configuration[key])
cert_file.write(cert_bytes.decode("utf-8"))
cert_file_name = cert_file.name
return cert_file_name
def _cleanup_cert_files(self, promehteus_kwargs):
verify = promehteus_kwargs.get("verify", True)
if isinstance(verify, str) and os.path.exists(verify):
os.remove(verify)
cert = promehteus_kwargs.get("cert", ())
for cert_file in cert:
if os.path.exists(cert_file):
os.remove(cert_file)
@classmethod
def configuration_schema(cls):
# files has to end with "File" in name
return {
"type": "object",
"properties": {
"url": {"type": "string", "title": "Prometheus API URL"},
"verify_ssl": {
"type": "boolean",
"title": "Verify SSL (Ignored, if SSL Root Certificate is given)",
"default": True,
},
"cert_File": {"type": "string", "title": "SSL Client Certificate", "default": None},
"cert_key_File": {"type": "string", "title": "SSL Client Key", "default": None},
"ca_cert_File": {"type": "string", "title": "SSL Root Certificate", "default": None},
},
"required": ["url"],
"secret": ["cert_File", "cert_key_File", "ca_cert_File"],
"extra_options": ["verify_ssl", "cert_File", "cert_key_File", "ca_cert_File"],
}
def test_connection(self):
result = False
promehteus_kwargs = {}
try:
promehteus_kwargs = self._get_prometheus_kwargs()
resp = requests.get(self.configuration.get("url", None), **promehteus_kwargs)
result = resp.ok
except Exception:
raise
finally:
self._cleanup_cert_files(promehteus_kwargs)
return result
def get_schema(self, get_stats=False):
schema = []
promehteus_kwargs = {}
try:
base_url = self.configuration["url"]
metrics_path = "/api/v1/label/__name__/values"
promehteus_kwargs = self._get_prometheus_kwargs()
response = requests.get(base_url + metrics_path, **promehteus_kwargs)
response.raise_for_status()
data = response.json()["data"]
schema = {}
for name in data:
schema[name] = {"name": name, "columns": []}
schema = list(schema.values())
except Exception:
raise
finally:
self._cleanup_cert_files(promehteus_kwargs)
return schema
def run_query(self, query, user):
"""
Query Syntax, actually it is the URL query string.
Check the Prometheus HTTP API for the details of the supported query string.
https://prometheus.io/docs/prometheus/latest/querying/api/
example: instant query
query=http_requests_total
example: range query
query=http_requests_total&start=2018-01-20T00:00:00.000Z&end=2018-01-25T00:00:00.000Z&step=60s
example: until now range query
query=http_requests_total&start=2018-01-20T00:00:00.000Z&step=60s
query=http_requests_total&start=2018-01-20T00:00:00.000Z&end=now&step=60s
"""
base_url = self.configuration["url"]
columns = [
{"friendly_name": "timestamp", "type": TYPE_DATETIME, "name": "timestamp"},
{"friendly_name": "value", "type": TYPE_STRING, "name": "value"},
]
promehteus_kwargs = {}
try:
error = None
query = query.strip()
# for backward compatibility
query = "query={}".format(query) if not query.startswith("query=") else query
payload = parse_qs(query)
query_type = "query_range" if "step" in payload.keys() else "query"
# for the range of until now
if query_type == "query_range" and ("end" not in payload.keys() or "now" in payload["end"]):
date_now = self._get_datetime_now()
payload.update({"end": [date_now]})
convert_query_range(payload)
api_endpoint = base_url + "/api/v1/{}".format(query_type)
promehteus_kwargs = self._get_prometheus_kwargs()
response = requests.get(api_endpoint, params=payload, **promehteus_kwargs)
response.raise_for_status()
metrics = response.json()["data"]["result"]
if len(metrics) == 0:
return None, "query result is empty."
metric_labels = metrics[0]["metric"].keys()
for label_name in metric_labels:
columns.append(
{
"friendly_name": label_name,
"type": TYPE_STRING,
"name": label_name,
}
)
if query_type == "query_range":
rows = get_range_rows(metrics)
else:
rows = get_instant_rows(metrics)
data = {"rows": rows, "columns": columns}
except requests.RequestException as e:
return None, str(e)
except Exception:
raise
finally:
self._cleanup_cert_files(promehteus_kwargs)
return data, error
register(Prometheus)
================================================
FILE: redash/query_runner/python.py
================================================
import datetime
import importlib
import logging
import sys
from RestrictedPython import compile_restricted
from RestrictedPython.Guards import (
guarded_iter_unpack_sequence,
guarded_unpack_sequence,
safe_builtins,
)
from RestrictedPython.transformer import IOPERATOR_TO_STR
from redash import models
from redash.query_runner import (
SUPPORTED_COLUMN_TYPES,
TYPE_BOOLEAN,
TYPE_DATE,
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseQueryRunner,
register,
)
from redash.utils.pandas import pandas_installed
if pandas_installed:
import pandas as pd
from redash.utils.pandas import pandas_to_result
enabled = True
else:
enabled = False
logger = logging.getLogger(__name__)
class CustomPrint:
"""CustomPrint redirect "print" calls to be sent as "log" on the result object."""
def __init__(self):
self.enabled = True
self.lines = []
def write(self, text):
if self.enabled:
if text and text.strip():
log_line = "[{0}] {1}".format(datetime.datetime.utcnow().isoformat(), text)
self.lines.append(log_line)
def enable(self):
self.enabled = True
def disable(self):
self.enabled = False
def __call__(self, *args):
return self
def _call_print(self, *objects, **kwargs):
print(*objects, file=self)
class Python(BaseQueryRunner):
should_annotate_query = False
safe_builtins = (
"abs",
"all",
"any",
"bool",
"complex",
"dict",
"divmod",
"enumerate",
"filter",
"float",
"int",
"len",
"list",
"map",
"max",
"min",
"next",
"reversed",
"round",
"set",
"slice",
"sorted",
"str",
"sum",
"tuple",
)
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"allowedImportModules": {
"type": "string",
"title": "Modules to import prior to running the script",
},
"additionalModulesPaths": {"type": "string"},
"additionalBuiltins": {"type": "string"},
},
}
@classmethod
def enabled(cls):
return True
def __init__(self, configuration):
super(Python, self).__init__(configuration)
self.syntax = "python"
self._allowed_modules = {}
self._script_locals = {"result": {"rows": [], "columns": [], "log": []}}
self._enable_print_log = True
self._custom_print = CustomPrint()
if self.configuration.get("allowedImportModules", None):
for item in self.configuration["allowedImportModules"].split(","):
self._allowed_modules[item] = None
if self.configuration.get("additionalModulesPaths", None):
for p in self.configuration["additionalModulesPaths"].split(","):
if p not in sys.path:
sys.path.append(p)
if self.configuration.get("additionalBuiltins", None):
for b in self.configuration["additionalBuiltins"].split(","):
if b not in self.safe_builtins:
self.safe_builtins += (b,)
def custom_import(self, name, globals=None, locals=None, fromlist=(), level=0):
if name in self._allowed_modules:
m = None
if self._allowed_modules[name] is None:
m = importlib.import_module(name)
self._allowed_modules[name] = m
else:
m = self._allowed_modules[name]
return m
raise Exception("'{0}' is not configured as a supported import module".format(name))
@staticmethod
def custom_write(obj):
"""
Custom hooks which controls the way objects/lists/tuples/dicts behave in
RestrictedPython
"""
return obj
@staticmethod
def custom_get_item(obj, key):
return obj[key]
@staticmethod
def custom_get_iter(obj):
return iter(obj)
@staticmethod
def custom_inplacevar(op, x, y):
if op not in IOPERATOR_TO_STR.values():
raise Exception("'{} is not supported inplace variable'".format(op))
glb = {"x": x, "y": y}
exec("x" + op + "y", glb)
return glb["x"]
@staticmethod
def add_result_column(result, column_name, friendly_name, column_type):
"""Helper function to add columns inside a Python script running in Redash in an easier way
Parameters:
:result dict: The result dict
:column_name string: Name of the column, which should be consisted of lowercase latin letters or underscore.
:friendly_name string: Name of the column for display
:column_type string: Type of the column. Check supported data types for details.
"""
if column_type not in SUPPORTED_COLUMN_TYPES:
raise Exception("'{0}' is not a supported column type".format(column_type))
if "columns" not in result:
result["columns"] = []
result["columns"].append({"name": column_name, "friendly_name": friendly_name, "type": column_type})
@staticmethod
def add_result_row(result, values):
"""Helper function to add one row to results set.
Parameters:
:result dict: The result dict
:values dict: One row of result in dict. The key should be one of the column names. The value is the value of the column in this row.
"""
if "rows" not in result:
result["rows"] = []
result["rows"].append(values)
@staticmethod
def execute_query(data_source_name_or_id, query, result_type=None):
"""Run query from specific data source.
Parameters:
:data_source_name_or_id string|integer: Name or ID of the data source
:query string: Query to run
"""
try:
if isinstance(data_source_name_or_id, int):
data_source = models.DataSource.get_by_id(data_source_name_or_id)
else:
data_source = models.DataSource.get_by_name(data_source_name_or_id)
except models.NoResultFound:
raise Exception("Wrong data source name/id: %s." % data_source_name_or_id)
# TODO: pass the user here...
data, error = data_source.query_runner.run_query(query, None)
if error is not None:
raise Exception(error)
# TODO: allow avoiding the JSON dumps/loads in same process
query_result = data
if result_type == "dataframe" and pandas_installed:
return pd.DataFrame(query_result["rows"])
return query_result
@staticmethod
def get_source_schema(data_source_name_or_id):
"""Get schema from specific data source.
:param data_source_name_or_id: string|integer: Name or ID of the data source
:return:
"""
try:
if isinstance(data_source_name_or_id, int):
data_source = models.DataSource.get_by_id(data_source_name_or_id)
else:
data_source = models.DataSource.get_by_name(data_source_name_or_id)
except models.NoResultFound:
raise Exception("Wrong data source name/id: %s." % data_source_name_or_id)
schema = data_source.query_runner.get_schema()
return schema
@staticmethod
def get_query_result(query_id):
"""Get result of an existing query.
Parameters:
:query_id integer: ID of existing query
"""
try:
query = models.Query.get_by_id(query_id)
except models.NoResultFound:
raise Exception("Query id %s does not exist." % query_id)
if query.latest_query_data is None:
raise Exception("Query does not have results yet.")
if query.latest_query_data.data is None:
raise Exception("Query does not have results yet.")
return query.latest_query_data.data
def dataframe_to_result(self, result, df):
converted_result = pandas_to_result(df)
result["rows"] = converted_result["rows"]
for column in converted_result["columns"]:
self.add_result_column(result, column["name"], column["friendly_name"], column["type"])
def get_current_user(self):
return self._current_user.to_dict()
def test_connection(self):
pass
def validate_result(self, result):
"""Validate the result after executing the query.
Parameters:
:result dict: The result dict.
"""
if not result:
raise Exception("local variable `result` should not be empty.")
if not isinstance(result, dict):
raise Exception("local variable `result` should be of type `dict`.")
if "rows" not in result:
raise Exception("Missing `rows` field in `result` dict.")
if "columns" not in result:
raise Exception("Missing `columns` field in `result` dict.")
if not isinstance(result["rows"], list):
raise Exception("`rows` field should be of type `list`.")
if not isinstance(result["columns"], list):
raise Exception("`columns` field should be of type `list`.")
def run_query(self, query, user):
self._current_user = user
try:
error = None
code = compile_restricted(query, "", "exec")
builtins = safe_builtins.copy()
builtins["_write_"] = self.custom_write
builtins["__import__"] = self.custom_import
builtins["_getattr_"] = getattr
builtins["getattr"] = getattr
builtins["_setattr_"] = setattr
builtins["setattr"] = setattr
builtins["_getitem_"] = self.custom_get_item
builtins["_getiter_"] = self.custom_get_iter
builtins["_print_"] = self._custom_print
builtins["_unpack_sequence_"] = guarded_unpack_sequence
builtins["_iter_unpack_sequence_"] = guarded_iter_unpack_sequence
builtins["_inplacevar_"] = self.custom_inplacevar
# Layer in our own additional set of builtins that we have
# considered safe.
for key in self.safe_builtins:
builtins[key] = __builtins__[key]
restricted_globals = dict(__builtins__=builtins)
restricted_globals["get_query_result"] = self.get_query_result
restricted_globals["get_source_schema"] = self.get_source_schema
restricted_globals["get_current_user"] = self.get_current_user
restricted_globals["execute_query"] = self.execute_query
restricted_globals["add_result_column"] = self.add_result_column
if pandas_installed:
restricted_globals["dataframe_to_result"] = self.dataframe_to_result
restricted_globals["add_result_row"] = self.add_result_row
restricted_globals["disable_print_log"] = self._custom_print.disable
restricted_globals["enable_print_log"] = self._custom_print.enable
# Supported data types
restricted_globals["TYPE_DATETIME"] = TYPE_DATETIME
restricted_globals["TYPE_BOOLEAN"] = TYPE_BOOLEAN
restricted_globals["TYPE_INTEGER"] = TYPE_INTEGER
restricted_globals["TYPE_STRING"] = TYPE_STRING
restricted_globals["TYPE_DATE"] = TYPE_DATE
restricted_globals["TYPE_FLOAT"] = TYPE_FLOAT
# TODO: Figure out the best way to have a timeout on a script
# One option is to use ETA with Celery + timeouts on workers
# And replacement of worker process every X requests handled.
exec(code, restricted_globals, self._script_locals)
data = self._script_locals["result"]
self.validate_result(data)
data["log"] = self._custom_print.lines
except Exception as e:
error = str(type(e)) + " " + str(e)
data = None
return data, error
register(Python)
================================================
FILE: redash/query_runner/query_results.py
================================================
import datetime
import decimal
import hashlib
import logging
import re
import sqlite3
from urllib.parse import parse_qs
from redash import models
from redash.permissions import has_access, view_only
from redash.query_runner import (
TYPE_STRING,
BaseQueryRunner,
JobTimeoutException,
guess_type,
register,
)
from redash.utils import json_dumps
logger = logging.getLogger(__name__)
class PermissionError(Exception):
pass
class CreateTableError(Exception):
pass
def extract_query_params(query):
return re.findall(r"(?:join|from)\s+param_query_(\d+)_{([^}]+)}", query, re.IGNORECASE)
def extract_query_ids(query):
queries = re.findall(r"(?:join|from)\s+query_(\d+)", query, re.IGNORECASE)
return [int(q) for q in queries]
def extract_cached_query_ids(query):
queries = re.findall(r"(?:join|from)\s+cached_query_(\d+)", query, re.IGNORECASE)
return [int(q) for q in queries]
def _load_query(user, query_id):
query = models.Query.get_by_id(query_id)
if user.org_id != query.org_id:
raise PermissionError("Query id {} not found.".format(query.id))
# TODO: this duplicates some of the logic we already have in the redash.handlers.query_results.
# We should merge it so it's consistent.
if not has_access(query.data_source, user, view_only):
raise PermissionError("You do not have access to query id {}.".format(query.id))
return query
def replace_query_parameters(query_text, params):
qs = parse_qs(params)
for key, value in qs.items():
query_text = query_text.replace("{{{{{my_key}}}}}".format(my_key=key), value[0])
return query_text
def get_query_results(user, query_id, bring_from_cache, params=None):
query = _load_query(user, query_id)
if bring_from_cache:
if query.latest_query_data_id is not None:
results = query.latest_query_data.data
else:
raise Exception("No cached result available for query {}.".format(query.id))
else:
query_text = query.query_text
if params is not None:
query_text = replace_query_parameters(query_text, params)
results, error = query.data_source.query_runner.run_query(query_text, user)
if error:
raise Exception("Failed loading results for query id {}.".format(query.id))
return results
def create_tables_from_query_ids(user, connection, query_ids, query_params, cached_query_ids=[]):
for query_id in set(cached_query_ids):
results = get_query_results(user, query_id, True)
table_name = "cached_query_{query_id}".format(query_id=query_id)
create_table(connection, table_name, results)
for query in set(query_params):
results = get_query_results(user, query[0], False, query[1])
table_hash = hashlib.md5(
"query_{query}_{hash}".format(query=query[0], hash=query[1]).encode(), usedforsecurity=False
).hexdigest()
table_name = "query_{query_id}_{param_hash}".format(query_id=query[0], param_hash=table_hash)
create_table(connection, table_name, results)
for query_id in set(query_ids):
results = get_query_results(user, query_id, False)
table_name = "query_{query_id}".format(query_id=query_id)
create_table(connection, table_name, results)
def fix_column_name(name):
return '"{}"'.format(re.sub(r'[:."\s]', "_", name, flags=re.UNICODE))
def flatten(value):
if isinstance(value, (list, dict)):
return json_dumps(value)
elif isinstance(value, decimal.Decimal):
return float(value)
elif isinstance(value, datetime.timedelta):
return str(value)
else:
return value
def create_table(connection, table_name, query_results):
try:
columns = [column["name"] for column in query_results["columns"]]
safe_columns = [fix_column_name(column) for column in columns]
column_list = ", ".join(safe_columns)
create_table = "CREATE TABLE {table_name} ({column_list})".format(
table_name=table_name, column_list=column_list
)
logger.debug("CREATE TABLE query: %s", create_table)
connection.execute(create_table)
except sqlite3.OperationalError as exc:
raise CreateTableError("Error creating table {}: {}".format(table_name, str(exc)))
insert_template = "insert into {table_name} ({column_list}) values ({place_holders})".format(
table_name=table_name,
column_list=column_list,
place_holders=",".join(["?"] * len(columns)),
)
for row in query_results["rows"]:
values = [flatten(row.get(column)) for column in columns]
connection.execute(insert_template, values)
def prepare_parameterized_query(query, query_params):
for params in query_params:
table_hash = hashlib.md5(
"query_{query}_{hash}".format(query=params[0], hash=params[1]).encode(), usedforsecurity=False
).hexdigest()
key = "param_query_{query_id}_{{{param_string}}}".format(query_id=params[0], param_string=params[1])
value = "query_{query_id}_{param_hash}".format(query_id=params[0], param_hash=table_hash)
query = query.replace(key, value)
return query
class Results(BaseQueryRunner):
should_annotate_query = False
noop_query = "SELECT 1"
@classmethod
def configuration_schema(cls):
return {"type": "object", "properties": {}}
@classmethod
def name(cls):
return "Query Results"
def run_query(self, query, user):
connection = sqlite3.connect(":memory:")
query_ids = extract_query_ids(query)
query_params = extract_query_params(query)
cached_query_ids = extract_cached_query_ids(query)
create_tables_from_query_ids(user, connection, query_ids, query_params, cached_query_ids)
cursor = connection.cursor()
if query_params is not None:
query = prepare_parameterized_query(query, query_params)
try:
cursor.execute(query)
if cursor.description is not None:
columns = self.fetch_columns([(i[0], None) for i in cursor.description])
rows = []
column_names = [c["name"] for c in columns]
for i, row in enumerate(cursor):
for j, col in enumerate(row):
guess = guess_type(col)
if columns[j]["type"] is None:
columns[j]["type"] = guess
elif columns[j]["type"] != guess:
columns[j]["type"] = TYPE_STRING
rows.append(dict(zip(column_names, row)))
data = {"columns": columns, "rows": rows}
error = None
else:
error = "Query completed but it returned no data."
data = None
except (KeyboardInterrupt, JobTimeoutException):
connection.cancel()
raise
finally:
connection.close()
return data, error
register(Results)
================================================
FILE: redash/query_runner/risingwave.py
================================================
from redash.query_runner import register
from redash.query_runner.pg import PostgreSQL
class RisingWave(PostgreSQL):
@classmethod
def type(cls):
return "risingwave"
@classmethod
def name(cls):
return "RisingWave"
def _get_tables(self, schema):
query = """
SELECT s.nspname as table_schema,
c.relname as table_name,
a.attname as column_name,
null as data_type
FROM pg_class c
JOIN pg_namespace s
ON c.relnamespace = s.oid
AND s.nspname NOT IN ('pg_catalog', 'information_schema', 'rw_catalog')
JOIN pg_attribute a
ON a.attrelid = c.oid
AND a.attnum > 0
AND NOT a.attisdropped
WHERE c.relkind IN ('m', 'f', 'p')
UNION
SELECT table_schema,
table_name,
column_name,
data_type
FROM information_schema.columns
WHERE table_schema NOT IN ('pg_catalog', 'information_schema', 'rw_catalog');
"""
self._get_definitions(schema, query)
return list(schema.values())
register(RisingWave)
================================================
FILE: redash/query_runner/rockset.py
================================================
import requests
from redash.query_runner import (
TYPE_BOOLEAN,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseSQLQueryRunner,
register,
)
def _get_type(value):
if isinstance(value, int):
return TYPE_INTEGER
elif isinstance(value, float):
return TYPE_FLOAT
elif isinstance(value, bool):
return TYPE_BOOLEAN
elif isinstance(value, str):
return TYPE_STRING
return TYPE_STRING
# The following is here, because Rockset's PyPi package is Python 3 only.
# Should be removed once we move to Python 3.
class RocksetAPI:
def __init__(self, api_key, api_server, vi_id):
self.api_key = api_key
self.api_server = api_server
self.vi_id = vi_id
def _request(self, endpoint, method="GET", body=None):
headers = {"Authorization": "ApiKey {}".format(self.api_key), "User-Agent": "rest:redash/1.0"}
url = "{}/v1/orgs/self/{}".format(self.api_server, endpoint)
if method == "GET":
r = requests.get(url, headers=headers)
return r.json()
elif method == "POST":
r = requests.post(url, headers=headers, json=body)
return r.json()
else:
raise "Unknown method: {}".format(method)
def list_workspaces(self):
response = self._request("ws")
return [x["name"] for x in response["data"] if x["collection_count"] > 0]
def list_collections(self, workspace="commons"):
response = self._request("ws/{}/collections".format(workspace))
return [x["name"] for x in response["data"]]
def collection_columns(self, workspace, collection):
response = self.query('DESCRIBE "{}"."{}" OPTION(max_field_depth=1)'.format(workspace, collection))
return sorted(set([x["field"][0] for x in response["results"]]))
def query(self, sql):
query_path = "queries"
if self.vi_id is not None and self.vi_id != "":
query_path = f"virtualinstances/{self.vi_id}/queries"
return self._request(query_path, "POST", {"sql": {"query": sql}})
class Rockset(BaseSQLQueryRunner):
noop_query = "SELECT 1"
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"api_server": {
"type": "string",
"title": "API Server",
"default": "https://api.rs2.usw2.rockset.com",
},
"api_key": {"title": "API Key", "type": "string"},
"vi_id": {"title": "Virtual Instance ID", "type": "string"},
},
"order": ["api_key", "api_server", "vi_id"],
"required": ["api_server", "api_key"],
"secret": ["api_key"],
}
@classmethod
def type(cls):
return "rockset"
def __init__(self, configuration):
super(Rockset, self).__init__(configuration)
self.api = RocksetAPI(
self.configuration.get("api_key"),
self.configuration.get("api_server", "https://api.usw2a1.rockset.com"),
self.configuration.get("vi_id"),
)
def _get_tables(self, schema):
for workspace in self.api.list_workspaces():
for collection in self.api.list_collections(workspace):
table_name = collection if workspace == "commons" else "{}.{}".format(workspace, collection)
schema[table_name] = {
"name": table_name,
"columns": self.api.collection_columns(workspace, collection),
}
return sorted(schema.values(), key=lambda x: x["name"])
def run_query(self, query, user):
results = self.api.query(query)
if "code" in results and results["code"] != 200:
return None, "{}: {}".format(results["type"], results["message"])
if "results" not in results:
message = results.get("message", "Unknown response from Rockset.")
return None, message
rows = results["results"]
columns = []
if len(rows) > 0:
columns = []
for k in rows[0]:
columns.append({"name": k, "friendly_name": k, "type": _get_type(rows[0][k])})
data = {"columns": columns, "rows": rows}
return data, None
register(Rockset)
================================================
FILE: redash/query_runner/salesforce.py
================================================
import logging
import re
from collections import OrderedDict
from redash.query_runner import (
TYPE_BOOLEAN,
TYPE_DATE,
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseQueryRunner,
register,
)
logger = logging.getLogger(__name__)
try:
from simple_salesforce import Salesforce as SimpleSalesforce
from simple_salesforce import SalesforceError
from simple_salesforce.api import DEFAULT_API_VERSION
enabled = True
except ImportError:
enabled = False
# See https://developer.salesforce.com/docs/atlas.en-us.api.meta/api/field_types.htm
TYPES_MAP = dict(
id=TYPE_STRING,
string=TYPE_STRING,
currency=TYPE_FLOAT,
reference=TYPE_STRING,
double=TYPE_FLOAT,
picklist=TYPE_STRING,
date=TYPE_DATE,
url=TYPE_STRING,
phone=TYPE_STRING,
textarea=TYPE_STRING,
int=TYPE_INTEGER,
datetime=TYPE_DATETIME,
boolean=TYPE_BOOLEAN,
percent=TYPE_FLOAT,
multipicklist=TYPE_STRING,
masterrecord=TYPE_STRING,
location=TYPE_STRING,
JunctionIdList=TYPE_STRING,
encryptedstring=TYPE_STRING,
email=TYPE_STRING,
DataCategoryGroupReference=TYPE_STRING,
combobox=TYPE_STRING,
calculated=TYPE_STRING,
anyType=TYPE_STRING,
address=TYPE_STRING,
)
# Query Runner for Salesforce SOQL Queries
# For example queries, see:
# https://developer.salesforce.com/docs/atlas.en-us.soql_sosl.meta/soql_sosl/sforce_api_calls_soql_select_examples.htm
class Salesforce(BaseQueryRunner):
should_annotate_query = False
@classmethod
def enabled(cls):
return enabled
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"username": {"type": "string"},
"password": {"type": "string"},
"token": {"type": "string", "title": "Security Token"},
"sandbox": {"type": "boolean"},
"api_version": {
"type": "string",
"title": "Salesforce API Version",
"default": DEFAULT_API_VERSION,
},
},
"required": ["username", "password"],
"secret": ["password", "token"],
}
def test_connection(self):
response = self._get_sf().describe()
if response is None:
raise Exception("Failed describing objects.")
pass
def _get_sf(self):
sf = SimpleSalesforce(
username=self.configuration["username"],
password=self.configuration["password"],
security_token=self.configuration["token"],
sandbox=self.configuration.get("sandbox", False),
version=self.configuration.get("api_version", DEFAULT_API_VERSION),
client_id="Redash",
)
return sf
def _clean_value(self, value):
if isinstance(value, OrderedDict) and "records" in value:
value = value["records"]
for row in value:
row.pop("attributes", None)
return value
def _get_value(self, dct, dots):
for key in dots.split("."):
if dct is not None and key in dct:
dct = dct.get(key)
else:
dct = None
return dct
def _get_column_name(self, key, parents=[]):
return ".".join(parents + [key])
def _build_columns(self, sf, child, parents=[]):
child_type = child["attributes"]["type"]
child_desc = sf.__getattr__(child_type).describe()
child_type_map = dict((f["name"], f["type"]) for f in child_desc["fields"])
columns = []
for key in child.keys():
if key != "attributes":
if isinstance(child[key], OrderedDict) and "attributes" in child[key]:
columns.extend(self._build_columns(sf, child[key], parents + [key]))
else:
column_name = self._get_column_name(key, parents)
key_type = child_type_map.get(key, "string")
column_type = TYPES_MAP.get(key_type, TYPE_STRING)
columns.append((column_name, column_type))
return columns
def _build_rows(self, columns, records):
rows = []
for record in records:
record.pop("attributes", None)
row = dict()
for column in columns:
key = column[0]
value = self._get_value(record, key)
row[key] = self._clean_value(value)
rows.append(row)
return rows
def run_query(self, query, user):
logger.debug("Salesforce is about to execute query: %s", query)
query = re.sub(r"/\*(.|\n)*?\*/", "", query).strip()
try:
columns = []
rows = []
sf = self._get_sf()
response = sf.query_all(query)
records = response["records"]
if response["totalSize"] > 0 and len(records) == 0:
columns = self.fetch_columns([("Count", TYPE_INTEGER)])
rows = [{"Count": response["totalSize"]}]
elif len(records) > 0:
cols = self._build_columns(sf, records[0])
rows = self._build_rows(cols, records)
columns = self.fetch_columns(cols)
error = None
data = {"columns": columns, "rows": rows}
except SalesforceError as err:
error = err.content
data = None
return data, error
def get_schema(self, get_stats=False):
sf = self._get_sf()
response = sf.describe()
if response is None:
raise Exception("Failed describing objects.")
schema = {}
for sobject in response["sobjects"]:
table_name = sobject["name"]
if sobject["queryable"] is True and table_name not in schema:
desc = sf.__getattr__(sobject["name"]).describe()
fields = desc["fields"]
schema[table_name] = {
"name": table_name,
"columns": [f["name"] for f in fields],
}
return list(schema.values())
register(Salesforce)
================================================
FILE: redash/query_runner/script.py
================================================
import os
import subprocess
from redash.query_runner import BaseQueryRunner, register
def query_to_script_path(path, query):
if path != "*":
script = os.path.join(path, query.split(" ")[0])
if not os.path.exists(script):
raise IOError("Script '{}' not found in script directory".format(query))
return os.path.join(path, query).split(" ")
return query
def run_script(script, shell):
output = subprocess.check_output(script, shell=shell)
if output is None:
return None, "Error reading output"
output = output.strip()
if not output:
return None, "Empty output from script"
return output, None
class Script(BaseQueryRunner):
should_annotate_query = False
@classmethod
def enabled(cls):
return "check_output" in subprocess.__dict__
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"path": {"type": "string", "title": "Scripts path"},
"shell": {
"type": "boolean",
"title": "Execute command through the shell",
},
},
"required": ["path"],
}
@classmethod
def type(cls):
return "insecure_script"
def __init__(self, configuration):
super(Script, self).__init__(configuration)
path = self.configuration.get("path", "")
# If path is * allow any execution path
if path == "*":
return
# Poor man's protection against running scripts from outside the scripts directory
if path.find("../") > -1:
raise ValueError("Scripts can only be run from the configured scripts directory")
def test_connection(self):
pass
def run_query(self, query, user):
try:
script = query_to_script_path(self.configuration["path"], query)
return run_script(script, self.configuration["shell"])
except IOError as e:
return None, str(e)
except subprocess.CalledProcessError as e:
return None, str(e)
register(Script)
================================================
FILE: redash/query_runner/snowflake.py
================================================
try:
import snowflake.connector
from cryptography.hazmat.primitives.serialization import load_pem_private_key
enabled = True
except ImportError:
enabled = False
from base64 import b64decode
from redash import __version__
from redash.query_runner import (
TYPE_BOOLEAN,
TYPE_DATE,
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseSQLQueryRunner,
register,
)
TYPES_MAP = {
0: TYPE_INTEGER,
1: TYPE_FLOAT,
2: TYPE_STRING,
3: TYPE_DATE,
4: TYPE_DATETIME,
5: TYPE_STRING,
6: TYPE_DATETIME,
7: TYPE_DATETIME,
8: TYPE_DATETIME,
13: TYPE_BOOLEAN,
}
class Snowflake(BaseSQLQueryRunner):
noop_query = "SELECT 1"
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"account": {"type": "string"},
"user": {"type": "string"},
"password": {"type": "string"},
"private_key_File": {"type": "string"},
"private_key_pwd": {"type": "string"},
"warehouse": {"type": "string"},
"database": {"type": "string"},
"region": {"type": "string", "default": "us-west"},
"lower_case_columns": {
"type": "boolean",
"title": "Lower Case Column Names in Results",
"default": False,
},
"host": {"type": "string"},
},
"order": [
"account",
"user",
"password",
"private_key_File",
"private_key_pwd",
"warehouse",
"database",
"region",
"host",
],
"required": ["user", "account", "database", "warehouse"],
"secret": ["password", "private_key_File", "private_key_pwd"],
"extra_options": [
"host",
],
}
@classmethod
def enabled(cls):
return enabled
@classmethod
def determine_type(cls, data_type, scale):
t = TYPES_MAP.get(data_type, None)
if t == TYPE_INTEGER and scale > 0:
return TYPE_FLOAT
return t
def _get_connection(self):
region = self.configuration.get("region")
account = self.configuration["account"]
# for us-west we don't need to pass a region (and if we do, it fails to connect)
if region == "us-west":
region = None
if self.configuration.get("host"):
host = self.configuration.get("host")
else:
if region:
host = "{}.{}.snowflakecomputing.com".format(account, region)
else:
host = "{}.snowflakecomputing.com".format(account)
params = {
"user": self.configuration["user"],
"account": account,
"region": region,
"host": host,
"application": "Redash/{} (Snowflake)".format(__version__.split("-")[0]),
}
if self.configuration.get("password"):
params["password"] = self.configuration["password"]
elif self.configuration.get("private_key_File"):
private_key_b64 = self.configuration.get("private_key_File")
private_key_bytes = b64decode(private_key_b64)
if self.configuration.get("private_key_pwd"):
private_key_pwd = self.configuration.get("private_key_pwd").encode()
else:
private_key_pwd = None
private_key_pem = load_pem_private_key(private_key_bytes, private_key_pwd)
params["private_key"] = private_key_pem
else:
raise Exception("Neither password nor private_key_b64 is set.")
connection = snowflake.connector.connect(**params)
return connection
def _column_name(self, column_name):
if self.configuration.get("lower_case_columns", False):
return column_name.lower()
return column_name
def _parse_results(self, cursor):
columns = self.fetch_columns(
[(self._column_name(i[0]), self.determine_type(i[1], i[5])) for i in cursor.description]
)
rows = [dict(zip((column["name"] for column in columns), row)) for row in cursor]
data = {"columns": columns, "rows": rows}
return data
def run_query(self, query, user):
connection = self._get_connection()
cursor = connection.cursor()
try:
cursor.execute("USE WAREHOUSE {}".format(self.configuration["warehouse"]))
cursor.execute("USE {}".format(self.configuration["database"]))
cursor.execute(query)
data = self._parse_results(cursor)
error = None
finally:
cursor.close()
connection.close()
return data, error
def _run_query_without_warehouse(self, query):
connection = self._get_connection()
cursor = connection.cursor()
try:
cursor.execute("USE {}".format(self.configuration["database"]))
cursor.execute(query)
data = self._parse_results(cursor)
error = None
finally:
cursor.close()
connection.close()
return data, error
def _database_name_includes_schema(self):
return "." in self.configuration.get("database")
def get_schema(self, get_stats=False):
if self._database_name_includes_schema():
query = "SHOW COLUMNS"
else:
query = "SHOW COLUMNS IN DATABASE"
results, error = self._run_query_without_warehouse(query)
if error is not None:
self._handle_run_query_error(error)
schema = {}
for row in results["rows"]:
if row["kind"] == "COLUMN":
table_name = "{}.{}".format(row["schema_name"], row["table_name"])
if table_name not in schema:
schema[table_name] = {"name": table_name, "columns": []}
schema[table_name]["columns"].append(row["column_name"])
return list(schema.values())
register(Snowflake)
================================================
FILE: redash/query_runner/sparql_endpoint.py
================================================
"""Provide the query runner for SPARQL Endpoints.
seeAlso: https://www.w3.org/TR/rdf-sparql-query/
"""
import json
import logging
from os import environ
from redash.query_runner import BaseQueryRunner
from . import register
try:
import requests
from cmem.cmempy.queries import SparqlQuery
from rdflib.plugins.sparql import prepareQuery # noqa
enabled = True
except ImportError:
enabled = False
logger = logging.getLogger(__name__)
class SPARQLEndpointQueryRunner(BaseQueryRunner):
"""Use SPARQL Endpoint as redash data source"""
# These environment keys are used by cmempy
KNOWN_CONFIG_KEYS = ("SPARQL_BASE_URI", "SSL_VERIFY")
# These variables hold secret data and should NOT be logged
KNOWN_SECRET_KEYS = ()
# This allows for an easy connection test
noop_query = "SELECT ?noop WHERE {BIND('noop' as ?noop)}"
def __init__(self, configuration):
"""init the class and configuration"""
super(SPARQLEndpointQueryRunner, self).__init__(configuration)
self.configuration = configuration
def _setup_environment(self):
"""provide environment for rdflib
rdflib environment variables need to match key in the properties
object of the configuration_schema
"""
for key in self.KNOWN_CONFIG_KEYS:
if key in environ:
environ.pop(key)
value = self.configuration.get(key, None)
if value is not None:
environ[key] = str(value)
if key in self.KNOWN_SECRET_KEYS:
logger.info("{} set by config".format(key))
else:
logger.info("{} set by config to {}".format(key, environ[key]))
@staticmethod
def _transform_sparql_results(results):
"""transforms a SPARQL query result to a redash query result
source structure: SPARQL 1.1 Query Results JSON Format
- seeAlso: https://www.w3.org/TR/sparql11-results-json/
target structure: redash result set
there is no good documentation available
so here an example result set as needed for redash:
data = {
"columns": [ {"name": "name", "type": "string", "friendly_name": "friendly name"}],
"rows": [
{"name": "value 1"},
{"name": "value 2"}
]}
FEATURE?: During the sparql_row loop, we could check the data types of the
values and, in case they are all the same, choose something better than
just string.
"""
logger.info("results are: {}".format(results))
# Not sure why we do not use the json package here but all other
# query runner do it the same way :-)
sparql_results = results
# transform all bindings to redash rows
rows = []
for sparql_row in sparql_results["results"]["bindings"]:
row = {}
for var in sparql_results["head"]["vars"]:
try:
row[var] = sparql_row[var]["value"]
except KeyError:
# not bound SPARQL variables are set as empty strings
row[var] = ""
rows.append(row)
# transform all vars to redash columns
columns = []
for var in sparql_results["head"]["vars"]:
columns.append({"name": var, "friendly_name": var, "type": "string"})
# Not sure why we do not use the json package here but all other
# query runner do it the same way :-)
return {"columns": columns, "rows": rows}
@classmethod
def name(cls):
return "SPARQL Endpoint"
@classmethod
def enabled(cls):
return enabled
@classmethod
def type(cls):
return "sparql_endpoint"
def remove_comments(self, string):
return string[string.index("*/") + 2 :].strip()
def run_query(self, query, user):
"""send a query to a sparql endpoint"""
logger.info("about to execute query (user='{}'): {}".format(user, query))
query_text = self.remove_comments(query)
query = SparqlQuery(query_text)
query_type = query.get_query_type()
if query_type not in ["SELECT", None]:
raise ValueError("Queries of type {} can not be processed by redash.".format(query_type))
self._setup_environment()
try:
endpoint = self.configuration.get("SPARQL_BASE_URI")
r = requests.get(
endpoint,
params=dict(query=query_text),
headers=dict(Accept="application/json"),
)
data = self._transform_sparql_results(r.text)
except Exception as error:
logger.info("Error: {}".format(error))
try:
# try to load Problem Details for HTTP API JSON
details = json.loads(error.response.text)
error = ""
if "title" in details:
error += details["title"] + ": "
if "detail" in details:
error += details["detail"]
return None, error
except Exception:
pass
return None, error
error = None
return data, error
@classmethod
def configuration_schema(cls):
"""provide the configuration of the data source as json schema"""
return {
"type": "object",
"properties": {
"SPARQL_BASE_URI": {"type": "string", "title": "Base URL"},
"SSL_VERIFY": {
"type": "boolean",
"title": "Verify SSL certificates for API requests",
"default": True,
},
},
"required": ["SPARQL_BASE_URI"],
"secret": [],
"extra_options": ["SSL_VERIFY"],
}
def get_schema(self, get_stats=False):
"""Get the schema structure (prefixes, graphs)."""
schema = dict()
schema["1"] = {
"name": "-> Common Prefixes <-",
"columns": self._get_common_prefixes_schema(),
}
schema["2"] = {"name": "-> Graphs <-", "columns": self._get_graphs_schema()}
# schema.update(self._get_query_schema())
logger.info(f"Getting Schema Values: {schema.values()}")
return schema.values()
def _get_graphs_schema(self):
"""Get a list of readable graph FROM clause strings."""
self._setup_environment()
endpoint = self.configuration.get("SPARQL_BASE_URI")
query_text = "SELECT DISTINCT ?g WHERE {GRAPH ?g {?s ?p ?o}}"
r = requests.get(
endpoint,
params=dict(query=query_text),
headers=dict(Accept="application/json"),
).json()
graph_iris = [g.get("g").get("value") for g in r.get("results").get("bindings")]
graphs = []
for graph in graph_iris:
graphs.append("FROM <{}>".format(graph))
return graphs
@staticmethod
def _get_common_prefixes_schema():
"""Get a list of SPARQL prefix declarations."""
common_prefixes = [
"PREFIX rdf: ",
"PREFIX rdfs: ",
"PREFIX owl: ",
"PREFIX schema: ",
"PREFIX dct: ",
"PREFIX skos: ",
]
return common_prefixes
register(SPARQLEndpointQueryRunner)
================================================
FILE: redash/query_runner/sqlite.py
================================================
import logging
import sqlite3
from redash.query_runner import (
BaseSQLQueryRunner,
JobTimeoutException,
register,
)
logger = logging.getLogger(__name__)
class Sqlite(BaseSQLQueryRunner):
noop_query = "pragma quick_check"
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {"dbpath": {"type": "string", "title": "Database Path"}},
"required": ["dbpath"],
}
@classmethod
def type(cls):
return "sqlite"
def __init__(self, configuration):
super(Sqlite, self).__init__(configuration)
self._dbpath = self.configuration.get("dbpath", "")
def _get_tables(self, schema):
query_table = "select tbl_name from sqlite_master where type='table'"
query_columns = 'PRAGMA table_info("%s")'
results, error = self.run_query(query_table, None)
if error is not None:
raise Exception("Failed getting schema.")
for row in results["rows"]:
table_name = row["tbl_name"]
schema[table_name] = {"name": table_name, "columns": []}
results_table, error = self.run_query(query_columns % (table_name,), None)
if error is not None:
self._handle_run_query_error(error)
for row_column in results_table["rows"]:
schema[table_name]["columns"].append(row_column["name"])
return list(schema.values())
def run_query(self, query, user):
connection = sqlite3.connect(self._dbpath)
cursor = connection.cursor()
try:
cursor.execute(query)
if cursor.description is not None:
columns = self.fetch_columns([(i[0], None) for i in cursor.description])
rows = [dict(zip((column["name"] for column in columns), row)) for row in cursor]
data = {"columns": columns, "rows": rows}
error = None
else:
error = "Query completed but it returned no data."
data = None
except (KeyboardInterrupt, JobTimeoutException):
connection.cancel()
raise
finally:
connection.close()
return data, error
register(Sqlite)
================================================
FILE: redash/query_runner/tinybird.py
================================================
import logging
import requests
from redash.query_runner import register
from redash.query_runner.clickhouse import ClickHouse
logger = logging.getLogger(__name__)
class Tinybird(ClickHouse):
noop_query = "SELECT count() FROM tinybird.pipe_stats LIMIT 1"
DEFAULT_URL = "https://api.tinybird.co"
SQL_ENDPOINT = "/v0/sql"
DATASOURCES_ENDPOINT = "/v0/datasources"
PIPES_ENDPOINT = "/v0/pipes"
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"url": {"type": "string", "default": cls.DEFAULT_URL},
"token": {"type": "string", "title": "Auth Token"},
"timeout": {
"type": "number",
"title": "Request Timeout",
"default": 30,
},
"verify": {
"type": "boolean",
"title": "Verify SSL certificate",
"default": True,
},
},
"order": ["url", "token"],
"required": ["token"],
"extra_options": ["timeout", "verify"],
"secret": ["token"],
}
def _get_tables(self, schema):
self._collect_tinybird_schema(
schema,
self.DATASOURCES_ENDPOINT,
"datasources",
)
self._collect_tinybird_schema(
schema,
self.PIPES_ENDPOINT,
"pipes",
)
return list(schema.values())
def _send_query(self, data, session_id=None, session_check=None):
return self._get_from_tinybird(
self.SQL_ENDPOINT,
params={"q": data.encode("utf-8", "ignore")},
)
def _collect_tinybird_schema(self, schema, endpoint, resource_type):
response = self._get_from_tinybird(endpoint)
resources = response.get(resource_type, [])
for r in resources:
if r["name"] not in schema:
schema[r["name"]] = {"name": r["name"], "columns": []}
if resource_type == "pipes" and not r.get("endpoint"):
continue
query = f"SELECT * FROM {r['name']} LIMIT 1 FORMAT JSON"
try:
query_result = self._send_query(query)
except Exception:
logger.exception(f"error in schema {r['name']}")
continue
columns = [meta["name"] for meta in query_result["meta"]]
schema[r["name"]]["columns"].extend(columns)
return schema
def _get_from_tinybird(self, endpoint, params=None):
url = f"{self.configuration.get('url', self.DEFAULT_URL)}{endpoint}"
authorization = f"Bearer {self.configuration.get('token')}"
try:
response = requests.get(
url,
timeout=self.configuration.get("timeout", 30),
params=params,
headers={"Authorization": authorization},
verify=self.configuration.get("verify", True),
)
except requests.RequestException as e:
if e.response:
details = f"({e.__class__.__name__}, Status Code: {e.response.status_code})"
else:
details = f"({e.__class__.__name__})"
raise Exception(f"Connection error to: {url} {details}.")
if response.status_code >= 400:
raise Exception(response.text)
return response.json()
register(Tinybird)
================================================
FILE: redash/query_runner/treasuredata.py
================================================
import logging
from redash.query_runner import (
TYPE_BOOLEAN,
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseQueryRunner,
register,
)
logger = logging.getLogger(__name__)
try:
import tdclient
from tdclient import errors
enabled = True
except ImportError:
enabled = False
TD_TYPES_MAPPING = {
"bigint": TYPE_INTEGER,
"tinyint": TYPE_INTEGER,
"smallint": TYPE_INTEGER,
"int": TYPE_INTEGER,
"integer": TYPE_INTEGER,
"long": TYPE_INTEGER,
"double": TYPE_FLOAT,
"decimal": TYPE_FLOAT,
"float": TYPE_FLOAT,
"real": TYPE_FLOAT,
"boolean": TYPE_BOOLEAN,
"timestamp": TYPE_DATETIME,
"date": TYPE_DATETIME,
"char": TYPE_STRING,
"string": TYPE_STRING,
"varchar": TYPE_STRING,
}
class TreasureData(BaseQueryRunner):
should_annotate_query = False
noop_query = "SELECT 1"
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"endpoint": {"type": "string"},
"apikey": {"type": "string"},
"type": {"type": "string"},
"db": {"type": "string", "title": "Database Name"},
"get_schema": {
"type": "boolean",
"title": "Auto Schema Retrieval",
"default": False,
},
},
"secret": ["apikey"],
"required": ["apikey", "db"],
}
@classmethod
def enabled(cls):
return enabled
@classmethod
def type(cls):
return "treasuredata"
def get_schema(self, get_stats=False):
schema = {}
if self.configuration.get("get_schema", False):
try:
with tdclient.Client(
self.configuration.get("apikey"), endpoint=self.configuration.get("endpoint")
) as client:
for table in client.tables(self.configuration.get("db")):
table_name = "{}.{}".format(self.configuration.get("db"), table.name)
for table_schema in table.schema:
schema[table_name] = {
"name": table_name,
"columns": [column[0] for column in table.schema],
}
except Exception:
raise Exception("Failed getting schema")
return list(schema.values())
def run_query(self, query, user):
connection = tdclient.connect(
endpoint=self.configuration.get("endpoint", "https://api.treasuredata.com"),
apikey=self.configuration.get("apikey"),
type=self.configuration.get("type", "hive").lower(),
db=self.configuration.get("db"),
)
cursor = connection.cursor()
try:
cursor.execute(query)
columns_tuples = [
(i[0], TD_TYPES_MAPPING.get(i[1], None)) for i in cursor.show_job()["hive_result_schema"]
]
columns = self.fetch_columns(columns_tuples)
if cursor.rowcount == 0:
rows = []
else:
rows = [dict(zip(([column["name"] for column in columns]), r)) for r in cursor.fetchall()]
data = {"columns": columns, "rows": rows}
error = None
except errors.InternalError as e:
data = None
error = "%s: %s" % (
str(e),
cursor.show_job().get("debug", {}).get("stderr", "No stderr message in the response"),
)
return data, error
register(TreasureData)
================================================
FILE: redash/query_runner/trino.py
================================================
import logging
import os
from redash.models.users import ApiUser, User
from redash.query_runner import (
TYPE_BOOLEAN,
TYPE_DATE,
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseQueryRunner,
InterruptException,
JobTimeoutException,
register,
)
from redash.settings import parse_boolean
logger = logging.getLogger(__name__)
ANNOTATE_QUERY = parse_boolean(os.environ.get("TRINO_ANNOTATE_QUERY", "true"))
try:
import trino
from trino.exceptions import DatabaseError
from trino.types import NamedRowTuple
enabled = True
except ImportError:
enabled = False
def _convert_row_types(value):
"""Convert NamedRowTuple instances to dicts so ROW fields are serialized with their names."""
if isinstance(value, NamedRowTuple):
names = value.__annotations__.get("names", [])
return {
name if name is not None else f"_field{i}": _convert_row_types(v)
for i, (name, v) in enumerate(zip(names, value))
}
if isinstance(value, (list, tuple)):
return [_convert_row_types(v) for v in value]
return value
TRINO_TYPES_MAPPING = {
"boolean": TYPE_BOOLEAN,
"tinyint": TYPE_INTEGER,
"smallint": TYPE_INTEGER,
"integer": TYPE_INTEGER,
"long": TYPE_INTEGER,
"bigint": TYPE_INTEGER,
"float": TYPE_FLOAT,
"real": TYPE_FLOAT,
"double": TYPE_FLOAT,
"decimal": TYPE_INTEGER,
"varchar": TYPE_STRING,
"char": TYPE_STRING,
"string": TYPE_STRING,
"json": TYPE_STRING,
"date": TYPE_DATE,
"timestamp": TYPE_DATETIME,
}
class Trino(BaseQueryRunner):
noop_query = "SELECT 1"
should_annotate_query = ANNOTATE_QUERY
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"protocol": {"type": "string", "default": "http"},
"host": {"type": "string"},
"port": {"type": "number"},
"username": {"type": "string"},
"password": {"type": "string"},
"source": {"type": "string", "default": "redash"},
"client_tags": {"type": "string", "title": "Client tags (comma separated)"},
"catalog": {"type": "string"},
"schema": {"type": "string"},
"impersonation": {"type": "boolean", "default": False},
"impersonationField": {
"type": "string",
"title": "Impersonation User Attribute",
"default": "email",
"extendedEnum": [{"value": "email", "name": "Email"}, {"value": "name", "name": "Name"}],
},
},
"order": [
"protocol",
"host",
"port",
"username",
"password",
"source",
"client_tags",
"catalog",
"schema",
"impersonation",
],
"required": ["host", "username"],
"secret": ["password"],
"extra_options": [
"client_tags",
"impersonation",
"impersonationField",
],
}
@classmethod
def enabled(cls):
return enabled
@classmethod
def type(cls):
return "trino"
def get_schema(self, get_stats=False):
if self.configuration.get("catalog"):
catalogs = [self.configuration.get("catalog")]
else:
catalogs = self._get_catalogs()
schema = {}
for catalog in catalogs:
query = f"""
SELECT table_schema, table_name, column_name, data_type
FROM {catalog}.information_schema.columns
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
"""
results, error = self.run_query(query, None)
if error is not None:
self._handle_run_query_error(error)
for row in results["rows"]:
table_name = f'{catalog}.{row["table_schema"]}.{row["table_name"]}'
if table_name not in schema:
schema[table_name] = {"name": table_name, "columns": []}
column = {"name": row["column_name"], "type": row["data_type"]}
schema[table_name]["columns"].append(column)
return list(schema.values())
def _get_catalogs(self):
query = """
SHOW CATALOGS
"""
results, error = self.run_query(query, None)
if error is not None:
self._handle_run_query_error(error)
catalogs = []
for row in results["rows"]:
catalog = row["Catalog"]
if "." in catalog:
catalog = f'"{catalog}"'
catalogs.append(catalog)
return catalogs
def _get_trino_user(self, user):
"""Determine the Trino user based on impersonation settings."""
default_user = self.configuration.get("username")
if not self.configuration.get("impersonation") or user is None:
return default_user
impersonation_field = self.configuration.get("impersonationField", "email")
if isinstance(user, User):
if impersonation_field == "email":
return user.email or default_user
elif impersonation_field == "name":
return user.name or default_user
elif isinstance(user, ApiUser):
return user.name or default_user
return default_user
def _get_client_tags(self):
client_tags = self.configuration.get("client_tags")
if not client_tags:
return None
tags = [tag.strip() for tag in client_tags.split(",") if tag.strip()]
return tags or None
def run_query(self, query, user):
if self.configuration.get("password"):
auth = trino.auth.BasicAuthentication(
username=self.configuration.get("username"), password=self.configuration.get("password")
)
else:
auth = trino.constants.DEFAULT_AUTH
connection = trino.dbapi.connect(
http_scheme=self.configuration.get("protocol", "http"),
host=self.configuration.get("host", ""),
source=self.configuration.get("source", "redash"),
port=self.configuration.get("port", 8080),
catalog=self.configuration.get("catalog", ""),
schema=self.configuration.get("schema", ""),
user=self._get_trino_user(user),
client_tags=self._get_client_tags(),
auth=auth,
)
cursor = connection.cursor()
try:
cursor.execute(query)
results = cursor.fetchall()
description = cursor.description
columns = self.fetch_columns([(c[0], TRINO_TYPES_MAPPING.get(c[1], None)) for c in description])
column_names = [c["name"] for c in columns]
rows = [dict(zip(column_names, [_convert_row_types(v) for v in r])) for r in results]
data = {"columns": columns, "rows": rows}
error = None
except DatabaseError as db:
data = None
default_message = "Unspecified DatabaseError: {0}".format(str(db))
if isinstance(db.args[0], dict):
message = db.args[0].get("failureInfo", {"message", None}).get("message")
else:
message = None
error = default_message if message is None else message
except (KeyboardInterrupt, InterruptException, JobTimeoutException):
cursor.cancel()
raise
return data, error
register(Trino)
================================================
FILE: redash/query_runner/uptycs.py
================================================
import datetime
import logging
import jwt
import requests
from redash.query_runner import BaseSQLQueryRunner, register
from redash.utils import json_loads
logger = logging.getLogger(__name__)
class Uptycs(BaseSQLQueryRunner):
should_annotate_query = False
noop_query = "SELECT 1"
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"url": {"type": "string"},
"customer_id": {"type": "string"},
"key": {"type": "string"},
"verify_ssl": {
"type": "boolean",
"default": True,
"title": "Verify SSL Certificates",
},
"secret": {"type": "string"},
},
"order": ["url", "customer_id", "key", "secret"],
"required": ["url", "customer_id", "key", "secret"],
"secret": ["secret", "key"],
}
def generate_header(self, key, secret):
header = {}
utcnow = datetime.datetime.utcnow()
date = utcnow.strftime("%a, %d %b %Y %H:%M:%S GMT")
auth_var = jwt.encode({"iss": key}, secret, algorithm="HS256")
authorization = "Bearer %s" % (auth_var)
header["date"] = date
header["Authorization"] = authorization
return header
def transformed_to_redash_json(self, data):
transformed_columns = []
rows = []
# convert all type to JSON string
# In future we correct data type mapping later
if "columns" in data:
for json_each in data["columns"]:
name = json_each["name"]
new_json = {"name": name, "type": "string", "friendly_name": name}
transformed_columns.append(new_json)
# Transfored items into rows.
if "items" in data:
rows = data["items"]
return {"columns": transformed_columns, "rows": rows}
def api_call(self, sql):
# JWT encoded header
header = self.generate_header(self.configuration.get("key"), self.configuration.get("secret"))
# URL form using API key file based on GLOBAL
url = "%s/public/api/customers/%s/query" % (
self.configuration.get("url"),
self.configuration.get("customer_id"),
)
# post data base sql
post_data_json = {"query": sql}
response = requests.post(
url,
headers=header,
json=post_data_json,
verify=self.configuration.get("verify_ssl", True),
)
if response.status_code == 200:
response_output = json_loads(response.content)
else:
error = "status_code " + str(response.status_code) + "\n"
error = error + "failed to connect"
data = {}
return data, error
# if we get right status code then call transfored_to_redash
data = self.transformed_to_redash_json(response_output)
error = None
# if we got error from Uptycs include error information
if "error" in response_output:
error = response_output["error"]["message"]["brief"]
error = error + "\n" + response_output["error"]["message"]["detail"]
return data, error
def run_query(self, query, user):
data, error = self.api_call(query)
logger.debug("%s", data)
return data, error
def get_schema(self, get_stats=False):
header = self.generate_header(self.configuration.get("key"), self.configuration.get("secret"))
url = "%s/public/api/customers/%s/schema/global" % (
self.configuration.get("url"),
self.configuration.get("customer_id"),
)
response = requests.get(url, headers=header, verify=self.configuration.get("verify_ssl", True))
redash_json = []
schema = json_loads(response.content)
for each_def in schema["tables"]:
table_name = each_def["name"]
columns = []
for col in each_def["columns"]:
columns.append(col["name"])
table_json = {"name": table_name, "columns": columns}
redash_json.append(table_json)
logger.debug("%s", list(schema.values()))
return redash_json
register(Uptycs)
================================================
FILE: redash/query_runner/url.py
================================================
from redash.query_runner import BaseHTTPQueryRunner, register
from redash.utils import deprecated
@deprecated()
class Url(BaseHTTPQueryRunner):
requires_url = False
def test_connection(self):
pass
def run_query(self, query, user):
base_url = self.configuration.get("url", None)
query = query.strip()
if base_url is not None and base_url != "":
if query.find("://") > -1:
return None, "Accepting only relative URLs to '%s'" % base_url
if base_url is None:
base_url = ""
url = base_url + query
response, error = self.get_response(url)
if error is not None:
return None, error
json_data = response.content.strip()
if json_data:
return json_data, None
else:
return None, "Got empty response from '{}'.".format(url)
register(Url)
================================================
FILE: redash/query_runner/vertica.py
================================================
import logging
from redash.query_runner import (
TYPE_BOOLEAN,
TYPE_DATE,
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_INTEGER,
TYPE_STRING,
BaseSQLQueryRunner,
register,
)
logger = logging.getLogger(__name__)
types_map = {
5: TYPE_BOOLEAN,
6: TYPE_INTEGER,
7: TYPE_FLOAT,
8: TYPE_STRING,
9: TYPE_STRING,
10: TYPE_DATE,
11: TYPE_DATETIME,
12: TYPE_DATETIME,
13: TYPE_DATETIME,
14: TYPE_DATETIME,
15: TYPE_DATETIME,
16: TYPE_FLOAT,
17: TYPE_STRING,
114: TYPE_DATETIME,
115: TYPE_STRING,
116: TYPE_STRING,
117: TYPE_STRING,
}
class Vertica(BaseSQLQueryRunner):
noop_query = "SELECT 1"
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"host": {"type": "string"},
"user": {"type": "string"},
"password": {"type": "string", "title": "Password"},
"database": {"type": "string", "title": "Database name"},
"port": {"type": "number"},
"read_timeout": {"type": "number", "title": "Read Timeout"},
"connection_timeout": {"type": "number", "title": "Connection Timeout"},
},
"required": ["database"],
"order": [
"host",
"port",
"user",
"password",
"database",
"read_timeout",
"connection_timeout",
],
"secret": ["password"],
}
@classmethod
def enabled(cls):
try:
import vertica_python # noqa: F401
except ImportError:
return False
return True
def _get_tables(self, schema):
query = """
Select table_schema, table_name, column_name from columns where is_system_table=false
union all
select table_schema, table_name, column_name from view_columns;
"""
results, error = self.run_query(query, None)
if error is not None:
self._handle_run_query_error(error)
for row in results["rows"]:
table_name = "{}.{}".format(row["table_schema"], row["table_name"])
if table_name not in schema:
schema[table_name] = {"name": table_name, "columns": []}
schema[table_name]["columns"].append(row["column_name"])
return list(schema.values())
def run_query(self, query, user):
import vertica_python
if query == "":
data = None
error = "Query is empty"
return data, error
connection = None
try:
conn_info = {
"host": self.configuration.get("host", ""),
"port": self.configuration.get("port", 5433),
"user": self.configuration.get("user", ""),
"password": self.configuration.get("password", ""),
"database": self.configuration.get("database", ""),
"read_timeout": self.configuration.get("read_timeout", 600),
}
if self.configuration.get("connection_timeout"):
conn_info["connection_timeout"] = self.configuration.get("connection_timeout")
connection = vertica_python.connect(**conn_info)
cursor = connection.cursor()
logger.debug("Vertica running query: %s", query)
cursor.execute(query)
if cursor.description is not None:
columns_data = [(i[0], types_map.get(i[1], None)) for i in cursor.description]
columns = self.fetch_columns(columns_data)
rows = [dict(zip(([c["name"] for c in columns]), r)) for r in cursor.fetchall()]
data = {"columns": columns, "rows": rows}
error = None
else:
data = None
error = "No data was returned."
cursor.close()
finally:
if connection:
connection.close()
return data, error
register(Vertica)
================================================
FILE: redash/query_runner/yandex_disk.py
================================================
import logging
from importlib.util import find_spec
import requests
import yaml
from redash.query_runner import BaseSQLQueryRunner, register
from redash.utils.pandas import pandas_installed
openpyxl_installed = find_spec("openpyxl")
if pandas_installed and openpyxl_installed:
import openpyxl # noqa: F401
import pandas as pd
from redash.utils.pandas import pandas_to_result
enabled = True
EXTENSIONS_READERS = {
"csv": pd.read_csv,
"tsv": pd.read_table,
"xls": pd.read_excel,
"xlsx": pd.read_excel,
}
else:
enabled = False
logger = logging.getLogger(__name__)
class YandexDisk(BaseSQLQueryRunner):
should_annotate_query = False
@classmethod
def type(cls):
return "yandex_disk"
@classmethod
def name(cls):
return "Yandex Disk"
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"token": {"type": "string", "title": "OAuth Token"},
},
"secret": ["token"],
"required": ["token"],
}
def __init__(self, configuration):
super(YandexDisk, self).__init__(configuration)
self.syntax = "yaml"
self.base_url = "https://cloud-api.yandex.net/v1/disk"
self.list_path = "counters"
def _get_tables(self, schema):
offset = 0
limit = 100
while True:
tmp_response = self._send_query(
"resources/public", media_type="spreadsheet,text", limit=limit, offset=offset
)
tmp_items = tmp_response["items"]
for file_info in tmp_items:
file_name = file_info["name"]
file_path = file_info["path"].replace("disk:", "")
file_extension = file_name.split(".")[-1].lower()
if file_extension not in EXTENSIONS_READERS:
continue
schema[file_name] = {"name": file_name, "columns": [file_path]}
if len(tmp_items) < limit:
break
offset += limit
return list(schema.values())
def test_connection(self):
self._send_query()
def _send_query(self, url_path="", **kwargs):
token = kwargs.pop("oauth_token", self.configuration["token"])
r = requests.get(
f"{self.base_url}/{url_path}",
headers={"Authorization": f"OAuth {token}"},
params=kwargs,
)
response_data = r.json()
if not r.ok:
error_message = f"Code: {r.status_code}, message: {r.text}"
raise Exception(error_message)
return response_data
def run_query(self, query, user):
logger.debug("Yandex Disk is about to execute query: %s", query)
data = None
if not query:
error = "Query is empty"
return data, error
try:
params = yaml.safe_load(query)
except (ValueError, AttributeError) as e:
logger.exception(e)
error = f"YAML read error: {str(e)}"
return data, error
if not isinstance(params, dict):
error = "The query format must be JSON or YAML"
return data, error
if "path" not in params:
error = "The query must contain path"
return data, error
file_extension = params["path"].split(".")[-1].lower()
read_params = {}
is_multiple_sheets = False
if file_extension not in EXTENSIONS_READERS:
error = f"Unsupported file extension: {file_extension}"
return data, error
elif file_extension in ("xls", "xlsx"):
read_params["sheet_name"] = params.get("sheet_name", 0)
if read_params["sheet_name"] is None:
is_multiple_sheets = True
file_url = self._send_query("resources/download", path=params["path"])["href"]
try:
df = EXTENSIONS_READERS[file_extension](file_url, **read_params)
except Exception as e:
logger.exception(e)
error = f"Read file error: {str(e)}"
return data, error
if is_multiple_sheets:
new_df = []
for sheet_name, sheet_df in df.items():
sheet_df["sheet_name"] = sheet_name
new_df.append(sheet_df)
new_df = pd.concat(new_df, ignore_index=True)
df = new_df.copy()
data = pandas_to_result(df)
error = None
return data, error
register(YandexDisk)
================================================
FILE: redash/query_runner/yandex_metrica.py
================================================
import logging
from urllib.parse import parse_qs, urlparse
import backoff
import requests
import yaml
from redash.query_runner import (
TYPE_DATE,
TYPE_DATETIME,
TYPE_FLOAT,
TYPE_STRING,
BaseSQLQueryRunner,
register,
)
logger = logging.getLogger(__name__)
COLUMN_TYPES = {
"date": (
"firstVisitDate",
"firstVisitStartOfYear",
"firstVisitStartOfQuarter",
"firstVisitStartOfMonth",
"firstVisitStartOfWeek",
),
"datetime": (
"firstVisitStartOfHour",
"firstVisitStartOfDekaminute",
"firstVisitStartOfMinute",
"firstVisitDateTime",
"firstVisitHour",
"firstVisitHourMinute",
),
"int": (
"pageViewsInterval",
"pageViews",
"firstVisitYear",
"firstVisitMonth",
"firstVisitDayOfMonth",
"firstVisitDayOfWeek",
"firstVisitMinute",
"firstVisitDekaminute",
),
}
for type_, elements in COLUMN_TYPES.items():
for el in elements:
if "first" in el:
el = el.replace("first", "last")
COLUMN_TYPES[type_] += (el,)
def parse_ym_response(response):
columns = []
dimensions_len = len(response["query"]["dimensions"])
for h in response["query"]["dimensions"] + response["query"]["metrics"]:
friendly_name = h.split(":")[-1]
if friendly_name in COLUMN_TYPES["date"]:
data_type = TYPE_DATE
elif friendly_name in COLUMN_TYPES["datetime"]:
data_type = TYPE_DATETIME
else:
data_type = TYPE_STRING
columns.append({"name": h, "friendly_name": friendly_name, "type": data_type})
rows = []
for num, row in enumerate(response["data"]):
res = {}
for i, d in enumerate(row["dimensions"]):
res[columns[i]["name"]] = d["name"]
for i, d in enumerate(row["metrics"]):
res[columns[dimensions_len + i]["name"]] = d
if num == 0 and isinstance(d, float):
columns[dimensions_len + i]["type"] = TYPE_FLOAT
rows.append(res)
return {"columns": columns, "rows": rows}
class QuotaException(Exception):
pass
class YandexMetrica(BaseSQLQueryRunner):
should_annotate_query = False
@classmethod
def type(cls):
# This is written with a "k" for backward-compatibility. See #2874.
return "yandex_metrika"
@classmethod
def name(cls):
return "Yandex Metrica"
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {"token": {"type": "string", "title": "OAuth Token"}},
"secret": ["token"],
"required": ["token"],
}
def __init__(self, configuration):
super(YandexMetrica, self).__init__(configuration)
self.syntax = "yaml"
self.url = "https://api-metrica.yandex.com"
self.list_path = "counters"
def _get_tables(self, schema):
counters = self._send_query(f"management/v1/{self.list_path}")
for row in counters[self.list_path]:
owner = row.get("owner_login")
counter = f"{row.get('name', 'Unknown')} | {row.get('id', 'Unknown')}"
if owner not in schema:
schema[owner] = {"name": owner, "columns": []}
schema[owner]["columns"].append(counter)
return list(schema.values())
def test_connection(self):
self._send_query(f"management/v1/{self.list_path}")
@backoff.on_exception(backoff.fibo, QuotaException, max_tries=10)
def _send_query(self, path="stat/v1/data", **kwargs):
token = kwargs.pop("oauth_token", self.configuration["token"])
r = requests.get(
f"{self.url}/{path}",
headers={"Authorization": f"OAuth {token}"},
params=kwargs,
)
response_data = r.json()
if not r.ok:
error_message = f"Code: {r.status_code}, message: {r.text}"
if r.status_code == 429:
logger.warning("Warning: 429 status code on Yandex Metrica query")
raise QuotaException(error_message)
raise Exception(error_message)
return response_data
def run_query(self, query, user):
logger.debug("Metrica is about to execute query: %s", query)
data = None
query = query.strip()
if query == "":
error = "Query is empty"
return data, error
try:
params = yaml.safe_load(query)
except ValueError as e:
logging.exception(e)
error = str(e)
return data, error
if isinstance(params, dict):
if "url" in params:
params = parse_qs(urlparse(params["url"]).query, keep_blank_values=True)
else:
error = "The query format must be JSON or YAML"
return data, error
try:
data = parse_ym_response(self._send_query(**params))
error = None
except Exception as e:
logging.exception(e)
error = str(e)
return data, error
class YandexAppMetrica(YandexMetrica):
@classmethod
def type(cls):
# This is written with a "k" for backward-compatibility. See #2874.
return "yandex_appmetrika"
@classmethod
def name(cls):
return "Yandex AppMetrica"
def __init__(self, configuration):
super(YandexAppMetrica, self).__init__(configuration)
self.url = "https://api.appmetrica.yandex.com"
self.list_path = "applications"
register(YandexMetrica)
register(YandexAppMetrica)
================================================
FILE: redash/security.py
================================================
import functools
from flask import request, session
from flask_login import current_user
from flask_talisman import talisman
from flask_wtf.csrf import CSRFProtect, generate_csrf
from redash import settings
talisman = talisman.Talisman()
csrf = CSRFProtect()
def csp_allows_embeding(fn):
@functools.wraps(fn)
def decorated(*args, **kwargs):
return fn(*args, **kwargs)
embedable_csp = talisman.content_security_policy + "frame-ancestors *;"
return talisman(content_security_policy=embedable_csp, frame_options=None)(decorated)
def init_app(app):
csrf.init_app(app)
app.config["WTF_CSRF_CHECK_DEFAULT"] = False
app.config["WTF_CSRF_SSL_STRICT"] = False
app.config["WTF_CSRF_TIME_LIMIT"] = settings.CSRF_TIME_LIMIT
app.config["SESSION_COOKIE_NAME"] = settings.SESSION_COOKIE_NAME
@app.after_request
def inject_csrf_token(response):
response.set_cookie("csrf_token", generate_csrf())
return response
if settings.ENFORCE_CSRF:
@app.before_request
def check_csrf():
# BEGIN workaround until https://github.com/lepture/flask-wtf/pull/419 is merged
if request.blueprint in csrf._exempt_blueprints:
return
view = app.view_functions.get(request.endpoint)
if view is not None and f"{view.__module__}.{view.__name__}" in csrf._exempt_views:
return
# END workaround
if not current_user.is_authenticated or "user_id" in session:
csrf.protect()
talisman.init_app(
app,
feature_policy=settings.FEATURE_POLICY,
force_https=settings.ENFORCE_HTTPS,
force_https_permanent=settings.ENFORCE_HTTPS_PERMANENT,
force_file_save=settings.ENFORCE_FILE_SAVE,
frame_options=settings.FRAME_OPTIONS,
frame_options_allow_from=settings.FRAME_OPTIONS_ALLOW_FROM,
strict_transport_security=settings.HSTS_ENABLED,
strict_transport_security_preload=settings.HSTS_PRELOAD,
strict_transport_security_max_age=settings.HSTS_MAX_AGE,
strict_transport_security_include_subdomains=settings.HSTS_INCLUDE_SUBDOMAINS,
content_security_policy=settings.CONTENT_SECURITY_POLICY,
content_security_policy_report_uri=settings.CONTENT_SECURITY_POLICY_REPORT_URI,
content_security_policy_report_only=settings.CONTENT_SECURITY_POLICY_REPORT_ONLY,
content_security_policy_nonce_in=settings.CONTENT_SECURITY_POLICY_NONCE_IN,
referrer_policy=settings.REFERRER_POLICY,
session_cookie_secure=settings.SESSION_COOKIE_SECURE,
session_cookie_http_only=settings.SESSION_COOKIE_HTTPONLY,
)
================================================
FILE: redash/serializers/__init__.py
================================================
"""
This will eventually replace all the `to_dict` methods of the different model
classes we have. This will ensure cleaner code and better
separation of concerns.
"""
from flask_login import current_user
from funcy import project
from rq.job import JobStatus
from rq.timeouts import JobTimeoutException
from redash import models
from redash.models.parameterized_query import ParameterizedQuery
from redash.permissions import has_access, view_only
from redash.serializers.query_result import (
serialize_query_result,
serialize_query_result_to_dsv,
serialize_query_result_to_xlsx,
)
def public_widget(widget):
res = {
"id": widget.id,
"width": widget.width,
"options": widget.options,
"text": widget.text,
"updated_at": widget.updated_at,
"created_at": widget.created_at,
}
v = widget.visualization
if v and v.id:
res["visualization"] = {
"type": v.type,
"name": v.name,
"description": v.description,
"options": v.options,
"updated_at": v.updated_at,
"created_at": v.created_at,
"query": {
"id": v.query_rel.id,
"name": v.query_rel.name,
"description": v.query_rel.description,
"options": v.query_rel.options,
},
}
return res
def public_dashboard(dashboard):
dashboard_dict = project(
serialize_dashboard(dashboard, with_favorite_state=False),
("name", "layout", "dashboard_filters_enabled", "updated_at", "created_at", "options"),
)
widget_list = (
models.Widget.query.filter(models.Widget.dashboard_id == dashboard.id)
.outerjoin(models.Visualization)
.outerjoin(models.Query)
)
dashboard_dict["widgets"] = [public_widget(w) for w in widget_list]
return dashboard_dict
class Serializer:
pass
class QuerySerializer(Serializer):
def __init__(self, object_or_list, **kwargs):
self.object_or_list = object_or_list
self.options = kwargs
def serialize(self):
if isinstance(self.object_or_list, models.Query):
result = serialize_query(self.object_or_list, **self.options)
if self.options.get("with_favorite_state", True) and not current_user.is_api_user():
result["is_favorite"] = models.Favorite.is_favorite(current_user.id, self.object_or_list)
else:
result = [serialize_query(query, **self.options) for query in self.object_or_list]
if self.options.get("with_favorite_state", True):
queries = list(self.object_or_list)
favorites = models.Favorite.query.filter(
models.Favorite.object_id.in_([o.id for o in queries]),
models.Favorite.object_type == "Query",
models.Favorite.user_id == current_user.id,
)
favorites_dict = {fav.object_id: fav for fav in favorites}
for query in result:
favorite = favorites_dict.get(query["id"])
query["is_favorite"] = favorite is not None
if favorite:
query["starred_at"] = favorite.created_at
return result
def serialize_query(
query,
with_stats=False,
with_visualizations=False,
with_user=True,
with_last_modified_by=True,
):
d = {
"id": query.id,
"latest_query_data_id": query.latest_query_data_id,
"name": query.name,
"description": query.description,
"query": query.query_text,
"query_hash": query.query_hash,
"schedule": query.schedule,
"api_key": query.api_key,
"is_archived": query.is_archived,
"is_draft": query.is_draft,
"updated_at": query.updated_at,
"created_at": query.created_at,
"data_source_id": query.data_source_id,
"options": query.options,
"version": query.version,
"tags": query.tags or [],
"is_safe": query.parameterized.is_safe,
}
if with_user:
d["user"] = query.user.to_dict()
else:
d["user_id"] = query.user_id
if with_last_modified_by:
d["last_modified_by"] = query.last_modified_by.to_dict() if query.last_modified_by is not None else None
else:
d["last_modified_by_id"] = query.last_modified_by_id
if with_stats:
if query.latest_query_data is not None:
d["retrieved_at"] = query.retrieved_at
d["runtime"] = query.runtime
else:
d["retrieved_at"] = None
d["runtime"] = None
if with_visualizations:
d["visualizations"] = [serialize_visualization(vis, with_query=False) for vis in query.visualizations]
return d
def serialize_visualization(object, with_query=True):
d = {
"id": object.id,
"type": object.type,
"name": object.name,
"description": object.description,
"options": object.options,
"updated_at": object.updated_at,
"created_at": object.created_at,
}
if with_query:
d["query"] = serialize_query(object.query_rel)
return d
def serialize_widget(object):
d = {
"id": object.id,
"width": object.width,
"options": object.options,
"dashboard_id": object.dashboard_id,
"text": object.text,
"updated_at": object.updated_at,
"created_at": object.created_at,
}
if object.visualization and object.visualization.id:
d["visualization"] = serialize_visualization(object.visualization)
return d
def serialize_alert(alert, full=True):
d = {
"id": alert.id,
"name": alert.name,
"options": alert.options,
"state": alert.state,
"last_triggered_at": alert.last_triggered_at,
"updated_at": alert.updated_at,
"created_at": alert.created_at,
"rearm": alert.rearm,
}
if full:
d["query"] = serialize_query(alert.query_rel)
d["user"] = alert.user.to_dict()
else:
d["query_id"] = alert.query_id
d["user_id"] = alert.user_id
return d
def serialize_dashboard(obj, with_widgets=False, user=None, with_favorite_state=True):
layout = obj.layout
widgets = []
if with_widgets:
for w in obj.widgets:
if w.visualization_id is None:
widgets.append(serialize_widget(w))
elif user and has_access(w.visualization.query_rel, user, view_only):
widgets.append(serialize_widget(w))
else:
widget = project(
serialize_widget(w),
(
"id",
"width",
"dashboard_id",
"options",
"created_at",
"updated_at",
),
)
widget["restricted"] = True
widgets.append(widget)
else:
widgets = None
d = {
"id": obj.id,
"slug": obj.name_as_slug,
"name": obj.name,
"user_id": obj.user_id,
"user": {
"id": obj.user.id,
"name": obj.user.name,
"email": obj.user.email,
"profile_image_url": obj.user.profile_image_url,
},
"layout": layout,
"dashboard_filters_enabled": obj.dashboard_filters_enabled,
"widgets": widgets,
"options": obj.options,
"is_archived": obj.is_archived,
"is_draft": obj.is_draft,
"tags": obj.tags or [],
"updated_at": obj.updated_at,
"created_at": obj.created_at,
"version": obj.version,
}
return d
class DashboardSerializer(Serializer):
def __init__(self, object_or_list, **kwargs):
self.object_or_list = object_or_list
self.options = kwargs
def serialize(self):
if isinstance(self.object_or_list, models.Dashboard):
result = serialize_dashboard(self.object_or_list, **self.options)
if self.options.get("with_favorite_state", True) and not current_user.is_api_user():
result["is_favorite"] = models.Favorite.is_favorite(current_user.id, self.object_or_list)
else:
result = [serialize_dashboard(obj, **self.options) for obj in self.object_or_list]
if self.options.get("with_favorite_state", True):
dashboards = list(self.object_or_list)
favorites = models.Favorite.query.filter(
models.Favorite.object_id.in_([o.id for o in dashboards]),
models.Favorite.object_type == "Dashboard",
models.Favorite.user_id == current_user.id,
)
favorites_dict = {fav.object_id: fav for fav in favorites}
for query in result:
favorite = favorites_dict.get(query["id"])
query["is_favorite"] = favorite is not None
if favorite:
query["starred_at"] = favorite.created_at
return result
def serialize_job(job):
# TODO: this is mapping to the old Job class statuses. Need to update the client side and remove this
STATUSES = {
JobStatus.QUEUED: 1,
JobStatus.STARTED: 2,
JobStatus.FINISHED: 3,
JobStatus.FAILED: 4,
JobStatus.CANCELED: 5,
JobStatus.DEFERRED: 6,
JobStatus.SCHEDULED: 7,
}
job_status = job.get_status()
if job.is_started:
updated_at = job.started_at or 0
else:
updated_at = 0
status = STATUSES[job_status]
result = query_result_id = None
if job.is_cancelled:
error = "Query cancelled by user."
status = 4
elif isinstance(job.result, Exception):
error = str(job.result)
status = 4
elif isinstance(job.result, dict) and "error" in job.result:
error = job.result["error"]
status = 4
else:
error = ""
result = query_result_id = job.result
return {
"job": {
"id": job.id,
"updated_at": updated_at,
"status": status,
"error": error,
"result": result,
"query_result_id": query_result_id,
}
}
================================================
FILE: redash/serializers/query_result.py
================================================
import csv
import io
import xlsxwriter
from dateutil.parser import isoparse as parse_date
from funcy import project, rpartial
from redash.authentication.org_resolving import current_org
from redash.query_runner import TYPE_BOOLEAN, TYPE_DATE, TYPE_DATETIME
def _convert_format(fmt):
return (
fmt.replace("DD", "%d")
.replace("MM", "%m")
.replace("YYYY", "%Y")
.replace("YY", "%y")
.replace("HH", "%H")
.replace("mm", "%M")
.replace("ss", "%S")
.replace("SSS", "%f")
)
def _convert_bool(value):
if value is True:
return "true"
elif value is False:
return "false"
return value
def _convert_datetime(value, fmt):
if not value:
return value
try:
parsed = parse_date(value)
ret = parsed.strftime(fmt)
except Exception:
return value
return ret
def _get_column_lists(columns):
date_format = _convert_format(current_org.get_setting("date_format"))
datetime_format = _convert_format(
"{} {}".format(
current_org.get_setting("date_format"),
current_org.get_setting("time_format"),
)
)
special_types = {
TYPE_BOOLEAN: _convert_bool,
TYPE_DATE: rpartial(_convert_datetime, date_format),
TYPE_DATETIME: rpartial(_convert_datetime, datetime_format),
}
fieldnames = []
special_columns = dict()
for col in columns:
fieldnames.append(col["name"])
for col_type in special_types.keys():
if col["type"] == col_type:
special_columns[col["name"]] = special_types[col_type]
return fieldnames, special_columns
def serialize_query_result(query_result, is_api_user):
if is_api_user:
publicly_needed_keys = ["data", "retrieved_at"]
return project(query_result.to_dict(), publicly_needed_keys)
else:
return query_result.to_dict()
def serialize_query_result_to_dsv(query_result, delimiter):
s = io.StringIO()
query_data = query_result.data
fieldnames, special_columns = _get_column_lists(query_data["columns"] or [])
writer = csv.DictWriter(s, extrasaction="ignore", fieldnames=fieldnames, delimiter=delimiter)
writer.writeheader()
for row in query_data["rows"]:
for col_name, converter in special_columns.items():
if col_name in row:
row[col_name] = converter(row[col_name])
writer.writerow(row)
return s.getvalue()
def serialize_query_result_to_xlsx(query_result):
output = io.BytesIO()
query_data = query_result.data
book = xlsxwriter.Workbook(output, {"constant_memory": True})
sheet = book.add_worksheet("result")
column_names = []
for c, col in enumerate(query_data["columns"]):
sheet.write(0, c, col["name"])
column_names.append(col["name"])
for r, row in enumerate(query_data["rows"]):
for c, name in enumerate(column_names):
v = row.get(name)
if isinstance(v, (dict, list)):
v = str(v)
sheet.write(r + 1, c, v)
book.close()
return output.getvalue()
================================================
FILE: redash/settings/__init__.py
================================================
import importlib
import os
import ssl
from flask_talisman import talisman
from funcy import distinct, remove
from redash.settings.helpers import (
add_decode_responses_to_redis_url,
array_from_string,
cast_int_or_default,
fix_assets_path,
int_or_none,
parse_boolean,
set_from_string,
)
from redash.settings.organization import DATE_FORMAT, TIME_FORMAT # noqa
# _REDIS_URL is the unchanged REDIS_URL we get from env vars, to be used later with RQ
_REDIS_URL = os.environ.get("REDASH_REDIS_URL", os.environ.get("REDIS_URL", "redis://localhost:6379/0"))
# This is the one to use for Redash' own connection:
REDIS_URL = add_decode_responses_to_redis_url(_REDIS_URL)
PROXIES_COUNT = int(os.environ.get("REDASH_PROXIES_COUNT", "1"))
STATSD_HOST = os.environ.get("REDASH_STATSD_HOST", "127.0.0.1")
STATSD_PORT = int(os.environ.get("REDASH_STATSD_PORT", "8125"))
STATSD_PREFIX = os.environ.get("REDASH_STATSD_PREFIX", "redash")
STATSD_USE_TAGS = parse_boolean(os.environ.get("REDASH_STATSD_USE_TAGS", "false"))
# Connection settings for Redash's own database (where we store the queries, results, etc)
SQLALCHEMY_DATABASE_URI = os.environ.get(
"REDASH_DATABASE_URL", os.environ.get("DATABASE_URL", "postgresql:///postgres")
)
SQLALCHEMY_MAX_OVERFLOW = int_or_none(os.environ.get("SQLALCHEMY_MAX_OVERFLOW"))
SQLALCHEMY_POOL_SIZE = int_or_none(os.environ.get("SQLALCHEMY_POOL_SIZE"))
SQLALCHEMY_DISABLE_POOL = parse_boolean(os.environ.get("SQLALCHEMY_DISABLE_POOL", "false"))
SQLALCHEMY_ENABLE_POOL_PRE_PING = parse_boolean(os.environ.get("SQLALCHEMY_ENABLE_POOL_PRE_PING", "false"))
SQLALCHEMY_TRACK_MODIFICATIONS = False
SQLALCHEMY_ECHO = False
RQ_REDIS_URL = os.environ.get("RQ_REDIS_URL", _REDIS_URL)
# The following enables periodic job (every 5 minutes) of removing unused query results.
QUERY_RESULTS_CLEANUP_ENABLED = parse_boolean(os.environ.get("REDASH_QUERY_RESULTS_CLEANUP_ENABLED", "true"))
QUERY_RESULTS_CLEANUP_COUNT = int(os.environ.get("REDASH_QUERY_RESULTS_CLEANUP_COUNT", "100"))
QUERY_RESULTS_CLEANUP_MAX_AGE = int(os.environ.get("REDASH_QUERY_RESULTS_CLEANUP_MAX_AGE", "7"))
QUERY_RESULTS_EXPIRED_TTL_ENABLED = parse_boolean(os.environ.get("REDASH_QUERY_RESULTS_EXPIRED_TTL_ENABLED", "false"))
# default set query results expired ttl 86400 seconds
QUERY_RESULTS_EXPIRED_TTL = int(os.environ.get("REDASH_QUERY_RESULTS_EXPIRED_TTL", "86400"))
SCHEMAS_REFRESH_SCHEDULE = int(os.environ.get("REDASH_SCHEMAS_REFRESH_SCHEDULE", 30))
SCHEMAS_REFRESH_TIMEOUT = int(os.environ.get("REDASH_SCHEMAS_REFRESH_TIMEOUT", 300))
AUTH_TYPE = os.environ.get("REDASH_AUTH_TYPE", "api_key")
INVITATION_TOKEN_MAX_AGE = int(os.environ.get("REDASH_INVITATION_TOKEN_MAX_AGE", 60 * 60 * 24 * 7))
# The secret key to use in the Flask app for various cryptographic features
SECRET_KEY = os.environ.get("REDASH_COOKIE_SECRET")
if SECRET_KEY is None:
raise Exception(
"You must set the REDASH_COOKIE_SECRET environment variable. Visit http://redash.io/help/open-source/admin-guide/secrets for more information."
)
# The secret key to use when encrypting data source options
DATASOURCE_SECRET_KEY = os.environ.get("REDASH_SECRET_KEY", SECRET_KEY)
# Whether and how to redirect non-HTTP requests to HTTPS. Disabled by default.
ENFORCE_HTTPS = parse_boolean(os.environ.get("REDASH_ENFORCE_HTTPS", "false"))
ENFORCE_HTTPS_PERMANENT = parse_boolean(os.environ.get("REDASH_ENFORCE_HTTPS_PERMANENT", "false"))
# Whether file downloads are enforced or not.
ENFORCE_FILE_SAVE = parse_boolean(os.environ.get("REDASH_ENFORCE_FILE_SAVE", "true"))
# Whether api calls using the json query runner will block private addresses
ENFORCE_PRIVATE_ADDRESS_BLOCK = parse_boolean(os.environ.get("REDASH_ENFORCE_PRIVATE_IP_BLOCK", "true"))
# Whether to use secure cookies by default.
COOKIES_SECURE = parse_boolean(os.environ.get("REDASH_COOKIES_SECURE", str(ENFORCE_HTTPS)))
# Whether the session cookie is set to secure.
SESSION_COOKIE_SECURE = parse_boolean(os.environ.get("REDASH_SESSION_COOKIE_SECURE") or str(COOKIES_SECURE))
# Whether the session cookie is set HttpOnly.
SESSION_COOKIE_HTTPONLY = parse_boolean(os.environ.get("REDASH_SESSION_COOKIE_HTTPONLY", "true"))
SESSION_EXPIRY_TIME = int(os.environ.get("REDASH_SESSION_EXPIRY_TIME", 60 * 60 * 6))
SESSION_COOKIE_NAME = os.environ.get("REDASH_SESSION_COOKIE_NAME", "session")
# Whether the session cookie is set to secure.
REMEMBER_COOKIE_SECURE = parse_boolean(os.environ.get("REDASH_REMEMBER_COOKIE_SECURE") or str(COOKIES_SECURE))
# Whether the remember cookie is set HttpOnly.
REMEMBER_COOKIE_HTTPONLY = parse_boolean(os.environ.get("REDASH_REMEMBER_COOKIE_HTTPONLY", "true"))
# The amount of time before the remember cookie expires.
REMEMBER_COOKIE_DURATION = int(os.environ.get("REDASH_REMEMBER_COOKIE_DURATION", 60 * 60 * 24 * 31))
# Doesn't set X-Frame-Options by default since it's highly dependent
# on the specific deployment.
# See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options
# for more information.
FRAME_OPTIONS = os.environ.get("REDASH_FRAME_OPTIONS", "deny")
FRAME_OPTIONS_ALLOW_FROM = os.environ.get("REDASH_FRAME_OPTIONS_ALLOW_FROM", "")
# Whether and how to send Strict-Transport-Security response headers.
# See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security
# for more information.
HSTS_ENABLED = parse_boolean(os.environ.get("REDASH_HSTS_ENABLED") or str(ENFORCE_HTTPS))
HSTS_PRELOAD = parse_boolean(os.environ.get("REDASH_HSTS_PRELOAD", "false"))
HSTS_MAX_AGE = int(os.environ.get("REDASH_HSTS_MAX_AGE", talisman.ONE_YEAR_IN_SECS))
HSTS_INCLUDE_SUBDOMAINS = parse_boolean(os.environ.get("REDASH_HSTS_INCLUDE_SUBDOMAINS", "false"))
# Whether and how to send Content-Security-Policy response headers.
# See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
# for more information.
# Overriding this value via an environment variables requires setting it
# as a string in the general CSP format of a semicolon separated list of
# individual CSP directives, see https://github.com/GoogleCloudPlatform/flask-talisman#example-7
# for more information. E.g.:
CONTENT_SECURITY_POLICY = os.environ.get(
"REDASH_CONTENT_SECURITY_POLICY",
"default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-eval'; font-src 'self' data:; img-src 'self' http: https: data: blob:; object-src 'none'; frame-ancestors 'none'; frame-src redash.io;",
)
CONTENT_SECURITY_POLICY_REPORT_URI = os.environ.get("REDASH_CONTENT_SECURITY_POLICY_REPORT_URI", "")
CONTENT_SECURITY_POLICY_REPORT_ONLY = parse_boolean(
os.environ.get("REDASH_CONTENT_SECURITY_POLICY_REPORT_ONLY", "false")
)
CONTENT_SECURITY_POLICY_NONCE_IN = array_from_string(os.environ.get("REDASH_CONTENT_SECURITY_POLICY_NONCE_IN", ""))
# Whether and how to send Referrer-Policy response headers. Defaults to
# 'strict-origin-when-cross-origin'.
# See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy
# for more information.
REFERRER_POLICY = os.environ.get("REDASH_REFERRER_POLICY", "strict-origin-when-cross-origin")
# Whether and how to send Feature-Policy response headers. Defaults to
# an empty value.
# See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Feature-Policy
# for more information.
FEATURE_POLICY = os.environ.get("REDASH_FEATURE_POLICY", "")
MULTI_ORG = parse_boolean(os.environ.get("REDASH_MULTI_ORG", "false"))
# If Redash is behind a proxy it might sometimes receive a X-Forwarded-Proto of HTTP
# even if your actual Redash URL scheme is HTTPS. This will cause Flask to build
# the OAuth redirect URL incorrectly thus failing auth. This is especially common if
# you're behind a SSL/TCP configured AWS ELB or similar.
# This setting will force the URL scheme.
GOOGLE_OAUTH_SCHEME_OVERRIDE = os.environ.get("REDASH_GOOGLE_OAUTH_SCHEME_OVERRIDE", "")
GOOGLE_CLIENT_ID = os.environ.get("REDASH_GOOGLE_CLIENT_ID", "")
GOOGLE_CLIENT_SECRET = os.environ.get("REDASH_GOOGLE_CLIENT_SECRET", "")
GOOGLE_OAUTH_ENABLED = bool(GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET)
# If Redash is behind a proxy it might sometimes receive a X-Forwarded-Proto of HTTP
# even if your actual Redash URL scheme is HTTPS. This will cause Flask to build
# the SAML redirect URL incorrect thus failing auth. This is especially common if
# you're behind a SSL/TCP configured AWS ELB or similar.
# This setting will force the URL scheme.
SAML_SCHEME_OVERRIDE = os.environ.get("REDASH_SAML_SCHEME_OVERRIDE", "")
SAML_ENCRYPTION_PEM_PATH = os.environ.get("REDASH_SAML_ENCRYPTION_PEM_PATH", "")
SAML_ENCRYPTION_CERT_PATH = os.environ.get("REDASH_SAML_ENCRYPTION_CERT_PATH", "")
SAML_ENCRYPTION_ENABLED = SAML_ENCRYPTION_PEM_PATH != "" and SAML_ENCRYPTION_CERT_PATH != ""
# Enables the use of an externally-provided and trusted remote user via an HTTP
# header. The "user" must be an email address.
#
# By default the trusted header is X-Forwarded-Remote-User. You can change
# this by setting REDASH_REMOTE_USER_HEADER.
#
# Enabling this authentication method is *potentially dangerous*, and it is
# your responsibility to ensure that only a trusted frontend (usually on the
# same server) can talk to the redash backend server, otherwise people will be
# able to login as anyone they want by directly talking to the redash backend.
# You must *also* ensure that any special header in the original request is
# removed or always overwritten by your frontend, otherwise your frontend may
# pass it through to the backend unchanged.
#
# Note that redash will only check the remote user once, upon the first need
# for a login, and then set a cookie which keeps the user logged in. Dropping
# the remote user header after subsequent requests won't automatically log the
# user out. Doing so could be done with further work, but usually it's
# unnecessary.
#
# If you also set the organization setting auth_password_login_enabled to false,
# then your authentication will be seamless. Otherwise a link will be presented
# on the login page to trigger remote user auth.
REMOTE_USER_LOGIN_ENABLED = parse_boolean(os.environ.get("REDASH_REMOTE_USER_LOGIN_ENABLED", "false"))
REMOTE_USER_HEADER = os.environ.get("REDASH_REMOTE_USER_HEADER", "X-Forwarded-Remote-User")
# If the organization setting auth_password_login_enabled is not false, then users will still be
# able to login through Redash instead of the LDAP server
LDAP_LOGIN_ENABLED = parse_boolean(os.environ.get("REDASH_LDAP_LOGIN_ENABLED", "false"))
# Bind LDAP using SSL. Default is False
LDAP_SSL = parse_boolean(os.environ.get("REDASH_LDAP_USE_SSL", "false"))
# Choose authentication method(SIMPLE, ANONYMOUS or NTLM). Default is SIMPLE
LDAP_AUTH_METHOD = os.environ.get("REDASH_LDAP_AUTH_METHOD", "SIMPLE")
# The LDAP directory address (ex. ldap://10.0.10.1:389)
LDAP_HOST_URL = os.environ.get("REDASH_LDAP_URL", None)
# The DN & password used to connect to LDAP to determine the identity of the user being authenticated.
# For AD this should be "org\\user".
LDAP_BIND_DN = os.environ.get("REDASH_LDAP_BIND_DN", None)
LDAP_BIND_DN_PASSWORD = os.environ.get("REDASH_LDAP_BIND_DN_PASSWORD", "")
# AD/LDAP email and display name keys
LDAP_DISPLAY_NAME_KEY = os.environ.get("REDASH_LDAP_DISPLAY_NAME_KEY", "displayName")
LDAP_EMAIL_KEY = os.environ.get("REDASH_LDAP_EMAIL_KEY", "mail")
# Prompt that should be shown above username/email field.
LDAP_CUSTOM_USERNAME_PROMPT = os.environ.get("REDASH_LDAP_CUSTOM_USERNAME_PROMPT", "LDAP/AD/SSO username:")
# LDAP Search DN TEMPLATE (for AD this should be "(sAMAccountName=%(username)s)"")
LDAP_SEARCH_TEMPLATE = os.environ.get("REDASH_LDAP_SEARCH_TEMPLATE", "(cn=%(username)s)")
# The schema to bind to (ex. cn=users,dc=ORG,dc=local)
LDAP_SEARCH_DN = os.environ.get("REDASH_LDAP_SEARCH_DN", os.environ.get("REDASH_SEARCH_DN"))
STATIC_ASSETS_PATH = fix_assets_path(os.environ.get("REDASH_STATIC_ASSETS_PATH", "../client/dist/"))
FLASK_TEMPLATE_PATH = fix_assets_path(os.environ.get("REDASH_FLASK_TEMPLATE_PATH", STATIC_ASSETS_PATH))
# Time limit (in seconds) for scheduled queries. Set this to -1 to execute without a time limit.
SCHEDULED_QUERY_TIME_LIMIT = int(os.environ.get("REDASH_SCHEDULED_QUERY_TIME_LIMIT", -1))
# Time limit (in seconds) for adhoc queries. Set this to -1 to execute without a time limit.
ADHOC_QUERY_TIME_LIMIT = int(os.environ.get("REDASH_ADHOC_QUERY_TIME_LIMIT", -1))
JOB_EXPIRY_TIME = int(os.environ.get("REDASH_JOB_EXPIRY_TIME", 3600 * 12))
JOB_DEFAULT_FAILURE_TTL = int(os.environ.get("REDASH_JOB_DEFAULT_FAILURE_TTL", 7 * 24 * 60 * 60))
LOG_LEVEL = os.environ.get("REDASH_LOG_LEVEL", "INFO")
LOG_STDOUT = parse_boolean(os.environ.get("REDASH_LOG_STDOUT", "false"))
LOG_PREFIX = os.environ.get("REDASH_LOG_PREFIX", "")
LOG_FORMAT = os.environ.get(
"REDASH_LOG_FORMAT",
LOG_PREFIX + "[%(asctime)s][PID:%(process)d][%(levelname)s][%(name)s] %(message)s",
)
RQ_WORKER_JOB_LOG_FORMAT = os.environ.get(
"REDASH_RQ_WORKER_JOB_LOG_FORMAT",
(
LOG_PREFIX + "[%(asctime)s][PID:%(process)d][%(levelname)s][%(name)s] "
"job.func_name=%(job_func_name)s "
"job.id=%(job_id)s %(message)s"
),
)
# Mail settings:
MAIL_SERVER = os.environ.get("REDASH_MAIL_SERVER", "localhost")
MAIL_PORT = int(os.environ.get("REDASH_MAIL_PORT", 25))
MAIL_USE_TLS = parse_boolean(os.environ.get("REDASH_MAIL_USE_TLS", "false"))
MAIL_USE_SSL = parse_boolean(os.environ.get("REDASH_MAIL_USE_SSL", "false"))
MAIL_USERNAME = os.environ.get("REDASH_MAIL_USERNAME", None)
MAIL_PASSWORD = os.environ.get("REDASH_MAIL_PASSWORD", None)
MAIL_DEFAULT_SENDER = os.environ.get("REDASH_MAIL_DEFAULT_SENDER", None)
MAIL_MAX_EMAILS = os.environ.get("REDASH_MAIL_MAX_EMAILS", None)
MAIL_ASCII_ATTACHMENTS = parse_boolean(os.environ.get("REDASH_MAIL_ASCII_ATTACHMENTS", "false"))
def email_server_is_configured():
return MAIL_DEFAULT_SENDER is not None
HOST = os.environ.get("REDASH_HOST", "")
SEND_FAILURE_EMAIL_INTERVAL = int(os.environ.get("REDASH_SEND_FAILURE_EMAIL_INTERVAL", 60))
MAX_FAILURE_REPORTS_PER_QUERY = int(os.environ.get("REDASH_MAX_FAILURE_REPORTS_PER_QUERY", 100))
ALERTS_DEFAULT_MAIL_SUBJECT_TEMPLATE = os.environ.get(
"REDASH_ALERTS_DEFAULT_MAIL_SUBJECT_TEMPLATE", "Alert: {alert_name} changed status to {state}"
)
REDASH_ALERTS_DEFAULT_MAIL_BODY_TEMPLATE_FILE = os.environ.get(
"REDASH_ALERTS_DEFAULT_MAIL_BODY_TEMPLATE_FILE", fix_assets_path("templates/emails/alert.html")
)
# How many requests are allowed per IP to the login page before
# being throttled?
# See https://flask-limiter.readthedocs.io/en/stable/#rate-limit-string-notation
RATELIMIT_ENABLED = parse_boolean(os.environ.get("REDASH_RATELIMIT_ENABLED", "true"))
THROTTLE_LOGIN_PATTERN = os.environ.get("REDASH_THROTTLE_LOGIN_PATTERN", "50/hour")
LIMITER_STORAGE = os.environ.get("REDASH_LIMITER_STORAGE", REDIS_URL)
THROTTLE_PASS_RESET_PATTERN = os.environ.get("REDASH_THROTTLE_PASS_RESET_PATTERN", "10/hour")
# CORS settings for the Query Result API (and possibly future external APIs).
# In most cases all you need to do is set REDASH_CORS_ACCESS_CONTROL_ALLOW_ORIGIN
# to the calling domain (or domains in a comma separated list).
ACCESS_CONTROL_ALLOW_ORIGIN = set_from_string(os.environ.get("REDASH_CORS_ACCESS_CONTROL_ALLOW_ORIGIN", ""))
ACCESS_CONTROL_ALLOW_CREDENTIALS = parse_boolean(
os.environ.get("REDASH_CORS_ACCESS_CONTROL_ALLOW_CREDENTIALS", "false")
)
ACCESS_CONTROL_REQUEST_METHOD = os.environ.get("REDASH_CORS_ACCESS_CONTROL_REQUEST_METHOD", "GET, POST, PUT")
ACCESS_CONTROL_ALLOW_HEADERS = os.environ.get("REDASH_CORS_ACCESS_CONTROL_ALLOW_HEADERS", "Content-Type")
# Query Runners
default_query_runners = [
"redash.query_runner.athena",
"redash.query_runner.big_query",
"redash.query_runner.google_spreadsheets",
"redash.query_runner.graphite",
"redash.query_runner.mongodb",
"redash.query_runner.couchbase",
"redash.query_runner.mysql",
"redash.query_runner.pg",
"redash.query_runner.url",
"redash.query_runner.influx_db",
"redash.query_runner.influx_db_v2",
"redash.query_runner.elasticsearch",
"redash.query_runner.elasticsearch2",
"redash.query_runner.amazon_elasticsearch",
"redash.query_runner.trino",
"redash.query_runner.presto",
"redash.query_runner.pinot",
"redash.query_runner.databricks",
"redash.query_runner.hive_ds",
"redash.query_runner.impala_ds",
"redash.query_runner.vertica",
"redash.query_runner.clickhouse",
"redash.query_runner.tinybird",
"redash.query_runner.yandex_metrica",
"redash.query_runner.yandex_disk",
"redash.query_runner.rockset",
"redash.query_runner.treasuredata",
"redash.query_runner.sqlite",
"redash.query_runner.mssql",
"redash.query_runner.mssql_odbc",
"redash.query_runner.memsql_ds",
"redash.query_runner.jql",
"redash.query_runner.google_analytics",
"redash.query_runner.axibase_tsd",
"redash.query_runner.salesforce",
"redash.query_runner.query_results",
"redash.query_runner.prometheus",
"redash.query_runner.db2",
"redash.query_runner.druid",
"redash.query_runner.kylin",
"redash.query_runner.drill",
"redash.query_runner.uptycs",
"redash.query_runner.snowflake",
"redash.query_runner.phoenix",
"redash.query_runner.json_ds",
"redash.query_runner.cass",
"redash.query_runner.dgraph",
"redash.query_runner.azure_kusto",
"redash.query_runner.exasol",
"redash.query_runner.cloudwatch",
"redash.query_runner.cloudwatch_insights",
"redash.query_runner.corporate_memory",
"redash.query_runner.sparql_endpoint",
"redash.query_runner.excel",
"redash.query_runner.csv",
"redash.query_runner.databend",
"redash.query_runner.nz",
"redash.query_runner.arango",
"redash.query_runner.google_analytics4",
"redash.query_runner.google_search_console",
"redash.query_runner.ignite",
"redash.query_runner.oracle",
"redash.query_runner.e6data",
"redash.query_runner.risingwave",
"redash.query_runner.duckdb",
]
enabled_query_runners = array_from_string(
os.environ.get("REDASH_ENABLED_QUERY_RUNNERS", ",".join(default_query_runners))
)
additional_query_runners = array_from_string(os.environ.get("REDASH_ADDITIONAL_QUERY_RUNNERS", ""))
disabled_query_runners = array_from_string(os.environ.get("REDASH_DISABLED_QUERY_RUNNERS", ""))
QUERY_RUNNERS = remove(
set(disabled_query_runners),
distinct(enabled_query_runners + additional_query_runners),
)
dynamic_settings = importlib.import_module(
os.environ.get("REDASH_DYNAMIC_SETTINGS_MODULE", "redash.settings.dynamic_settings")
)
# Destinations
default_destinations = [
"redash.destinations.email",
"redash.destinations.slack",
"redash.destinations.webhook",
"redash.destinations.discord",
"redash.destinations.mattermost",
"redash.destinations.chatwork",
"redash.destinations.pagerduty",
"redash.destinations.hangoutschat",
"redash.destinations.microsoft_teams_webhook",
"redash.destinations.asana",
"redash.destinations.webex",
"redash.destinations.datadog",
]
enabled_destinations = array_from_string(os.environ.get("REDASH_ENABLED_DESTINATIONS", ",".join(default_destinations)))
additional_destinations = array_from_string(os.environ.get("REDASH_ADDITIONAL_DESTINATIONS", ""))
DESTINATIONS = distinct(enabled_destinations + additional_destinations)
EVENT_REPORTING_WEBHOOKS = array_from_string(os.environ.get("REDASH_EVENT_REPORTING_WEBHOOKS", ""))
# Support for Sentry (https://getsentry.com/). Just set your Sentry DSN to enable it:
SENTRY_DSN = os.environ.get("REDASH_SENTRY_DSN", "")
SENTRY_ENVIRONMENT = os.environ.get("REDASH_SENTRY_ENVIRONMENT")
# Client side toggles:
ALLOW_SCRIPTS_IN_USER_INPUT = parse_boolean(os.environ.get("REDASH_ALLOW_SCRIPTS_IN_USER_INPUT", "false"))
DASHBOARD_REFRESH_INTERVALS = list(
map(
int,
array_from_string(os.environ.get("REDASH_DASHBOARD_REFRESH_INTERVALS", "60,300,600,1800,3600,43200,86400")),
)
)
QUERY_REFRESH_INTERVALS = list(
map(
int,
array_from_string(
os.environ.get(
"REDASH_QUERY_REFRESH_INTERVALS",
"60, 300, 600, 900, 1800, 3600, 7200, 10800, 14400, 18000, 21600, 25200, 28800, 32400, 36000, 39600, 43200, 86400, 604800, 1209600, 2592000",
)
),
)
)
PAGE_SIZE = int(os.environ.get("REDASH_PAGE_SIZE", 20))
PAGE_SIZE_OPTIONS = list(
map(
int,
array_from_string(os.environ.get("REDASH_PAGE_SIZE_OPTIONS", "5,10,20,50,100")),
)
)
TABLE_CELL_MAX_JSON_SIZE = int(os.environ.get("REDASH_TABLE_CELL_MAX_JSON_SIZE", 50000))
# Features:
VERSION_CHECK = parse_boolean(os.environ.get("REDASH_VERSION_CHECK", "true"))
FEATURE_DISABLE_REFRESH_QUERIES = parse_boolean(os.environ.get("REDASH_FEATURE_DISABLE_REFRESH_QUERIES", "false"))
FEATURE_SHOW_QUERY_RESULTS_COUNT = parse_boolean(os.environ.get("REDASH_FEATURE_SHOW_QUERY_RESULTS_COUNT", "true"))
FEATURE_ALLOW_CUSTOM_JS_VISUALIZATIONS = parse_boolean(
os.environ.get("REDASH_FEATURE_ALLOW_CUSTOM_JS_VISUALIZATIONS", "true")
)
FEATURE_AUTO_PUBLISH_NAMED_QUERIES = parse_boolean(os.environ.get("REDASH_FEATURE_AUTO_PUBLISH_NAMED_QUERIES", "true"))
FEATURE_EXTENDED_ALERT_OPTIONS = parse_boolean(os.environ.get("REDASH_FEATURE_EXTENDED_ALERT_OPTIONS", "false"))
# BigQuery
BIGQUERY_HTTP_TIMEOUT = int(os.environ.get("REDASH_BIGQUERY_HTTP_TIMEOUT", "600"))
# Allow Parameters in Embeds
# WARNING: Deprecated!
# See https://discuss.redash.io/t/support-for-parameters-in-embedded-visualizations/3337 for more details.
ALLOW_PARAMETERS_IN_EMBEDS = parse_boolean(os.environ.get("REDASH_ALLOW_PARAMETERS_IN_EMBEDS", "false"))
# Enhance schema fetching
SCHEMA_RUN_TABLE_SIZE_CALCULATIONS = parse_boolean(
os.environ.get("REDASH_SCHEMA_RUN_TABLE_SIZE_CALCULATIONS", "false")
)
# kylin
KYLIN_OFFSET = int(os.environ.get("REDASH_KYLIN_OFFSET", 0))
KYLIN_LIMIT = int(os.environ.get("REDASH_KYLIN_LIMIT", 50000))
KYLIN_ACCEPT_PARTIAL = parse_boolean(os.environ.get("REDASH_KYLIN_ACCEPT_PARTIAL", "false"))
# sqlparse
SQLPARSE_FORMAT_OPTIONS = {
"reindent": parse_boolean(os.environ.get("SQLPARSE_FORMAT_REINDENT", "true")),
"keyword_case": os.environ.get("SQLPARSE_FORMAT_KEYWORD_CASE", "upper"),
}
# requests
REQUESTS_ALLOW_REDIRECTS = parse_boolean(os.environ.get("REDASH_REQUESTS_ALLOW_REDIRECTS", "false"))
# Enforces CSRF token validation on API requests.
# This is turned off by default to avoid breaking any existing deployments but it is highly recommended to turn this toggle on to prevent CSRF attacks.
ENFORCE_CSRF = parse_boolean(os.environ.get("REDASH_ENFORCE_CSRF", "false"))
# Databricks
CSRF_TIME_LIMIT = int(os.environ.get("REDASH_CSRF_TIME_LIMIT", 3600 * 6))
# Email blocked domains, use delimiter comma to separated multiple domains
BLOCKED_DOMAINS = set_from_string(os.environ.get("REDASH_BLOCKED_DOMAINS", "qq.com"))
================================================
FILE: redash/settings/dynamic_settings.py
================================================
from collections import defaultdict
# Replace this method with your own implementation in case you want to limit the time limit on certain queries or users.
def query_time_limit(is_scheduled, user_id, org_id):
from redash import settings
if is_scheduled:
return settings.SCHEDULED_QUERY_TIME_LIMIT
else:
return settings.ADHOC_QUERY_TIME_LIMIT
def periodic_jobs():
"""Schedule any custom periodic jobs here. For example:
from time import timedelta
from somewhere import some_job, some_other_job
return [
{"func": some_job, "interval": timedelta(hours=1)},
{"func": some_other_job, "interval": timedelta(days=1)}
]
"""
pass
# This provides the ability to override the way we store QueryResult's data column.
# Reference implementation: redash.models.DBPersistence
QueryResultPersistence = None
def ssh_tunnel_auth():
"""
To enable data source connections via SSH tunnels, provide your SSH authentication
pkey here. Return a string pointing at your **private** key's path (which will be used
to extract the public key), or a `paramiko.pkey.PKey` instance holding your **public** key.
"""
return {
# 'ssh_pkey': 'path_to_private_key', # or instance of `paramiko.pkey.PKey`
# 'ssh_private_key_password': 'optional_passphrase_of_private_key',
}
def database_key_definitions(default):
"""
All primary/foreign keys in Redash are of type `db.Integer` by default.
You may choose to use different column types for primary/foreign keys. To do so, add an entry below for each model you'd like to modify.
For each model, add a tuple with the database type as the first item, and a dict including any kwargs for the column definition as the second item.
"""
definitions = defaultdict(lambda: default)
definitions.update(
{
# "DataSource": (db.String(255), {
# "default": generate_key
# })
}
)
return definitions
# Since you can define custom primary key types using `database_key_definitions`, you may want to load certain extensions when creating the database.
# To do so, simply add the name of the extension you'd like to load to this list.
database_extensions = []
================================================
FILE: redash/settings/helpers.py
================================================
import os
from urllib.parse import urlparse, urlunparse
def fix_assets_path(path):
fullpath = os.path.join(os.path.dirname(__file__), "../", path)
return fullpath
def array_from_string(s):
array = s.split(",")
if "" in array:
array.remove("")
return array
def set_from_string(s):
return set(array_from_string(s))
def parse_boolean(s):
"""Takes a string and returns the equivalent as a boolean value."""
s = s.strip().lower()
if s in ("yes", "true", "on", "1"):
return True
elif s in ("no", "false", "off", "0", "none"):
return False
else:
raise ValueError("Invalid boolean value %r" % s)
def cast_int_or_default(val, default=None):
try:
return int(val)
except (ValueError, TypeError):
return default
def int_or_none(value):
if value is None:
return value
return int(value)
def add_decode_responses_to_redis_url(url):
"""Make sure that the Redis URL includes the `decode_responses` option."""
parsed = urlparse(url)
query = "decode_responses=True"
if parsed.query and "decode_responses" not in parsed.query:
query = "{}&{}".format(parsed.query, query)
elif "decode_responses" in parsed.query:
query = parsed.query
return urlunparse(
[
parsed.scheme,
parsed.netloc,
parsed.path,
parsed.params,
query,
parsed.fragment,
]
)
================================================
FILE: redash/settings/organization.py
================================================
import os
from .helpers import parse_boolean
if os.environ.get("REDASH_SAML_LOCAL_METADATA_PATH") is not None:
print("DEPRECATION NOTICE:\n")
print("SAML_LOCAL_METADATA_PATH is no longer supported. Only URL metadata is supported now, please update")
print("your configuration and reload.")
raise SystemExit(1)
PASSWORD_LOGIN_ENABLED = parse_boolean(os.environ.get("REDASH_PASSWORD_LOGIN_ENABLED", "true"))
SAML_LOGIN_TYPE = os.environ.get("REDASH_SAML_AUTH_TYPE", "")
SAML_METADATA_URL = os.environ.get("REDASH_SAML_METADATA_URL", "")
SAML_ENTITY_ID = os.environ.get("REDASH_SAML_ENTITY_ID", "")
SAML_NAMEID_FORMAT = os.environ.get("REDASH_SAML_NAMEID_FORMAT", "")
SAML_SSO_URL = os.environ.get("REDASH_SAML_SSO_URL", "")
SAML_X509_CERT = os.environ.get("REDASH_SAML_X509_CERT", "")
SAML_SP_SETTINGS = os.environ.get("REDASH_SAML_SP_SETTINGS", "")
if SAML_LOGIN_TYPE == "static":
SAML_LOGIN_ENABLED = SAML_SSO_URL != "" and SAML_METADATA_URL != ""
else:
SAML_LOGIN_ENABLED = SAML_METADATA_URL != ""
DATE_FORMAT = os.environ.get("REDASH_DATE_FORMAT", "DD/MM/YY")
TIME_FORMAT = os.environ.get("REDASH_TIME_FORMAT", "HH:mm")
INTEGER_FORMAT = os.environ.get("REDASH_INTEGER_FORMAT", "0,0")
FLOAT_FORMAT = os.environ.get("REDASH_FLOAT_FORMAT", "0,0.00")
NULL_VALUE = os.environ.get("REDASH_NULL_VALUE", "null")
MULTI_BYTE_SEARCH_ENABLED = parse_boolean(os.environ.get("MULTI_BYTE_SEARCH_ENABLED", "false"))
JWT_LOGIN_ENABLED = parse_boolean(os.environ.get("REDASH_JWT_LOGIN_ENABLED", "false"))
JWT_AUTH_ISSUER = os.environ.get("REDASH_JWT_AUTH_ISSUER", "")
JWT_AUTH_PUBLIC_CERTS_URL = os.environ.get("REDASH_JWT_AUTH_PUBLIC_CERTS_URL", "")
JWT_AUTH_AUDIENCE = os.environ.get("REDASH_JWT_AUTH_AUDIENCE", "")
JWT_AUTH_ALGORITHMS = os.environ.get("REDASH_JWT_AUTH_ALGORITHMS", "HS256,RS256,ES256").split(",")
JWT_AUTH_COOKIE_NAME = os.environ.get("REDASH_JWT_AUTH_COOKIE_NAME", "")
JWT_AUTH_HEADER_NAME = os.environ.get("REDASH_JWT_AUTH_HEADER_NAME", "")
FEATURE_SHOW_PERMISSIONS_CONTROL = parse_boolean(os.environ.get("REDASH_FEATURE_SHOW_PERMISSIONS_CONTROL", "false"))
SEND_EMAIL_ON_FAILED_SCHEDULED_QUERIES = parse_boolean(
os.environ.get("REDASH_SEND_EMAIL_ON_FAILED_SCHEDULED_QUERIES", "false")
)
HIDE_PLOTLY_MODE_BAR = parse_boolean(os.environ.get("HIDE_PLOTLY_MODE_BAR", "false"))
DISABLE_PUBLIC_URLS = parse_boolean(os.environ.get("REDASH_DISABLE_PUBLIC_URLS", "false"))
settings = {
"beacon_consent": None,
"auth_password_login_enabled": PASSWORD_LOGIN_ENABLED,
"auth_saml_enabled": SAML_LOGIN_ENABLED,
"auth_saml_type": SAML_LOGIN_TYPE,
"auth_saml_entity_id": SAML_ENTITY_ID,
"auth_saml_metadata_url": SAML_METADATA_URL,
"auth_saml_nameid_format": SAML_NAMEID_FORMAT,
"auth_saml_sso_url": SAML_SSO_URL,
"auth_saml_x509_cert": SAML_X509_CERT,
"auth_saml_sp_settings": SAML_SP_SETTINGS,
"date_format": DATE_FORMAT,
"time_format": TIME_FORMAT,
"integer_format": INTEGER_FORMAT,
"float_format": FLOAT_FORMAT,
"null_value": NULL_VALUE,
"multi_byte_search_enabled": MULTI_BYTE_SEARCH_ENABLED,
"auth_jwt_login_enabled": JWT_LOGIN_ENABLED,
"auth_jwt_auth_issuer": JWT_AUTH_ISSUER,
"auth_jwt_auth_public_certs_url": JWT_AUTH_PUBLIC_CERTS_URL,
"auth_jwt_auth_audience": JWT_AUTH_AUDIENCE,
"auth_jwt_auth_algorithms": JWT_AUTH_ALGORITHMS,
"auth_jwt_auth_cookie_name": JWT_AUTH_COOKIE_NAME,
"auth_jwt_auth_header_name": JWT_AUTH_HEADER_NAME,
"feature_show_permissions_control": FEATURE_SHOW_PERMISSIONS_CONTROL,
"send_email_on_failed_scheduled_queries": SEND_EMAIL_ON_FAILED_SCHEDULED_QUERIES,
"hide_plotly_mode_bar": HIDE_PLOTLY_MODE_BAR,
"disable_public_urls": DISABLE_PUBLIC_URLS,
}
================================================
FILE: redash/tasks/__init__.py
================================================
from rq.connections import pop_connection, push_connection
from redash import rq_redis_connection
from redash.tasks.alerts import check_alerts_for_query
from redash.tasks.failure_report import send_aggregated_errors
from redash.tasks.general import (
record_event,
send_mail,
sync_user_details,
version_check,
)
from redash.tasks.queries import (
cleanup_query_results,
empty_schedules,
enqueue_query,
execute_query,
refresh_queries,
refresh_schemas,
remove_ghost_locks,
)
from redash.tasks.schedule import (
periodic_job_definitions,
rq_scheduler,
schedule_periodic_jobs,
)
from redash.tasks.worker import Job, Queue, Worker
def init_app(app):
app.before_request(lambda: push_connection(rq_redis_connection))
app.teardown_request(lambda _: pop_connection())
================================================
FILE: redash/tasks/alerts.py
================================================
import datetime
from flask import current_app
from redash import models, utils
from redash.worker import get_job_logger, job
logger = get_job_logger(__name__)
def notify_subscriptions(alert, new_state, metadata):
host = utils.base_url(alert.query_rel.org)
for subscription in alert.subscriptions:
try:
subscription.notify(alert, alert.query_rel, subscription.user, new_state, current_app, host, metadata)
except Exception:
logger.exception("Error with processing destination")
def should_notify(alert, new_state):
passed_rearm_threshold = False
if alert.rearm and alert.last_triggered_at:
passed_rearm_threshold = alert.last_triggered_at + datetime.timedelta(seconds=alert.rearm) < utils.utcnow()
return new_state != alert.state or (alert.state == models.Alert.TRIGGERED_STATE and passed_rearm_threshold)
@job("default", timeout=300)
def check_alerts_for_query(query_id, metadata):
logger.debug("Checking query %d for alerts", query_id)
query = models.Query.query.get(query_id)
for alert in query.alerts:
logger.info("Checking alert (%d) of query %d.", alert.id, query_id)
new_state = alert.evaluate()
if should_notify(alert, new_state):
logger.info("Alert %d new state: %s", alert.id, new_state)
old_state = alert.state
alert.state = new_state
alert.last_triggered_at = utils.utcnow()
models.db.session.commit()
if old_state == models.Alert.UNKNOWN_STATE and new_state == models.Alert.OK_STATE:
logger.debug("Skipping notification (previous state was unknown and now it's ok).")
continue
if alert.muted:
logger.debug("Skipping notification (alert muted).")
continue
notify_subscriptions(alert, new_state, metadata)
================================================
FILE: redash/tasks/databricks.py
================================================
from redash import models, redis_connection
from redash.tasks.worker import Queue
from redash.utils import json_dumps
from redash.worker import job
DATABRICKS_REDIS_EXPIRATION_TIME = 3600
@job("schemas", queue_class=Queue, at_front=True, timeout=300, ttl=90)
def get_databricks_databases(data_source_id, redis_key):
try:
data_source = models.DataSource.get_by_id(data_source_id)
databases = data_source.query_runner.get_databases()
redis_connection.set(redis_key, json_dumps(databases))
redis_connection.expire(redis_key, DATABRICKS_REDIS_EXPIRATION_TIME)
return databases
except Exception:
return {"error": {"code": 2, "message": "Error retrieving database list."}}
@job("schemas", queue_class=Queue, at_front=True, timeout=300, ttl=90)
def get_database_tables_with_columns(data_source_id, database_name, redis_key):
try:
data_source = models.DataSource.get_by_id(data_source_id)
tables = data_source.query_runner.get_database_tables_with_columns(database_name)
# check for tables since it doesn't return an error when the requested database doesn't exist
if tables or redis_connection.exists(redis_key):
redis_connection.set(redis_key, json_dumps(tables))
redis_connection.expire(
redis_key,
DATABRICKS_REDIS_EXPIRATION_TIME,
)
return {"schema": tables, "has_columns": True}
except Exception:
return {"error": {"code": 2, "message": "Error retrieving schema."}}
@job("schemas", queue_class=Queue, at_front=True, timeout=300, ttl=90)
def get_databricks_tables(data_source_id, database_name):
try:
data_source = models.DataSource.get_by_id(data_source_id)
tables = data_source.query_runner.get_database_tables_with_columns(database_name)
return {"schema": tables, "has_columns": False}
except Exception:
return {"error": {"code": 2, "message": "Error retrieving schema."}}
@job("schemas", queue_class=Queue, at_front=True, timeout=300, ttl=90)
def get_databricks_table_columns(data_source_id, database_name, table_name):
try:
data_source = models.DataSource.get_by_id(data_source_id)
return data_source.query_runner.get_table_columns(database_name, table_name)
except Exception:
return {"error": {"code": 2, "message": "Error retrieving table columns."}}
================================================
FILE: redash/tasks/failure_report.py
================================================
import datetime
import re
from collections import Counter
from redash import models, redis_connection, settings
from redash.tasks.general import send_mail
from redash.utils import base_url, json_dumps, json_loads, render_template
from redash.worker import get_job_logger
logger = get_job_logger(__name__)
def key(user_id):
return "aggregated_failures:{}".format(user_id)
def comment_for(failure):
schedule_failures = failure.get("schedule_failures")
if schedule_failures > settings.MAX_FAILURE_REPORTS_PER_QUERY * 0.75:
return """NOTICE: This query has failed a total of {schedule_failures} times.
Reporting may stop when the query exceeds {max_failure_reports} overall failures.""".format(
schedule_failures=schedule_failures,
max_failure_reports=settings.MAX_FAILURE_REPORTS_PER_QUERY,
)
def send_aggregated_errors():
for k in redis_connection.scan_iter(key("*")):
user_id = re.search(r"\d+", k).group()
send_failure_report(user_id)
def send_failure_report(user_id):
user = models.User.get_by_id(user_id)
errors = [json_loads(e) for e in redis_connection.lrange(key(user_id), 0, -1)]
if errors:
errors.reverse()
occurrences = Counter((e.get("id"), e.get("message")) for e in errors)
unique_errors = {(e.get("id"), e.get("message")): e for e in errors}
context = {
"failures": [
{
"id": v.get("id"),
"name": v.get("name"),
"failed_at": v.get("failed_at"),
"failure_reason": v.get("message"),
"failure_count": occurrences[k],
"comment": comment_for(v),
}
for k, v in unique_errors.items()
],
"base_url": base_url(user.org),
}
subject = f"Redash failed to execute {len(unique_errors.keys())} of your scheduled queries"
html, text = [
render_template("emails/failures.{}".format(f), context)
for f in ["html", "txt"]
] # fmt: skip
send_mail.delay([user.email], subject, html, text)
redis_connection.delete(key(user_id))
def notify_of_failure(message, query):
subscribed = query.org.get_setting("send_email_on_failed_scheduled_queries")
exceeded_threshold = query.schedule_failures >= settings.MAX_FAILURE_REPORTS_PER_QUERY
if subscribed and not query.user.is_disabled and not exceeded_threshold:
redis_connection.lpush(
key(query.user.id),
json_dumps(
{
"id": query.id,
"name": query.name,
"message": message,
"schedule_failures": query.schedule_failures,
"failed_at": datetime.datetime.utcnow().strftime("%B %d, %Y %I:%M%p UTC"),
}
),
)
def track_failure(query, error):
logger.debug(error)
query.schedule_failures += 1
query.skip_updated_at = True
models.db.session.add(query)
models.db.session.commit()
notify_of_failure(error, query)
================================================
FILE: redash/tasks/general.py
================================================
import requests
from flask_mail import Message
from redash import mail, models, settings
from redash.models import users
from redash.query_runner import NotSupported
from redash.tasks.worker import Queue
from redash.version_check import run_version_check
from redash.worker import get_job_logger, job
logger = get_job_logger(__name__)
@job("default")
def record_event(raw_event):
event = models.Event.record(raw_event)
models.db.session.commit()
for hook in settings.EVENT_REPORTING_WEBHOOKS:
logger.debug("Forwarding event to: %s", hook)
try:
data = {
"schema": "iglu:io.redash.webhooks/event/jsonschema/1-0-0",
"data": event.to_dict(),
}
response = requests.post(hook, json=data)
if response.status_code != 200:
logger.error("Failed posting to %s: %s", hook, response.content)
except Exception:
logger.exception("Failed posting to %s", hook)
def version_check():
run_version_check()
@job("default")
def subscribe(form):
logger.info(
"Subscribing to: [security notifications=%s], [newsletter=%s]",
form["security_notifications"],
form["newsletter"],
)
data = {
"admin_name": form["name"],
"admin_email": form["email"],
"org_name": form["org_name"],
"security_notifications": form["security_notifications"],
"newsletter": form["newsletter"],
}
requests.post("https://version.redash.io/subscribe", json=data)
@job("emails")
def send_mail(to, subject, html, text):
try:
message = Message(recipients=to, subject=subject, html=html, body=text)
mail.send(message)
except Exception:
logger.exception("Failed sending message: %s", message.subject)
@job("queries", timeout=30, ttl=90)
def test_connection(data_source_id):
try:
data_source = models.DataSource.get_by_id(data_source_id)
data_source.query_runner.test_connection()
except Exception as e:
return e
else:
return True
@job("schemas", queue_class=Queue, at_front=True, timeout=settings.SCHEMAS_REFRESH_TIMEOUT, ttl=90)
def get_schema(data_source_id, refresh):
try:
data_source = models.DataSource.get_by_id(data_source_id)
return data_source.get_schema(refresh)
except NotSupported:
return {
"error": {
"code": 1,
"message": "Data source type does not support retrieving schema",
}
}
except Exception as e:
return {"error": {"code": 2, "message": "Error retrieving schema", "details": str(e)}}
def sync_user_details():
users.sync_last_active_at()
================================================
FILE: redash/tasks/queries/__init__.py
================================================
from .execution import enqueue_query, execute_query
from .maintenance import (
cleanup_query_results,
empty_schedules,
refresh_queries,
refresh_schemas,
remove_ghost_locks,
)
================================================
FILE: redash/tasks/queries/execution.py
================================================
import signal
import sys
import time
from collections import deque
import redis
from rq import get_current_job
from rq.exceptions import NoSuchJobError
from rq.job import JobStatus
from rq.timeouts import JobTimeoutException
from redash import models, redis_connection, settings
from redash.query_runner import InterruptException
from redash.tasks.alerts import check_alerts_for_query
from redash.tasks.failure_report import track_failure
from redash.tasks.worker import Job, Queue
from redash.utils import gen_query_hash, utcnow
from redash.worker import get_job_logger
logger = get_job_logger(__name__)
TIMEOUT_MESSAGE = "Query exceeded Redash query execution time limit."
def _job_lock_id(query_hash, data_source_id):
return "query_hash_job:%s:%s" % (data_source_id, query_hash)
def _unlock(query_hash, data_source_id):
redis_connection.delete(_job_lock_id(query_hash, data_source_id))
def enqueue_query(query, data_source, user_id, is_api_key=False, scheduled_query=None, metadata={}):
query_hash = gen_query_hash(query)
logger.info("Inserting job for %s with metadata=%s", query_hash, metadata)
try_count = 0
job = None
while try_count < 5:
try_count += 1
pipe = redis_connection.pipeline()
try:
pipe.watch(_job_lock_id(query_hash, data_source.id))
job_id = pipe.get(_job_lock_id(query_hash, data_source.id))
if job_id:
logger.info("[%s] Found existing job: %s", query_hash, job_id)
job_complete = None
job_cancelled = None
try:
job = Job.fetch(job_id)
job_exists = True
status = job.get_status()
job_complete = status in [JobStatus.FINISHED, JobStatus.FAILED]
job_cancelled = job.is_cancelled
if job_complete:
message = "job found is complete (%s)" % status
elif job_cancelled:
message = "job found has been cancelled"
except NoSuchJobError:
message = "job found has expired"
job_exists = False
lock_is_irrelevant = job_complete or job_cancelled or not job_exists
if lock_is_irrelevant:
logger.info("[%s] %s, removing lock", query_hash, message)
redis_connection.delete(_job_lock_id(query_hash, data_source.id))
job = None
if not job:
pipe.multi()
if scheduled_query:
queue_name = data_source.scheduled_queue_name
scheduled_query_id = scheduled_query.id
else:
queue_name = data_source.queue_name
scheduled_query_id = None
time_limit = settings.dynamic_settings.query_time_limit(scheduled_query, user_id, data_source.org_id)
metadata["Queue"] = queue_name
queue = Queue(queue_name)
enqueue_kwargs = {
"user_id": user_id,
"scheduled_query_id": scheduled_query_id,
"is_api_key": is_api_key,
"job_timeout": time_limit,
"failure_ttl": settings.JOB_DEFAULT_FAILURE_TTL,
"meta": {
"data_source_id": data_source.id,
"org_id": data_source.org_id,
"scheduled": scheduled_query_id is not None,
"query_id": metadata.get("query_id"),
"user_id": user_id,
},
}
if not scheduled_query:
enqueue_kwargs["result_ttl"] = settings.JOB_EXPIRY_TIME
job = queue.enqueue(execute_query, query, data_source.id, metadata, **enqueue_kwargs)
logger.info("[%s] Created new job: %s", query_hash, job.id)
pipe.set(
_job_lock_id(query_hash, data_source.id),
job.id,
settings.JOB_EXPIRY_TIME,
)
pipe.execute()
break
except redis.WatchError:
continue
finally:
pipe.reset()
if not job:
logger.error("[Manager][%s] Failed adding job for query.", query_hash)
return job
def signal_handler(*args):
raise InterruptException
class QueryExecutionError(Exception):
pass
def _resolve_user(user_id, is_api_key, query_id):
if user_id is not None:
if is_api_key:
api_key = user_id
if query_id is not None:
q = models.Query.get_by_id(query_id)
else:
q = models.Query.by_api_key(api_key)
return models.ApiUser(api_key, q.org, q.groups)
else:
return models.User.get_by_id(user_id)
else:
return None
def _get_size_iterative(dict_obj):
"""Iteratively finds size of objects in bytes"""
seen = set()
size = 0
objects = deque([dict_obj])
while objects:
current = objects.popleft()
if id(current) in seen:
continue
seen.add(id(current))
size += sys.getsizeof(current)
if isinstance(current, dict):
objects.extend(current.keys())
objects.extend(current.values())
elif hasattr(current, "__dict__"):
objects.append(current.__dict__)
elif hasattr(current, "__iter__") and not isinstance(current, (str, bytes, bytearray)):
objects.extend(current)
return size
class QueryExecutor:
def __init__(self, query, data_source_id, user_id, is_api_key, metadata, is_scheduled_query):
self.job = get_current_job()
self.query = query
self.data_source_id = data_source_id
self.metadata = metadata
self.data_source = self._load_data_source()
self.query_id = metadata.get("query_id")
self.user = _resolve_user(user_id, is_api_key, metadata.get("query_id"))
self.query_model = (
models.Query.query.get(self.query_id)
if self.query_id and self.query_id != "adhoc"
else None
) # fmt: skip
# Close DB connection to prevent holding a connection for a long time while the query is executing.
models.db.session.close()
self.query_hash = gen_query_hash(self.query)
self.is_scheduled_query = is_scheduled_query
if self.is_scheduled_query:
# Load existing tracker or create a new one if the job was created before code update:
models.scheduled_queries_executions.update(self.query_model.id)
def run(self):
signal.signal(signal.SIGINT, signal_handler)
started_at = time.time()
logger.debug("Executing query:\n%s", self.query)
self._log_progress("executing_query")
query_runner = self.data_source.query_runner
annotated_query = self._annotate_query(query_runner)
try:
data, error = query_runner.run_query(annotated_query, self.user)
except Exception as e:
if isinstance(e, JobTimeoutException):
error = TIMEOUT_MESSAGE
else:
error = str(e)
data = None
logger.warning("Unexpected error while running query:", exc_info=1)
run_time = time.time() - started_at
logger.info(
"job=execute_query query_hash=%s ds_id=%d data_length=%s error=[%s]",
self.query_hash,
self.data_source_id,
data and _get_size_iterative(data),
error,
)
_unlock(self.query_hash, self.data_source.id)
if error is not None and data is None:
result = QueryExecutionError(error)
if self.is_scheduled_query:
self.query_model = models.db.session.merge(self.query_model, load=False)
track_failure(self.query_model, error)
raise result
else:
if self.query_model and self.query_model.schedule_failures > 0:
self.query_model = models.db.session.merge(self.query_model, load=False)
self.query_model.schedule_failures = 0
self.query_model.skip_updated_at = True
models.db.session.add(self.query_model)
query_result = models.QueryResult.store_result(
self.data_source.org_id,
self.data_source,
self.query_hash,
self.query,
data,
run_time,
utcnow(),
)
updated_query_ids = models.Query.update_latest_result(query_result)
models.db.session.commit() # make sure that alert sees the latest query result
self._log_progress("checking_alerts")
for query_id in updated_query_ids:
check_alerts_for_query.delay(query_id, self.metadata)
self._log_progress("finished")
result = query_result.id
models.db.session.commit()
return result
def _annotate_query(self, query_runner):
self.metadata["Job ID"] = self.job.id
self.metadata["Query Hash"] = self.query_hash
self.metadata["Scheduled"] = self.is_scheduled_query
return query_runner.annotate_query(self.query, self.metadata)
def _log_progress(self, state):
logger.info(
"job=execute_query state=%s query_hash=%s type=%s ds_id=%d "
"job_id=%s queue=%s query_id=%s username=%s", # fmt: skip
state,
self.query_hash,
self.data_source.type,
self.data_source.id,
self.job.id,
self.metadata.get("Queue", "unknown"),
self.metadata.get("query_id", "unknown"),
self.metadata.get("Username", "unknown"),
)
def _load_data_source(self):
logger.info("job=execute_query state=load_ds ds_id=%d", self.data_source_id)
return models.DataSource.query.get(self.data_source_id)
# user_id is added last as a keyword argument for backward compatability -- to support executing previously submitted
# jobs before the upgrade to this version.
def execute_query(
query,
data_source_id,
metadata,
user_id=None,
scheduled_query_id=None,
is_api_key=False,
):
try:
return QueryExecutor(
query,
data_source_id,
user_id,
is_api_key,
metadata,
scheduled_query_id is not None,
).run()
except QueryExecutionError as e:
models.db.session.rollback()
return e
================================================
FILE: redash/tasks/queries/maintenance.py
================================================
import logging
import time
from rq.timeouts import JobTimeoutException
from redash import models, redis_connection, settings, statsd_client
from redash.models.parameterized_query import (
InvalidParameterError,
QueryDetachedFromDataSourceError,
)
from redash.monitor import rq_job_ids
from redash.query_runner import NotSupported
from redash.tasks.failure_report import track_failure
from redash.utils import json_dumps, sentry
from redash.worker import get_job_logger, job
from .execution import enqueue_query
logger = get_job_logger(__name__)
def empty_schedules():
logger.info("Deleting schedules of past scheduled queries...")
queries = models.Query.past_scheduled_queries()
for query in queries:
query.schedule = None
models.db.session.commit()
logger.info("Deleted %d schedules.", len(queries))
def _should_refresh_query(query):
if settings.FEATURE_DISABLE_REFRESH_QUERIES:
logger.info("Disabled refresh queries.")
return False
elif query.org.is_disabled:
logger.debug("Skipping refresh of %s because org is disabled.", query.id)
return False
elif query.data_source is None:
logger.debug("Skipping refresh of %s because the datasource is none.", query.id)
return False
elif query.data_source.paused:
logger.debug(
"Skipping refresh of %s because datasource - %s is paused (%s).",
query.id,
query.data_source.name,
query.data_source.pause_reason,
)
return False
else:
return True
def _apply_default_parameters(query):
parameters = {p["name"]: p.get("value") for p in query.parameters}
if any(parameters):
try:
return query.parameterized.apply(parameters).query
except InvalidParameterError as e:
error = f"Skipping refresh of {query.id} because of invalid parameters: {str(e)}"
track_failure(query, error)
raise
except QueryDetachedFromDataSourceError as e:
error = (
f"Skipping refresh of {query.id} because a related dropdown "
f"query ({e.query_id}) is unattached to any datasource."
)
track_failure(query, error)
raise
else:
return query.query_text
class RefreshQueriesError(Exception):
pass
def _apply_auto_limit(query_text, query):
should_apply_auto_limit = query.options.get("apply_auto_limit", False)
return query.data_source.query_runner.apply_auto_limit(query_text, should_apply_auto_limit)
def refresh_queries():
started_at = time.time()
logger.info("Refreshing queries...")
enqueued = []
for query in models.Query.outdated_queries():
if not _should_refresh_query(query):
continue
try:
query_text = _apply_default_parameters(query)
query_text = _apply_auto_limit(query_text, query)
enqueue_query(
query_text,
query.data_source,
query.user_id,
scheduled_query=query,
metadata={"query_id": query.id, "Username": query.user.get_actual_user()},
)
enqueued.append(query)
except Exception as e:
message = "Could not enqueue query %d due to %s" % (query.id, repr(e))
logging.info(message)
error = RefreshQueriesError(message).with_traceback(e.__traceback__)
sentry.capture_exception(error)
status = {
"started_at": started_at,
"outdated_queries_count": len(enqueued),
"last_refresh_at": time.time(),
"query_ids": json_dumps([q.id for q in enqueued]),
}
redis_connection.hset("redash:status", mapping=status)
logger.info("Done refreshing queries: %s" % status)
def cleanup_query_results():
"""
Job to cleanup unused query results -- such that no query links to them anymore, and older than
settings.QUERY_RESULTS_CLEANUP_MAX_AGE (a week by default, so it's less likely to be open in someone's browser and be used).
Each time the job deletes only settings.QUERY_RESULTS_CLEANUP_COUNT (100 by default) query results so it won't choke
the database in case of many such results.
"""
logger.info(
"Running query results clean up (removing maximum of %d unused results, that are %d days old or more)",
settings.QUERY_RESULTS_CLEANUP_COUNT,
settings.QUERY_RESULTS_CLEANUP_MAX_AGE,
)
unused_query_results = models.QueryResult.unused(settings.QUERY_RESULTS_CLEANUP_MAX_AGE)
deleted_count = models.QueryResult.query.filter(
models.QueryResult.id.in_(unused_query_results.limit(settings.QUERY_RESULTS_CLEANUP_COUNT).subquery())
).delete(synchronize_session=False)
models.db.session.commit()
logger.info("Deleted %d unused query results.", deleted_count)
def remove_ghost_locks():
"""
Removes query locks that reference a non existing RQ job.
"""
keys = redis_connection.keys("query_hash_job:*")
locks = {k: redis_connection.get(k) for k in keys}
jobs = list(rq_job_ids())
count = 0
for lock, job_id in locks.items():
if job_id not in jobs:
redis_connection.delete(lock)
count += 1
logger.info("Locks found: {}, Locks removed: {}".format(len(locks), count))
@job("schemas", timeout=settings.SCHEMAS_REFRESH_TIMEOUT)
def refresh_schema(data_source_id):
ds = models.DataSource.get_by_id(data_source_id)
logger.info("task=refresh_schema state=start ds_id=%s", ds.id)
start_time = time.time()
try:
ds.get_schema(refresh=True)
logger.info(
"task=refresh_schema state=finished ds_id=%s runtime=%.2f",
ds.id,
time.time() - start_time,
)
statsd_client.incr("refresh_schema.success")
except JobTimeoutException:
logger.info(
"task=refresh_schema state=timeout ds_id=%s runtime=%.2f",
ds.id,
time.time() - start_time,
)
statsd_client.incr("refresh_schema.timeout")
except NotSupported:
logger.debug("Datasource %s does not support schema refresh", ds.name)
except Exception:
logger.warning("Failed refreshing schema for the data source: %s", ds.name, exc_info=1)
statsd_client.incr("refresh_schema.error")
logger.info(
"task=refresh_schema state=failed ds_id=%s runtime=%.2f",
ds.id,
time.time() - start_time,
)
def refresh_schemas():
"""
Refreshes the data sources schemas.
"""
blacklist = [int(ds_id) for ds_id in redis_connection.smembers("data_sources:schema:blacklist") if ds_id]
global_start_time = time.time()
logger.info("task=refresh_schemas state=start")
for ds in models.DataSource.query:
if ds.paused:
logger.info(
"task=refresh_schema state=skip ds_id=%s reason=paused(%s)",
ds.id,
ds.pause_reason,
)
elif ds.id in blacklist:
logger.info("task=refresh_schema state=skip ds_id=%s reason=blacklist", ds.id)
elif ds.org.is_disabled:
logger.info("task=refresh_schema state=skip ds_id=%s reason=org_disabled", ds.id)
else:
refresh_schema.delay(ds.id)
logger.info(
"task=refresh_schemas state=finish total_runtime=%.2f",
time.time() - global_start_time,
)
================================================
FILE: redash/tasks/schedule.py
================================================
import hashlib
import json
import logging
from datetime import datetime, timedelta
from rq.job import Job
from rq_scheduler import Scheduler
from redash import rq_redis_connection, settings
from redash.tasks.failure_report import send_aggregated_errors
from redash.tasks.general import sync_user_details, version_check
from redash.tasks.queries import (
cleanup_query_results,
empty_schedules,
refresh_queries,
refresh_schemas,
remove_ghost_locks,
)
from redash.tasks.worker import Queue
logger = logging.getLogger(__name__)
class StatsdRecordingScheduler(Scheduler):
"""
RQ Scheduler Mixin that uses Redash's custom RQ Queue class to increment/modify metrics via Statsd
"""
queue_class = Queue
rq_scheduler = StatsdRecordingScheduler(connection=rq_redis_connection, queue_name="periodic", interval=5)
def job_id(kwargs):
metadata = kwargs.copy()
metadata["func"] = metadata["func"].__name__
return hashlib.sha1(json.dumps(metadata, sort_keys=True).encode()).hexdigest()
def prep(kwargs):
interval = kwargs["interval"]
if isinstance(interval, timedelta):
interval = int(interval.total_seconds())
kwargs["interval"] = interval
kwargs["result_ttl"] = kwargs.get("result_ttl", interval * 2)
return kwargs
def schedule(kwargs):
rq_scheduler.schedule(scheduled_time=datetime.utcnow(), id=job_id(kwargs), **kwargs)
def periodic_job_definitions():
jobs = [
{"func": refresh_queries, "timeout": 600, "interval": 30, "result_ttl": 600},
{
"func": remove_ghost_locks,
"interval": timedelta(minutes=1),
"result_ttl": 600,
},
{"func": empty_schedules, "interval": timedelta(minutes=60)},
{
"func": refresh_schemas,
"interval": timedelta(minutes=settings.SCHEMAS_REFRESH_SCHEDULE),
},
{
"func": sync_user_details,
"timeout": 60,
"interval": timedelta(minutes=1),
"result_ttl": 600,
},
{
"func": send_aggregated_errors,
"interval": timedelta(minutes=settings.SEND_FAILURE_EMAIL_INTERVAL),
},
]
if settings.VERSION_CHECK:
jobs.append({"func": version_check, "interval": timedelta(days=1)})
if settings.QUERY_RESULTS_CLEANUP_ENABLED:
jobs.append({"func": cleanup_query_results, "interval": timedelta(minutes=5)})
# Add your own custom periodic jobs in your dynamic_settings module.
jobs.extend(settings.dynamic_settings.periodic_jobs() or [])
return jobs
def schedule_periodic_jobs(jobs):
job_definitions = [prep(job) for job in jobs]
jobs_to_clean_up = Job.fetch_many(
set([job.id for job in rq_scheduler.get_jobs()]) - set([job_id(job) for job in job_definitions]),
rq_redis_connection,
)
jobs_to_schedule = [job for job in job_definitions if job_id(job) not in rq_scheduler]
for job in jobs_to_clean_up:
logger.info("Removing %s (%s) from schedule.", job.id, job.func_name)
rq_scheduler.cancel(job)
job.delete()
for job in jobs_to_schedule:
logger.info(
"Scheduling %s (%s) with interval %s.",
job_id(job),
job["func"].__name__,
job.get("interval"),
)
schedule(job)
================================================
FILE: redash/tasks/worker.py
================================================
import errno
import os
import signal
import sys
from rq import Queue as BaseQueue
from rq.job import Job as BaseJob
from rq.job import JobStatus
from rq.timeouts import HorseMonitorTimeoutException
from rq.utils import utcnow
from rq.worker import (
HerokuWorker, # HerokuWorker implements graceful shutdown on SIGTERM
Worker,
)
from redash import statsd_client
# HerokuWorker does not work in OSX https://github.com/getredash/redash/issues/5413
if sys.platform == "darwin":
BaseWorker = Worker
else:
BaseWorker = HerokuWorker
class CancellableJob(BaseJob):
def cancel(self, pipeline=None):
self.meta["cancelled"] = True
self.save_meta()
super().cancel(pipeline=pipeline)
@property
def is_cancelled(self):
return self.meta.get("cancelled", False)
class StatsdRecordingQueue(BaseQueue):
"""
RQ Queue Mixin that overrides `enqueue_call` to increment metrics via Statsd
"""
def enqueue_job(self, *args, **kwargs):
job = super().enqueue_job(*args, **kwargs)
statsd_client.incr("rq.jobs.created.{}".format(self.name))
return job
class CancellableQueue(BaseQueue):
job_class = CancellableJob
class RedashQueue(StatsdRecordingQueue, CancellableQueue):
pass
class StatsdRecordingWorker(BaseWorker):
"""
RQ Worker Mixin that overrides `execute_job` to increment/modify metrics via Statsd
"""
def execute_job(self, job, queue):
statsd_client.incr("rq.jobs.running.{}".format(queue.name))
statsd_client.incr("rq.jobs.started.{}".format(queue.name))
try:
super().execute_job(job, queue)
finally:
statsd_client.decr("rq.jobs.running.{}".format(queue.name))
if job.get_status() == JobStatus.FINISHED:
statsd_client.incr("rq.jobs.finished.{}".format(queue.name))
else:
statsd_client.incr("rq.jobs.failed.{}".format(queue.name))
class HardLimitingWorker(BaseWorker):
"""
RQ's work horses enforce time limits by setting a timed alarm and stopping jobs
when they reach their time limits. However, the work horse may be entirely blocked
and may not respond to the alarm interrupt. Since respecting timeouts is critical
in Redash (if we don't respect them, workers may be infinitely stuck and as a result,
service may be denied for other queries), we enforce two time limits:
1. A soft time limit, enforced by the work horse
2. A hard time limit, enforced by the parent worker
The HardLimitingWorker class changes the default monitoring behavior of the default
RQ Worker by checking if the work horse is still busy with the job, even after
it should have timed out (+ a grace period of 15s). If it does, it kills the work horse.
"""
grace_period = 15
queue_class = RedashQueue
job_class = CancellableJob
def stop_executing_job(self, job):
os.kill(self.horse_pid, signal.SIGINT)
self.log.warning("Job %s has been cancelled.", job.id)
def soft_limit_exceeded(self, job):
job_has_time_limit = job.timeout != -1
if job_has_time_limit:
seconds_under_monitor = (utcnow() - self.monitor_started).seconds
return seconds_under_monitor > job.timeout + self.grace_period
else:
return False
def enforce_hard_limit(self, job):
self.log.warning(
"Job %s exceeded timeout of %ds (+%ds grace period) but work horse did not terminate it. "
"Killing the work horse.",
job.id,
job.timeout,
self.grace_period,
)
self.kill_horse()
def monitor_work_horse(self, job: "Job", queue: "Queue"):
"""The worker will monitor the work horse and make sure that it
either executes successfully or the status of the job is set to
failed
Args:
job (Job): _description_
queue (Queue): _description_
"""
self.monitor_started = utcnow()
retpid = ret_val = rusage = None
job.started_at = utcnow()
while True:
try:
with self.death_penalty_class(self.job_monitoring_interval, HorseMonitorTimeoutException):
retpid, ret_val, rusage = self.wait_for_horse()
break
except HorseMonitorTimeoutException:
# Horse has not exited yet and is still running.
# Send a heartbeat to keep the worker alive.
self.set_current_job_working_time((utcnow() - job.started_at).total_seconds())
job.refresh()
# Kill the job from this side if something is really wrong (interpreter lock/etc).
if job.timeout != -1 and self.current_job_working_time > (job.timeout + 60): # type: ignore
self.heartbeat(self.job_monitoring_interval + 60)
self.kill_horse()
self.wait_for_horse()
break
self.maintain_heartbeats(job)
if job.is_cancelled:
self.stop_executing_job(job)
if self.soft_limit_exceeded(job):
self.enforce_hard_limit(job)
except OSError as e:
# In case we encountered an OSError due to EINTR (which is
# caused by a SIGINT or SIGTERM signal during
# os.waitpid()), we simply ignore it and enter the next
# iteration of the loop, waiting for the child to end. In
# any other case, this is some other unexpected OS error,
# which we don't want to catch, so we re-raise those ones.
if e.errno != errno.EINTR:
raise
# Send a heartbeat to keep the worker alive.
self.heartbeat()
self.set_current_job_working_time(0)
self._horse_pid = 0 # Set horse PID to 0, horse has finished working
if ret_val == os.EX_OK: # The process exited normally.
return
job_status = job.get_status()
if job_status is None: # Job completed and its ttl has expired
return
elif self._stopped_job_id == job.id:
# Work-horse killed deliberately
self.log.warning("Job stopped by user, moving job to FailedJobRegistry")
if job.stopped_callback:
job.execute_stopped_callback(self.death_penalty_class)
self.handle_job_failure(job, queue=queue, exc_string="Job stopped by user, work-horse terminated.")
elif job_status not in [JobStatus.FINISHED, JobStatus.FAILED]:
if not job.ended_at:
job.ended_at = utcnow()
# Unhandled failure: move the job to the failed queue
signal_msg = f" (signal {os.WTERMSIG(ret_val)})" if ret_val and os.WIFSIGNALED(ret_val) else ""
exc_string = f"Work-horse terminated unexpectedly; waitpid returned {ret_val}{signal_msg}; "
self.log.warning("Moving job to FailedJobRegistry (%s)", exc_string)
self.handle_work_horse_killed(job, retpid, ret_val, rusage)
self.handle_job_failure(job, queue=queue, exc_string=exc_string)
class RedashWorker(StatsdRecordingWorker, HardLimitingWorker):
queue_class = RedashQueue
Job = CancellableJob
Queue = RedashQueue
Worker = RedashWorker
================================================
FILE: redash/templates/_includes/signed_out_tail.html
================================================
================================================
FILE: redash/templates/_includes/tail.html
================================================
================================================
FILE: redash/templates/emails/alert.html
================================================
STATUS: {{ALERT_STATUS}}
CONDITION:
{{QUERY_RESULT_VALUE}} {{ALERT_CONDITION}} {{ALERT_THRESHOLD}}
QUERY:
{{QUERY_NAME}}
{{#QUERY_RESULT_COLS}}
{{friendly_name}}
{{/QUERY_RESULT_COLS}}
{{#QUERY_RESULT_TABLE}}
{{#.}}
{{.}}
{{/.}}
{{/QUERY_RESULT_TABLE}}